commit fcc537b02403bb77247f69f1b2fbe31cc5f65951 Author: Albrecht Degering Date: Fri Jul 24 14:58:03 2026 +0200 feat: publish av-tools 0.1.0 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..55b415e --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +LICENSES/native/** whitespace=-trailing-space,-space-before-tab diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..26a47bf --- /dev/null +++ b/.gitignore @@ -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? diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..90e6041 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +@add-ideas:registry=https://git.add-ideas.de/api/packages/zemion/npm/ diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..8efd881 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,10 @@ +node_modules +coverage +dist +release +test-results +playwright-report +public/vendor/ffmpeg +public/toolbox-app.json +LICENSES/native +*.zip diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..94e30b6 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "singleQuote": true, + "semi": true, + "trailingComma": "es5", + "printWidth": 80 +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..aa905af --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f6cdd22 --- /dev/null +++ b/LICENSE @@ -0,0 +1,232 @@ +GNU GENERAL PUBLIC LICENSE +Version 3, 29 June 2007 + +Copyright © 2007 Free Software Foundation, Inc. + +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. + + + Copyright (C) + + 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 . + +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: + + Copyright (C) + 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 . + +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 . diff --git a/LICENSES/CC0-1.0.txt b/LICENSES/CC0-1.0.txt new file mode 100644 index 0000000..0e259d4 --- /dev/null +++ b/LICENSES/CC0-1.0.txt @@ -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. diff --git a/LICENSES/GPL-2.0-or-later.txt b/LICENSES/GPL-2.0-or-later.txt new file mode 100644 index 0000000..25e7612 --- /dev/null +++ b/LICENSES/GPL-2.0-or-later.txt @@ -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. + + + Copyright (C) + + 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. + + , 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. diff --git a/LICENSES/README.md b/LICENSES/README.md new file mode 100644 index 0000000..5b1de5a --- /dev/null +++ b/LICENSES/README.md @@ -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. diff --git a/LICENSES/ffmpeg.wasm-MIT.txt b/LICENSES/ffmpeg.wasm-MIT.txt new file mode 100644 index 0000000..3032c67 --- /dev/null +++ b/LICENSES/ffmpeg.wasm-MIT.txt @@ -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. diff --git a/LICENSES/native/FFmpeg-LICENSE.md b/LICENSES/native/FFmpeg-LICENSE.md new file mode 100644 index 0000000..613070e --- /dev/null +++ b/LICENSES/native/FFmpeg-LICENSE.md @@ -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. diff --git a/LICENSES/native/FreeType-LICENSE.txt b/LICENSES/native/FreeType-LICENSE.txt new file mode 100644 index 0000000..af5a1c5 --- /dev/null +++ b/LICENSES/native/FreeType-LICENSE.txt @@ -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 --- diff --git a/LICENSES/native/FriBidi-COPYING.txt b/LICENSES/native/FriBidi-COPYING.txt new file mode 100644 index 0000000..ae23fcf --- /dev/null +++ b/LICENSES/native/FriBidi-COPYING.txt @@ -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. + + + Copyright (C) + + 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. + + , 1 April 1990 + Ty Coon, President of Vice + +That's all there is to it! + + diff --git a/LICENSES/native/HarfBuzz-COPYING.txt b/LICENSES/native/HarfBuzz-COPYING.txt new file mode 100644 index 0000000..48d1b30 --- /dev/null +++ b/LICENSES/native/HarfBuzz-COPYING.txt @@ -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. diff --git a/LICENSES/native/LAME-COPYING.txt b/LICENSES/native/LAME-COPYING.txt new file mode 100644 index 0000000..f503049 --- /dev/null +++ b/LICENSES/native/LAME-COPYING.txt @@ -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. + + + Copyright (C) + + 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. + + , 1 April 1990 + Ty Coon, President of Vice + +That's all there is to it! diff --git a/LICENSES/native/LAME-LICENSE.txt b/LICENSES/native/LAME-LICENSE.txt new file mode 100644 index 0000000..430a6a0 --- /dev/null +++ b/LICENSES/native/LAME-LICENSE.txt @@ -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. diff --git a/LICENSES/native/Ogg-COPYING.txt b/LICENSES/native/Ogg-COPYING.txt new file mode 100644 index 0000000..6111c6c --- /dev/null +++ b/LICENSES/native/Ogg-COPYING.txt @@ -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. diff --git a/LICENSES/native/Opus-COPYING.txt b/LICENSES/native/Opus-COPYING.txt new file mode 100644 index 0000000..9c739c3 --- /dev/null +++ b/LICENSES/native/Opus-COPYING.txt @@ -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/ diff --git a/LICENSES/native/Theora-COPYING.txt b/LICENSES/native/Theora-COPYING.txt new file mode 100644 index 0000000..c8ccce4 --- /dev/null +++ b/LICENSES/native/Theora-COPYING.txt @@ -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. diff --git a/LICENSES/native/Vorbis-COPYING.txt b/LICENSES/native/Vorbis-COPYING.txt new file mode 100644 index 0000000..28de72a --- /dev/null +++ b/LICENSES/native/Vorbis-COPYING.txt @@ -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. diff --git a/LICENSES/native/libass-COPYING.txt b/LICENSES/native/libass-COPYING.txt new file mode 100644 index 0000000..3ff0e11 --- /dev/null +++ b/LICENSES/native/libass-COPYING.txt @@ -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. diff --git a/LICENSES/native/libvpx-LICENSE.txt b/LICENSES/native/libvpx-LICENSE.txt new file mode 100644 index 0000000..1ce4434 --- /dev/null +++ b/LICENSES/native/libvpx-LICENSE.txt @@ -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. + diff --git a/LICENSES/native/libvpx-PATENTS.txt b/LICENSES/native/libvpx-PATENTS.txt new file mode 100644 index 0000000..caedf60 --- /dev/null +++ b/LICENSES/native/libvpx-PATENTS.txt @@ -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. diff --git a/LICENSES/native/libwebp-COPYING.txt b/LICENSES/native/libwebp-COPYING.txt new file mode 100644 index 0000000..7a6f995 --- /dev/null +++ b/LICENSES/native/libwebp-COPYING.txt @@ -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. + diff --git a/LICENSES/native/libwebp-PATENTS.txt b/LICENSES/native/libwebp-PATENTS.txt new file mode 100644 index 0000000..caedf60 --- /dev/null +++ b/LICENSES/native/libwebp-PATENTS.txt @@ -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. diff --git a/LICENSES/native/x264-COPYING.txt b/LICENSES/native/x264-COPYING.txt new file mode 100644 index 0000000..d60c31a --- /dev/null +++ b/LICENSES/native/x264-COPYING.txt @@ -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. + + + Copyright (C) + + 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. + + , 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. diff --git a/LICENSES/native/x265-COPYING.txt b/LICENSES/native/x265-COPYING.txt new file mode 100644 index 0000000..d5457c9 --- /dev/null +++ b/LICENSES/native/x265-COPYING.txt @@ -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. + + + Copyright (C) + + 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. + + , 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. diff --git a/LICENSES/native/zimg-COPYING.txt b/LICENSES/native/zimg-COPYING.txt new file mode 100644 index 0000000..5a8e332 --- /dev/null +++ b/LICENSES/native/zimg-COPYING.txt @@ -0,0 +1,14 @@ + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + Version 2, December 2004 + + Copyright (C) 2004 Sam Hocevar + + 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. + diff --git a/LICENSES/native/zlib-README.txt b/LICENSES/native/zlib-README.txt new file mode 100644 index 0000000..51106de --- /dev/null +++ b/LICENSES/native/zlib-README.txt @@ -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 , or to Gilles Vollant + 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 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 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 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 , 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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d366fe7 --- /dev/null +++ b/README.md @@ -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 1–300 seconds and +choose a maximum width from 480–1920 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-.zip +release/av-tools-.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. diff --git a/SOURCE.md b/SOURCE.md new file mode 100644 index 0000000..843b38b --- /dev/null +++ b/SOURCE.md @@ -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. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..26144cf --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -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`. diff --git a/deployment/caddy.example b/deployment/caddy.example new file mode 100644 index 0000000..0d68d24 --- /dev/null +++ b/deployment/caddy.example @@ -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 + } +} diff --git a/deployment/headers.md b/deployment/headers.md new file mode 100644 index 0000000..338cb53 --- /dev/null +++ b/deployment/headers.md @@ -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. diff --git a/deployment/nginx.example.conf b/deployment/nginx.example.conf new file mode 100644 index 0000000..2986e15 --- /dev/null +++ b/deployment/nginx.example.conf @@ -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; + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..7aa5b00 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -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/`. diff --git a/docs/FFMPEG_BUILD.md b/docs/FFMPEG_BUILD.md new file mode 100644 index 0000000..72ec04f --- /dev/null +++ b/docs/FFMPEG_BUILD.md @@ -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`. diff --git a/docs/FORMAT_SUPPORT.md b/docs/FORMAT_SUPPORT.md new file mode 100644 index 0000000..56b240a --- /dev/null +++ b/docs/FORMAT_SUPPORT.md @@ -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. diff --git a/docs/KNOWN_LIMITATIONS.md b/docs/KNOWN_LIMITATIONS.md new file mode 100644 index 0000000..0cc7e68 --- /dev/null +++ b/docs/KNOWN_LIMITATIONS.md @@ -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. diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md new file mode 100644 index 0000000..41a0a41 --- /dev/null +++ b/docs/PERFORMANCE.md @@ -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. diff --git a/docs/PORTAL_REQUIREMENTS.md b/docs/PORTAL_REQUIREMENTS.md new file mode 100644 index 0000000..129dfb8 --- /dev/null +++ b/docs/PORTAL_REQUIREMENTS.md @@ -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. diff --git a/docs/REFERENCE_IMPLEMENTATIONS.md b/docs/REFERENCE_IMPLEMENTATIONS.md new file mode 100644 index 0000000..c82b75a --- /dev/null +++ b/docs/REFERENCE_IMPLEMENTATIONS.md @@ -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. diff --git a/docs/RELEASE.md b/docs/RELEASE.md new file mode 100644 index 0000000..6d970e1 --- /dev/null +++ b/docs/RELEASE.md @@ -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. diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..c3b2b08 --- /dev/null +++ b/docs/SECURITY.md @@ -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. diff --git a/docs/STORAGE.md b/docs/STORAGE.md new file mode 100644 index 0000000..6db83bf --- /dev/null +++ b/docs/STORAGE.md @@ -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 +1–300 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. diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..22dd3b3 --- /dev/null +++ b/eslint.config.mjs @@ -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 }, + } +); diff --git a/index.html b/index.html new file mode 100644 index 0000000..64798e5 --- /dev/null +++ b/index.html @@ -0,0 +1,19 @@ + + + + + + + + + + Audio & Video Tools + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..a098896 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,4105 @@ +{ + "name": "av-tools", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "av-tools", + "version": "0.1.0", + "license": "GPL-3.0-or-later", + "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" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@add-ideas/toolbox-contract": { + "version": "0.2.2", + "resolved": "https://git.add-ideas.de/api/packages/zemion/npm/%40add-ideas%2Ftoolbox-contract/-/0.2.2/toolbox-contract-0.2.2.tgz", + "integrity": "sha512-VcZ8j4O2PgFaIYgxs6r9u0uPxA+hHKwhCW+omfeVCtbjAAYhH2KhRlBGm0ERVVYcK5kchyzZLXrC4LKWRotVzg==", + "license": "Apache-2.0" + }, + "node_modules/@add-ideas/toolbox-shell-react": { + "version": "0.2.2", + "resolved": "https://git.add-ideas.de/api/packages/zemion/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.2.2/toolbox-shell-react-0.2.2.tgz", + "integrity": "sha512-w/xbLCd50a2Jq7vQ9Z9ygUOuqlOCRkIYlk4vSJx0mf4REJNeMYi3PE2MbaLUC4DkQWyrmatsa5HNT89o0l91BA==", + "license": "Apache-2.0", + "dependencies": { + "@add-ideas/toolbox-contract": "0.2.2" + }, + "peerDependencies": { + "react": ">=18 <20", + "react-dom": ">=18 <20" + } + }, + "node_modules/@add-ideas/toolbox-testkit": { + "version": "0.2.2", + "resolved": "https://git.add-ideas.de/api/packages/zemion/npm/%40add-ideas%2Ftoolbox-testkit/-/0.2.2/toolbox-testkit-0.2.2.tgz", + "integrity": "sha512-ZxrRYiJI/j5aXwcd3mQItgDl1iOJ6N26+MpIG18qVZNwKKFCTCfSTzgzRooPt8rRc1MQYTPo4c+l/QpOWuTXxA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@add-ideas/toolbox-contract": "0.2.2" + }, + "bin": { + "toolbox-check": "dist/cli.js" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", + "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", + "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@ffmpeg/core": { + "version": "0.12.10", + "resolved": "https://registry.npmjs.org/@ffmpeg/core/-/core-0.12.10.tgz", + "integrity": "sha512-dzNplnn2Nxle2c2i2rrDhqcB19q9cglCkWnoMTDN9Q9l3PvdjZWd1HfSPjCNWc/p8Q3CT+Es9fWOR0UhAeYQZA==", + "license": "GPL-2.0-or-later", + "engines": { + "node": ">=16.x" + } + }, + "node_modules/@ffmpeg/core-mt": { + "version": "0.12.10", + "resolved": "https://registry.npmjs.org/@ffmpeg/core-mt/-/core-mt-0.12.10.tgz", + "integrity": "sha512-atyRTOpa58bLCIgd6GXBZAXWyWD3AUoQyzxqjvGhp9MuSzdILtOTI62ffLswBsCnLq15lQ8IETHUpm1oe4V9FQ==", + "license": "GPL-2.0-or-later", + "engines": { + "node": ">=16.x" + } + }, + "node_modules/@ffmpeg/ffmpeg": { + "version": "0.12.15", + "resolved": "https://registry.npmjs.org/@ffmpeg/ffmpeg/-/ffmpeg-0.12.15.tgz", + "integrity": "sha512-1C8Obr4GsN3xw+/1Ww6PFM84wSQAGsdoTuTWPOj2OizsRDLT4CXTaVjPhkw6ARyDus1B9X/L2LiXHqYYsGnRFw==", + "license": "MIT", + "dependencies": { + "@ffmpeg/types": "^0.12.4" + }, + "engines": { + "node": ">=18.x" + } + }, + "node_modules/@ffmpeg/types": { + "version": "0.12.4", + "resolved": "https://registry.npmjs.org/@ffmpeg/types/-/types-0.12.4.tgz", + "integrity": "sha512-k9vJQNBGTxE5AhYDtOYR5rO5fKsspbg51gbcwtbkw2lCdoIILzklulcjJfIDwrtn7XhDeF2M+THwJ2FGrLeV6A==", + "license": "MIT", + "engines": { + "node": ">=16.x" + } + }, + "node_modules/@ffmpeg/util": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@ffmpeg/util/-/util-0.12.2.tgz", + "integrity": "sha512-ouyoW+4JB7WxjeZ2y6KpRvB+dLp7Cp4ro8z0HIVpZVCM7AwFlHa0c4R8Y/a4M3wMqATpYKhC7lSFHQ0T11MEDw==", + "license": "MIT", + "engines": { + "node": ">=18.x" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.9.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.5.tgz", + "integrity": "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.64.0.tgz", + "integrity": "sha512-CGvQPBxN3wZLu6Rz2kFUpZeoCm78xUic92ck39KPePkO1NPOwjCqdQnm5Q87tpWw9vcBvW8XLrDXjH9PWYtJ3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.64.0", + "@typescript-eslint/type-utils": "8.64.0", + "@typescript-eslint/utils": "8.64.0", + "@typescript-eslint/visitor-keys": "8.64.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.64.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.64.0.tgz", + "integrity": "sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.64.0", + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/typescript-estree": "8.64.0", + "@typescript-eslint/visitor-keys": "8.64.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.64.0.tgz", + "integrity": "sha512-tk4WpOJ6IEbGrVHaNmM0YRrwAD3exZlIK3iadQNAxh4YKk6jvUQ4ecq18n+v7+meh+cJ3j+D8nbk8sRKhlwLQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.64.0", + "@typescript-eslint/types": "^8.64.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.64.0.tgz", + "integrity": "sha512-CXEaFdYXjSTgKhisNkwCcJwTP8Pl+fmRrEQrri4nm3vU743bALrxzLmq7fHG/7e6a5xO0lDYeURpZmBuhHk54w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/visitor-keys": "8.64.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.64.0.tgz", + "integrity": "sha512-2yo8rRNKuzbVWQp5kslhANqZ2uDAeROQHBRZNPu8JDsHmeFNj/XJJhX/FhNUWmkHHvoNsKa6+tHJiig87EzsQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.64.0.tgz", + "integrity": "sha512-XWG4Fmmv/6SvyS9nH8jWrKs6terwJvE8cyRt1CzYYqzp9OrPhCT4cMc/f7C6RZCwG+qMmiffJS1/qJP8G1URtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/typescript-estree": "8.64.0", + "@typescript-eslint/utils": "8.64.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.64.0.tgz", + "integrity": "sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.64.0.tgz", + "integrity": "sha512-Pztpsn1aCE1oWDvDEfUk31nngvvF7vUB5SwHFEaZIFpvw7WJtqUHHL4plBZDA9HfWJJjL13BdG0YrJInTUvoVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.64.0", + "@typescript-eslint/tsconfig-utils": "8.64.0", + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/visitor-keys": "8.64.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.64.0.tgz", + "integrity": "sha512-aJUGVB3+U0htrrCjoA8qukw8cm8fNCGAxK/tVoS70k8aeb7DETKeFozRiVFIwEeN9WJLsjaP3ph8I60tY2XZoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.64.0", + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/typescript-estree": "8.64.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.64.0.tgz", + "integrity": "sha512-mrtuL8Nsn6gi2H4mo5KMTp823M+3Q19Ew/i+Zlikq20tIMm99C3Ez0dCmkWWnxut20esQvTg8aUSEhMcAOXhEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.64.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.1.tgz", + "integrity": "sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/electron-to-chromium": { + "version": "1.5.395", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.395.tgz", + "integrity": "sha512-7zt9Aw+SrmxLWLN0zhaTWZQiCdryLVrYTq5R7iZakLvi2UQPYMMsROYV/2qVCzMeCiSXHwKOU+sZ4zOVVlrtKA==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.7.0.tgz", + "integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", + "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.3.tgz", + "integrity": "sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.7.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", + "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/postcss": { + "version": "8.5.22", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz", + "integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz", + "integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz", + "integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.9" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz", + "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.64.0.tgz", + "integrity": "sha512-0qg+pDNMnqYzqH9AnNK+39tejHvsShUOUUoRUgtnTGE7QuMZhiFDnozq8nHJVq+Wae6NMLKNWLg5WmkcC/ndyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.64.0", + "@typescript-eslint/parser": "8.64.0", + "@typescript-eslint/typescript-estree": "8.64.0", + "@typescript-eslint/utils": "8.64.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a518a2d --- /dev/null +++ b/package.json @@ -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" +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..69cdda4 --- /dev/null +++ b/playwright.config.ts @@ -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'] }, + }, + ], +}); diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/toolbox-app.json b/public/toolbox-app.json new file mode 100644 index 0000000..b5a3861 --- /dev/null +++ b/public/toolbox-app.json @@ -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" + ] +} diff --git a/scripts/checksum-release.mjs b/scripts/checksum-release.mjs new file mode 100644 index 0000000..05554f5 --- /dev/null +++ b/scripts/checksum-release.mjs @@ -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 ` `.' + ); + } + 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(); +} diff --git a/scripts/copy-ffmpeg-assets.mjs b/scripts/copy-ffmpeg-assets.mjs new file mode 100644 index 0000000..363513b --- /dev/null +++ b/scripts/copy-ffmpeg-assets.mjs @@ -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 ?? ''} 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)}` +); diff --git a/scripts/ffmpeg-source-README.md b/scripts/ffmpeg-source-README.md new file mode 100644 index 0000000..221d364 --- /dev/null +++ b/scripts/ffmpeg-source-README.md @@ -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 +test "$(git -C source-check rev-parse FETCH_HEAD)" = "" +``` + +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. diff --git a/scripts/ffmpeg-source-lock.json b/scripts/ffmpeg-source-lock.json new file mode 100644 index 0000000..767e5d2 --- /dev/null +++ b/scripts/ffmpeg-source-lock.json @@ -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" + } + ] +} diff --git a/scripts/generate-fixtures.sh b/scripts/generate-fixtures.sh new file mode 100644 index 0000000..c0dde3d --- /dev/null +++ b/scripts/generate-fixtures.sh @@ -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 +) diff --git a/scripts/generate-toolbox-manifest.mjs b/scripts/generate-toolbox-manifest.mjs new file mode 100644 index 0000000..84025a3 --- /dev/null +++ b/scripts/generate-toolbox-manifest.mjs @@ -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)}`); +} diff --git a/scripts/package-ffmpeg-source.mjs b/scripts/package-ffmpeg-source.mjs new file mode 100644 index 0000000..90d3d98 --- /dev/null +++ b/scripts/package-ffmpeg-source.mjs @@ -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 }); +} diff --git a/scripts/package-release.mjs b/scripts/package-release.mjs new file mode 100644 index 0000000..be0a51b --- /dev/null +++ b/scripts/package-release.mjs @@ -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(); +} diff --git a/scripts/portal-assembly-smoke.mjs b/scripts/portal-assembly-smoke.mjs new file mode 100644 index 0000000..f316958 --- /dev/null +++ b/scripts/portal-assembly-smoke.mjs @@ -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(); +} diff --git a/scripts/verify-ffmpeg-assets.mjs b/scripts/verify-ffmpeg-assets.mjs new file mode 100644 index 0000000..0490c70 --- /dev/null +++ b/scripts/verify-ffmpeg-assets.mjs @@ -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)}` +); diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..ca3b2be --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,4584 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { AppShell } from '@add-ideas/toolbox-shell-react'; +import '@add-ideas/toolbox-shell-react/styles.css'; +import './index.css'; + +import { + createAsset, + type ImportedMediaAsset, + type WorkspaceMode, +} from './app/application-state'; +import { AppErrorBoundary } from './app/AppErrorBoundary'; +import { FileDropZone } from './components/FileDropZone'; +import { MediaBin } from './components/MediaBin'; +import { + MediaPreview, + type PreviewProxySettings, +} from './components/MediaPreview'; +import { + QuickConvert, + type QuickExportConfiguration, +} from './components/QuickConvert'; +import { CapabilityPanel } from './components/CapabilityPanel'; +import { JobPanel } from './components/JobPanel'; +import { StoragePanel } from './components/StoragePanel'; +import { HelpDialog } from './components/HelpDialog'; +import { Icon } from './components/Icon'; +import { Timeline } from './components/Timeline'; +import { + AdvancedOperations, + type AdvancedAudioRequest, + type AdvancedCapabilityKey, + type AdvancedCapabilityStatus, + type AdvancedExportPresetOption, + type AdvancedSplitBatchStatus, + type AdvancedSplitRequest, + type ChapterExecutionRequest, + type ContactSheetRequest, + type MetadataExecutionRequest, + type SubtitleExecutionRequest, + type ThumbnailSeriesRequest, + type UserPresetAction, +} from './components/AdvancedOperations'; +import { + StructuralOperations, + type StructuralCompatibilityDiagnostic, + type StructuralConcatRequest, + type StructuralTrimRequest, +} from './components/StructuralOperations'; +import { Inspector, type ThumbnailRequest } from './components/Inspector'; +import { engineManager } from './ffmpeg/EngineManager'; +import type { + EngineJobContext, + EnginePreference, + EngineState, +} from './ffmpeg/ffmpeg.types'; +import { EngineError } from './ffmpeg/ffmpeg-error'; +import { normalizeFfprobeReport } from './media'; +import { + createEmptyProject, + importProjectDocument, + projectDocumentFileName, + projectReducer, + rankReattachmentCandidates, + serializeProject, + type AvProjectV1, + type MediaAssetReference, + type OutputSettings, + type ProjectAction, + type TimelineClip, +} from './project'; +import type { FFmpegCommandPlan } from './commands/command-plan'; +import { + buildConvertPlan, + requirementsForUserPreset, +} from './commands/convert'; +import { buildRemuxPlan, type RemuxStream } from './commands/remux'; +import { buildSingleThumbnailPlan } from './commands/thumbnails'; +import { buildPreviewProxyPlan } from './commands/preview-proxy'; +import { buildTimelineExportPlan } from './commands/timeline-export'; +import { + buildContactSheetPlan, + buildThumbnailSeriesPlan, +} from './commands/thumbnails'; +import { buildSplitPlan } from './commands/split'; +import { buildTrimPlan } from './commands/trim'; +import { + buildFastConcatPlan, + buildNormalizedConcatPlan, + validateConcatCompatibility, + type ConcatSource, +} from './commands/concatenate'; +import { + buildStaticWaveformPlan, + buildWaveformAnalysisPlan, +} from './commands/waveform'; +import { + buildLoudnessAnalysisPlan, + buildLoudnessApplyPlan, + buildPeakAnalysisPlan, + buildPeakNormalizationPlan, + parseLoudnessMeasurement, + parsePeakMeasurement, + type LoudnessMeasurement, +} from './commands/loudness'; +import { buildMetadataEditPlan, type MetadataEdits } from './commands/metadata'; +import { buildChapterMuxPlan } from './commands/chapters'; +import { + buildSubtitleBurnPlan, + buildSubtitleConversionPlan, + buildSubtitleExtractionPlan, + buildSubtitleMuxPlan, +} from './commands/subtitles'; +import { calculateResize, validateCrop } from './commands/transform'; +import { + BUILT_IN_PRESETS, + findBuiltInPreset, + presetAvailability, +} from './presets/preset-registry'; +import { + IndexedDbUserPresetPersistence, + MemoryUserPresetPersistence, + UserPresetRegistry, +} from './presets/user-presets'; +import type { UserExportPreset } from './presets/preset.types'; +import { + collectCommandOutputs, + createExportReport, + downloadBlob, + ExportResultCollection, + saveCollectionResult, + saveExportReport, + saveResultsZip, + safeExportFileName, + type ExportReport, + type ExportResult, +} from './export'; +import { manifest } from './toolbox/manifest'; +import { cacheDerivative, readCachedDerivative } from './app/derived-cache'; +import { + DEFAULT_RESOURCE_POLICY, + estimatePlanResources, + policyForDeviceMemory, + preflightResources, + type ResourcePolicy, +} from './storage'; +import { assessGeneratedResultPreview, type MediaProbe } from './media'; +import { + createWaveformRuntime, + deserializeWaveformPeaks, + serializeWaveformPeaks, + WAVEFORM_PEAKS_MEDIA_TYPE, + type WaveformPeakData, +} from './waveform'; +import type { MediaJob, MediaJobStep } from './jobs'; + +function timestamp(): string { + return new Date().toISOString(); +} + +function canPreviewGeneratedResult( + result: Pick, + probe?: MediaProbe +): boolean { + const element = document.createElement( + result.mimeType.startsWith('video/') ? 'video' : 'audio' + ); + return assessGeneratedResultPreview( + { + fileName: result.fileName, + mimeType: result.mimeType, + ...(probe ? { probe } : {}), + }, + (mimeType) => element.canPlayType(mimeType) + ).previewable; +} + +interface RuntimeResourceContext { + readonly deviceMemoryGiB?: number; + readonly policy: ResourcePolicy; +} + +function createRuntimeResourceContext(): RuntimeResourceContext { + const reportedDeviceMemory = + typeof globalThis.navigator === 'undefined' + ? undefined + : ( + globalThis.navigator as Navigator & { + readonly deviceMemory?: unknown; + } + ).deviceMemory; + const deviceMemoryGiB = + typeof reportedDeviceMemory === 'number' && + Number.isFinite(reportedDeviceMemory) && + reportedDeviceMemory > 0 + ? reportedDeviceMemory + : undefined; + return { + ...(deviceMemoryGiB === undefined ? {} : { deviceMemoryGiB }), + policy: policyForDeviceMemory(DEFAULT_RESOURCE_POLICY, deviceMemoryGiB), + }; +} + +function fileDescriptor(file: File) { + return { + name: file.name, + size: file.size, + lastModified: file.lastModified, + mimeType: file.type, + }; +} + +function extensionFromName(fileName: string): string { + const extension = fileName.split('.').at(-1)?.toLowerCase(); + return extension && /^[a-z0-9]{1,10}$/u.test(extension) ? extension : 'mkv'; +} + +function muxerForExtension(extension: string): string { + const muxers: Readonly> = { + m4a: 'ipod', + mkv: 'matroska', + jpg: 'image2', + jpeg: 'image2', + png: 'image2', + }; + return muxers[extension] ?? extension; +} + +function outputWithOptionalNumber( + output: OutputSettings, + field: 'width' | 'height' | 'frameRate', + rawValue: string +): OutputSettings { + const next = { ...output }; + if (rawValue.trim() === '') { + delete next[field]; + return next; + } + const value = Number(rawValue); + if (!Number.isFinite(value)) return output; + if (field === 'frameRate') { + next.frameRate = Math.min(240, Math.max(0.01, value)); + } else { + next[field] = Math.min(8192, Math.max(2, Math.round(value / 2) * 2)); + } + return next; +} + +function subtitleCodecForFormat(format: 'srt' | 'vtt' | 'ass'): string { + return format === 'srt' ? 'subrip' : format === 'vtt' ? 'webvtt' : 'ass'; +} + +function projectMetadataFromEdits( + edits: MetadataEdits +): AvProjectV1['metadata'] { + const format = edits.format ?? {}; + const text = (key: string) => { + const value = format[key]; + return value === null || value === undefined ? undefined : String(value); + }; + const known = new Set(['title', 'artist', 'album', 'comment']); + return { + ...(text('title') ? { title: text('title') } : {}), + ...(text('artist') ? { artist: text('artist') } : {}), + ...(text('album') ? { album: text('album') } : {}), + ...(text('comment') ? { comment: text('comment') } : {}), + custom: Object.fromEntries( + Object.entries(format) + .filter(([key, value]) => !known.has(key) && value !== null) + .map(([key, value]) => [key, String(value)]) + ), + }; +} + +function metadataEditsFromProject( + metadata: AvProjectV1['metadata'] +): MetadataEdits { + return { + format: { + ...(metadata.title ? { title: metadata.title } : {}), + ...(metadata.artist ? { artist: metadata.artist } : {}), + ...(metadata.album ? { album: metadata.album } : {}), + ...(metadata.comment ? { comment: metadata.comment } : {}), + ...metadata.custom, + }, + }; +} + +function resultAsFile(result: ExportResult): File { + return new File([result.blob], result.fileName, { + type: result.mimeType, + lastModified: Date.now(), + }); +} + +function concatSourceForClip( + clip: TimelineClip, + asset: ImportedMediaAsset, + sourceIndex: number +): ConcatSource { + const durationSeconds = asset.probe?.durationSeconds; + if (durationSeconds === undefined || durationSeconds <= 0) { + throw new Error(`Source ${asset.file.name} has no validated duration.`); + } + const streams = + asset.probe?.streams.flatMap((stream) => + stream.type === 'video' || + stream.type === 'audio' || + stream.type === 'subtitle' + ? [ + { + kind: stream.type, + codec: stream.codecName ?? 'unknown', + ...(stream.width === undefined ? {} : { width: stream.width }), + ...(stream.height === undefined ? {} : { height: stream.height }), + ...(stream.pixelFormat === undefined + ? {} + : { pixelFormat: stream.pixelFormat }), + ...(stream.timeBase === undefined + ? {} + : { timeBase: stream.timeBase }), + ...(stream.sampleRate === undefined + ? {} + : { sampleRate: stream.sampleRate }), + ...(stream.channelLayout === undefined + ? {} + : { channelLayout: stream.channelLayout }), + }, + ] + : [] + ) ?? []; + return { + id: clip.id, + sourceIndex, + fileName: asset.file.name, + durationSeconds, + sourceInSeconds: clip.sourceInSeconds, + sourceOutSeconds: clip.sourceOutSeconds, + streams, + }; +} + +function remuxStreamsFromProbe(probe: MediaProbe): readonly RemuxStream[] { + return probe.streams.flatMap((stream) => + stream.type === 'video' || + stream.type === 'audio' || + stream.type === 'subtitle' || + stream.type === 'attachment' || + stream.type === 'data' + ? [ + { + index: stream.index, + kind: stream.type, + codec: stream.codecName ?? 'unknown', + }, + ] + : [] + ); +} + +function createPresetRegistry(): { + readonly registry: UserPresetRegistry; + readonly persistent: boolean; +} { + try { + return { + registry: new UserPresetRegistry(new IndexedDbUserPresetPersistence()), + persistent: true, + }; + } catch { + return { + registry: new UserPresetRegistry(new MemoryUserPresetPersistence()), + persistent: false, + }; + } +} + +function replaceVirtualPaths( + plan: FFmpegCommandPlan, + context: EngineJobContext +): readonly string[] { + const replacements = new Map(); + plan.inputs.forEach((input, index) => { + const actual = context.inputPaths[index]; + if (actual) replacements.set(input.path, actual); + }); + plan.outputs.forEach((output, index) => { + const actual = context.outputPaths[index]; + if (actual) replacements.set(output.path, actual); + }); + plan.temporaryFiles.forEach((temporary, index) => { + const actual = context.temporaryPaths[index]; + if (actual) replacements.set(temporary.path, actual); + }); + return plan.args.map((argument) => + [...replacements].reduce( + (resolved, [virtualPath, actualPath]) => + resolved.replaceAll(virtualPath, actualPath), + argument + ) + ); +} + +function replaceTemporaryContentPaths( + content: string | Uint8Array, + plan: FFmpegCommandPlan, + context: EngineJobContext +): string | Uint8Array { + if (typeof content !== 'string') return content; + const replacements = new Map(); + plan.inputs.forEach((input, index) => { + const actual = context.inputPaths[index]; + if (actual) replacements.set(input.path, actual); + }); + plan.outputs.forEach((output, index) => { + const actual = context.outputPaths[index]; + if (actual) replacements.set(output.path, actual); + }); + plan.temporaryFiles.forEach((temporary, index) => { + const actual = context.temporaryPaths[index]; + if (actual) replacements.set(temporary.path, actual); + }); + return [...replacements].reduce( + (resolved, [virtualPath, actualPath]) => + resolved.replaceAll(virtualPath, actualPath), + content + ); +} + +export interface OutputVerification { + readonly probe?: MediaProbe; + readonly warning?: string; +} + +interface PlanExecution { + readonly results: readonly ExportResult[]; + readonly logText: string; + readonly verifications?: Readonly>; + readonly report?: ExportReport; +} + +type ExecutePlan = ( + plan: FFmpegCommandPlan, + inputs: readonly File[], + operation: string, + onStage?: (stage: 'reading-output') => void +) => Promise; + +interface ScheduledWorkDefinition { + readonly operation: string; + readonly projectId: string; + readonly totalSteps: number; + readonly retryCount: number; + readonly work: (executeStep: ExecutePlan) => Promise; + cancelled: boolean; + counted: boolean; + started: boolean; +} + +function createQueuedJob( + id: string, + operation: string, + totalSteps: number, + createdAt: string, + retryCount: number +): MediaJob { + return { + id, + operation, + status: 'queued', + steps: Array.from({ length: totalSteps }, (_, index) => ({ + id: `${id}:step-${index + 1}`, + name: totalSteps === 1 ? operation : `Step ${index + 1}`, + weight: 1 / totalSteps, + status: 'queued', + progress: 0, + planId: 'pending', + })), + progress: 0, + createdAt, + updatedAt: createdAt, + retryCount, + }; +} + +function replaceJob( + jobs: readonly MediaJob[], + id: string, + update: (job: MediaJob) => MediaJob +): readonly MediaJob[] { + return jobs.map((job) => (job.id === id ? update(job) : job)); +} + +export function AvToolsApplication() { + const [mode, setMode] = useState('quick-convert'); + const [assets, setAssets] = useState([]); + const [selectedAssetId, setSelectedAssetId] = useState(); + const [selectedClipId, setSelectedClipId] = useState(); + const [project, setProject] = useState(() => + createEmptyProject() + ); + const [subtitleFiles, setSubtitleFiles] = useState>( + () => new Map() + ); + const [waveformByAsset, setWaveformByAsset] = useState< + Readonly> + >({}); + const [splitMarkersByAsset, setSplitMarkersByAsset] = useState< + Readonly> + >({}); + const [splitBatch, setSplitBatch] = useState(); + const [loudnessByAsset, setLoudnessByAsset] = useState< + Readonly> + >({}); + const [waveformProgress, setWaveformProgress] = useState(); + const [playheadByAsset, setPlayheadByAsset] = useState< + Readonly> + >({}); + const [previewProxySettings, setPreviewProxySettings] = + useState({ + maxDurationSeconds: 30, + maxWidth: 960, + }); + const [presetSetup] = useState(createPresetRegistry); + const [userPresets, setUserPresets] = useState( + [] + ); + const [waveformRuntime] = useState(createWaveformRuntime); + const [engineState, setEngineState] = useState( + engineManager.state + ); + const [enginePreference, setEnginePreference] = + useState('automatic'); + const [resourceContext] = useState(createRuntimeResourceContext); + const [logs, setLogs] = useState([]); + const [resultCollection] = useState(() => new ExportResultCollection()); + const [results, setResults] = useState([]); + const [previewResultId, setPreviewResultId] = useState(); + const [outputVerifications, setOutputVerifications] = useState< + Readonly> + >({}); + const [latestReport, setLatestReport] = useState(); + const [operationError, setOperationError] = useState(); + const [projectNotice, setProjectNotice] = useState(); + const [sourceProbeBusy, setSourceProbeBusy] = useState(false); + const [scheduledPlanCount, setScheduledPlanCount] = useState(0); + const [scheduledJobs, setScheduledJobs] = useState([]); + const [editDrawer, setEditDrawer] = useState< + 'media' | 'inspector' | undefined + >(); + const projectInput = useRef(null); + const mediaDrawerTrigger = useRef(null); + const inspectorDrawerTrigger = useRef(null); + const assetsRef = useRef(assets); + const projectRef = useRef(project); + const sourceProbeTail = useRef>(Promise.resolve()); + const sourceProbePendingRef = useRef(0); + const planExecutionTail = useRef>(Promise.resolve()); + const scheduledPlanCountRef = useRef(0); + const scheduledWorkRef = useRef(new Map()); + const activeScheduledJobIdRef = useRef(undefined); + const activeScheduledStepIndexRef = useRef(undefined); + + useEffect(() => { + assetsRef.current = assets; + }, [assets]); + + useEffect(() => { + projectRef.current = project; + }, [project]); + + useEffect(() => { + const activeJobId = activeScheduledJobIdRef.current; + const activeStepIndex = activeScheduledStepIndexRef.current; + if ( + !activeJobId || + activeStepIndex === undefined || + engineState.status !== 'running' || + engineState.progress === undefined + ) { + return; + } + setScheduledJobs((current) => + replaceJob(current, activeJobId, (job) => { + const steps = job.steps.map((step, index) => + index === activeStepIndex + ? { + ...step, + progress: Math.min( + 0.99, + Math.max(0, engineState.progress ?? 0) + ), + } + : step + ); + const progress = Math.min( + 0.99, + steps.reduce( + (sum, step) => + sum + + step.weight * (step.status === 'completed' ? 1 : step.progress), + 0 + ) + ); + return { + ...job, + status: 'running', + steps, + progress, + updatedAt: timestamp(), + }; + }) + ); + }, [engineState]); + + useEffect(() => { + const activeJobId = activeScheduledJobIdRef.current; + if (!activeJobId) return; + const status = + engineState.status === 'recovering' + ? ('recovering' as const) + : engineState.status === 'loading' + ? ('preparing' as const) + : undefined; + if (!status) return; + setScheduledJobs((current) => + replaceJob(current, activeJobId, (job) => ({ + ...job, + status, + updatedAt: timestamp(), + })) + ); + }, [engineState.status]); + + const closeEditDrawer = useCallback(() => { + const closing = editDrawer; + setEditDrawer(undefined); + globalThis.queueMicrotask(() => { + (closing === 'media' + ? mediaDrawerTrigger.current + : inspectorDrawerTrigger.current + )?.focus(); + }); + }, [editDrawer]); + + const selectWorkspaceMode = useCallback((nextMode: WorkspaceMode) => { + setEditDrawer(undefined); + setMode(nextMode); + }, []); + + useEffect(() => { + if (!editDrawer) return; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + closeEditDrawer(); + } + }; + globalThis.addEventListener('keydown', closeOnEscape); + const closeButton = document.querySelector( + editDrawer === 'media' + ? '#edit-media-drawer .mobile-edit-drawer__close' + : '#edit-inspector-drawer .mobile-edit-drawer__close' + ); + closeButton?.focus(); + return () => globalThis.removeEventListener('keydown', closeOnEscape); + }, [closeEditDrawer, editDrawer]); + + useEffect( + () => + engineManager.subscribe((state) => { + setEngineState(state); + }), + [] + ); + + useEffect(() => { + let active = true; + void presetSetup.registry + .initialize() + .then(() => { + if (active) setUserPresets(presetSetup.registry.list()); + }) + .catch((error: unknown) => { + if (active) { + setProjectNotice( + error instanceof Error + ? `User presets could not be loaded: ${error.message}` + : 'User presets could not be loaded.' + ); + } + }); + return () => { + active = false; + }; + }, [presetSetup]); + + useEffect( + () => + engineManager.onLog(({ message }) => { + setLogs((current) => [...current.slice(-399), message]); + }), + [] + ); + + useEffect(() => { + return () => { + for (const asset of assetsRef.current) { + URL.revokeObjectURL(asset.objectUrl); + } + resultCollection.dispose(); + waveformRuntime.terminate(); + }; + }, [resultCollection, waveformRuntime]); + + const applyProjectAction = useCallback((action: ProjectAction) => { + setProject((current) => projectReducer(current, action)); + }, []); + + const updateAssetState = useCallback( + (id: string, changes: Partial) => { + setAssets((current) => + current.map((asset) => + asset.id === id ? { ...asset, ...changes } : asset + ) + ); + }, + [] + ); + + const runSourceProbe = useCallback( + (probe: () => Promise): Promise => { + sourceProbePendingRef.current += 1; + setSourceProbeBusy(true); + const execution = sourceProbeTail.current.then(probe, probe); + sourceProbeTail.current = execution.then( + () => undefined, + () => undefined + ); + void execution + .finally(() => { + sourceProbePendingRef.current = Math.max( + 0, + sourceProbePendingRef.current - 1 + ); + setSourceProbeBusy(sourceProbePendingRef.current > 0); + }) + .catch(() => undefined); + return execution; + }, + [] + ); + + const inspectAsset = useCallback( + async (asset: ImportedMediaAsset, existingAsset: boolean) => { + updateAssetState(asset.id, { + phase: 'probing', + error: undefined, + }); + try { + const probeResult = await runSourceProbe(() => + engineManager.probe(asset.file) + ); + const probe = normalizeFfprobeReport(probeResult.json); + const rawProbe = `${JSON.stringify(probeResult.json, null, 2)}\n`; + const importedChapters = probe.chapters.map((chapter) => ({ + id: `${asset.id}-chapter-${chapter.id}`, + startSeconds: chapter.startSeconds, + endSeconds: chapter.endSeconds, + title: chapter.title ?? `Chapter ${chapter.id + 1}`, + ...(chapter.timeBase ? { timeBase: chapter.timeBase } : {}), + ...(Object.keys(chapter.tags).length > 0 + ? { metadata: chapter.tags } + : {}), + })); + updateAssetState(asset.id, { phase: 'ready', probe, rawProbe }); + setProject((current) => { + let next = projectReducer(current, { + type: 'asset/update', + assetId: asset.id, + changes: { probe, mimeType: asset.file.type }, + updatedAt: timestamp(), + }); + if ( + existingAsset && + next.assets.find((entry) => entry.id === asset.id)?.sourceStatus === + 'missing' + ) { + next = projectReducer(next, { + type: 'asset/set-source-status', + assetId: asset.id, + sourceStatus: 'attached', + updatedAt: timestamp(), + }); + } + const hasClip = next.timeline.some( + (clip) => clip.assetId === asset.id + ); + if ( + !hasClip && + probe.durationSeconds !== undefined && + probe.durationSeconds >= 0.001 + ) { + next = projectReducer(next, { + type: 'clip/add', + clip: { + id: crypto.randomUUID(), + assetId: asset.id, + sourceInSeconds: 0, + sourceOutSeconds: probe.durationSeconds, + audio: { enabled: true }, + video: { enabled: true }, + }, + updatedAt: timestamp(), + }); + } + if (next.chapters.length === 0 && importedChapters.length > 0) { + next = projectReducer(next, { + type: 'chapters/set', + chapters: importedChapters, + updatedAt: timestamp(), + }); + } + return next; + }); + } catch (error) { + updateAssetState(asset.id, { + phase: 'error', + error: + error instanceof Error + ? error.message + : 'The file could not be inspected.', + }); + } + }, + [runSourceProbe, updateAssetState] + ); + + const addFiles = useCallback( + async (files: File[]) => { + setOperationError(undefined); + setProjectNotice(undefined); + if ( + scheduledPlanCountRef.current > 0 || + (engineManager.state.status === 'running' && + sourceProbePendingRef.current === 0) + ) { + setOperationError( + 'Wait for the active export or analysis job before importing media.' + ); + return; + } + const importDecision = preflightResources( + { + inputBytes: [ + ...assetsRef.current.map((asset) => asset.file.size), + ...files.map((file) => file.size), + ], + }, + resourceContext.policy + ); + if (importDecision.status === 'blocked') { + setOperationError( + importDecision.issues + .map((issue) => `${issue.message} ${issue.suggestions.join(' / ')}`) + .join(' ') + ); + return; + } + if ( + importDecision.status === 'warning' && + !globalThis.confirm( + `${importDecision.issues + .map((issue) => issue.message) + .join( + '\n' + )}\n\n${importDecision.estimateDisclaimer}\n\nImport and inspect anyway?` + ) + ) { + return; + } + const pending: Array<{ + asset: ImportedMediaAsset; + existingAsset: boolean; + }> = []; + const claimedIds = new Set(assetsRef.current.map((asset) => asset.id)); + + for (const file of files) { + const candidateMatches = project.assets + .filter( + (entry) => + entry.sourceStatus === 'missing' && !claimedIds.has(entry.id) + ) + .map((entry) => ({ + entry, + match: rankReattachmentCandidates(entry, [fileDescriptor(file)])[0], + })) + .filter( + ( + value + ): value is { + entry: MediaAssetReference; + match: NonNullable<(typeof value)['match']>; + } => value.match?.confidence !== 'none' + ) + .sort((left, right) => right.match.score - left.match.score); + + const match = candidateMatches[0]; + const reattach = + match !== undefined && + globalThis.confirm( + `Reattach “${file.name}” to the missing project source “${match.entry.originalName}”?\n\n${match.match.evidence + .map((evidence) => evidence.message) + .join(' ')}` + ); + const asset = createAsset(file); + if (reattach && match) { + asset.id = match.entry.id; + claimedIds.add(match.entry.id); + pending.push({ asset, existingAsset: true }); + } else { + setProject((current) => + projectReducer(current, { + type: 'asset/add', + asset: { + id: asset.id, + originalName: file.name, + size: file.size, + lastModified: file.lastModified, + mimeType: file.type, + sourceStatus: 'attached', + }, + updatedAt: timestamp(), + }) + ); + pending.push({ asset, existingAsset: false }); + } + } + + setAssets((current) => { + const next = [...current, ...pending.map(({ asset }) => asset)]; + assetsRef.current = next; + return next; + }); + setSelectedAssetId((current) => current ?? pending[0]?.asset.id); + for (const item of pending) { + await inspectAsset(item.asset, item.existingAsset); + } + }, + [inspectAsset, project.assets, resourceContext.policy] + ); + + const removeAsset = useCallback( + (id: string) => { + const asset = assets.find((entry) => entry.id === id); + if (asset) URL.revokeObjectURL(asset.objectUrl); + setAssets((current) => current.filter((entry) => entry.id !== id)); + setWaveformByAsset((current) => + Object.fromEntries( + Object.entries(current).filter(([assetId]) => assetId !== id) + ) + ); + setSplitMarkersByAsset((current) => + Object.fromEntries( + Object.entries(current).filter(([assetId]) => assetId !== id) + ) + ); + setLoudnessByAsset((current) => + Object.fromEntries( + Object.entries(current).filter(([assetId]) => assetId !== id) + ) + ); + setPlayheadByAsset((current) => + Object.fromEntries( + Object.entries(current).filter(([assetId]) => assetId !== id) + ) + ); + if (selectedAssetId === id) { + setSelectedAssetId(assets.find((entry) => entry.id !== id)?.id); + } + applyProjectAction({ + type: 'asset/remove', + assetId: id, + updatedAt: timestamp(), + }); + }, + [applyProjectAction, assets, selectedAssetId] + ); + + const selectedAsset = assets.find((asset) => asset.id === selectedAssetId); + const selectedClip = project.timeline.find( + (clip) => clip.id === selectedClipId + ); + const inspectedClipAsset = selectedClip + ? assets.find((asset) => asset.id === selectedClip.assetId) + : selectedAsset; + const previewOutput = results.find((entry) => entry.id === previewResultId); + const previewProbe = previewOutput + ? outputVerifications[previewOutput.id]?.probe + : undefined; + const previewResult = previewOutput + ? { + name: previewOutput.fileName, + mimeType: previewOutput.mimeType, + objectUrl: resultCollection.objectUrl(previewOutput.id), + ...(previewProbe ? { probe: previewProbe } : {}), + } + : undefined; + + const refreshResults = useCallback(() => { + const current = resultCollection.list(); + setResults(current); + setPreviewResultId((selected) => + selected && current.some((entry) => entry.id === selected) + ? selected + : current.find((entry) => canPreviewGeneratedResult(entry))?.id + ); + }, [resultCollection]); + + const clearResult = useCallback(() => { + resultCollection.clear(); + setResults([]); + setPreviewResultId(undefined); + setOutputVerifications({}); + setLatestReport(undefined); + }, [resultCollection]); + + const executePlan = useCallback( + async ( + plan: FFmpegCommandPlan, + inputs: readonly File[], + operation: string, + onStage?: (stage: 'reading-output') => void + ): Promise => { + if ( + plan.diagnostics.some((diagnostic) => diagnostic.severity === 'error') + ) { + throw new Error( + plan.diagnostics + .filter((diagnostic) => diagnostic.severity === 'error') + .map((diagnostic) => diagnostic.message) + .join(' ') + ); + } + if (sourceProbePendingRef.current > 0) { + throw new EngineError( + 'busy', + 'Wait for media inspection to finish before starting an export.' + ); + } + const resourceSources = inputs.map((file) => { + const asset = assetsRef.current.find((entry) => entry.file === file); + return { + size: file.size, + ...(asset?.probe ? { probe: asset.probe } : {}), + }; + }); + const resourceDecision = preflightResources( + { + inputBytes: resourceSources.map((source) => source.size), + ...estimatePlanResources(plan, resourceSources), + retainedOutputs: resultCollection.size, + streamsPerInput: resourceSources.map( + (source) => source.probe?.streams.length ?? 0 + ), + }, + resourceContext.policy + ); + if (resourceDecision.status === 'blocked') { + throw new Error( + resourceDecision.issues + .map((issue) => `${issue.message} ${issue.suggestions.join(' / ')}`) + .join(' ') + ); + } + if ( + resourceDecision.status === 'warning' && + !globalThis.confirm( + `${resourceDecision.issues + .map((issue) => issue.message) + .join('\n')}\n\n${resourceDecision.estimateDisclaimer}\n\nContinue?` + ) + ) { + throw new EngineError('cancelled', 'The operation was not started.'); + } + setOperationError(undefined); + setLogs([]); + await engineManager.initialize(); + const readyState = engineManager.state; + if (readyState.status !== 'ready') { + throw new Error('The local FFmpeg engine did not become ready.'); + } + const missing = ( + [ + ['muxers', plan.requiredCapabilities.muxers], + ['encoders', plan.requiredCapabilities.encoders], + ['decoders', plan.requiredCapabilities.decoders], + ['filters', plan.requiredCapabilities.filters], + ] as const + ).flatMap(([kind, required]) => + (required ?? []) + .filter((name) => !readyState.capabilities[kind].has(name)) + .map((name) => `${kind.slice(0, -1)} ${name}`) + ); + if (missing.length > 0) { + throw new Error( + `The pinned FFmpeg core is missing required capabilities: ${missing.join(', ')}.` + ); + } + const completed = await engineManager.runJob({ + id: plan.id.split(':')[0] ?? crypto.randomUUID(), + operation, + coreWorkload: plan.operation, + inputs, + temporaryFiles: plan.temporaryFiles.map((temporary) => ({ + name: temporary.path.split('/').at(-1) ?? 'temporary.dat', + content: (context: EngineJobContext) => + replaceTemporaryContentPaths(temporary.content, plan, context), + })), + outputs: plan.outputs.map((output) => ({ + name: output.fileName, + mimeType: output.mediaType, + })), + expectedDurationSeconds: plan.expectedDurationSeconds, + timeoutMilliseconds: plan.timeoutMs, + buildArguments: (context) => replaceVirtualPaths(plan, context), + }); + onStage?.('reading-output'); + const logText = completed.logs.map((entry) => entry.message).join('\n'); + if (plan.outputs.length === 0) { + return { results: [], logText }; + } + const outputMap = new Map( + completed.outputs.flatMap((output, index) => { + const planned = plan.outputs[index]; + return planned + ? ([[planned.id, Uint8Array.from(output.bytes)] as const] as const) + : []; + }) + ); + const collected = collectCommandOutputs( + plan, + outputMap, + resultCollection + ); + const verifications: Record = {}; + const probeLimit = 8; + for (const [index, output] of collected.entries()) { + if ( + !output.mimeType.startsWith('video/') && + !output.mimeType.startsWith('audio/') + ) { + continue; + } + if (index >= probeLimit) { + verifications[output.id] = { + warning: + 'This batch item was not re-probed because output verification is bounded to the first eight results.', + }; + continue; + } + try { + const probeResult = await engineManager.probe( + new File([output.blob], output.fileName, { + type: output.mimeType, + lastModified: Date.now(), + }) + ); + verifications[output.id] = { + probe: normalizeFfprobeReport(probeResult.json), + }; + } catch (error) { + verifications[output.id] = { + warning: + error instanceof Error + ? `Output was created, but its verification probe failed: ${error.message}` + : 'Output was created, but its verification probe failed.', + }; + } + } + setOutputVerifications((current) => ({ + ...current, + ...verifications, + })); + const readyStateAfterRun = engineManager.state; + const report = createExportReport({ + plan, + results: collected, + execution: { + status: 'completed', + exitCode: completed.exitCode, + elapsedSeconds: + (completed.timings.conversionMilliseconds + + completed.timings.outputReadMilliseconds + + completed.timings.cleanupMilliseconds) / + 1_000, + conversionMilliseconds: completed.timings.conversionMilliseconds, + outputReadMilliseconds: completed.timings.outputReadMilliseconds, + cleanupMilliseconds: completed.timings.cleanupMilliseconds, + ...(readyStateAfterRun.status === 'ready' + ? { + engineMode: + readyStateAfterRun.mode === 'multithread' + ? ('multi-thread' as const) + : ('single-thread' as const), + ffmpegVersion: readyStateAfterRun.capabilities.versionText, + coreVersion: '0.12.10', + } + : {}), + }, + diagnostics: Object.values(verifications).flatMap((verification) => + verification.warning + ? [ + { + code: 'output-probe-failed', + severity: 'warning' as const, + message: verification.warning, + }, + ] + : [] + ), + }); + setLatestReport(report); + refreshResults(); + setPreviewResultId( + collected.find((entry) => + canPreviewGeneratedResult(entry, verifications[entry.id]?.probe) + )?.id + ); + return { results: collected, logText, verifications, report }; + }, + [refreshResults, resourceContext.policy, resultCollection] + ); + + const enqueueWork = useCallback( + ( + operation: string, + totalSteps: number, + work: (executeStep: ExecutePlan) => Promise, + retryCount = 0 + ): Promise => { + if (sourceProbePendingRef.current > 0) { + return Promise.reject( + new EngineError( + 'busy', + 'Wait for media inspection to finish before adding an export.' + ) + ); + } + if (scheduledPlanCountRef.current >= 8) { + return Promise.reject( + new EngineError( + 'busy', + 'The local queue already contains eight operations. Wait for one to finish or cancel a waiting job.' + ) + ); + } + const id = crypto.randomUUID(); + const createdAt = timestamp(); + const definition: ScheduledWorkDefinition = { + operation, + projectId: projectRef.current.id, + totalSteps: Math.max(1, Math.floor(totalSteps)), + retryCount, + work: work as (executeStep: ExecutePlan) => Promise, + cancelled: false, + counted: true, + started: false, + }; + scheduledWorkRef.current.set(id, definition); + setScheduledJobs((current) => { + const retained = current.slice(-23); + const retainedIds = new Set(retained.map((job) => job.id)); + for (const job of current) { + if (!retainedIds.has(job.id)) { + scheduledWorkRef.current.delete(job.id); + } + } + return [ + ...retained, + createQueuedJob( + id, + operation, + definition.totalSteps, + createdAt, + definition.retryCount + ), + ]; + }); + scheduledPlanCountRef.current += 1; + setScheduledPlanCount(scheduledPlanCountRef.current); + + const start = async (): Promise => { + definition.started = true; + activeScheduledJobIdRef.current = id; + if (definition.cancelled) { + activeScheduledJobIdRef.current = undefined; + throw new EngineError('cancelled', 'The queued job was cancelled.'); + } + setScheduledJobs((current) => + replaceJob(current, id, (job) => ({ + ...job, + status: 'preparing', + startedAt: timestamp(), + updatedAt: timestamp(), + })) + ); + let stepIndex = 0; + const executeTrackedStep: ExecutePlan = async ( + plan, + inputs, + stepOperation + ) => { + const currentStepIndex = stepIndex; + stepIndex += 1; + activeScheduledStepIndexRef.current = currentStepIndex; + setScheduledJobs((current) => + replaceJob(current, id, (job) => { + const steps = [...job.steps]; + const existing = steps[currentStepIndex]; + const nextStep: MediaJobStep = { + id: existing?.id ?? `${id}:step-${currentStepIndex + 1}`, + name: stepOperation, + weight: existing?.weight ?? 1 / definition.totalSteps, + status: 'running', + progress: 0, + planId: plan.id, + }; + steps[currentStepIndex] = nextStep; + return { + ...job, + status: 'running', + steps, + updatedAt: timestamp(), + }; + }) + ); + try { + const result = await executePlan( + plan, + inputs, + stepOperation, + () => { + setScheduledJobs((current) => + replaceJob(current, id, (job) => ({ + ...job, + status: 'reading-output', + updatedAt: timestamp(), + })) + ); + } + ); + setScheduledJobs((current) => + replaceJob(current, id, (job) => { + const steps = job.steps.map((step, index) => + index === currentStepIndex + ? { ...step, status: 'completed' as const, progress: 1 } + : step + ); + return { + ...job, + steps, + progress: Math.min( + 0.99, + steps.reduce( + (sum, step) => + sum + + step.weight * + (step.status === 'completed' ? 1 : step.progress), + 0 + ) + ), + updatedAt: timestamp(), + }; + }) + ); + return result; + } catch (error) { + const cancelled = + definition.cancelled || + (error instanceof EngineError && error.code === 'cancelled'); + setScheduledJobs((current) => + replaceJob(current, id, (job) => ({ + ...job, + steps: job.steps.map((step, index) => + index === currentStepIndex + ? { + ...step, + status: cancelled + ? ('cancelled' as const) + : ('failed' as const), + progress: Math.min(step.progress, 0.99), + } + : step + ), + updatedAt: timestamp(), + })) + ); + throw error; + } + }; + + try { + const result = await work(executeTrackedStep); + setScheduledJobs((current) => + replaceJob(current, id, (job) => ({ + ...job, + status: 'completed', + progress: 1, + completedAt: timestamp(), + updatedAt: timestamp(), + })) + ); + return result; + } catch (error) { + const cancelled = + definition.cancelled || + (error instanceof EngineError && error.code === 'cancelled'); + setScheduledJobs((current) => + replaceJob(current, id, (job) => ({ + ...job, + status: cancelled ? 'cancelled' : 'failed', + completedAt: timestamp(), + updatedAt: timestamp(), + error: cancelled + ? undefined + : { + code: 'execution-failed', + message: + error instanceof Error + ? error.message + : 'The local job failed.', + recoverable: true, + }, + steps: job.steps.map((step) => + step.status === 'queued' + ? { + ...step, + status: cancelled + ? ('cancelled' as const) + : ('failed' as const), + } + : step + ), + })) + ); + throw error; + } finally { + if (activeScheduledJobIdRef.current === id) { + activeScheduledJobIdRef.current = undefined; + activeScheduledStepIndexRef.current = undefined; + } + } + }; + + const execution = planExecutionTail.current.then(start, start); + planExecutionTail.current = execution.then( + () => undefined, + () => undefined + ); + void execution + .finally(() => { + if (definition.counted) { + definition.counted = false; + scheduledPlanCountRef.current = Math.max( + 0, + scheduledPlanCountRef.current - 1 + ); + setScheduledPlanCount(scheduledPlanCountRef.current); + } + }) + .catch(() => undefined); + return execution; + }, + [executePlan] + ); + + const runPlan = useCallback( + ( + plan: FFmpegCommandPlan, + inputs: readonly File[], + operation: string + ): Promise => + enqueueWork(operation, 1, (executeStep) => + executeStep(plan, inputs, operation) + ), + [enqueueWork] + ); + + const runWorkflow = useCallback( + ( + operation: string, + totalSteps: number, + work: (executeStep: ExecutePlan) => Promise + ): Promise => enqueueWork(operation, totalSteps, work), + [enqueueWork] + ); + + const cancelScheduledJob = useCallback(async (id: string) => { + const definition = scheduledWorkRef.current.get(id); + if (!definition) return; + definition.cancelled = true; + setScheduledJobs((current) => + replaceJob(current, id, (job) => ({ + ...job, + status: job.status === 'queued' ? 'cancelled' : 'cancelling', + completedAt: job.status === 'queued' ? timestamp() : job.completedAt, + updatedAt: timestamp(), + steps: job.steps.map((step) => + step.status === 'queued' + ? { ...step, status: 'cancelled' as const } + : step + ), + })) + ); + if (!definition.started && definition.counted) { + definition.counted = false; + scheduledPlanCountRef.current = Math.max( + 0, + scheduledPlanCountRef.current - 1 + ); + setScheduledPlanCount(scheduledPlanCountRef.current); + return; + } + if (activeScheduledJobIdRef.current === id) { + await engineManager.cancelActive(); + } + }, []); + + const retryScheduledJob = useCallback( + (id: string) => { + const definition = scheduledWorkRef.current.get(id); + if (!definition || definition.projectId !== projectRef.current.id) { + setOperationError( + 'This job cannot be retried after its project has been replaced.' + ); + return; + } + void enqueueWork( + definition.operation, + definition.totalSteps, + definition.work, + definition.retryCount + 1 + ).catch(() => undefined); + }, + [enqueueWork] + ); + + const clearScheduledJobHistory = useCallback(() => { + setScheduledJobs((current) => { + const retained = current.filter( + (job) => + job.status !== 'completed' && + job.status !== 'failed' && + job.status !== 'cancelled' + ); + const retainedIds = new Set(retained.map((job) => job.id)); + for (const id of scheduledWorkRef.current.keys()) { + if (!retainedIds.has(id)) scheduledWorkRef.current.delete(id); + } + return retained; + }); + }, []); + + const exportQuick = useCallback( + async (configuration: QuickExportConfiguration) => { + if (!selectedAsset?.probe) return; + const jobId = crypto.randomUUID(); + try { + let plan: FFmpegCommandPlan; + if (configuration.operation === 'remux') { + const container = configuration.remuxContainer ?? 'matroska'; + plan = buildRemuxPlan({ + jobId, + source: { + id: selectedAsset.id, + sourceIndex: 0, + fileName: selectedAsset.file.name, + }, + targetContainer: container, + fileExtension: + container === 'matroska' + ? 'mkv' + : container === 'ogg' + ? 'ogg' + : container, + description: { + streams: selectedAsset.probe.streams.flatMap((stream) => + stream.type === 'video' || + stream.type === 'audio' || + stream.type === 'subtitle' || + stream.type === 'attachment' || + stream.type === 'data' + ? [ + { + index: stream.index, + kind: stream.type, + codec: stream.codecName ?? 'unknown', + }, + ] + : [] + ), + hasChapters: selectedAsset.probe.chapters.length > 0, + hasMetadata: Object.keys(selectedAsset.probe.tags).length > 0, + }, + streamSelection: configuration.streamSelection, + metadataPolicy: configuration.removeMetadata ? 'remove' : 'copy', + chapterPolicy: configuration.removeChapters ? 'remove' : 'keep', + expectedDurationSeconds: selectedAsset.probe.durationSeconds, + }); + } else { + if (!configuration.preset) return; + plan = buildConvertPlan({ + jobId, + source: { + id: selectedAsset.id, + sourceIndex: 0, + fileName: selectedAsset.file.name, + }, + preset: { + ...configuration.preset, + metadataPolicy: configuration.removeMetadata ? 'remove' : 'copy', + chapterPolicy: configuration.removeChapters ? 'remove' : 'keep', + }, + streamSelection: configuration.streamSelection, + expectedDurationSeconds: selectedAsset.probe.durationSeconds, + }); + } + await runPlan(plan, [selectedAsset.file], plan.operation); + } catch (error) { + setOperationError( + error instanceof EngineError && error.code === 'cancelled' + ? 'The operation was cancelled and the engine was recreated.' + : error instanceof Error + ? error.message + : 'The output could not be generated.' + ); + } + }, + [runPlan, selectedAsset] + ); + + const extractThumbnail = useCallback( + async (request: ThumbnailRequest) => { + const asset = inspectedClipAsset; + if (!asset) return; + try { + const plan = buildSingleThumbnailPlan({ + jobId: crypto.randomUUID(), + source: { + id: asset.id, + sourceIndex: 0, + fileName: asset.file.name, + }, + timeSeconds: request.timeSeconds, + size: { + ...(request.width === undefined ? {} : { width: request.width }), + ...(request.height === undefined ? {} : { height: request.height }), + maintainAspect: request.maintainAspect, + }, + format: request.format === 'jpg' ? 'jpeg' : request.format, + quality: request.quality, + }); + const execution = await runPlan( + plan, + [asset.file], + 'Extract thumbnail' + ); + const derivative = execution.results[0]; + if (!derivative) { + throw new Error('The thumbnail output was not returned.'); + } + try { + await cacheDerivative( + asset.file, + 'thumbnail', + 'thumbnail', + project.id, + derivative.blob, + { + timeSeconds: request.timeSeconds, + width: request.width, + height: request.height, + maintainAspect: request.maintainAspect, + format: request.format, + quality: request.quality, + } + ); + } catch { + setProjectNotice( + 'Thumbnail created, but this browser could not cache the derivative.' + ); + } + } catch (error) { + setOperationError( + error instanceof Error ? error.message : 'Frame extraction failed.' + ); + } + }, + [inspectedClipAsset, project.id, runPlan] + ); + + const createPreviewProxy = useCallback( + async (asset: ImportedMediaAsset | undefined) => { + const duration = asset?.probe?.durationSeconds; + if (!asset || duration === undefined || duration <= 0) return; + try { + const includeAudio = + asset.probe?.streams.some((stream) => stream.type === 'audio') === + true; + const hasVideo = + asset.probe?.streams.some( + (stream) => + stream.type === 'video' && + stream.disposition.attached_pic !== true + ) === true; + const cacheSettings = { + ...previewProxySettings, + includeAudio, + hasVideo, + }; + const plan = buildPreviewProxyPlan({ + jobId: crypto.randomUUID(), + source: { + id: asset.id, + sourceIndex: 0, + fileName: asset.file.name, + }, + sourceDurationSeconds: duration, + ...previewProxySettings, + includeAudio, + hasVideo, + }); + let cached: Blob | undefined; + try { + cached = await readCachedDerivative( + asset.file, + 'preview-proxy', + cacheSettings + ); + } catch { + // Cache availability is optional; generation remains the fallback. + } + const plannedOutput = plan.outputs[0]; + if (cached && plannedOutput) { + const cachedResult = resultCollection.add({ + id: `${plan.id}:${plannedOutput.id}:cached`, + planId: plan.id, + outputId: plannedOutput.id, + fileName: plannedOutput.fileName, + blob: cached, + mimeType: plannedOutput.mediaType, + role: plannedOutput.role, + }); + refreshResults(); + setPreviewResultId( + canPreviewGeneratedResult(cachedResult) + ? cachedResult.id + : undefined + ); + setOutputVerifications((current) => ({ + ...current, + [cachedResult.id]: { + warning: + 'Reused the matching local preview derivative; it was not re-probed in this run.', + }, + })); + setProjectNotice('Reused the matching local preview proxy.'); + return; + } + const execution = await runPlan( + plan, + [asset.file], + 'Create preview proxy' + ); + const derivative = execution.results[0]; + if (!derivative) { + throw new Error('The preview proxy output was not returned.'); + } + try { + await cacheDerivative( + asset.file, + 'preview-proxy', + 'preview-proxy', + project.id, + derivative.blob, + cacheSettings + ); + } catch { + setProjectNotice( + 'Preview proxy created, but this browser could not cache the derivative.' + ); + } + } catch (error) { + setOperationError( + error instanceof Error + ? error.message + : 'The preview proxy could not be generated.' + ); + } + }, + [ + previewProxySettings, + project.id, + refreshResults, + resultCollection, + runPlan, + ] + ); + + const exportTimeline = useCallback(async () => { + const preset = + findBuiltInPreset(project.output.presetId) ?? + userPresets.find((entry) => entry.id === project.output.presetId); + if (!preset?.video) { + setOperationError( + 'Choose an available video preset in the timeline output settings.' + ); + return; + } + const timelineVideoPreset = preset.video; + if (!['mp4', 'webm', 'matroska'].includes(preset.container)) { + setOperationError( + 'Timeline export currently supports MP4, WebM, or Matroska video presets.' + ); + return; + } + const timelineContainer = preset.container as 'mp4' | 'webm' | 'matroska'; + try { + const enabledSubtitleTracks = project.subtitles.filter( + (entry) => entry.mode !== 'exclude' + ); + const timelineStepCount = + 2 + + enabledSubtitleTracks.length + + enabledSubtitleTracks.filter( + (entry) => (entry.offsetSeconds ?? 0) !== 0 + ).length + + (project.chapters.length > 0 ? 1 : 0); + await runWorkflow( + 'Render timeline project', + timelineStepCount, + async (executeStep) => { + const resolved = project.timeline.map((clip, index) => { + const asset = assets.find((entry) => entry.id === clip.assetId); + const video = asset?.probe?.streams.find( + (stream) => + stream.type === 'video' && + stream.disposition.attached_pic !== true + ); + if (!asset || !video || !video.width || !video.height) { + throw new Error( + `Clip ${index + 1} has no attached, inspected video source.` + ); + } + if (clip.video?.enabled === false) { + throw new Error( + `Clip ${index + 1} has video disabled. The sequential video timeline requires video in every clip; use Quick Convert for an audio-only export.` + ); + } + const crop = clip.crop + ? validateCrop({ + ...clip.crop, + sourceWidth: video.width, + sourceHeight: video.height, + requireEvenDimensions: true, + }) + : undefined; + const croppedWidth = crop?.width ?? video.width; + const croppedHeight = crop?.height ?? video.height; + const rotationSwapsDimensions = + clip.rotation === 90 || clip.rotation === 270; + const resizeSourceWidth = rotationSwapsDimensions + ? croppedHeight + : croppedWidth; + const resizeSourceHeight = rotationSwapsDimensions + ? croppedWidth + : croppedHeight; + const resize = clip.resize + ? { + sourceWidth: resizeSourceWidth, + sourceHeight: resizeSourceHeight, + width: clip.resize.width, + height: clip.resize.height, + mode: + clip.resize.mode === 'fit' + ? ('contain' as const) + : clip.resize.mode === 'fill' + ? ('cover' as const) + : ('stretch' as const), + noUpscaling: clip.resize.allowUpscale === false, + algorithm: clip.resize.algorithm, + paddingColor: clip.resize.paddingColor, + } + : undefined; + const audioNormalization = clip.audio?.normalization + ? (() => { + const measurement = loudnessByAsset[asset.id]; + if (!measurement) { + throw new Error( + `Clip ${index + 1} requests two-pass loudness normalization. Measure its source in Advanced operations first.` + ); + } + return { + mode: 'loudness' as const, + targets: { + integratedLufs: clip.audio.normalization.targetLufs, + truePeakDbtp: clip.audio.normalization.truePeakDb, + loudnessRangeLu: + clip.audio.normalization.loudnessRangeLufs ?? 11, + }, + measurement, + }; + })() + : undefined; + const calculatedResize = resize + ? calculateResize(resize) + : undefined; + const targetBoxIsFinal = + resize !== undefined && + resize.width !== undefined && + resize.height !== undefined && + (resize.mode === 'contain' || resize.mode === 'cover'); + const transformedWidth = targetBoxIsFinal + ? (resize.width as number) + : (calculatedResize?.width ?? resizeSourceWidth); + const transformedHeight = targetBoxIsFinal + ? (resize.height as number) + : (calculatedResize?.height ?? resizeSourceHeight); + return { + clip: { + id: clip.id, + source: { + id: asset.id, + sourceIndex: index, + fileName: asset.file.name, + }, + sourceInSeconds: clip.sourceInSeconds, + sourceOutSeconds: clip.sourceOutSeconds, + sourceDurationSeconds: asset.probe?.durationSeconds, + hasVideo: true, + hasAudio: + project.output.audioEnabled && + clip.audio?.enabled !== false && + asset.probe?.streams.some( + (stream) => stream.type === 'audio' + ) === true, + transform: + crop || resize || clip.rotation + ? { + crop, + resize, + rotation: clip.rotation, + } + : undefined, + audioGainDb: clip.audio?.gainDb, + audioNormalization, + audioFadeInSeconds: clip.audio?.fadeInSeconds, + audioFadeOutSeconds: clip.audio?.fadeOutSeconds, + audioFadeCurve: clip.audio?.fadeCurve, + videoFadeInSeconds: clip.video?.fadeInSeconds, + videoFadeOutSeconds: clip.video?.fadeOutSeconds, + videoFadeCurve: clip.video?.fadeCurve, + }, + file: asset.file, + width: transformedWidth, + height: transformedHeight, + requestedFrameRate: clip.frameRate, + sourceFrameRate: video.frameRate, + }; + }); + const first = resolved[0]; + if (!first) throw new Error('Add at least one timeline clip.'); + const even = (value: number) => + Math.max(2, Math.round(value / 2) * 2); + const requestedWidth = + project.output.width ?? timelineVideoPreset.width ?? undefined; + const requestedHeight = + project.output.height ?? timelineVideoPreset.height ?? undefined; + let outputWidth: number; + let outputHeight: number; + if (requestedWidth && requestedHeight) { + outputWidth = even(requestedWidth); + outputHeight = even(requestedHeight); + } else if (requestedWidth) { + outputWidth = even(requestedWidth); + outputHeight = even((requestedWidth * first.height) / first.width); + } else if (requestedHeight) { + outputHeight = even(requestedHeight); + outputWidth = even((requestedHeight * first.width) / first.height); + } else { + const scale = Math.min(1, 1920 / first.width, 1080 / first.height); + outputWidth = even(first.width * scale); + outputHeight = even(first.height * scale); + } + const requestedClipFrameRates = [ + ...new Set( + resolved.flatMap((entry) => + entry.requestedFrameRate === undefined + ? [] + : [entry.requestedFrameRate] + ) + ), + ]; + if ( + project.output.frameRate === undefined && + timelineVideoPreset.frameRate === undefined && + requestedClipFrameRates.length > 1 + ) { + throw new Error( + 'Timeline clips contain conflicting legacy frame-rate settings. Choose one frame rate in Timeline output.' + ); + } + const frameRate = + project.output.frameRate ?? + timelineVideoPreset.frameRate ?? + requestedClipFrameRates[0] ?? + first.sourceFrameRate ?? + 30; + const sampleRate = + project.output.sampleRate ?? preset.audio?.sampleRate ?? 48_000; + const channelLayout = + project.output.channelLayout ?? + (preset.audio?.channels === 1 + ? 'mono' + : preset.audio?.channels === 6 + ? '5.1' + : 'stereo'); + const missingAudioPolicy = + !project.output.audioEnabled || !preset.audio + ? ('drop-all' as const) + : (project.output.missingAudioPolicy ?? 'insert-silence'); + const outputFileName = safeExportFileName(project.output.fileName, { + extension: preset.fileExtension, + fallback: 'timeline', + }); + const plan = buildTimelineExportPlan({ + jobId: crypto.randomUUID(), + clips: resolved.map(({ clip }) => clip), + preset, + outputWidth, + outputHeight, + frameRate, + sampleRate, + channelLayout, + missingAudioPolicy, + outputFileName, + }); + const rendered = await executeStep( + plan, + resolved.map(({ file }) => file), + 'Render timeline' + ); + const renderedResult = rendered.results[0]; + if (!renderedResult) { + throw new Error('The rendered timeline output was not returned.'); + } + let currentResult = renderedResult; + let currentFile = resultAsFile(currentResult); + const totalDuration = resolved.reduce( + (sum, entry) => + sum + entry.clip.sourceOutSeconds - entry.clip.sourceInSeconds, + 0 + ); + + const activeSubtitleTracks = project.subtitles + .filter((entry) => entry.mode !== 'exclude') + .sort((left, right) => { + if (left.mode === right.mode) return 0; + return left.mode === 'burn-in' ? -1 : 1; + }); + for (const track of activeSubtitleTracks) { + const originalSubtitle = subtitleFiles.get(track.id); + if (!originalSubtitle || !track.sourceFormat) { + throw new Error( + `Reattach subtitle ${track.sourceName ?? track.id} before rendering the project.` + ); + } + let subtitleFile = originalSubtitle; + let shiftedResultForCleanup: ExportResult | undefined; + if ((track.offsetSeconds ?? 0) !== 0) { + const shifted = await executeStep( + buildSubtitleConversionPlan({ + jobId: crypto.randomUUID(), + subtitle: { + id: track.id, + sourceIndex: 0, + fileName: originalSubtitle.name, + }, + outputFormat: track.sourceFormat, + offsetSeconds: track.offsetSeconds ?? 0, + }), + [originalSubtitle], + `Shift subtitle ${track.sourceName ?? track.id}` + ); + const shiftedResult = shifted.results[0]; + if (!shiftedResult) { + throw new Error( + 'The shifted subtitle output was not returned.' + ); + } + shiftedResultForCleanup = shiftedResult; + subtitleFile = resultAsFile(shiftedResult); + } + + const mediaSource = { + id: `timeline-${project.id}`, + sourceIndex: 0, + fileName: currentFile.name, + }; + const subtitleSource = { + id: track.id, + sourceIndex: 1, + fileName: subtitleFile.name, + }; + const sourceStreams = + track.mode === 'burn-in' + ? [] + : remuxStreamsFromProbe( + normalizeFfprobeReport( + (await engineManager.probe(currentFile)).json + ) + ); + const subtitlePlan = + track.mode === 'burn-in' + ? buildSubtitleBurnPlan({ + jobId: crypto.randomUUID(), + source: mediaSource, + subtitle: subtitleSource, + preset, + expectedDurationSeconds: totalDuration, + }) + : buildSubtitleMuxPlan({ + jobId: crypto.randomUUID(), + source: mediaSource, + subtitle: subtitleSource, + subtitleCodec: subtitleCodecForFormat(track.sourceFormat), + sourceStreams, + targetContainer: timelineContainer, + targetExtension: preset.fileExtension, + ...(track.language ? { language: track.language } : {}), + ...(track.title ? { title: track.title } : {}), + default: track.default ?? false, + forced: track.forced ?? false, + expectedDurationSeconds: totalDuration, + }); + const subtitled = await executeStep( + subtitlePlan, + [currentFile, subtitleFile], + track.mode === 'burn-in' + ? 'Burn project subtitle' + : 'Mux project subtitle' + ); + const subtitledResult = subtitled.results[0]; + if (!subtitledResult) { + throw new Error( + 'The subtitled timeline output was not returned.' + ); + } + resultCollection.remove(currentResult.id); + if (shiftedResultForCleanup) + resultCollection.remove(shiftedResultForCleanup.id); + currentResult = subtitledResult; + currentFile = resultAsFile(subtitledResult); + } + + if (project.chapters.length > 0) { + const chaptered = await executeStep( + buildChapterMuxPlan({ + jobId: crypto.randomUUID(), + source: { + id: `timeline-${project.id}`, + sourceIndex: 0, + fileName: currentFile.name, + }, + chapters: project.chapters.map((chapter) => ({ + id: chapter.id, + title: chapter.title, + startSeconds: chapter.startSeconds, + endSeconds: chapter.endSeconds, + timeBase: chapter.timeBase ?? '1/1000', + })), + targetExtension: preset.fileExtension, + targetMuxer: preset.container, + durationSeconds: totalDuration, + }), + [currentFile], + 'Mux project chapters' + ); + const chapteredResult = chaptered.results[0]; + if (!chapteredResult) { + throw new Error( + 'The chaptered timeline output was not returned.' + ); + } + resultCollection.remove(currentResult.id); + currentResult = chapteredResult; + currentFile = resultAsFile(chapteredResult); + } + + const metadataEdits = metadataEditsFromProject(project.metadata); + const finalized = await executeStep( + buildMetadataEditPlan({ + jobId: crypto.randomUUID(), + source: { + id: `timeline-${project.id}`, + sourceIndex: 0, + fileName: currentFile.name, + }, + edits: metadataEdits, + targetExtension: preset.fileExtension, + targetMuxer: preset.container, + sourceMetadataPolicy: + preset.metadataPolicy === 'remove' ? 'remove' : 'copy', + chapterPolicy: 'keep', + expectedDurationSeconds: totalDuration, + outputFileName, + }), + [currentFile], + 'Finalize timeline output' + ); + if (!finalized.results[0]) { + throw new Error('The finalized timeline output was not returned.'); + } + resultCollection.remove(currentResult.id); + refreshResults(); + } + ); + } catch (error) { + setOperationError( + error instanceof Error + ? error.message + : 'The timeline could not be rendered.' + ); + } + }, [ + assets, + loudnessByAsset, + project, + refreshResults, + resultCollection, + runWorkflow, + subtitleFiles, + userPresets, + ]); + + const resetProject = useCallback(() => { + for (const asset of assets) URL.revokeObjectURL(asset.objectUrl); + clearResult(); + setAssets([]); + setSelectedAssetId(undefined); + setSelectedClipId(undefined); + setSubtitleFiles(new Map()); + setWaveformByAsset({}); + setSplitMarkersByAsset({}); + setSplitBatch(undefined); + setLoudnessByAsset({}); + setPlayheadByAsset({}); + setScheduledJobs([]); + scheduledWorkRef.current.clear(); + setProject(createEmptyProject()); + setProjectNotice('Started a new empty project.'); + }, [assets, clearResult]); + + const exportProject = useCallback(() => { + const json = serializeProject(project, { + pretty: true, + includeUiState: true, + }); + downloadBlob( + new Blob([json], { type: 'application/json' }), + projectDocumentFileName(project) + ); + setProjectNotice( + 'Project document downloaded. Source files are not embedded.' + ); + }, [project]); + + const importProject = useCallback( + async (file: File) => { + try { + const imported = importProjectDocument(await file.text()); + for (const asset of assets) URL.revokeObjectURL(asset.objectUrl); + clearResult(); + setAssets([]); + setSelectedAssetId(undefined); + setSelectedClipId(imported.project.timeline[0]?.id); + setSubtitleFiles(new Map()); + setWaveformByAsset({}); + setSplitMarkersByAsset({}); + setSplitBatch(undefined); + setLoudnessByAsset({}); + setPlayheadByAsset({}); + setScheduledJobs([]); + scheduledWorkRef.current.clear(); + setProject(imported.project); + selectWorkspaceMode('edit'); + setProjectNotice( + imported.warnings.length > 0 + ? imported.warnings.map((warning) => warning.message).join(' ') + : 'Project loaded.' + ); + } catch (error) { + setProjectNotice( + error instanceof Error ? error.message : 'Project import failed.' + ); + } + }, + [assets, clearResult, selectWorkspaceMode] + ); + + const moveClip = useCallback( + (clipId: string, toIndex: number) => { + if (toIndex < 0 || toIndex >= project.timeline.length) return; + applyProjectAction({ + type: 'clip/move', + clipId, + toIndex, + updatedAt: timestamp(), + }); + }, + [applyProjectAction, project.timeline.length] + ); + + const updateClip = useCallback( + (clipId: string, changes: Partial) => { + const clip = project.timeline.find((entry) => entry.id === clipId); + if (!clip) return; + const sourceIn = changes.sourceInSeconds ?? clip.sourceInSeconds; + const sourceOut = changes.sourceOutSeconds ?? clip.sourceOutSeconds; + if ( + !Number.isFinite(sourceIn) || + !Number.isFinite(sourceOut) || + sourceIn < 0 || + sourceOut - sourceIn < 0.001 + ) { + setProjectNotice('Clip out-point must remain after its in-point.'); + return; + } + applyProjectAction({ + type: 'clip/update', + clipId, + changes, + updatedAt: timestamp(), + }); + }, + [applyProjectAction, project.timeline] + ); + + const changeSubtitles = useCallback( + (subtitles: AvProjectV1['subtitles']) => { + const retainedIds = new Set(subtitles.map((track) => track.id)); + setSubtitleFiles( + (current) => + new Map([...current].filter(([trackId]) => retainedIds.has(trackId))) + ); + applyProjectAction({ + type: 'subtitles/set', + subtitles, + updatedAt: timestamp(), + }); + }, + [applyProjectAction] + ); + + const importSubtitleFile = useCallback( + (file: File) => { + const extension = file.name.split('.').at(-1)?.toLowerCase(); + if (extension !== 'srt' && extension !== 'vtt' && extension !== 'ass') { + setProjectNotice('Choose an SRT, WebVTT, or ASS subtitle file.'); + return; + } + if (file.size > 20 * 1024 * 1024) { + setProjectNotice('Subtitle files are limited to 20 MiB.'); + return; + } + const missing = project.subtitles.find( + (track) => + track.sourceStatus === 'missing' && + track.sourceName?.toLocaleLowerCase() === + file.name.toLocaleLowerCase() + ); + const trackId = missing?.id ?? crypto.randomUUID(); + const tracks: AvProjectV1['subtitles'] = missing + ? project.subtitles.map((track) => + track.id === missing.id + ? { + ...track, + sourceName: file.name, + sourceFormat: extension, + sourceStatus: 'attached' as const, + } + : track + ) + : [ + ...project.subtitles, + { + id: trackId, + sourceName: file.name, + sourceFormat: extension, + mode: 'mux' as const, + sourceStatus: 'attached' as const, + }, + ]; + setSubtitleFiles((current) => new Map(current).set(trackId, file)); + applyProjectAction({ + type: 'subtitles/set', + subtitles: tracks, + updatedAt: timestamp(), + }); + setProjectNotice( + missing + ? `Reattached subtitle ${file.name}.` + : `Imported subtitle ${file.name}.` + ); + }, + [applyProjectAction, project.subtitles] + ); + + const resolvePreset = useCallback( + (presetId: string) => + findBuiltInPreset(presetId) ?? + userPresets.find((preset) => preset.id === presetId), + [userPresets] + ); + + const handleStructuralTrim = useCallback( + async (request: StructuralTrimRequest) => { + const clip = project.timeline.find( + (entry) => entry.id === request.clipId + ); + const asset = clip + ? assets.find((entry) => entry.id === clip.assetId) + : undefined; + if (!clip || !asset?.probe) { + throw new Error('The selected clip source is not attached and probed.'); + } + const preset = + request.mode === 'accurate' + ? resolvePreset(request.presetId) + : undefined; + if (request.mode === 'accurate' && !preset) { + throw new Error('The accurate trim preset is unavailable.'); + } + const selection = + request.mode === 'accurate' && preset + ? { + video: preset.video + ? asset.probe.streams + .filter( + (stream) => + stream.type === 'video' && + stream.disposition.attached_pic !== true + ) + .slice(0, 1) + .map((stream) => stream.index) + : [], + audio: preset.audio + ? asset.probe.streams + .filter((stream) => stream.type === 'audio') + .slice(0, 1) + .map((stream) => stream.index) + : [], + subtitles: [], + attachments: [], + } + : undefined; + const plan = buildTrimPlan({ + jobId: crypto.randomUUID(), + source: { + id: asset.id, + sourceIndex: 0, + fileName: asset.file.name, + }, + mode: request.mode, + startSeconds: request.startSeconds, + endSeconds: request.endSeconds, + sourceDurationSeconds: asset.probe.durationSeconds, + ...(selection ? { streamSelection: selection } : {}), + ...(preset ? { preset } : {}), + ...(request.mode === 'fast' + ? { targetExtension: request.targetExtension } + : {}), + }); + await runPlan( + plan, + [asset.file], + request.mode === 'fast' ? 'Fast keyframe trim' : 'Accurate trim' + ); + }, + [assets, project.timeline, resolvePreset, runPlan] + ); + + const handleStructuralConcat = useCallback( + async (request: StructuralConcatRequest) => { + const resolved = request.clipIds.map((clipId, index) => { + const clip = project.timeline.find((entry) => entry.id === clipId); + const asset = clip + ? assets.find((entry) => entry.id === clip.assetId) + : undefined; + if (!clip || !asset?.probe) { + throw new Error( + `Timeline clip ${index + 1} has no attached, probed source.` + ); + } + return { + clip, + asset, + source: concatSourceForClip(clip, asset, index), + }; + }); + if (resolved.length < 2) { + throw new Error('Concatenation requires at least two timeline clips.'); + } + const plan = + request.mode === 'fast' + ? buildFastConcatPlan({ + jobId: crypto.randomUUID(), + sources: resolved.map((entry) => entry.source), + targetExtension: request.targetExtension, + targetMuxer: request.targetMuxer, + }) + : (() => { + const preset = resolvePreset(request.presetId); + if (!preset?.video) { + throw new Error( + 'The normalized concat video preset is unavailable.' + ); + } + const firstVideo = resolved[0]?.asset.probe?.streams.find( + (stream) => + stream.type === 'video' && + stream.disposition.attached_pic !== true + ); + if (!firstVideo?.width || !firstVideo.height) { + throw new Error( + 'The first concat source has no validated video dimensions.' + ); + } + const even = (value: number) => + Math.max(2, Math.round(value / 2) * 2); + const scale = Math.min( + 1, + 1920 / firstVideo.width, + 1080 / firstVideo.height + ); + return buildNormalizedConcatPlan({ + jobId: crypto.randomUUID(), + sources: resolved.map((entry) => entry.source), + targetExtension: preset.fileExtension, + targetMuxer: preset.container, + preset, + width: even(firstVideo.width * scale), + height: even(firstVideo.height * scale), + frameRate: firstVideo.frameRate ?? 30, + sampleRate: 48_000, + channelLayout: 'stereo', + missingAudioPolicy: request.missingAudioPolicy, + subtitlePolicy: request.subtitlePolicy, + }); + })(); + await runPlan( + plan, + resolved.map((entry) => entry.asset.file), + request.mode === 'fast' ? 'Fast compatible concat' : 'Normalized concat' + ); + }, + [assets, project.timeline, resolvePreset, runPlan] + ); + + const handleAdvancedSplit = useCallback( + async (request: AdvancedSplitRequest) => { + const asset = inspectedClipAsset; + const duration = asset?.probe?.durationSeconds; + if (!asset || duration === undefined) { + throw new Error( + 'Select an attached, inspected source before splitting.' + ); + } + const sourceExtension = extensionFromName(asset.file.name); + const preset = + request.mode === 'accurate' + ? resolvePreset(request.presetId) + : undefined; + if (request.mode === 'accurate' && !preset) { + throw new Error('The accurate split preset is unavailable.'); + } + if ( + request.mode === 'accurate' && + preset && + request.targetExtension !== preset.fileExtension + ) { + throw new Error( + `Accurate split output must use the preset extension .${preset.fileExtension}.` + ); + } + if ( + request.mode === 'fast' && + request.targetExtension !== sourceExtension + ) { + throw new Error( + `Fast stream-copy split keeps the source container .${sourceExtension}; choose accurate mode to change format.` + ); + } + const plan = buildSplitPlan({ + jobId: crypto.randomUUID(), + source: { + id: asset.id, + sourceIndex: 0, + fileName: asset.file.name, + }, + durationSeconds: duration, + definition: request.definition, + mode: request.mode, + targetExtension: request.targetExtension, + ...(preset ? { preset } : {}), + }); + const pendingBatch: AdvancedSplitBatchStatus = { + planId: plan.id, + segments: plan.outputs.map((output) => { + if (!output.timeRange) { + throw new Error( + `Split output ${output.id} has no declared segment range.` + ); + } + return { + outputId: output.id, + fileName: output.fileName, + startSeconds: output.timeRange.startSeconds, + endSeconds: output.timeRange.endSeconds, + state: 'queued', + }; + }), + }; + setSplitBatch(pendingBatch); + try { + const execution = await runWorkflow( + 'Split media', + 1, + async (executeStep) => { + setSplitBatch((current) => + current?.planId === plan.id + ? { + ...current, + segments: current.segments.map((segment) => ({ + ...segment, + state: 'running', + })), + } + : current + ); + return executeStep(plan, [asset.file], 'Split media'); + } + ); + const resultIds = new Set( + execution.results.map((result) => result.outputId) + ); + setSplitBatch((current) => + current?.planId === plan.id + ? { + ...current, + segments: current.segments.map((segment) => { + const result = execution.results.find( + (entry) => entry.outputId === segment.outputId + ); + if (!result || !resultIds.has(segment.outputId)) { + return { + ...segment, + state: 'failed', + message: + 'FFmpeg returned successfully, but this expected segment was not retained.', + }; + } + const verification = execution.verifications?.[result.id]; + if (verification?.warning) { + return { + ...segment, + state: 'warning', + message: verification.warning, + }; + } + return { + ...segment, + state: verification?.probe ? 'verified' : 'completed', + }; + }), + } + : current + ); + } catch (error) { + const cancelled = + error instanceof EngineError && error.code === 'cancelled'; + const message = + error instanceof Error + ? error.message + : 'The shared FFmpeg split execution failed.'; + setSplitBatch((current) => + current?.planId === plan.id + ? { + ...current, + segments: current.segments.map((segment) => ({ + ...segment, + state: cancelled ? 'cancelled' : 'failed', + message, + })), + } + : current + ); + throw error; + } + }, + [inspectedClipAsset, resolvePreset, runWorkflow] + ); + + const handleAdvancedAudio = useCallback( + async (request: AdvancedAudioRequest) => { + const asset = inspectedClipAsset; + const duration = asset?.probe?.durationSeconds; + if (!asset || duration === undefined) { + throw new Error('Select an attached, inspected source first.'); + } + const firstAudioIndex = asset.probe?.streams.find( + (stream) => stream.type === 'audio' + )?.index; + const audioStreamIndex = request.audioStreamIndex ?? firstAudioIndex; + if (audioStreamIndex === undefined) { + throw new Error('The source has no selected audio stream.'); + } + const source = { + id: asset.id, + sourceIndex: 0, + fileName: asset.file.name, + }; + if (request.type === 'static-waveform') { + const plan = buildStaticWaveformPlan({ + jobId: crypto.randomUUID(), + source, + audioStreamIndex, + width: request.width, + height: request.height, + color: request.foreground, + background: request.background, + expectedDurationSeconds: duration, + }); + const execution = await runPlan( + plan, + [asset.file], + 'Create static waveform' + ); + const output = execution.results[0]; + if (!output) throw new Error('The waveform image was not returned.'); + try { + await cacheDerivative( + asset.file, + 'waveform-image', + 'waveform-image', + project.id, + output.blob, + { + audioStreamIndex, + width: request.width, + height: request.height, + foreground: request.foreground, + background: request.background, + } + ); + } catch { + setProjectNotice( + 'Waveform image created, but this browser could not cache the derivative.' + ); + } + return; + } + if (request.type === 'waveform-peaks') { + const cacheSettings = { + audioStreamIndex, + sampleRate: request.sampleRate, + bucketCount: request.bucketCount, + }; + try { + const cached = await readCachedDerivative( + asset.file, + 'waveform-peaks', + cacheSettings + ); + if (cached) { + const peaks = deserializeWaveformPeaks(await cached.arrayBuffer()); + setWaveformByAsset((current) => ({ + ...current, + [asset.id]: peaks, + })); + setProjectNotice('Reused matching bounded waveform peak data.'); + return; + } + } catch { + // A missing or invalid derivative is safely regenerated below. + } + const plan = buildWaveformAnalysisPlan({ + jobId: crypto.randomUUID(), + source, + audioStreamIndex, + sampleRate: request.sampleRate, + expectedDurationSeconds: duration, + }); + const execution = await runPlan( + plan, + [asset.file], + 'Extract reduced waveform audio' + ); + const output = execution.results[0]; + if (!output) { + throw new Error('The reduced PCM analysis output was not returned.'); + } + setWaveformProgress(0); + try { + const peaks = await waveformRuntime.aggregate( + { + container: 'wav', + data: new Uint8Array(await output.blob.arrayBuffer()), + }, + { + bucketCount: request.bucketCount, + onProgress: setWaveformProgress, + } + ); + setWaveformByAsset((current) => ({ + ...current, + [asset.id]: peaks, + })); + const serialized = serializeWaveformPeaks(peaks); + try { + await cacheDerivative( + asset.file, + 'waveform-peaks', + 'waveform-peaks', + project.id, + new Blob([Uint8Array.from(serialized)], { + type: WAVEFORM_PEAKS_MEDIA_TYPE, + }), + cacheSettings + ); + } catch { + setProjectNotice( + 'Waveform peaks created, but this browser could not cache the derivative.' + ); + } + } finally { + setWaveformProgress(undefined); + resultCollection.remove(output.id); + refreshResults(); + } + return; + } + if (request.type === 'loudness-measurement') { + const plan = buildLoudnessAnalysisPlan({ + jobId: crypto.randomUUID(), + source, + targets: request.targets, + audioStreamIndex, + expectedDurationSeconds: duration, + }); + const execution = await runPlan( + plan, + [asset.file], + 'Measure EBU R128 loudness' + ); + const measurement = parseLoudnessMeasurement(execution.logText); + setLoudnessByAsset((current) => ({ + ...current, + [asset.id]: measurement, + })); + setProjectNotice( + `Measured ${measurement.inputIntegratedLufs.toFixed(1)} LUFS, ${measurement.inputTruePeakDbtp.toFixed(1)} dBTP, LRA ${measurement.inputLoudnessRangeLu.toFixed(1)} LU.` + ); + return; + } + const preset = resolvePreset(request.presetId); + if (!preset) throw new Error('The selected audio preset is unavailable.'); + if (request.type === 'peak-normalization') { + const analysis = await runWorkflow( + 'Peak-normalize audio', + 2, + async (executeStep) => { + const measured = await executeStep( + buildPeakAnalysisPlan({ + jobId: crypto.randomUUID(), + source, + audioStreamIndex, + preset, + expectedDurationSeconds: duration, + }), + [asset.file], + 'Measure source peak' + ); + const measurement = parsePeakMeasurement(measured.logText); + await executeStep( + buildPeakNormalizationPlan({ + jobId: crypto.randomUUID(), + source, + audioStreamIndex, + targetPeakDb: request.targetPeakDb, + measurement, + preset, + expectedDurationSeconds: duration, + }), + [asset.file], + 'Apply measured peak normalization' + ); + return measured; + } + ); + setLogs((current) => + [ + ...analysis.logText.split('\n').filter(Boolean).slice(-120), + ...current, + ].slice(-400) + ); + return; + } + const plan = buildLoudnessApplyPlan({ + jobId: crypto.randomUUID(), + source, + audioStreamIndex, + targets: request.targets, + measurement: request.measurement, + preset, + expectedDurationSeconds: duration, + }); + await runPlan( + plan, + [asset.file], + 'Apply measured EBU R128 normalization' + ); + }, + [ + inspectedClipAsset, + project.id, + refreshResults, + resultCollection, + resolvePreset, + runPlan, + runWorkflow, + waveformRuntime, + ] + ); + + const handleThumbnailSeries = useCallback( + async (request: ThumbnailSeriesRequest) => { + const asset = inspectedClipAsset; + const duration = asset?.probe?.durationSeconds; + if (!asset || duration === undefined) { + throw new Error('Select an attached, inspected video source first.'); + } + const distribution = + request.distribution.type === 'evenly-spaced' + ? { count: request.distribution.count } + : request.distribution.type === 'fixed-interval' + ? { intervalSeconds: request.distribution.intervalSeconds } + : { timesSeconds: request.distribution.timesSeconds }; + const plan = buildThumbnailSeriesPlan({ + jobId: crypto.randomUUID(), + source: { + id: asset.id, + sourceIndex: 0, + fileName: asset.file.name, + }, + durationSeconds: duration, + ...distribution, + size: { + ...(request.width ? { width: request.width } : {}), + ...(request.height ? { height: request.height } : {}), + }, + format: request.format, + quality: request.quality, + }); + const execution = await runPlan( + plan, + [asset.file], + 'Create thumbnail series' + ); + try { + await Promise.all( + execution.results.map((output, index) => + cacheDerivative( + asset.file, + `thumbnail-series-${index}`, + 'thumbnail', + project.id, + output.blob, + { + distribution: request.distribution, + index, + width: request.width ?? null, + height: request.height ?? null, + format: request.format, + } + ) + ) + ); + } catch { + setProjectNotice( + 'Thumbnail series created, but this browser could not cache every derivative.' + ); + } + }, + [inspectedClipAsset, project.id, runPlan] + ); + + const handleContactSheet = useCallback( + async (request: ContactSheetRequest) => { + const asset = inspectedClipAsset; + const duration = asset?.probe?.durationSeconds; + if (!asset || duration === undefined) { + throw new Error('Select an attached, inspected video source first.'); + } + const plan = buildContactSheetPlan({ + jobId: crypto.randomUUID(), + source: { + id: asset.id, + sourceIndex: 0, + fileName: asset.file.name, + }, + durationSeconds: duration, + ...request, + drawtextAvailable: false, + }); + const execution = await runPlan( + plan, + [asset.file], + 'Create contact sheet' + ); + const output = execution.results[0]; + if (!output) throw new Error('The contact sheet was not returned.'); + try { + await cacheDerivative( + asset.file, + 'contact-sheet', + 'contact-sheet', + project.id, + output.blob, + request as unknown as Readonly> + ); + } catch { + setProjectNotice( + 'Contact sheet created, but this browser could not cache the derivative.' + ); + } + }, + [inspectedClipAsset, project.id, runPlan] + ); + + const handleMetadataAction = useCallback( + async (request: MetadataExecutionRequest) => { + if (request.type === 'import') { + applyProjectAction({ + type: 'metadata/set', + metadata: projectMetadataFromEdits(request.edits), + updatedAt: timestamp(), + }); + setProjectNotice('Imported validated metadata JSON into the project.'); + return; + } + if (request.type === 'export') { + downloadBlob( + new Blob([request.json], { type: 'application/json' }), + 'av-tools-metadata.json' + ); + return; + } + const asset = inspectedClipAsset; + if (!asset?.probe) { + throw new Error('Select an attached, inspected source first.'); + } + const extension = extensionFromName(asset.file.name); + const selection = + request.coverArtPolicy === 'remove' + ? { + video: asset.probe.streams + .filter( + (stream) => + stream.type === 'video' && + stream.disposition.attached_pic !== true + ) + .map((stream) => stream.index), + audio: asset.probe.streams + .filter((stream) => stream.type === 'audio') + .map((stream) => stream.index), + subtitles: asset.probe.streams + .filter((stream) => stream.type === 'subtitle') + .map((stream) => stream.index), + attachments: [], + } + : undefined; + const plan = buildMetadataEditPlan({ + jobId: crypto.randomUUID(), + source: { + id: asset.id, + sourceIndex: 0, + fileName: asset.file.name, + }, + edits: request.edits, + targetExtension: extension, + targetMuxer: muxerForExtension(extension), + ...(selection ? { streamSelection: selection } : {}), + sourceMetadataPolicy: request.sourceMetadataPolicy, + chapterPolicy: request.chapterPolicy, + expectedDurationSeconds: asset.probe.durationSeconds, + }); + await runPlan(plan, [asset.file], 'Write metadata'); + }, + [applyProjectAction, inspectedClipAsset, runPlan] + ); + + const handleChapterAction = useCallback( + async (request: ChapterExecutionRequest) => { + if (request.type === 'import') { + applyProjectAction({ + type: 'chapters/set', + chapters: request.chapters.map((chapter) => ({ + id: chapter.id, + startSeconds: chapter.startSeconds, + endSeconds: chapter.endSeconds, + title: chapter.title, + timeBase: chapter.timeBase ?? '1/1000', + })), + updatedAt: timestamp(), + }); + setProjectNotice('Imported validated chapter JSON into the project.'); + return; + } + if (request.type === 'export') { + downloadBlob( + new Blob([request.json], { type: 'application/json' }), + 'av-tools-chapters.json' + ); + return; + } + const asset = inspectedClipAsset; + if (!asset?.probe) { + throw new Error('Select an attached, inspected source first.'); + } + const extension = extensionFromName(asset.file.name); + const source = { + id: asset.id, + sourceIndex: 0, + fileName: asset.file.name, + }; + const plan = + request.type === 'remove' + ? buildMetadataEditPlan({ + jobId: crypto.randomUUID(), + source, + edits: {}, + targetExtension: extension, + targetMuxer: muxerForExtension(extension), + sourceMetadataPolicy: 'copy', + chapterPolicy: 'remove', + expectedDurationSeconds: asset.probe.durationSeconds, + }) + : buildChapterMuxPlan({ + jobId: crypto.randomUUID(), + source, + chapters: request.chapters, + targetExtension: extension, + targetMuxer: muxerForExtension(extension), + durationSeconds: asset.probe.durationSeconds, + }); + await runPlan( + plan, + [asset.file], + request.type === 'remove' ? 'Remove chapters' : 'Replace chapters' + ); + }, + [applyProjectAction, inspectedClipAsset, runPlan] + ); + + const handleSubtitleAction = useCallback( + async (request: SubtitleExecutionRequest) => { + const asset = inspectedClipAsset; + if (request.type === 'extract') { + if (!asset) throw new Error('Select an inspected media source first.'); + const plan = buildSubtitleExtractionPlan({ + jobId: crypto.randomUUID(), + source: { + id: asset.id, + sourceIndex: 0, + fileName: asset.file.name, + }, + stream: request.stream, + outputFormat: request.outputFormat, + }); + await runPlan(plan, [asset.file], 'Extract text subtitle'); + return; + } + const attachedFile = + [...subtitleFiles.values()].find( + (file) => file.name === request.file.name + ) ?? request.file; + if (request.type === 'convert') { + const plan = buildSubtitleConversionPlan({ + jobId: crypto.randomUUID(), + subtitle: { + id: crypto.randomUUID(), + sourceIndex: 0, + fileName: attachedFile.name, + }, + outputFormat: request.outputFormat, + offsetSeconds: request.offsetSeconds, + }); + await runPlan(plan, [attachedFile], 'Convert text subtitle'); + return; + } + if (!asset?.probe) { + throw new Error('Select an attached, inspected video source first.'); + } + const mediaSource = { + id: asset.id, + sourceIndex: 0, + fileName: asset.file.name, + }; + const subtitleSource = { + id: crypto.randomUUID(), + sourceIndex: 1, + fileName: attachedFile.name, + }; + if (request.type === 'soft-mux') { + const targetExtension = + request.targetContainer === 'matroska' + ? 'mkv' + : request.targetContainer; + const plan = buildSubtitleMuxPlan({ + jobId: crypto.randomUUID(), + source: mediaSource, + subtitle: subtitleSource, + subtitleCodec: + request.subtitleCodec || + subtitleCodecForFormat(request.inputFormat), + sourceStreams: remuxStreamsFromProbe(asset.probe), + targetContainer: request.targetContainer, + targetExtension, + ...(request.language ? { language: request.language } : {}), + ...(request.title ? { title: request.title } : {}), + default: request.default, + forced: request.forced, + expectedDurationSeconds: asset.probe.durationSeconds, + }); + await runPlan(plan, [asset.file, attachedFile], 'Mux soft subtitle'); + return; + } + const preset = resolvePreset(request.presetId); + if (!preset) + throw new Error('The subtitle burn-in preset is unavailable.'); + const plan = buildSubtitleBurnPlan({ + jobId: crypto.randomUUID(), + source: mediaSource, + subtitle: subtitleSource, + preset, + expectedDurationSeconds: asset.probe.durationSeconds, + }); + await runPlan( + plan, + [asset.file, attachedFile], + 'Burn subtitle into video' + ); + }, + [inspectedClipAsset, resolvePreset, runPlan, subtitleFiles] + ); + + const handleUserPresetAction = useCallback( + async (action: UserPresetAction) => { + const registry = presetSetup.registry; + if (action.type === 'create' || action.type === 'update') { + await registry.save(action.preset); + } else if (action.type === 'clone') { + await registry.clone(action.presetId, action.newId, action.newName); + } else if (action.type === 'rename') { + await registry.rename(action.presetId, action.name); + } else if (action.type === 'delete') { + await registry.delete(action.presetId); + } else if (action.type === 'reset-all') { + if ( + !globalThis.confirm( + 'Delete every user-created export preset from this browser?' + ) + ) { + return; + } + await registry.reset(); + } else if (action.type === 'import') { + await registry.save(action.preset); + } else { + downloadBlob( + new Blob([action.json], { type: 'application/json' }), + `${action.preset.id}.json` + ); + return; + } + setUserPresets(registry.list()); + if (!presetSetup.persistent) { + setProjectNotice( + 'IndexedDB is unavailable; user presets will last only for this page session.' + ); + } + }, + [presetSetup] + ); + + const saveManagedResult = useCallback( + async (output: ExportResult) => { + try { + await saveCollectionResult(resultCollection, output.id); + refreshResults(); + } catch (error) { + setOperationError( + error instanceof Error + ? error.message + : 'The result could not be saved.' + ); + } + }, + [refreshResults, resultCollection] + ); + + const saveManagedZip = useCallback( + async (selected: readonly ExportResult[]) => { + if (selected.length === 0) return; + try { + const saved = await saveResultsZip(selected, { + fileName: `${project.name || 'av-tools'}-results.zip`, + ...(latestReport ? { report: latestReport } : {}), + // Compression finishes asynchronously, after transient picker + // activation has expired. A normal download is the honest fallback. + saver: async (blob, fileName) => { + downloadBlob(blob, fileName); + return 'downloaded'; + }, + }); + if (saved.outcome !== 'cancelled') { + for (const result of selected) resultCollection.remove(result.id); + } + refreshResults(); + } catch (error) { + setOperationError( + error instanceof Error + ? error.message + : 'The ZIP could not be created.' + ); + } + }, + [latestReport, project.name, refreshResults, resultCollection] + ); + + const saveManagedReport = useCallback(async () => { + if (!latestReport) return; + try { + await saveExportReport(latestReport); + } catch (error) { + setOperationError( + error instanceof Error + ? error.message + : 'The validation report could not be saved.' + ); + } + }, [latestReport]); + + const activeCapabilities = + engineState.status === 'ready' ? engineState.capabilities : undefined; + const capabilityStatus = ( + requirements: { + readonly muxers?: readonly string[]; + readonly encoders?: readonly string[]; + readonly filters?: readonly string[]; + }, + unavailableReason?: string + ): AdvancedCapabilityStatus => { + if (!activeCapabilities) { + return { + available: false, + reason: 'Inspect a source or initialize the local FFmpeg engine first.', + }; + } + const missing = [ + ...(requirements.muxers ?? []) + .filter((name) => !activeCapabilities.muxers.has(name)) + .map((name) => `muxer ${name}`), + ...(requirements.encoders ?? []) + .filter((name) => !activeCapabilities.encoders.has(name)) + .map((name) => `encoder ${name}`), + ...(requirements.filters ?? []) + .filter((name) => !activeCapabilities.filters.has(name)) + .map((name) => `filter ${name}`), + ]; + return missing.length === 0 && !unavailableReason + ? { available: true } + : { + available: false, + reason: + unavailableReason ?? + `The loaded core is missing ${missing.join(', ')}.`, + }; + }; + + const advancedPresetOptions: readonly AdvancedExportPresetOption[] = [ + ...BUILT_IN_PRESETS, + ...userPresets, + ].map((preset) => { + const requirements = + 'builtIn' in preset + ? preset.requirements + : requirementsForUserPreset(preset); + const availability = activeCapabilities + ? presetAvailability({ requirements }, activeCapabilities) + : { + status: 'unavailable' as const, + reasons: ['Initialize the engine to verify this preset.'], + }; + const opusBlocked = preset.audio?.codec === 'libopus'; + return { + id: preset.id, + name: preset.name, + fileExtension: preset.fileExtension, + supportsAudio: preset.audio !== undefined, + supportsVideo: preset.video !== undefined, + ...(availability.status === 'unavailable' || opusBlocked + ? { + disabledReason: opusBlocked + ? 'Opus output remains disabled until the pinned core passes its non-empty ST/MT regression smoke.' + : availability.reasons.join(' '), + } + : {}), + }; + }); + const timelinePresetOptions = advancedPresetOptions.filter((option) => { + const preset = resolvePreset(option.id); + return ( + option.supportsVideo && + preset !== undefined && + ['mp4', 'webm', 'matroska'].includes(preset.container) + ); + }); + const selectedTimelinePreset = resolvePreset(project.output.presetId); + const selectedTimelinePresetOption = timelinePresetOptions.find( + (option) => option.id === project.output.presetId + ); + const timelineOutputReason = + project.timeline.length === 0 + ? 'Add at least one timeline clip.' + : !selectedTimelinePreset?.video + ? 'Choose a video export preset.' + : selectedTimelinePresetOption?.disabledReason; + + const anyAccuratePreset = advancedPresetOptions.some( + (preset) => !preset.disabledReason + ); + const anyAudioPreset = advancedPresetOptions.some( + (preset) => preset.supportsAudio && !preset.disabledReason + ); + const anyVideoPreset = advancedPresetOptions.some( + (preset) => preset.supportsVideo && !preset.disabledReason + ); + const structuralContext = (() => { + const diagnostics: StructuralCompatibilityDiagnostic[] = []; + const sources: ConcatSource[] = []; + const clips = project.timeline.map((clip, index) => { + const asset = assets.find((entry) => entry.id === clip.assetId); + const projectAsset = project.assets.find( + (entry) => entry.id === clip.assetId + ); + const label = `${index + 1}. ${ + asset?.file.name ?? projectAsset?.originalName ?? 'Missing source' + }`; + if (!asset?.probe) { + diagnostics.push({ + code: 'source-unavailable', + severity: 'error', + message: `${label} is not attached and probed.`, + path: `timeline.${index}.assetId`, + }); + return { + id: clip.id, + label, + hasAudio: false, + hasVideo: false, + hasSubtitles: false, + }; + } + const unsupportedStreams = asset.probe.streams.filter( + (stream) => + stream.type === 'attachment' || + stream.type === 'data' || + stream.type === 'unknown' + ); + if (unsupportedStreams.length > 0) { + diagnostics.push({ + code: 'unvalidated-stream-type', + severity: 'error', + message: `${label} contains ${unsupportedStreams + .map((stream) => `${stream.type} stream ${stream.index}`) + .join( + ', ' + )}; fast concat is disabled because those streams cannot yet be validated safely.`, + path: `timeline.${index}.streams`, + }); + } + try { + sources.push(concatSourceForClip(clip, asset, index)); + } catch (error) { + diagnostics.push({ + code: 'source-range', + severity: 'error', + message: + error instanceof Error + ? `${label}: ${error.message}` + : `${label} has an invalid source range.`, + path: `timeline.${index}`, + }); + } + return { + id: clip.id, + label, + hasAudio: asset.probe.streams.some((stream) => stream.type === 'audio'), + hasVideo: asset.probe.streams.some( + (stream) => + stream.type === 'video' && stream.disposition.attached_pic !== true + ), + hasSubtitles: asset.probe.streams.some( + (stream) => stream.type === 'subtitle' + ), + }; + }); + if ( + project.timeline.length >= 2 && + sources.length === project.timeline.length + ) { + diagnostics.push(...validateConcatCompatibility(sources)); + } + const firstAsset = project.timeline[0] + ? assets.find((entry) => entry.id === project.timeline[0]?.assetId) + : undefined; + const fastTargetExtension = firstAsset + ? extensionFromName(firstAsset.file.name) + : ''; + return { + clips, + sources, + diagnostics, + fastTargetExtension, + fastTargetMuxer: fastTargetExtension + ? muxerForExtension(fastTargetExtension) + : '', + }; + })(); + const structuralSelectedClip = + selectedClip && inspectedClipAsset?.probe + ? { + id: selectedClip.id, + label: inspectedClipAsset.file.name, + sourceInSeconds: selectedClip.sourceInSeconds, + sourceOutSeconds: selectedClip.sourceOutSeconds, + ...(inspectedClipAsset.probe.durationSeconds === undefined + ? {} + : { + sourceDurationSeconds: inspectedClipAsset.probe.durationSeconds, + }), + sourceExtension: extensionFromName(inspectedClipAsset.file.name), + hasAudio: inspectedClipAsset.probe.streams.some( + (stream) => stream.type === 'audio' + ), + hasVideo: inspectedClipAsset.probe.streams.some( + (stream) => + stream.type === 'video' && + stream.disposition.attached_pic !== true + ), + } + : undefined; + const structuralHasAnyAudio = structuralContext.clips.some( + (clip) => clip.hasAudio + ); + const structuralHasMissingAudio = structuralContext.clips.some( + (clip) => !clip.hasAudio + ); + const sourceExtension = inspectedClipAsset + ? extensionFromName(inspectedClipAsset.file.name) + : ''; + const sourceMuxer = sourceExtension + ? muxerForExtension(sourceExtension) + : undefined; + const advancedAvailability: Partial< + Readonly> + > = { + 'split-fast': sourceMuxer + ? capabilityStatus({ muxers: [sourceMuxer] }) + : { available: false, reason: 'Select a source first.' }, + 'split-accurate': capabilityStatus( + {}, + anyAccuratePreset ? undefined : 'No verified export preset is available.' + ), + 'waveform-static': capabilityStatus({ + muxers: ['image2'], + encoders: ['png'], + filters: ['color', 'aformat', 'showwavespic', 'overlay'], + }), + 'waveform-peaks': capabilityStatus({ + muxers: ['wav'], + encoders: ['pcm_s16le'], + }), + 'loudness-measure': capabilityStatus({ + muxers: ['null'], + filters: ['loudnorm'], + }), + 'peak-normalize': capabilityStatus( + { filters: ['volume'] }, + anyAudioPreset ? undefined : 'No verified audio preset is available.' + ), + 'loudness-normalize': capabilityStatus( + { filters: ['loudnorm'] }, + anyAudioPreset ? undefined : 'No verified audio preset is available.' + ), + 'thumbnail-series': capabilityStatus({ + filters: ['split', 'select', 'setpts'], + }), + 'contact-sheet': capabilityStatus({ + filters: ['fps', 'scale', 'tile'], + }), + 'contact-sheet-labels': { + available: false, + reason: + 'No reviewed local font is bundled, so labels are omitted instead of fetching a remote font.', + }, + 'metadata-write': sourceMuxer + ? capabilityStatus({ muxers: [sourceMuxer] }) + : { available: false, reason: 'Select a source first.' }, + 'chapter-write': sourceMuxer + ? capabilityStatus({ muxers: [sourceMuxer] }) + : { available: false, reason: 'Select a source first.' }, + 'subtitle-extract': capabilityStatus({}), + 'subtitle-convert': capabilityStatus({}), + 'subtitle-mux': capabilityStatus({}), + 'subtitle-burn': capabilityStatus( + { filters: ['subtitles'] }, + anyVideoPreset ? undefined : 'No verified video preset is available.' + ), + 'preset-storage': { available: true }, + }; + const structuralAvailability = { + 'trim-fast': sourceMuxer + ? capabilityStatus({ muxers: [sourceMuxer] }) + : { available: false, reason: 'Select an attached source first.' }, + 'trim-accurate': capabilityStatus( + {}, + anyAccuratePreset ? undefined : 'No verified export preset is available.' + ), + 'concat-fast': structuralContext.fastTargetMuxer + ? capabilityStatus({ + muxers: [structuralContext.fastTargetMuxer], + }) + : { + available: false, + reason: 'Attach and probe the first timeline source.', + }, + 'concat-normalized': capabilityStatus( + { + filters: [ + 'trim', + 'setpts', + 'scale', + 'pad', + 'setsar', + 'fps', + 'format', + 'concat', + ...(structuralHasAnyAudio + ? ['atrim', 'asetpts', 'aresample', 'aformat'] + : []), + ...(structuralHasAnyAudio && structuralHasMissingAudio + ? ['anullsrc'] + : []), + ], + }, + anyVideoPreset ? undefined : 'No verified video preset is available.' + ), + } as const; + + const advancedSource = inspectedClipAsset?.probe + ? { + sourceId: inspectedClipAsset.id, + ...(inspectedClipAsset.probe.durationSeconds === undefined + ? {} + : { + durationSeconds: inspectedClipAsset.probe.durationSeconds, + }), + sourceExtension, + hasAudio: inspectedClipAsset.probe.streams.some( + (stream) => stream.type === 'audio' + ), + hasVideo: inspectedClipAsset.probe.streams.some( + (stream) => + stream.type === 'video' && stream.disposition.attached_pic !== true + ), + ...(() => { + const index = inspectedClipAsset.probe?.streams.find( + (stream) => stream.type === 'audio' + )?.index; + return index === undefined ? {} : { selectedAudioStreamIndex: index }; + })(), + } + : undefined; + + const advancedMetadataStreams = + inspectedClipAsset?.probe?.streams.map((stream) => ({ + index: stream.index, + type: stream.type, + codec: stream.codecName ?? 'unknown', + ...(stream.language ? { language: stream.language } : {}), + ...(stream.title ? { title: stream.title } : {}), + disposition: stream.disposition, + })) ?? []; + const advancedChapters = project.chapters.map((chapter) => ({ + id: chapter.id, + title: chapter.title, + startSeconds: chapter.startSeconds, + endSeconds: chapter.endSeconds, + timeBase: chapter.timeBase ?? '1/1000', + })); + const advancedTimelineClips = inspectedClipAsset + ? project.timeline + .filter((clip) => clip.assetId === inspectedClipAsset.id) + .map((clip, index) => ({ + startSeconds: clip.sourceInSeconds, + endSeconds: clip.sourceOutSeconds, + label: `clip-${index + 1}`, + })) + : []; + const advancedSubtitleStreams = + inspectedClipAsset?.probe?.streams.flatMap((stream) => + stream.type === 'subtitle' + ? [ + { + index: stream.index, + codec: stream.codecName ?? 'unknown', + ...(stream.language ? { language: stream.language } : {}), + ...(stream.title ? { title: stream.title } : {}), + default: stream.disposition.default ?? false, + forced: stream.disposition.forced ?? false, + }, + ] + : [] + ) ?? []; + + const quickBusy = + engineState.status === 'loading' || + engineState.status === 'recovering' || + sourceProbeBusy || + waveformProgress !== undefined || + scheduledPlanCount >= 8; + const sourceManagementBusy = + sourceProbeBusy || scheduledPlanCount > 0 || waveformProgress !== undefined; + + const editWorkspace = ( + <> + +
+
+
+
+

Local sources

+

Media bin

+
+
+ {assets.length} + +
+
+ + +
+
+
+
+

Browser playback

+

Preview

+
+ + + Object URL + +
+ void createPreviewProxy(inspectedClipAsset)} + proxySettings={previewProxySettings} + onProxySettingsChange={setPreviewProxySettings} + proxyBusy={quickBusy} + {...(inspectedClipAsset + ? { + currentTimeSeconds: + playheadByAsset[inspectedClipAsset.id] ?? 0, + onCurrentTimeChange: (timeSeconds: number) => + setPlayheadByAsset((current) => ({ + ...current, + [inspectedClipAsset.id]: timeSeconds, + })), + } + : {})} + /> +
+ { + if (selectedClip) updateClip(selectedClip.id, changes); + }} + onMetadataChange={(metadata) => + applyProjectAction({ + type: 'metadata/set', + metadata, + updatedAt: timestamp(), + }) + } + onChaptersChange={(chapters) => + applyProjectAction({ + type: 'chapters/set', + chapters, + updatedAt: timestamp(), + }) + } + onSubtitlesChange={changeSubtitles} + onSubtitleImport={importSubtitleFile} + onThumbnail={extractThumbnail} + currentPreviewTimeSeconds={ + inspectedClipAsset + ? (playheadByAsset[inspectedClipAsset.id] ?? 0) + : undefined + } + drawerOpen={editDrawer === 'inspector'} + onDrawerClose={closeEditDrawer} + /> +
+ { + if (!selectedClip) return; + setPlayheadByAsset((current) => ({ + ...current, + [selectedClip.assetId]: timeSeconds, + })); + }} + onSelect={(clipId) => { + setSelectedClipId(clipId); + const clip = project.timeline.find((entry) => entry.id === clipId); + if (clip) setSelectedAssetId(clip.assetId); + }} + onMove={moveClip} + onUpdate={updateClip} + onSelectionChange={(selection) => { + if (!selectedClip) return; + setSplitMarkersByAsset((current) => ({ + ...current, + [selectedClip.assetId]: selection.markers, + })); + }} + onRemove={(clipId) => { + applyProjectAction({ + type: 'clip/remove', + clipId, + updatedAt: timestamp(), + }); + if (selectedClipId === clipId) setSelectedClipId(undefined); + }} + /> + = 2 + ? { + timeline: { + clips: structuralContext.clips, + fastTargetExtension: structuralContext.fastTargetExtension, + fastTargetMuxer: structuralContext.fastTargetMuxer, + fastCompatibilityDiagnostics: structuralContext.diagnostics, + }, + } + : {})} + exportPresets={advancedPresetOptions} + availability={structuralAvailability} + busy={quickBusy} + onTrim={handleStructuralTrim} + onConcat={handleStructuralConcat} + /> + +
+
+

Timeline export

+

Render the sequence

+

+ Normalize the sequential clips to these explicit typed settings. The + source files remain local. +

+
+
+ + + + + + + + + +
+
+

+ Empty canvas dimensions preserve the first transformed clip up to + 1920×1080 without upscaling. Video is required for this sequential + timeline renderer. +

+
+ {timelineOutputReason ? ( + {timelineOutputReason} + ) : null} + +
+
+
+ + ); + + return ( +
+
+
+ + +
+
+ {project.name} + + + + { + const file = event.currentTarget.files?.[0]; + if (file) void importProject(file); + event.currentTarget.value = ''; + }} + /> +
+
+ + {projectNotice ? ( +

+ + {projectNotice} +

+ ) : null} + + {waveformProgress !== undefined ? ( +
+ + + Building bounded waveform peaks locally…{' '} + {Math.round(waveformProgress * 100)}% + + + +
+ ) : null} + + {mode === 'quick-convert' ? ( +
+
+ + {assets.length > 0 ? ( +
+
+
+

Imported locally

+

Sources

+
+ {assets.length} +
+ +
+ ) : null} +
+ 0 + } + presets={[...BUILT_IN_PRESETS, ...userPresets]} + onExport={exportQuick} + /> +
+
+
+

Source or latest result

+

Preview

+
+
+ void createPreviewProxy(selectedAsset)} + proxySettings={previewProxySettings} + onProxySettingsChange={setPreviewProxySettings} + proxyBusy={quickBusy} + {...(selectedAsset + ? { + currentTimeSeconds: playheadByAsset[selectedAsset.id] ?? 0, + onCurrentTimeChange: (timeSeconds: number) => + setPlayheadByAsset((current) => ({ + ...current, + [selectedAsset.id]: timeSeconds, + })), + } + : {})} + /> +
+
+ ) : ( + editWorkspace + )} + + { + const activeJobId = activeScheduledJobIdRef.current; + if (activeJobId) void cancelScheduledJob(activeJobId); + else void engineManager.cancelActive(); + }} + onCancelJob={(job) => void cancelScheduledJob(job.id)} + onRetryJob={(job) => retryScheduledJob(job.id)} + onClearJobHistory={clearScheduledJobHistory} + onSave={(output) => void saveManagedResult(output)} + onSaveZip={(selected) => void saveManagedZip(selected)} + onSaveReport={() => void saveManagedReport()} + onPreview={(output) => setPreviewResultId(output.id)} + onRemove={(output) => { + resultCollection.remove(output.id); + refreshResults(); + }} + onClearResults={clearResult} + /> + { + setEnginePreference(preference); + engineManager.setPreference(preference); + }} + onInitialize={() => { + if (engineManager.state.status === 'error') { + engineManager.dispose(); + } + void engineManager.initialize().catch(() => undefined); + }} + /> + +
+ ); +} + +export default function App() { + const [helpOpen, setHelpOpen] = useState(false); + return ( + + setHelpOpen(true) }} + onContextError={(error) => { + console.warn( + 'Toolbox context unavailable; continuing standalone.', + error + ); + }} + > + + + setHelpOpen(false)} /> + + ); +} diff --git a/src/app/AppErrorBoundary.tsx b/src/app/AppErrorBoundary.tsx new file mode 100644 index 0000000..06c797f --- /dev/null +++ b/src/app/AppErrorBoundary.tsx @@ -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 { + 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 ( +
+

Application error

+

The workspace could not continue.

+

{this.state.error.message}

+ +
+ ); + } + return this.props.children; + } +} diff --git a/src/app/ApplicationRuntime.tsx b/src/app/ApplicationRuntime.tsx new file mode 100644 index 0000000..7bbc17b --- /dev/null +++ b/src/app/ApplicationRuntime.tsx @@ -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 ; +} diff --git a/src/app/application-state.ts b/src/app/application-state.ts new file mode 100644 index 0000000..5ac1851 --- /dev/null +++ b/src/app/application-state.ts @@ -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); +} diff --git a/src/app/derived-cache.ts b/src/app/derived-cache.ts new file mode 100644 index 0000000..8472e27 --- /dev/null +++ b/src/app/derived-cache.ts @@ -0,0 +1,61 @@ +import { + createCacheKey, + createSourceFingerprint, + openDerivedCache, + type DerivedCache, + type DerivedCacheKind, +} from '../storage'; + +let cachePromise: Promise | undefined; + +export function getDerivedCache(): Promise { + cachePromise ??= openDerivedCache(); + return cachePromise; +} + +function derivativeCacheKey( + file: File, + operation: string, + settings: Readonly> +): string { + return createCacheKey({ + sourceFingerprint: createSourceFingerprint(file), + operation, + settings, + version: 1, + }); +} + +export async function readCachedDerivative( + file: File, + operation: string, + settings: Readonly> +): Promise { + 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> +): Promise { + 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 + ); +} diff --git a/src/app/feature-detection.ts b/src/app/feature-detection.ts new file mode 100644 index 0000000..b72d792 --- /dev/null +++ b/src/app/feature-detection.ts @@ -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', + }; +} diff --git a/src/assets/hero.png b/src/assets/hero.png new file mode 100644 index 0000000..02251f4 Binary files /dev/null and b/src/assets/hero.png differ diff --git a/src/commands/chapters.ts b/src/commands/chapters.ts new file mode 100644 index 0000000..ca0add6 --- /dev/null +++ b/src/commands/chapters.ts @@ -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(); + const titles = new Set(); + 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 { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/src/commands/command-plan.ts b/src/commands/command-plan.ts new file mode 100644 index 0000000..5e67609 --- /dev/null +++ b/src/commands/command-plan.ts @@ -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; + } +} diff --git a/src/commands/command-utils.ts b/src/commands/command-utils.ts new file mode 100644 index 0000000..7fbeef1 --- /dev/null +++ b/src/commands/command-utils.ts @@ -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> = { + 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> = { + 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(); + 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'); +} diff --git a/src/commands/concatenate.ts b/src/commands/concatenate.ts new file mode 100644 index 0000000..3dc85c9 --- /dev/null +++ b/src/commands/concatenate.ts @@ -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, + }); +} diff --git a/src/commands/convert.ts b/src/commands/convert.ts new file mode 100644 index 0000000..9b49198 --- /dev/null +++ b/src/commands/convert.ts @@ -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 +): { + 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] }, + }; +} diff --git a/src/commands/fades.ts b/src/commands/fades.ts new file mode 100644 index 0000000..01bd9e5 --- /dev/null +++ b/src/commands/fades.ts @@ -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` + ); + } +} diff --git a/src/commands/filtergraph-builder.ts b/src/commands/filtergraph-builder.ts new file mode 100644 index 0000000..85d3e08 --- /dev/null +++ b/src/commands/filtergraph-builder.ts @@ -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 { + 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 { + readonly #order: readonly TStage[]; + readonly #nodes: FilterNode[] = []; + + constructor(order: readonly TStage[]) { + this.#order = order; + } + + add(node: FilterNode): 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 { + return new FiltergraphBuilder(VIDEO_ORDER); +} + +export function createAudioFiltergraph(): FiltergraphBuilder { + return new FiltergraphBuilder(AUDIO_ORDER); +} + +export function subtitleFilter(path: string): FilterNode { + 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[] { + assertFadeCurve(curve); + const nodes: FilterNode[] = []; + 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[] { + assertFadeCurve(curve); + const nodes: FilterNode[] = []; + 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 +): 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(':'); +} diff --git a/src/commands/index.ts b/src/commands/index.ts new file mode 100644 index 0000000..72c7d9e --- /dev/null +++ b/src/commands/index.ts @@ -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'; diff --git a/src/commands/loudness.ts b/src/commands/loudness.ts new file mode 100644 index 0000000..b431bff --- /dev/null +++ b/src/commands/loudness.ts @@ -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 { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/src/commands/metadata.ts b/src/commands/metadata.ts new file mode 100644 index 0000000..484369d --- /dev/null +++ b/src/commands/metadata.ts @@ -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>; + readonly streams?: Readonly< + Record>> + >; + readonly dispositions?: Readonly< + Record + >; +} + +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> + > = {}; + 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 = {}; + 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> | undefined { + if (value === undefined) { + return undefined; + } + if (!isRecord(value)) { + throw new TypeError(`${label} must be an object`); + } + const result: Record = {}; + 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( + value: Readonly> +): readonly (readonly [string, T])[] { + return Object.entries(value).sort(([left], [right]) => + left.localeCompare(right) + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/src/commands/preview-proxy.ts b/src/commands/preview-proxy.ts new file mode 100644 index 0000000..16de2ad --- /dev/null +++ b/src/commands/preview-proxy.ts @@ -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.` + ), + ], + }); +} diff --git a/src/commands/probe.ts b/src/commands/probe.ts new file mode 100644 index 0000000..d010125 --- /dev/null +++ b/src/commands/probe.ts @@ -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, + ]); +} diff --git a/src/commands/remux.ts b/src/commands/remux.ts new file mode 100644 index 0000000..414262b --- /dev/null +++ b/src/commands/remux.ts @@ -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>>>> +> = { + 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; +} diff --git a/src/commands/split.ts b/src/commands/split.ts new file mode 100644 index 0000000..5b0e24b --- /dev/null +++ b/src/commands/split.ts @@ -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.' + ), + ], + }); +} diff --git a/src/commands/streams.ts b/src/commands/streams.ts new file mode 100644 index 0000000..15979ec --- /dev/null +++ b/src/commands/streams.ts @@ -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>> = { + 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; +} diff --git a/src/commands/subtitles.ts b/src/commands/subtitles.ts new file mode 100644 index 0000000..4af6e7d --- /dev/null +++ b/src/commands/subtitles.ts @@ -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(); + 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); + } +} diff --git a/src/commands/thumbnails.ts b/src/commands/thumbnails.ts new file mode 100644 index 0000000..e4a4798 --- /dev/null +++ b/src/commands/thumbnails.ts @@ -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 1–500 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 32–4096 pixels'); + } + const spacing = options.spacing ?? 4; + if (!Number.isSafeInteger(spacing) || spacing < 0 || spacing > 100) { + throw new RangeError('Contact sheet spacing must be 0–100 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; +} diff --git a/src/commands/timeline-export.ts b/src/commands/timeline-export.ts new file mode 100644 index 0000000..18d9c23 --- /dev/null +++ b/src/commands/timeline-export.ts @@ -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 1–100 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(); + 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([ + '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` + ); + } +} diff --git a/src/commands/transform.ts b/src/commands/transform.ts new file mode 100644 index 0000000..d81bafe --- /dev/null +++ b/src/commands/transform.ts @@ -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[]; +} + +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['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); +} diff --git a/src/commands/trim.ts b/src/commands/trim.ts new file mode 100644 index 0000000..5d6c757 --- /dev/null +++ b/src/commands/trim.ts @@ -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.' + ), + ], + }); +} diff --git a/src/commands/waveform.ts b/src/commands/waveform.ts new file mode 100644 index 0000000..8a61a7a --- /dev/null +++ b/src/commands/waveform.ts @@ -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.' + ), + ], + }); +} diff --git a/src/components/AdvancedOperations.css b/src/components/AdvancedOperations.css new file mode 100644 index 0000000..e5ce90e --- /dev/null +++ b/src/components/AdvancedOperations.css @@ -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; + } +} diff --git a/src/components/AdvancedOperations.tsx b/src/components/AdvancedOperations.tsx new file mode 100644 index 0000000..ca94a38 --- /dev/null +++ b/src/components/AdvancedOperations.tsx @@ -0,0 +1,3935 @@ +import { + useId, + useRef, + useState, + type ChangeEvent, + type KeyboardEvent, +} from 'react'; +import { + EDITABLE_STREAM_DISPOSITIONS, + LOUDNESS_PROFILES, + classifySubtitleCodec, + createChaptersAtIntervals, + createChaptersFromMarkers, + parseChapterJson, + parseMetadataJson, + rangesForSplit, + serializeChapterJson, + serializeMetadataJson, + validateSplitMarkers, + type EditableChapter, + type ImageFormat, + type LoudnessMeasurement, + type LoudnessTargets, + type MetadataEdits, + type StreamDisposition, + type SplitDefinition, + type SubtitleFormat, + type SubtitleStreamDescription, + type TimeRange, +} from '../commands'; +import { + GENERATED_OUTPUT_LIMIT_EXPLANATION, + MAX_GENERATED_OUTPUT_FILES, +} from '../limits'; +import { + parseUserPresetJson, + serializeUserPreset, + validateUserPreset, + type AudioCodec, + type ChapterPolicy, + type MetadataPolicy, + type PresetKind, + type QualityMode, + type SubtitlePolicy, + type UserExportPreset, + type VideoCodec, +} from '../presets'; +import './AdvancedOperations.css'; + +export type AdvancedCapabilityKey = + | 'split-fast' + | 'split-accurate' + | 'waveform-static' + | 'waveform-peaks' + | 'loudness-measure' + | 'peak-normalize' + | 'loudness-normalize' + | 'thumbnail-series' + | 'contact-sheet' + | 'contact-sheet-labels' + | 'metadata-write' + | 'chapter-write' + | 'subtitle-extract' + | 'subtitle-convert' + | 'subtitle-mux' + | 'subtitle-burn' + | 'preset-storage'; + +export interface AdvancedCapabilityStatus { + readonly available: boolean; + readonly reason?: string; +} + +export interface AdvancedSourceContext { + readonly sourceId?: string; + readonly durationSeconds?: number; + readonly sourceExtension: string; + readonly hasAudio: boolean; + readonly hasVideo: boolean; + readonly selectedAudioStreamIndex?: number; +} + +export interface MetadataStreamDescription { + readonly index: number; + readonly type: string; + readonly codec: string; + readonly language?: string; + readonly title?: string; + readonly disposition: Readonly>; +} + +export interface AdvancedExportPresetOption { + readonly id: string; + readonly name: string; + readonly fileExtension: string; + readonly supportsAudio: boolean; + readonly supportsVideo: boolean; + readonly disabledReason?: string; +} + +export type AdvancedSplitRequest = + | { + readonly mode: 'fast'; + readonly definition: SplitDefinition; + readonly targetExtension: string; + } + | { + readonly mode: 'accurate'; + readonly definition: SplitDefinition; + readonly targetExtension: string; + readonly presetId: string; + }; + +export type AdvancedSplitSegmentState = + | 'queued' + | 'running' + | 'completed' + | 'verified' + | 'warning' + | 'failed' + | 'cancelled'; + +export interface AdvancedSplitSegmentStatus { + readonly outputId: string; + readonly fileName: string; + readonly startSeconds: number; + readonly endSeconds: number; + readonly state: AdvancedSplitSegmentState; + readonly message?: string; +} + +export interface AdvancedSplitBatchStatus { + readonly planId: string; + readonly segments: readonly AdvancedSplitSegmentStatus[]; +} + +export interface StaticWaveformRequest { + readonly type: 'static-waveform'; + readonly audioStreamIndex?: number; + readonly width: number; + readonly height: number; + readonly foreground: string; + readonly background: string; +} + +export interface WaveformPeaksRequest { + readonly type: 'waveform-peaks'; + readonly audioStreamIndex?: number; + readonly sampleRate: number; + readonly bucketCount: number; +} + +export interface LoudnessMeasurementRequest { + readonly type: 'loudness-measurement'; + readonly audioStreamIndex?: number; + readonly targets: LoudnessTargets; +} + +export interface PeakNormalizationRequest { + readonly type: 'peak-normalization'; + readonly audioStreamIndex?: number; + readonly targetPeakDb: number; + readonly presetId: string; +} + +export interface LoudnessNormalizationRequest { + readonly type: 'loudness-normalization'; + readonly audioStreamIndex?: number; + readonly targets: LoudnessTargets; + readonly measurement: LoudnessMeasurement; + readonly presetId: string; +} + +export type AdvancedAudioRequest = + | StaticWaveformRequest + | WaveformPeaksRequest + | LoudnessMeasurementRequest + | PeakNormalizationRequest + | LoudnessNormalizationRequest; + +export type ThumbnailDistribution = + | { readonly type: 'evenly-spaced'; readonly count: number } + | { readonly type: 'fixed-interval'; readonly intervalSeconds: number } + | { + readonly type: 'chapter-starts' | 'split-markers'; + readonly timesSeconds: readonly number[]; + }; + +export interface ThumbnailSeriesRequest { + readonly distribution: ThumbnailDistribution; + readonly width?: number; + readonly height?: number; + readonly format: ImageFormat; + readonly quality: number; +} + +export interface ContactSheetRequest { + 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; +} + +export type MetadataExecutionRequest = + | { + readonly type: 'apply'; + readonly edits: MetadataEdits; + readonly sourceMetadataPolicy: 'copy' | 'remove'; + readonly chapterPolicy: 'keep' | 'remove'; + readonly coverArtPolicy: 'keep' | 'remove'; + } + | { + readonly type: 'import'; + readonly edits: MetadataEdits; + } + | { + readonly type: 'export'; + readonly edits: MetadataEdits; + readonly json: string; + }; + +export type ChapterExecutionRequest = + | { + readonly type: 'replace'; + readonly chapters: readonly EditableChapter[]; + } + | { readonly type: 'remove' } + | { + readonly type: 'import'; + readonly chapters: readonly EditableChapter[]; + } + | { + readonly type: 'export'; + readonly chapters: readonly EditableChapter[]; + readonly json: string; + }; + +export type SubtitleExecutionRequest = + | { + readonly type: 'extract'; + readonly stream: SubtitleStreamDescription; + readonly outputFormat: SubtitleFormat; + } + | { + readonly type: 'convert'; + readonly file: File; + readonly inputFormat: SubtitleFormat; + readonly outputFormat: SubtitleFormat; + readonly offsetSeconds: number; + } + | { + readonly type: 'soft-mux'; + readonly file: File; + readonly inputFormat: SubtitleFormat; + readonly subtitleCodec: string; + readonly targetContainer: 'mp4' | 'webm' | 'matroska'; + readonly language?: string; + readonly title?: string; + readonly default: boolean; + readonly forced: boolean; + } + | { + readonly type: 'burn-in'; + readonly file: File; + readonly inputFormat: SubtitleFormat; + readonly presetId: string; + }; + +export type UserPresetAction = + | { readonly type: 'create'; readonly preset: UserExportPreset } + | { readonly type: 'update'; readonly preset: UserExportPreset } + | { + readonly type: 'clone'; + readonly presetId: string; + readonly newId: string; + readonly newName: string; + } + | { + readonly type: 'rename'; + readonly presetId: string; + readonly name: string; + } + | { readonly type: 'delete'; readonly presetId: string } + | { readonly type: 'reset-all' } + | { + readonly type: 'export'; + readonly preset: UserExportPreset; + readonly json: string; + } + | { readonly type: 'import'; readonly preset: UserExportPreset }; + +type RequestHandler = (request: T) => void | Promise; + +export interface AdvancedOperationsProps { + readonly source?: AdvancedSourceContext; + readonly splitMarkers?: readonly number[]; + readonly splitBatch?: AdvancedSplitBatchStatus; + readonly chapters?: readonly EditableChapter[]; + readonly timelineClips?: readonly TimeRange[]; + readonly metadataStreams?: readonly MetadataStreamDescription[]; + readonly metadataFormatTags?: Readonly>; + readonly subtitleStreams?: readonly SubtitleStreamDescription[]; + readonly loudnessMeasurement?: LoudnessMeasurement; + readonly exportPresets?: readonly AdvancedExportPresetOption[]; + readonly userPresets?: readonly UserExportPreset[]; + /** + * Missing entries are intentionally treated as unavailable. The integration + * layer must opt in only after checking the loaded FFmpeg core and browser. + */ + readonly availability?: Partial< + Readonly> + >; + readonly imageFormatAvailability?: Partial< + Readonly> + >; + readonly subtitleFormatAvailability?: Partial< + Readonly> + >; + readonly subtitleContainerAvailability?: Partial< + Readonly> + >; + readonly busy?: boolean; + readonly onSplit?: RequestHandler; + readonly onAudioAction?: RequestHandler; + readonly onThumbnailSeries?: RequestHandler; + readonly onContactSheet?: RequestHandler; + readonly onMetadataAction?: RequestHandler; + readonly onChapterAction?: RequestHandler; + readonly onSubtitleAction?: RequestHandler; + readonly onUserPresetAction?: RequestHandler; +} + +type OperationTab = + | 'split' + | 'audio' + | 'images' + | 'metadata' + | 'chapters' + | 'subtitles' + | 'presets'; + +const OPERATION_TABS: readonly { + readonly id: OperationTab; + readonly label: string; + readonly shortLabel: string; +}[] = [ + { id: 'split', label: 'Split media', shortLabel: 'Split' }, + { id: 'audio', label: 'Waveform and loudness', shortLabel: 'Audio' }, + { + id: 'images', + label: 'Thumbnails and contact sheets', + shortLabel: 'Images', + }, + { id: 'metadata', label: 'Metadata policies', shortLabel: 'Metadata' }, + { id: 'chapters', label: 'Chapter execution', shortLabel: 'Chapters' }, + { id: 'subtitles', label: 'Subtitle operations', shortLabel: 'Subtitles' }, + { id: 'presets', label: 'User export presets', shortLabel: 'Presets' }, +]; + +interface ActionContext { + readonly reasonFor: ( + capability: AdvancedCapabilityKey, + localReason: string | undefined, + connected: boolean + ) => string | undefined; + readonly run: ( + handler: RequestHandler | undefined, + request: T, + successMessage: string + ) => void; +} + +export function AdvancedOperations({ + source, + splitMarkers = [], + splitBatch, + chapters = [], + timelineClips = [], + metadataStreams = [], + metadataFormatTags, + subtitleStreams = [], + loudnessMeasurement, + exportPresets = [], + userPresets = [], + availability = {}, + imageFormatAvailability = {}, + subtitleFormatAvailability = {}, + subtitleContainerAvailability = {}, + busy = false, + onSplit, + onAudioAction, + onThumbnailSeries, + onContactSheet, + onMetadataAction, + onChapterAction, + onSubtitleAction, + onUserPresetAction, +}: AdvancedOperationsProps) { + const componentId = useId(); + const [activeTab, setActiveTab] = useState('split'); + const [pending, setPending] = useState(false); + const [status, setStatus] = useState(); + const tabRefs = useRef>([]); + + const reasonFor: ActionContext['reasonFor'] = ( + capability, + localReason, + connected + ) => { + if (busy || pending) { + return 'Another local operation is currently running.'; + } + if (localReason) { + return localReason; + } + if (!connected) { + return 'This action has not been connected to the job runner.'; + } + const capabilityStatus = availability[capability]; + if (!capabilityStatus?.available) { + return ( + capabilityStatus?.reason ?? + 'The required browser and FFmpeg capabilities have not been verified.' + ); + } + return undefined; + }; + + const run: ActionContext['run'] = ( + handler: RequestHandler | undefined, + request: T, + successMessage: string + ) => { + if (!handler) { + return; + } + setPending(true); + setStatus(undefined); + void Promise.resolve(handler(request)) + .then(() => setStatus(successMessage)) + .catch((error: unknown) => + setStatus( + error instanceof Error + ? error.message + : 'The requested local operation failed.' + ) + ) + .finally(() => setPending(false)); + }; + + const actionContext: ActionContext = { reasonFor, run }; + + const handleTabKeyDown = ( + event: KeyboardEvent, + index: number + ) => { + let nextIndex: number | undefined; + if (event.key === 'ArrowRight' || event.key === 'ArrowDown') { + nextIndex = (index + 1) % OPERATION_TABS.length; + } else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') { + nextIndex = (index - 1 + OPERATION_TABS.length) % OPERATION_TABS.length; + } else if (event.key === 'Home') { + nextIndex = 0; + } else if (event.key === 'End') { + nextIndex = OPERATION_TABS.length - 1; + } + if (nextIndex === undefined) { + return; + } + event.preventDefault(); + const next = OPERATION_TABS[nextIndex]; + if (!next) { + return; + } + setActiveTab(next.id); + tabRefs.current[nextIndex]?.focus(); + }; + + return ( +
+
+
+

Typed local workflows

+

Advanced operations

+

+ Configure reviewed operations only. This surface never accepts raw + FFmpeg arguments, filter graphs, remote URLs, or filesystem paths. +

+
+ Runs in this browser +
+ +
+ {OPERATION_TABS.map((tab, index) => ( + + ))} +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ {status} +
+
+ ); +} + +function OperationPanel({ + id, + tab, + activeTab, + children, +}: { + readonly id: string; + readonly tab: OperationTab; + readonly activeTab: OperationTab; + readonly children: React.ReactNode; +}) { + return ( + + ); +} + +function SplitPanel({ + source, + initialMarkers, + splitBatch, + chapters, + timelineClips, + exportPresets, + onSplit, + action, +}: { + readonly source?: AdvancedSourceContext; + readonly initialMarkers: readonly number[]; + readonly splitBatch?: AdvancedSplitBatchStatus; + readonly chapters: readonly EditableChapter[]; + readonly timelineClips: readonly TimeRange[]; + readonly exportPresets: readonly AdvancedExportPresetOption[]; + readonly onSplit?: RequestHandler; + readonly action: ActionContext; +}) { + const splitModeName = useId(); + const [definitionType, setDefinitionType] = + useState('markers'); + const [mode, setMode] = useState<'fast' | 'accurate'>('fast'); + const [markerOverride, setMarkerOverride] = useState(); + const [markerInput, setMarkerInput] = useState(''); + const [segmentDuration, setSegmentDuration] = useState('60'); + const [partCount, setPartCount] = useState('3'); + const [targetExtensionOverride, setTargetExtensionOverride] = + useState(); + const [presetId, setPresetId] = useState(''); + + const markers = markerOverride ?? initialMarkers; + const targetExtension = + targetExtensionOverride ?? + normalizeExtension(source?.sourceExtension ?? 'mkv'); + const duration = source?.durationSeconds; + const availablePresets = exportPresets.filter( + (preset) => !preset.disabledReason + ); + const effectivePresetId = + availablePresets.find((preset) => preset.id === presetId)?.id ?? + availablePresets[0]?.id ?? + ''; + const selectedPreset = exportPresets.find( + (preset) => preset.id === effectivePresetId + ); + + let definition: SplitDefinition | undefined; + let definitionReason: string | undefined; + let segmentCount: number | undefined; + let segmentRanges: readonly TimeRange[] = []; + if (!isPositive(duration)) { + definitionReason = 'A probed source duration is required.'; + } else { + try { + if (definitionType === 'markers') { + definition = { + type: 'markers', + markers: validateSplitMarkers(markers, duration), + }; + if (markers.length === 0) { + definitionReason = 'Add at least one marker inside the source.'; + } + } else if (definitionType === 'equal-duration') { + definition = { + type: 'equal-duration', + segmentDurationSeconds: Number(segmentDuration), + }; + } else if (definitionType === 'part-count') { + definition = { type: 'part-count', parts: Number(partCount) }; + } else if (definitionType === 'chapters') { + definition = { + type: 'chapters', + chapters: chapters.map(chapterToRange), + }; + if (chapters.length === 0) { + definitionReason = 'No validated chapter ranges are available.'; + } + } else { + definition = { type: 'clips', clips: timelineClips }; + if (timelineClips.length === 0) { + definitionReason = 'The timeline has no clips to export separately.'; + } + } + if (definition && !definitionReason) { + segmentRanges = rangesForSplit(definition, duration); + segmentCount = segmentRanges.length; + } + } catch (error) { + definitionReason = + error instanceof Error + ? error.message + : 'The split settings are invalid.'; + } + } + + const extensionReason = /^[a-z0-9]{1,10}$/u.test(targetExtension) + ? undefined + : 'Use a 1–10 character lowercase file extension.'; + const modeReason = + mode === 'accurate' && !selectedPreset + ? 'Accurate splitting requires an available export preset.' + : undefined; + const localReason = !source + ? 'Select a source before splitting.' + : (definitionReason ?? extensionReason ?? modeReason); + const capability = mode === 'fast' ? 'split-fast' : 'split-accurate'; + const reason = action.reasonFor(capability, localReason, Boolean(onSplit)); + + const addMarker = () => { + if (!isPositive(duration)) { + return; + } + const next = Number(markerInput); + try { + const validated = validateSplitMarkers([...markers, next], duration); + setMarkerOverride([...validated]); + setMarkerInput(''); + } catch { + // The inline validity message below remains the single user-facing error. + } + }; + + return ( +
+
+
+ 1 +
+

Segment definition

+

Every output receives a deterministic name and visible range.

+
+
+ + + {definitionType === 'markers' ? ( + <> +
+ + +
+ {markers.length > 0 ? ( +
    + {[...markers] + .sort((left, right) => left - right) + .map((marker) => ( +
  1. + {formatSeconds(marker)} + +
  2. + ))} +
+ ) : ( +

No manual markers yet.

+ )} + + ) : null} + + {definitionType === 'equal-duration' ? ( + + ) : null} + + {definitionType === 'part-count' ? ( + + ) : null} + + {segmentCount !== undefined ? ( + <> +

+ This configuration creates {segmentCount}{' '} + {segmentCount === 1 ? 'segment' : 'segments'} in one FFmpeg + execution. +

+ + + ) : ( + + )} +
+ +
+
+ 2 +
+

Boundary strategy

+

Choose speed or frame-accurate boundaries explicitly.

+
+
+
+ Split strategy + + +
+ + {mode === 'fast' ? ( + + ) : ( + + )} + + { + if (!definition || reason) { + return; + } + const request: AdvancedSplitRequest = + mode === 'fast' + ? { + mode, + definition, + targetExtension, + } + : { + mode, + definition, + targetExtension: selectedPreset?.fileExtension ?? '', + presetId: effectivePresetId, + }; + action.run(onSplit, request, 'Split job added to the local queue.'); + }} + /> +
+ {splitBatch ? : null} +
+ ); +} + +function SegmentRangeList({ + ranges, + label, +}: { + readonly ranges: readonly TimeRange[]; + readonly label: string; +}) { + return ( +
+ {label} +
    + {ranges.map((range, index) => ( +
  1. + Segment {String(index + 1).padStart(3, '0')} + + {formatSplitTime(range.startSeconds)}– + {formatSplitTime(range.endSeconds)} + + + {formatSplitTime(range.endSeconds - range.startSeconds)} duration + +
  2. + ))} +
+
+ ); +} + +function SplitBatchStatus({ + batch, +}: { + readonly batch: AdvancedSplitBatchStatus; +}) { + return ( +
+
+ S +
+

Latest split batch status

+

+ All segments share one FFmpeg execution; each expected output is + still tracked and verified separately. +

+
+
+
    + {batch.segments.map((segment) => ( +
  1. +
    + {segment.fileName} + + {formatSplitTime(segment.startSeconds)}– + {formatSplitTime(segment.endSeconds)} + +
    + {segment.state} + {segment.message ? {segment.message} : null} +
  2. + ))} +
+
+ ); +} + +function AudioPanel({ + source, + measurement, + exportPresets, + onAction, + action, +}: { + readonly source?: AdvancedSourceContext; + readonly measurement?: LoudnessMeasurement; + readonly exportPresets: readonly AdvancedExportPresetOption[]; + readonly onAction?: RequestHandler; + readonly action: ActionContext; +}) { + const [waveformWidth, setWaveformWidth] = useState('1600'); + const [waveformHeight, setWaveformHeight] = useState('320'); + const [waveformForeground, setWaveformForeground] = useState('#5b7cff'); + const [waveformBackground, setWaveformBackground] = useState('#101629'); + const [sampleRate, setSampleRate] = useState('8000'); + const [bucketCount, setBucketCount] = useState('2000'); + const [profile, setProfile] = useState< + keyof typeof LOUDNESS_PROFILES | 'custom' + >('web-video'); + const [integratedLufs, setIntegratedLufs] = useState('-14'); + const [truePeakDbtp, setTruePeakDbtp] = useState('-1'); + const [loudnessRange, setLoudnessRange] = useState('11'); + const [targetPeakDb, setTargetPeakDb] = useState('-1'); + const [presetId, setPresetId] = useState(''); + + const audioPresets = exportPresets.filter( + (preset) => preset.supportsAudio && !preset.supportsVideo + ); + const effectivePresetId = + audioPresets.find( + (preset) => preset.id === presetId && !preset.disabledReason + )?.id ?? + audioPresets.find((preset) => !preset.disabledReason)?.id ?? + ''; + const presetReason = effectivePresetId + ? undefined + : 'An available audio-capable export preset is required.'; + const sourceReason = !source + ? 'Select a source first.' + : !source.hasAudio + ? 'The selected source has no audio stream.' + : undefined; + const targets = { + integratedLufs: Number(integratedLufs), + truePeakDbtp: Number(truePeakDbtp), + loudnessRangeLu: Number(loudnessRange), + }; + const targetsReason = loudnessTargetsReason(targets); + const staticReason = + sourceReason ?? + integerRangeReason(waveformWidth, 64, 8192, 'Width') ?? + integerRangeReason(waveformHeight, 32, 4096, 'Height'); + const peaksReason = + sourceReason ?? + integerRangeReason(sampleRate, 1000, 48000, 'Sample rate') ?? + integerRangeReason(bucketCount, 64, 100_000, 'Peak bucket count'); + const peakNormalizeReason = + sourceReason ?? + numberRangeReason(targetPeakDb, -20, 0, 'Peak target') ?? + presetReason; + const loudnessNormalizeReason = + sourceReason ?? + targetsReason ?? + presetReason ?? + (measurement + ? undefined + : 'Run the first-pass loudness measurement before normalization.'); + + const changeProfile = ( + nextProfile: keyof typeof LOUDNESS_PROFILES | 'custom' + ) => { + setProfile(nextProfile); + if (nextProfile === 'custom') { + return; + } + const values = LOUDNESS_PROFILES[nextProfile]; + setIntegratedLufs(String(values.integratedLufs)); + setTruePeakDbtp(String(values.truePeakDbtp)); + setLoudnessRange(String(values.loudnessRangeLu)); + }; + + return ( +
+
+
+ W +
+

Waveform derivatives

+

+ Only reduced analysis audio and bounded peak arrays may be cached; + original media is never stored automatically. +

+
+
+
+ + + + +
+ + action.run( + onAction, + { + type: 'static-waveform', + audioStreamIndex: source?.selectedAudioStreamIndex, + width: Number(waveformWidth), + height: Number(waveformHeight), + foreground: waveformForeground, + background: waveformBackground, + }, + 'Static waveform job added to the local queue.' + ) + } + /> + +
+
+ + +
+

+ FFmpeg first creates reduced-rate mono PCM. A dedicated worker then + aggregates min/max peaks and can be cancelled without retaining the + source. +

+ + action.run( + onAction, + { + type: 'waveform-peaks', + audioStreamIndex: source?.selectedAudioStreamIndex, + sampleRate: Number(sampleRate), + bucketCount: Number(bucketCount), + }, + 'Waveform peak analysis added to the local queue.' + ) + } + /> +
+ +
+
+ L +
+

Loudness and normalization

+

+ Peak matching is not perceived-loudness normalization. EBU R128 + uses a measured first pass and a separate apply pass. +

+
+
+ +
+ { + setIntegratedLufs(value); + setProfile('custom'); + }} + /> + { + setTruePeakDbtp(value); + setProfile('custom'); + }} + /> + { + setLoudnessRange(value); + setProfile('custom'); + }} + /> +
+
+ + action.run( + onAction, + { + type: 'loudness-measurement', + audioStreamIndex: source?.selectedAudioStreamIndex, + targets, + }, + 'First-pass loudness measurement added to the local queue.' + ) + } + /> + { + if (!measurement) { + return; + } + action.run( + onAction, + { + type: 'loudness-normalization', + audioStreamIndex: source?.selectedAudioStreamIndex, + targets, + measurement, + presetId: effectivePresetId, + }, + 'Second-pass loudness normalization added to the local queue.' + ); + }} + /> +
+ {measurement ? ( +
+
+
Measured loudness
+
{measurement.inputIntegratedLufs} LUFS
+
+
+
Measured true peak
+
{measurement.inputTruePeakDbtp} dBTP
+
+
+
Measured range
+
{measurement.inputLoudnessRangeLu} LU
+
+
+ ) : ( +

+ No complete loudnorm measurement is attached to this source. +

+ )} + +
+
+ + +
+ + action.run( + onAction, + { + type: 'peak-normalization', + audioStreamIndex: source?.selectedAudioStreamIndex, + targetPeakDb: Number(targetPeakDb), + presetId: effectivePresetId, + }, + 'Peak normalization added to the local queue.' + ) + } + /> +
+
+ ); +} + +function ImagePanel({ + source, + markers, + chapters, + formatAvailability, + labelAvailability, + onThumbnailSeries, + onContactSheet, + action, +}: { + readonly source?: AdvancedSourceContext; + readonly markers: readonly number[]; + readonly chapters: readonly EditableChapter[]; + readonly formatAvailability: Partial< + Readonly> + >; + readonly labelAvailability?: AdvancedCapabilityStatus; + readonly onThumbnailSeries?: RequestHandler; + readonly onContactSheet?: RequestHandler; + readonly action: ActionContext; +}) { + const [distribution, setDistribution] = + useState('evenly-spaced'); + const [seriesCount, setSeriesCount] = useState('12'); + const [interval, setInterval] = useState('30'); + const [width, setWidth] = useState('480'); + const [height, setHeight] = useState(''); + const [format, setFormat] = useState('jpeg'); + const [quality, setQuality] = useState('85'); + const [sheetFrames, setSheetFrames] = useState('12'); + const [sheetRows, setSheetRows] = useState('3'); + const [sheetColumns, setSheetColumns] = useState('4'); + const [cellWidth, setCellWidth] = useState('320'); + const [spacing, setSpacing] = useState('6'); + const [background, setBackground] = useState('#101629'); + const [maintainAspect, setMaintainAspect] = useState(true); + const [timestampLabels, setTimestampLabels] = useState(false); + const [sourceLabel, setSourceLabel] = useState(false); + + const videoReason = !source + ? 'Select a source first.' + : !source.hasVideo + ? 'The selected source has no video stream.' + : !isPositive(source.durationSeconds) + ? 'A probed source duration is required.' + : undefined; + const formatReason = formatAvailability[format]?.available + ? undefined + : (formatAvailability[format]?.reason ?? + `${format.toUpperCase()} image output has not been verified.`); + const sizeReason = + optionalIntegerRangeReason(width, 1, 8192, 'Width') ?? + optionalIntegerRangeReason(height, 1, 8192, 'Height'); + let distributionReason: string | undefined; + let resolvedDistribution: ThumbnailDistribution | undefined; + if (distribution === 'evenly-spaced') { + distributionReason = integerRangeReason( + seriesCount, + 1, + MAX_GENERATED_OUTPUT_FILES, + 'Frame count' + ); + resolvedDistribution = { + type: distribution, + count: Number(seriesCount), + }; + } else if (distribution === 'fixed-interval') { + distributionReason = numberRangeReason( + interval, + 0.04, + source?.durationSeconds ?? Number.MAX_SAFE_INTEGER, + 'Interval' + ); + if ( + !distributionReason && + isPositive(source?.durationSeconds) && + Math.ceil(source.durationSeconds / Number(interval)) > + MAX_GENERATED_OUTPUT_FILES + ) { + distributionReason = `This interval would create more than ${MAX_GENERATED_OUTPUT_FILES} images. ${GENERATED_OUTPUT_LIMIT_EXPLANATION}`; + } + resolvedDistribution = { + type: distribution, + intervalSeconds: Number(interval), + }; + } else if (distribution === 'chapter-starts') { + const times = chapters.map((chapter) => chapter.startSeconds); + distributionReason = + times.length === 0 + ? 'No chapter starts are available.' + : times.length > MAX_GENERATED_OUTPUT_FILES + ? `This source has more than ${MAX_GENERATED_OUTPUT_FILES} chapter starts. ${GENERATED_OUTPUT_LIMIT_EXPLANATION}` + : undefined; + resolvedDistribution = { type: distribution, timesSeconds: times }; + } else { + distributionReason = + markers.length === 0 + ? 'No split markers are available.' + : markers.length > MAX_GENERATED_OUTPUT_FILES + ? `This selection has more than ${MAX_GENERATED_OUTPUT_FILES} split markers. ${GENERATED_OUTPUT_LIMIT_EXPLANATION}` + : undefined; + resolvedDistribution = { + type: distribution, + timesSeconds: [...markers], + }; + } + const seriesReason = + videoReason ?? + distributionReason ?? + sizeReason ?? + numberRangeReason(quality, 1, 100, 'Quality') ?? + formatReason; + + const labelsRequested = timestampLabels || sourceLabel; + const labelReason = + labelsRequested && !labelAvailability?.available + ? (labelAvailability?.reason ?? + 'Timestamp and filename labels require verified drawtext and a safe local font.') + : undefined; + const gridReason = + integerRangeReason(sheetFrames, 1, 500, 'Frame count') ?? + integerRangeReason(sheetRows, 1, 500, 'Rows') ?? + integerRangeReason(sheetColumns, 1, 500, 'Columns') ?? + integerRangeReason(cellWidth, 32, 4096, 'Cell width') ?? + integerRangeReason(spacing, 0, 100, 'Spacing') ?? + (Number(sheetRows) * Number(sheetColumns) < Number(sheetFrames) + ? 'The grid needs at least one cell per requested frame.' + : Number(sheetRows) * Number(sheetColumns) > 500 + ? 'The grid may contain at most 500 cells.' + : undefined); + const contactReason = + videoReason ?? + gridReason ?? + numberRangeReason(quality, 1, 100, 'Quality') ?? + formatReason ?? + labelReason; + + return ( +
+
+
+ T +
+

Thumbnail series

+

+ Decode the sequence once, preview each result, then save or ZIP. +

+
+
+ + {distribution === 'evenly-spaced' ? ( + + ) : null} + {distribution === 'fixed-interval' ? ( + + ) : null} + + { + if (!resolvedDistribution) { + return; + } + action.run( + onThumbnailSeries, + { + distribution: resolvedDistribution, + ...(width ? { width: Number(width) } : {}), + ...(height ? { height: Number(height) } : {}), + format, + quality: Number(quality), + }, + 'Thumbnail series added to the local queue.' + ); + }} + /> +
+ +
+
+ C +
+

Contact sheet

+

+ Produces one independent image. There is no runtime dependency on + image-tools. +

+
+
+
+ + + + + + +
+
+ + + +
+ {!labelAvailability?.available ? ( +

+ {labelAvailability?.reason ?? + 'Labels stay off until drawtext and a safe local font are verified.'} +

+ ) : null} + + + action.run( + onContactSheet, + { + frameCount: Number(sheetFrames), + rows: Number(sheetRows), + columns: Number(sheetColumns), + cellWidth: Number(cellWidth), + spacing: Number(spacing), + background, + maintainAspect, + timestampLabels, + sourceLabel, + format, + quality: Number(quality), + }, + 'Contact sheet added to the local queue.' + ) + } + /> +
+
+ ); +} + +function MetadataPanel({ + source, + streams, + formatTags, + onAction, + action, +}: { + readonly source?: AdvancedSourceContext; + readonly streams: readonly MetadataStreamDescription[]; + readonly formatTags?: Readonly>; + readonly onAction?: RequestHandler; + readonly action: ActionContext; +}) { + const [title, setTitle] = useState(() => metadataText(formatTags?.title)); + const [artist, setArtist] = useState(() => metadataText(formatTags?.artist)); + const [album, setAlbum] = useState(() => metadataText(formatTags?.album)); + const [date, setDate] = useState(() => metadataText(formatTags?.date)); + const [comment, setComment] = useState(() => + metadataText(formatTags?.comment) + ); + const [touchedFields, setTouchedFields] = useState< + ReadonlySet + >(() => new Set()); + const [importedFormatExtras, setImportedFormatExtras] = useState< + Readonly> + >({}); + const [sourcePolicy, setSourcePolicy] = useState<'copy' | 'remove'>('copy'); + const [chapterPolicy, setChapterPolicy] = useState<'keep' | 'remove'>('keep'); + const [coverPolicy, setCoverPolicy] = useState<'keep' | 'remove'>('keep'); + const [streamEdits, setStreamEdits] = useState(); + const [streamDispositions, setStreamDispositions] = + useState(); + const [fileMessage, setFileMessage] = useState(); + + const commonFormatEdits = + metadataFromFields({ title, artist, album, date, comment }, touchedFields) + .format ?? {}; + const edits: MetadataEdits = { + format: { ...importedFormatExtras, ...commonFormatEdits }, + ...(streamEdits ? { streams: streamEdits } : {}), + ...(streamDispositions ? { dispositions: streamDispositions } : {}), + }; + const writeReason = action.reasonFor( + 'metadata-write', + source ? undefined : 'Select a source before writing metadata.', + Boolean(onAction) + ); + + const updateMetadataField = ( + field: MetadataFieldKey, + setter: (value: string) => void, + value: string + ) => { + setter(value); + setTouchedFields((current) => new Set(current).add(field)); + }; + + const importMetadata = async (event: ChangeEvent) => { + const file = event.currentTarget.files?.[0]; + event.currentTarget.value = ''; + if (!file) { + return; + } + try { + const json = await readSmallTextFile(file, 256 * 1024); + const imported = parseMetadataJson(json); + setMetadataFields(imported, { + setTitle, + setArtist, + setAlbum, + setDate, + setComment, + }); + setTouchedFields( + new Set( + METADATA_FIELD_KEYS.filter( + (key) => imported.format?.[key] !== undefined + ) + ) + ); + setImportedFormatExtras( + Object.fromEntries( + Object.entries(imported.format ?? {}).filter( + ([key]) => !METADATA_FIELD_KEYS.includes(key as MetadataFieldKey) + ) + ) + ); + setStreamEdits(imported.streams); + setStreamDispositions(imported.dispositions); + setFileMessage(`Validated ${file.name}. Review before applying.`); + action.run( + onAction, + { type: 'import', edits: imported }, + 'Metadata JSON imported into the typed editor.' + ); + } catch (error) { + setFileMessage( + error instanceof Error ? error.message : 'Metadata import failed.' + ); + } + }; + + const streamTagValue = ( + stream: MetadataStreamDescription, + key: 'language' | 'title' + ): string => { + const edited = streamEdits?.[stream.index]?.[key]; + if (edited !== undefined) { + return edited === null ? '' : String(edited); + } + return stream[key] ?? ''; + }; + + const updateStreamTag = ( + streamIndex: number, + key: 'language' | 'title', + value: string + ) => { + setStreamEdits((current) => ({ + ...current, + [streamIndex]: { + ...current?.[streamIndex], + [key]: value === '' ? null : value, + }, + })); + }; + + const dispositionsFor = ( + stream: MetadataStreamDescription + ): readonly StreamDisposition[] => { + const edited = streamDispositions?.[stream.index]; + if (edited !== undefined) return edited; + return EDITABLE_STREAM_DISPOSITIONS.filter( + (disposition) => stream.disposition[disposition] === true + ); + }; + + const toggleDisposition = ( + stream: MetadataStreamDescription, + disposition: StreamDisposition, + enabled: boolean + ) => { + const current = new Set(dispositionsFor(stream)); + if (enabled) current.add(disposition); + else current.delete(disposition); + setStreamDispositions((edits) => ({ + ...edits, + [stream.index]: EDITABLE_STREAM_DISPOSITIONS.filter((entry) => + current.has(entry) + ), + })); + }; + + return ( +
+
+
+ M +
+

Container metadata

+

+ Common tags are allowlisted. Output is re-probed to report which + tags and container-specific fields survived. +

+
+
+
+ updateMetadataField('title', setTitle, value)} + /> + + updateMetadataField('artist', setArtist, value) + } + /> + updateMetadataField('album', setAlbum, value)} + /> + updateMetadataField('date', setDate, value)} + /> + + updateMetadataField('comment', setComment, value) + } + /> +
+ {streamEdits ? ( +

+ {Object.keys(streamEdits).length} stream metadata edit + {Object.keys(streamEdits).length === 1 ? '' : 's'} loaded from the + validated JSON and retained for Apply/Export. +

+ ) : ( +

+ Stream language, title, and other allowlisted tags can be supplied + through the reproducible metadata JSON editor below. +

+ )} + {streams.length > 0 ? ( +
+ {streams.map((stream) => { + const dispositions = dispositionsFor(stream); + return ( +
+ + #{stream.index} · {stream.type} · {stream.codec} + +
+ + updateStreamTag(stream.index, 'language', value) + } + /> + + updateStreamTag(stream.index, 'title', value) + } + /> +
+
+ {(['default', 'forced'] as const).map((disposition) => ( + + toggleDisposition(stream, disposition, enabled) + } + /> + ))} +
+
+ Other stream dispositions +
+ {EDITABLE_STREAM_DISPOSITIONS.filter( + (disposition) => + disposition !== 'default' && disposition !== 'forced' + ).map((disposition) => ( + + toggleDisposition(stream, disposition, enabled) + } + /> + ))} +
+
+ + Dispositions are requested explicitly; the target muxer may + normalize or auto-assign defaults, which the result probe + reports. + +
+ ); + })} +
+ ) : ( +

+ Probe a source to edit per-stream language, title, and disposition. +

+ )} +
+ setSourcePolicy(value as 'copy' | 'remove')} + /> + setChapterPolicy(value as 'keep' | 'remove')} + /> + setCoverPolicy(value as 'keep' | 'remove')} + /> +
+ {sourcePolicy === 'remove' ? ( +

+ “Remove” prevents copying common removable tags. It does not claim + that every structural or container-specific metadata field + disappears. +

+ ) : null} +
+ + action.run( + onAction, + { + type: 'apply', + edits, + sourceMetadataPolicy: sourcePolicy, + chapterPolicy, + coverArtPolicy: coverPolicy, + }, + 'Metadata operation added to the local queue.' + ) + } + /> + + action.run( + onAction, + { type: 'export', edits, json: serializeMetadataJson(edits) }, + 'Versioned metadata JSON prepared for saving.' + ) + } + /> + +
+ {fileMessage ?

{fileMessage}

: null} +
+
+ ); +} + +function ChapterPanel({ + source, + markers, + chapters, + onAction, + action, +}: { + readonly source?: AdvancedSourceContext; + readonly markers: readonly number[]; + readonly chapters: readonly EditableChapter[]; + readonly onAction?: RequestHandler; + readonly action: ActionContext; +}) { + const [chapterSource, setChapterSource] = useState< + 'existing' | 'markers' | 'fixed' + >('existing'); + const [interval, setInterval] = useState('300'); + const [fileMessage, setFileMessage] = useState(); + const duration = source?.durationSeconds; + + let proposedChapters: readonly EditableChapter[] = chapters; + let chapterReason: string | undefined; + try { + if (chapterSource === 'markers') { + if (!isPositive(duration) || markers.length === 0) { + chapterReason = + 'Validated split markers and a probed duration are required.'; + } else { + proposedChapters = createChaptersFromMarkers(markers, duration); + } + } else if (chapterSource === 'fixed') { + if (!isPositive(duration)) { + chapterReason = 'A probed source duration is required.'; + } else { + proposedChapters = createChaptersAtIntervals( + duration, + Number(interval) + ); + } + } else if (chapters.length === 0) { + chapterReason = 'There are no edited chapters to write.'; + } + } catch (error) { + chapterReason = + error instanceof Error ? error.message : 'Chapter settings are invalid.'; + } + + const writeReason = action.reasonFor( + 'chapter-write', + !source ? 'Select a source before writing chapters.' : chapterReason, + Boolean(onAction) + ); + + const importChapters = async (event: ChangeEvent) => { + const file = event.currentTarget.files?.[0]; + event.currentTarget.value = ''; + if (!file) { + return; + } + try { + const json = await readSmallTextFile(file, 256 * 1024); + const document = parseChapterJson(json); + setFileMessage( + `Validated ${String(document.chapters.length)} chapter(s) from ${file.name}.` + ); + action.run( + onAction, + { type: 'import', chapters: document.chapters }, + 'Chapter JSON imported into the project.' + ); + } catch (error) { + setFileMessage( + error instanceof Error ? error.message : 'Chapter import failed.' + ); + } + }; + + return ( +
+
+
+ C +
+

Chapter source

+

+ Chapter titles and times are validated before an escaped + FFMETADATA derivative is generated. +

+
+
+ + {chapterSource === 'fixed' ? ( + + ) : null} +

+ {chapterReason ?? + `${String(proposedChapters.length)} chapter(s) will be written and verified by re-probing.`} +

+
+ + action.run( + onAction, + { type: 'replace', chapters: proposedChapters }, + 'Chapter mux operation added to the local queue.' + ) + } + /> + + action.run( + onAction, + { type: 'remove' }, + 'Chapter removal added to the local queue.' + ) + } + /> +
+
+ +
+
+ J +
+

Chapter JSON

+

Import and export the documented schemaVersion 1 format.

+
+
+
+ + action.run( + onAction, + { + type: 'export', + chapters, + json: serializeChapterJson(chapters), + }, + 'Versioned chapter JSON prepared for saving.' + ) + } + /> + +
+ {fileMessage ?

{fileMessage}

: null} +
+
+ ); +} + +function SubtitlePanel({ + source, + streams, + exportPresets, + formatAvailability, + containerAvailability, + onAction, + action, +}: { + readonly source?: AdvancedSourceContext; + readonly streams: readonly SubtitleStreamDescription[]; + readonly exportPresets: readonly AdvancedExportPresetOption[]; + readonly formatAvailability: Partial< + Readonly> + >; + readonly containerAvailability: Partial< + Readonly> + >; + readonly onAction?: RequestHandler; + readonly action: ActionContext; +}) { + const [operation, setOperation] = useState< + 'extract' | 'convert' | 'soft-mux' | 'burn-in' + >('extract'); + const [streamIndex, setStreamIndex] = useState(''); + const [outputFormat, setOutputFormat] = useState('srt'); + const [subtitleFile, setSubtitleFile] = useState(); + const [inputFormat, setInputFormat] = useState(); + const [offset, setOffset] = useState('0'); + const [targetContainer, setTargetContainer] = useState< + 'mp4' | 'webm' | 'matroska' + >('matroska'); + const [language, setLanguage] = useState(''); + const [title, setTitle] = useState(''); + const [isDefault, setIsDefault] = useState(false); + const [isForced, setIsForced] = useState(false); + const [presetId, setPresetId] = useState(''); + const [fileMessage, setFileMessage] = useState(); + + const selectedStream = + streams.find((stream) => String(stream.index) === streamIndex) ?? + streams[0]; + const effectiveStreamIndex = selectedStream + ? String(selectedStream.index) + : ''; + const videoPresets = exportPresets.filter((preset) => preset.supportsVideo); + const effectivePresetId = + videoPresets.find( + (preset) => preset.id === presetId && !preset.disabledReason + )?.id ?? + videoPresets.find((preset) => !preset.disabledReason)?.id ?? + ''; + const presetReason = effectivePresetId + ? undefined + : 'An available video export preset is required.'; + const fileReason = + subtitleFile && inputFormat + ? undefined + : 'Choose a local SRT, WebVTT, ASS, or SSA file.'; + const sourceReason = source ? undefined : 'Select a media source first.'; + const extractReason = + sourceReason ?? + (!selectedStream + ? 'No subtitle stream is available.' + : classifySubtitleCodec(selectedStream.codec) === 'bitmap' + ? 'Bitmap subtitle extraction is not text conversion. Preserve it in a compatible bitmap format instead.' + : classifySubtitleCodec(selectedStream.codec) === 'unknown' + ? `The ${selectedStream.codec} subtitle codec is not classified as supported text.` + : undefined); + const convertReason = + fileReason ?? + numberRangeReason(offset, -86400, 86400, 'Time offset') ?? + availabilityReason(formatAvailability[outputFormat]); + const muxReason = + sourceReason ?? + fileReason ?? + availabilityReason(containerAvailability[targetContainer]); + const exactExtractReason = + extractReason ?? availabilityReason(formatAvailability[outputFormat]); + const burnReason = + sourceReason ?? + (!source?.hasVideo + ? 'Subtitle burn-in requires a video stream.' + : undefined) ?? + fileReason ?? + presetReason; + + const chooseSubtitleFile = (event: ChangeEvent) => { + const file = event.currentTarget.files?.[0]; + if (!file) { + setSubtitleFile(undefined); + setInputFormat(undefined); + return; + } + const detected = detectSubtitleFormat(file.name); + if (!detected) { + setSubtitleFile(undefined); + setInputFormat(undefined); + setFileMessage( + 'Only local SRT, WebVTT, ASS, and SSA files are accepted.' + ); + event.currentTarget.value = ''; + return; + } + setSubtitleFile(file); + setInputFormat(detected); + setFileMessage(`${file.name} will be mounted only for this local job.`); + }; + + const capability: AdvancedCapabilityKey = + operation === 'extract' + ? 'subtitle-extract' + : operation === 'convert' + ? 'subtitle-convert' + : operation === 'soft-mux' + ? 'subtitle-mux' + : 'subtitle-burn'; + const localReason = + operation === 'extract' + ? exactExtractReason + : operation === 'convert' + ? convertReason + : operation === 'soft-mux' + ? muxReason + : burnReason; + const reason = action.reasonFor(capability, localReason, Boolean(onAction)); + + const submit = () => { + if (reason) { + return; + } + let request: SubtitleExecutionRequest | undefined; + if (operation === 'extract' && selectedStream) { + request = { + type: 'extract', + stream: selectedStream, + outputFormat, + }; + } else if (operation === 'convert' && subtitleFile && inputFormat) { + request = { + type: 'convert', + file: subtitleFile, + inputFormat, + outputFormat, + offsetSeconds: Number(offset), + }; + } else if (operation === 'soft-mux' && subtitleFile && inputFormat) { + request = { + type: 'soft-mux', + file: subtitleFile, + inputFormat, + subtitleCodec: codecForSubtitleFormat(inputFormat), + targetContainer, + ...(language.trim() ? { language: language.trim() } : {}), + ...(title.trim() ? { title: title.trim() } : {}), + default: isDefault, + forced: isForced, + }; + } else if (operation === 'burn-in' && subtitleFile && inputFormat) { + request = { + type: 'burn-in', + file: subtitleFile, + inputFormat, + presetId: effectivePresetId, + }; + } + if (request) { + action.run( + onAction, + request, + 'Subtitle operation added to the local queue.' + ); + } + }; + + return ( +
+
+
+ S +
+

Subtitle operation

+

+ Text and bitmap streams are distinguished. No OCR or automatic + bitmap-to-text conversion is implied. +

+
+
+ + + {operation === 'extract' ? ( + + ) : ( + <> + + + )} + + {operation === 'extract' || operation === 'convert' ? ( + + ) : null} + + {operation === 'convert' ? ( + + ) : null} + + {operation === 'soft-mux' ? ( + <> + +
+ + +
+
+ + +
+ + ) : null} + + {operation === 'burn-in' ? ( + + ) : null} + + +
+ +
+
+ i +
+

Known source streams

+

Classification is codec-based and deliberately conservative.

+
+
+ {streams.length > 0 ? ( +
    + {streams.map((stream) => ( +
  • + + #{stream.index} · {stream.codec} + + {classifySubtitleCodec(stream.codec)} + + {[stream.language, stream.title] + .filter(Boolean) + .join(' · ') || 'No language or title'} + +
  • + ))} +
+ ) : ( +

+ No subtitle streams were identified by the validated probe. +

+ )} +
+
+ ); +} + +interface PresetDraft { + readonly id: string; + readonly name: string; + readonly description: string; + readonly kind: Exclude; + readonly container: string; + readonly fileExtension: string; + readonly videoCodec: VideoCodec | 'none'; + readonly videoQuality: QualityMode; + readonly videoCrf: string; + readonly videoBitrate: string; + readonly width: string; + readonly height: string; + readonly frameRate: string; + readonly audioCodec: AudioCodec | 'none'; + readonly audioBitrate: string; + readonly sampleRate: '' | '22050' | '32000' | '44100' | '48000' | '96000'; + readonly channels: '' | '1' | '2' | '6'; + readonly metadataPolicy: MetadataPolicy; + readonly chapterPolicy: ChapterPolicy; + readonly subtitlePolicy: SubtitlePolicy; + readonly fastStart: boolean; +} + +const EMPTY_PRESET_DRAFT: PresetDraft = { + id: '', + name: '', + description: '', + kind: 'video', + container: 'mp4', + fileExtension: 'mp4', + videoCodec: 'libx264', + videoQuality: 'balanced', + videoCrf: '23', + videoBitrate: '', + width: '', + height: '', + frameRate: '', + audioCodec: 'aac', + audioBitrate: '160', + sampleRate: '48000', + channels: '2', + metadataPolicy: 'copy', + chapterPolicy: 'keep', + subtitlePolicy: 'none', + fastStart: true, +}; + +function PresetPanel({ + presets, + onAction, + action, +}: { + readonly presets: readonly UserExportPreset[]; + readonly onAction?: RequestHandler; + readonly action: ActionContext; +}) { + const [selectedId, setSelectedId] = useState(''); + const selected = + presets.find((preset) => preset.id === selectedId) ?? presets[0]; + const effectiveSelectedId = selected?.id ?? ''; + const [draftOverride, setDraftOverride] = useState(); + const draft = + draftOverride ?? (selected ? presetToDraft(selected) : EMPTY_PRESET_DRAFT); + const [creatingOverride, setCreatingOverride] = useState(); + const isCreating = creatingOverride ?? !selected; + const [editorMessage, setEditorMessage] = useState(); + const [confirmDelete, setConfirmDelete] = useState(false); + const [confirmReset, setConfirmReset] = useState(false); + + const setDraft = ( + value: PresetDraft | ((current: PresetDraft) => PresetDraft) + ) => { + setDraftOverride((current) => + typeof value === 'function' ? value(current ?? draft) : value + ); + }; + const setIsCreating = (value: boolean) => setCreatingOverride(value); + + const capabilityReason = action.reasonFor( + 'preset-storage', + undefined, + Boolean(onAction) + ); + + const selectPreset = (id: string) => { + setSelectedId(id); + const preset = presets.find((entry) => entry.id === id); + if (preset) { + setDraft(presetToDraft(preset)); + setIsCreating(false); + setEditorMessage(undefined); + setConfirmDelete(false); + } + }; + + const saveDraft = () => { + try { + const preset = draftToPreset(draft); + action.run( + onAction, + { type: isCreating ? 'create' : 'update', preset }, + isCreating ? 'User preset created.' : 'User preset updated.' + ); + setEditorMessage(undefined); + } catch (error) { + setEditorMessage( + error instanceof Error ? error.message : 'Preset settings are invalid.' + ); + } + }; + + const importPreset = async (event: ChangeEvent) => { + const file = event.currentTarget.files?.[0]; + event.currentTarget.value = ''; + if (!file) { + return; + } + try { + const json = await readSmallTextFile(file, 64 * 1024); + const preset = parseUserPresetJson(json); + setDraft(presetToDraft(preset)); + setIsCreating(true); + setEditorMessage( + `Validated ${file.name}. Save or revise the allowlisted fields.` + ); + action.run( + onAction, + { type: 'import', preset }, + 'Validated user preset imported.' + ); + } catch (error) { + setEditorMessage( + error instanceof Error ? error.message : 'Preset import failed.' + ); + } + }; + + const updateDraft = ( + key: K, + value: PresetDraft[K] + ) => setDraft((current) => ({ ...current, [key]: value })); + + return ( +
+
+
+ P +
+

User preset library

+

+ Imported presets use a strict versioned schema. Unknown fields, + raw arguments, filters, paths, and protocols are rejected. +

+
+
+
+ + + { + if (!selected) { + return; + } + const newId = uniqueCloneId(selected.id, presets); + action.run( + onAction, + { + type: 'clone', + presetId: selected.id, + newId, + newName: `${selected.name} copy`, + }, + 'User preset cloned.' + ); + }} + /> + +
+
+ +
+
+ E +
+

{isCreating ? 'Create typed preset' : 'Edit typed preset'}

+

Every field maps to a reviewed setting in the preset model.

+
+
+
+ updateDraft('id', value)} + /> + updateDraft('name', value)} + /> + updateDraft('description', value)} + /> + { + const kind = value as PresetDraft['kind']; + setDraft((current) => ({ + ...current, + kind, + ...(kind === 'audio' + ? { videoCodec: 'none' as const } + : current.videoCodec === 'none' + ? { videoCodec: 'libx264' as const } + : {}), + })); + }} + /> + updateDraft('container', value)} + /> + + updateDraft('fileExtension', normalizeExtension(value)) + } + /> +
+ +

Video settings

+
+ + updateDraft('videoCodec', value as PresetDraft['videoCodec']) + } + /> + + updateDraft('videoQuality', value as QualityMode) + } + /> + updateDraft('videoCrf', value)} + /> + updateDraft('videoBitrate', value)} + /> + updateDraft('width', value)} + /> + updateDraft('height', value)} + /> + updateDraft('frameRate', value)} + /> +
+ +

Audio settings

+
+ + updateDraft('audioCodec', value as PresetDraft['audioCodec']) + } + /> + updateDraft('audioBitrate', value)} + /> + + updateDraft('sampleRate', value as PresetDraft['sampleRate']) + } + /> + + updateDraft('channels', value as PresetDraft['channels']) + } + /> +
+ +

Output policies

+

+ User presets expose only policies the Quick workflow can execute + directly. Metadata edits, chapter replacement, and subtitle burn-in + remain explicit operations with their own required inputs. +

+
+ + updateDraft('metadataPolicy', value as MetadataPolicy) + } + /> + + updateDraft('chapterPolicy', value as ChapterPolicy) + } + /> + + updateDraft('subtitlePolicy', value as SubtitlePolicy) + } + /> +
+ updateDraft('fastStart', value)} + /> + {editorMessage ? ( +

+ {editorMessage} +

+ ) : null} +
+ + {!isCreating && selected ? ( + + action.run( + onAction, + { + type: 'rename', + presetId: selected.id, + name: draft.name.trim(), + }, + 'User preset renamed.' + ) + } + /> + ) : null} + +
+
+ +
+
+ +
+

Preset file and deletion actions

+

Destructive actions require a second explicit click.

+
+
+
+ { + if (!selected) { + return; + } + action.run( + onAction, + { + type: 'export', + preset: selected, + json: serializeUserPreset(selected), + }, + 'Versioned preset JSON prepared for saving.' + ); + }} + /> + {!confirmDelete ? ( + + ) : ( + <> + + + + )} + {!confirmReset ? ( + + ) : ( + <> + + + + )} +
+ +
+
+ ); +} + +function ActionButton({ + label, + reason, + onClick, + secondary = false, +}: { + readonly label: string; + readonly reason?: string; + readonly onClick: () => void; + readonly secondary?: boolean; +}) { + const reasonId = useId(); + return ( +
+ + {reason ? ( + + {reason} + + ) : null} +
+ ); +} + +function InlineReason({ reason }: { readonly reason?: string }) { + return reason ?

{reason}

: null; +} + +function NumberField({ + label, + value, + min, + max, + step = 1, + suffix, + optional = false, + disabled = false, + onChange, +}: { + readonly label: string; + readonly value: string; + readonly min?: number; + readonly max?: number; + readonly step?: number; + readonly suffix?: string; + readonly optional?: boolean; + readonly disabled?: boolean; + readonly onChange: (value: string) => void; +}) { + return ( + + ); +} + +function TextField({ + label, + value, + maxLength, + disabled = false, + onChange, +}: { + readonly label: string; + readonly value: string; + readonly maxLength: number; + readonly disabled?: boolean; + readonly onChange: (value: string) => void; +}) { + return ( + + ); +} + +function ColorField({ + label, + value, + onChange, +}: { + readonly label: string; + readonly value: string; + readonly onChange: (value: string) => void; +}) { + return ( + + ); +} + +function CheckField({ + label, + checked, + disabled = false, + onChange, +}: { + readonly label: string; + readonly checked: boolean; + readonly disabled?: boolean; + readonly onChange: (checked: boolean) => void; +}) { + return ( + + ); +} + +function PolicySelect({ + label, + value, + options, + disabled = false, + onChange, +}: { + readonly label: string; + readonly value: string; + readonly options: readonly (readonly [string, string])[]; + readonly disabled?: boolean; + readonly onChange: (value: string) => void; +}) { + return ( + + ); +} + +function PresetSelect({ + label, + presets, + value, + onChange, +}: { + readonly label: string; + readonly presets: readonly AdvancedExportPresetOption[]; + readonly value: string; + readonly onChange: (value: string) => void; +}) { + return ( + + ); +} + +function ImageSettings({ + width, + height, + format, + quality, + onWidth, + onHeight, + onFormat, + onQuality, +}: { + readonly width?: string; + readonly height?: string; + readonly format: ImageFormat; + readonly quality: string; + readonly onWidth?: (value: string) => void; + readonly onHeight?: (value: string) => void; + readonly onFormat: (value: ImageFormat) => void; + readonly onQuality: (value: string) => void; +}) { + return ( +
+ {width !== undefined && onWidth ? ( + + ) : null} + {height !== undefined && onHeight ? ( + + ) : null} + + +
+ ); +} + +const METADATA_FIELD_KEYS = [ + 'title', + 'artist', + 'album', + 'date', + 'comment', +] as const; + +type MetadataFieldKey = (typeof METADATA_FIELD_KEYS)[number]; + +function metadataFromFields( + fields: { + readonly title: string; + readonly artist: string; + readonly album: string; + readonly date: string; + readonly comment: string; + }, + touchedFields: ReadonlySet +): MetadataEdits { + return { + format: Object.freeze( + Object.fromEntries( + METADATA_FIELD_KEYS.flatMap((key) => + touchedFields.has(key) ? [[key, fields[key] || null]] : [] + ) + ) + ), + }; +} + +function setMetadataFields( + edits: MetadataEdits, + setters: { + readonly setTitle: (value: string) => void; + readonly setArtist: (value: string) => void; + readonly setAlbum: (value: string) => void; + readonly setDate: (value: string) => void; + readonly setComment: (value: string) => void; + } +) { + const format = edits.format ?? {}; + setters.setTitle(metadataText(format.title)); + setters.setArtist(metadataText(format.artist)); + setters.setAlbum(metadataText(format.album)); + setters.setDate(metadataText(format.date)); + setters.setComment(metadataText(format.comment)); +} + +function metadataText(value: string | number | null | undefined): string { + return value === null || value === undefined ? '' : String(value); +} + +function availabilityReason( + availability: AdvancedCapabilityStatus | undefined +): string | undefined { + return availability && !availability.available + ? (availability.reason ?? 'This exact output combination is unavailable.') + : undefined; +} + +function humanizeDisposition(disposition: StreamDisposition): string { + const label = disposition.replaceAll('_', ' '); + return `${label[0]?.toUpperCase() ?? ''}${label.slice(1)}`; +} + +function presetToDraft(preset: UserExportPreset): PresetDraft { + return { + id: preset.id, + name: preset.name, + description: preset.description ?? '', + kind: preset.kind, + container: preset.container, + fileExtension: preset.fileExtension, + videoCodec: preset.video?.codec ?? 'none', + videoQuality: preset.video?.qualityMode ?? 'balanced', + videoCrf: optionalNumber(preset.video?.crf), + videoBitrate: optionalNumber(preset.video?.bitrateKbps), + width: optionalNumber(preset.video?.width), + height: optionalNumber(preset.video?.height), + frameRate: optionalNumber(preset.video?.frameRate), + audioCodec: preset.audio?.codec ?? 'none', + audioBitrate: optionalNumber(preset.audio?.bitrateKbps), + sampleRate: preset.audio?.sampleRate + ? (String(preset.audio.sampleRate) as PresetDraft['sampleRate']) + : '', + channels: preset.audio?.channels + ? (String(preset.audio.channels) as PresetDraft['channels']) + : '', + metadataPolicy: preset.metadataPolicy, + chapterPolicy: preset.chapterPolicy, + subtitlePolicy: preset.subtitlePolicy, + fastStart: preset.fastStart ?? false, + }; +} + +function draftToPreset(draft: PresetDraft): UserExportPreset { + const candidate = { + schemaVersion: 1, + id: draft.id.trim(), + name: draft.name.trim(), + ...(draft.description.trim() + ? { description: draft.description.trim() } + : {}), + kind: draft.kind, + container: draft.container.trim(), + fileExtension: draft.fileExtension, + ...(draft.videoCodec === 'none' + ? {} + : { + video: { + codec: draft.videoCodec, + qualityMode: draft.videoQuality, + ...optionalNumberProperty('crf', draft.videoCrf), + ...optionalNumberProperty('bitrateKbps', draft.videoBitrate), + ...optionalNumberProperty('width', draft.width), + ...optionalNumberProperty('height', draft.height), + ...optionalNumberProperty('frameRate', draft.frameRate), + }, + }), + ...(draft.audioCodec === 'none' + ? {} + : { + audio: { + codec: draft.audioCodec, + ...optionalNumberProperty('bitrateKbps', draft.audioBitrate), + ...(draft.sampleRate + ? { sampleRate: Number(draft.sampleRate) } + : {}), + ...(draft.channels ? { channels: Number(draft.channels) } : {}), + }, + }), + metadataPolicy: draft.metadataPolicy, + chapterPolicy: draft.chapterPolicy, + subtitlePolicy: draft.subtitlePolicy, + fastStart: draft.fastStart, + }; + const result = validateUserPreset(candidate); + if (!result.valid || !result.preset) { + throw new TypeError(`Invalid preset: ${result.errors.join('; ')}`); + } + return result.preset; +} + +function optionalNumberProperty( + key: string, + value: string +): Readonly> { + return value ? { [key]: Number(value) } : {}; +} + +function uniqueCloneId( + id: string, + presets: readonly UserExportPreset[] +): string { + const ids = new Set(presets.map((preset) => preset.id)); + const base = `${id.slice(0, 72)}-copy`; + if (!ids.has(base)) { + return base; + } + for (let index = 2; index < 1000; index += 1) { + const suffix = `-copy-${String(index)}`; + const candidate = `${id.slice(0, 80 - suffix.length)}${suffix}`; + if (!ids.has(candidate)) { + return candidate; + } + } + throw new RangeError('Could not create a unique preset clone ID.'); +} + +function chapterToRange(chapter: EditableChapter): TimeRange { + return { + startSeconds: chapter.startSeconds, + endSeconds: chapter.endSeconds, + label: chapter.title, + }; +} + +function normalizeExtension(value: string): string { + return value + .toLowerCase() + .replace(/^\./u, '') + .replace(/[^a-z0-9]/gu, ''); +} + +function detectSubtitleFormat(fileName: string): SubtitleFormat | undefined { + const extension = fileName.split('.').pop()?.toLowerCase(); + return extension === 'srt' + ? 'srt' + : extension === 'vtt' + ? 'vtt' + : extension === 'ass' || extension === 'ssa' + ? 'ass' + : undefined; +} + +function codecForSubtitleFormat(format: SubtitleFormat): string { + return format === 'srt' ? 'subrip' : format === 'vtt' ? 'webvtt' : 'ass'; +} + +async function readSmallTextFile(file: File, maximumBytes: number) { + if (file.size > maximumBytes) { + throw new RangeError( + `Import file exceeds the ${String(Math.round(maximumBytes / 1024))} KiB limit.` + ); + } + return file.text(); +} + +function isPositive(value: number | undefined): value is number { + return value !== undefined && Number.isFinite(value) && value > 0; +} + +function integerRangeReason( + value: string, + minimum: number, + maximum: number, + label: string +): string | undefined { + const number = Number(value); + return Number.isSafeInteger(number) && number >= minimum && number <= maximum + ? undefined + : `${label} must be an integer from ${String(minimum)} to ${String(maximum)}.`; +} + +function optionalIntegerRangeReason( + value: string, + minimum: number, + maximum: number, + label: string +): string | undefined { + return value ? integerRangeReason(value, minimum, maximum, label) : undefined; +} + +function numberRangeReason( + value: string, + minimum: number, + maximum: number, + label: string +): string | undefined { + const number = Number(value); + return Number.isFinite(number) && number >= minimum && number <= maximum + ? undefined + : `${label} must be between ${String(minimum)} and ${String(maximum)}.`; +} + +function loudnessTargetsReason(targets: LoudnessTargets): string | undefined { + if ( + !Number.isFinite(targets.integratedLufs) || + targets.integratedLufs < -70 || + targets.integratedLufs > -5 + ) { + return 'Integrated loudness must be between -70 and -5 LUFS.'; + } + if ( + !Number.isFinite(targets.truePeakDbtp) || + targets.truePeakDbtp < -9 || + targets.truePeakDbtp > 0 + ) { + return 'True peak must be between -9 and 0 dBTP.'; + } + if ( + !Number.isFinite(targets.loudnessRangeLu) || + targets.loudnessRangeLu < 1 || + targets.loudnessRangeLu > 50 + ) { + return 'Loudness range must be between 1 and 50 LU.'; + } + return undefined; +} + +function optionalNumber(value: number | undefined): string { + return value === undefined ? '' : String(value); +} + +function formatSeconds(value: number): string { + return `${value.toFixed(3).replace(/0+$/u, '').replace(/\.$/u, '')} s`; +} + +function formatSplitTime(value: number): string { + return `${value.toFixed(6).replace(/0+$/u, '').replace(/\.$/u, '')} s`; +} diff --git a/src/components/CapabilityPanel.tsx b/src/components/CapabilityPanel.tsx new file mode 100644 index 0000000..fdfde15 --- /dev/null +++ b/src/components/CapabilityPanel.tsx @@ -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 ( +
+ + + + Engine & browser + + + {state.status === 'idle' + ? 'Not loaded' + : state.status === 'loading' + ? 'Loading' + : state.status === 'ready' + ? describeEngineMode(state.mode) + : state.status === 'running' + ? 'Processing' + : state.status === 'recovering' + ? 'Recovering' + : 'Unavailable'} + + +
+ +
+
+
Selected
+
{mode ? describeEngineMode(mode) : 'After first import'}
+
+
+
Isolation
+
+ {environment.crossOriginIsolated ? 'Enabled' : 'Unavailable'} +
+
+
+
Processors
+
{environment.hardwareConcurrency ?? 'Not exposed'}
+
+
+
Wrapper / core
+
+ {FFMPEG_WRAPPER_VERSION} / {FFMPEG_CORE_VERSION} +
+
+ {state.status === 'ready' ? ( + <> +
+
FFmpeg
+
+ {versionLine(state.capabilities.versionText)} +
+
+
+
Demuxers
+
{state.capabilities.demuxers.size}
+
+
+
Muxers
+
{state.capabilities.muxers.size}
+
+
+
Decoders
+
{state.capabilities.decoders.size}
+
+
+
Encoders
+
{state.capabilities.encoders.size}
+
+
+
Filters
+
{state.capabilities.filters.size}
+
+ + ) : null} +
+
+
+

Active resource limits

+ + {deviceMemoryGiB === undefined + ? 'Conservative defaults' + : `Adjusted for ${deviceMemoryGiB} GiB device memory`} + +
+
+
+
Input warning / file limit
+
+ {formatBytes(resourcePolicy.softInputBytes)} /{' '} + {formatBytes(resourcePolicy.hardSingleInputBytes)} +
+
+
+
Total input limit
+
{formatBytes(resourcePolicy.hardTotalInputBytes)}
+
+
+
Output warning / limit
+
+ {formatBytes(resourcePolicy.softOutputEstimateBytes)} /{' '} + {formatBytes(resourcePolicy.hardOutputEstimateBytes)} +
+
+
+
Image limit
+
+ {resourcePolicy.maxImageDimension.toLocaleString()} px / side +
+
+
+
Timeline / streams
+
+ {resourcePolicy.maxTimelineClips} clips /{' '} + {resourcePolicy.maxStreamsPerInput} streams +
+
+
+
Generated files / images
+
+ {resourcePolicy.maxGeneratedOutputs} /{' '} + {resourcePolicy.maxThumbnailCount} +
+
+
+
FFmpeg concurrency
+
{resourcePolicy.maxConcurrentFFmpegJobs}
+
+
+
Runtime limit
+
{formatDuration(resourcePolicy.maxJobRuntimeMs)}
+
+
+

+ 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. +

+
+ {state.status === 'idle' ? ( + + ) : null} + {state.status === 'loading' ? ( +
+ + {state.stage ?? 'Loading local engine…'} +
+ ) : null} + {state.status === 'ready' && state.warning ? ( +

+ + {state.warning.message} +

+ ) : null} + {state.status === 'ready' ? ( + <> +
+ + Detected capability inventory ( + {capabilityCount(state.capabilities)} entries) + +
{capabilityInventory(state.capabilities)}
+
+
+ + FFmpeg build configuration ( + {state.capabilities.buildConfiguration.length} flags) + +
+                {state.capabilities.buildConfiguration.length > 0
+                  ? state.capabilities.buildConfiguration.join('\n')
+                  : 'No build flags were reported.'}
+              
+
+ + ) : null} + {state.status === 'error' ? ( +
+

+ + + Engine initialization failed + {state.error.message} + +

+
+
+
Error code
+
{state.error.code}
+
+ {state.error.exitCode === undefined ? null : ( +
+
FFmpeg exit code
+
{state.error.exitCode}
+
+ )} +
+ {state.error.details ? ( +
+ Technical details +
{state.error.details}
+
+ ) : null} + +
+ ) : null} +
+
+ ); +} + +function capabilityCount( + capabilities: Extract['capabilities'] +): number { + return ( + capabilities.demuxers.size + + capabilities.muxers.size + + capabilities.decoders.size + + capabilities.encoders.size + + capabilities.filters.size + ); +} + +function capabilityInventory( + capabilities: Extract['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`; +} diff --git a/src/components/CropOverlay.tsx b/src/components/CropOverlay.tsx new file mode 100644 index 0000000..f536e61 --- /dev/null +++ b/src/components/CropOverlay.tsx @@ -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; +} + +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> = { + n: 'top', + ne: 'top right', + e: 'right', + se: 'bottom right', + s: 'bottom', + sw: 'bottom left', + w: 'left', + nw: 'top left', +}; + +const ratioValues: Readonly, 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(null); + const dragRef = useRef(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(() => ({ + sourceKey: cropSourceKey, + values: cropToDraft(initialCrop), + })); + const [aspectLocked, setAspectLocked] = useState(false); + const [ratioChoice, setRatioChoice] = useState('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, + 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) => { + 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) => { + if (dragRef.current?.pointerId === event.pointerId) { + event.currentTarget.releasePointerCapture?.(event.pointerId); + dragRef.current = undefined; + } + }; + + const handleKeyboard = ( + event: KeyboardEvent, + 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 + ) => { + 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) => { + 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) => { + const locked = event.currentTarget.checked; + setAspectLocked(locked); + if (locked) { + setRatioChoice('current'); + setLockedDisplayRatio(displayedCrop.width / displayedCrop.height); + } + }; + + return ( +
+
+
+ {children} +
+ + +
+ + + +
+ +
+ Encoded source pixels + {(['x', 'y', 'width', 'height'] as const).map((field) => ( + + ))} +
+

+ Display orientation: {orientation}°. Crop values are saved against the + encoded {sourceDimensions.width}×{sourceDimensions.height} source. +

+ {visibleErrors.length > 0 ? ( + + ) : null} +
+ ); +} + +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 { + 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, + 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); +} diff --git a/src/components/FileDropZone.tsx b/src/components/FileDropZone.tsx new file mode 100644 index 0000000..413498b --- /dev/null +++ b/src/components/FileDropZone.tsx @@ -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(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) => { + event.preventDefault(); + setDragging(false); + if (!disabled) receive(event.dataTransfer.files); + }; + + return ( +
{ + 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} + > + receive(event.currentTarget.files)} + /> +
+ +
+
+ {compact ? 'Add media' : 'Drop audio or video here'} + + {compact + ? 'Files stay on this device' + : 'Any FFmpeg-supported local file · nothing is uploaded'} + +
+ +
+ ); +} diff --git a/src/components/HelpDialog.tsx b/src/components/HelpDialog.tsx new file mode 100644 index 0000000..05716c8 --- /dev/null +++ b/src/components/HelpDialog.tsx @@ -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(null); + + useEffect(() => { + const element = dialog.current; + if (!element) return; + if (open && !element.open) element.showModal(); + if (!open && element.open) element.close(); + }, [open]); + + return ( + { + if (event.target === event.currentTarget) onClose(); + }} + > +
+
+
+

Local media studio

+

Audio & Video Tools

+
+ +
+
+
+ 1 +

Add media

+

+ 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. +

+
+
+ 2 +

Choose an operation

+

+ Quick Convert produces one output. Edit arranges non-destructive + clips on a sequential timeline. +

+
+
+ 3 +

Export locally

+

+ The FFmpeg WebAssembly core runs here. Source media is never + uploaded and the result is saved by your browser. +

+
+
+
+
+
+

Versions, source and legal context

+

About & licences

+
+ av-tools {APP_VERSION} +
+
+
+
FFmpeg engine
+
{FFMPEG_ENGINE_VERSION}
+
+
+
ffmpeg.wasm wrapper
+
{FFMPEG_WRAPPER_VERSION}
+
+
+
FFmpeg WASM cores
+
{FFMPEG_CORE_VERSION} · ST & MT
+
+
+
ffmpeg.wasm utilities
+
{FFMPEG_UTIL_VERSION}
+
+
+
+
+

Application licence

+

+ + Copyright © 2026 Albrecht Degering. GPL-3.0-or-later. + {' '} + 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. +

+ +
+
+

Bundled runtime notices

+
    +
  • + ffmpeg.wasm wrapper and utilities — MIT + + MIT text + +
  • +
  • + FFmpeg single- and multithread cores — package declares + GPL-2.0-or-later + + GPL text + +
  • +
  • + FFmpeg, linked codecs, Toolbox SDK, React and other runtime + packages retain their respective terms. + + Complete inventory + +
  • +
+
+
+
+ + +
+
+ ); +} diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx new file mode 100644 index 0000000..bea2122 --- /dev/null +++ b/src/components/Icon.tsx @@ -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 = { + add: , + audio: ( + <> + + + + + ), + cancel: , + check: , + chevron: , + download: ( + <> + + + + ), + edit: ( + <> + + + + ), + file: ( + <> + + + + ), + info: ( + <> + + + + ), + play: , + queue: ( + <> + + + + ), + settings: ( + <> + + + + ), + sparkles: ( + <> + + + + + ), + trash: ( + <> + + + + ), + video: ( + <> + + + + ), + warning: ( + <> + + + + ), +}; + +export function Icon({ + name, + ...props +}: { name: IconName } & SVGProps) { + return ( + + ); +} diff --git a/src/components/Inspector.tsx b/src/components/Inspector.tsx new file mode 100644 index 0000000..245bc82 --- /dev/null +++ b/src/components/Inspector.tsx @@ -0,0 +1,1415 @@ +import { useState } from 'react'; +import type { ImportedMediaAsset } from '../app/application-state'; +import { formatBytes, formatDuration } from '../app/application-state'; +import type { + AvProjectV1, + FadeCurve, + ProjectChapter, + ProjectMetadataSettings, + ProjectSubtitleTrack, + TimelineClip, +} from '../project'; +import { CropOverlay } from './CropOverlay'; +import { Icon } from './Icon'; + +type InspectorTab = + | 'media' + | 'transform' + | 'audio' + | 'metadata' + | 'chapters' + | 'subtitles' + | 'thumbnails'; + +export interface ThumbnailRequest { + timeSeconds: number; + width?: number; + height?: number; + maintainAspect: boolean; + format: 'png' | 'jpg' | 'webp'; + quality: number; +} + +export interface InspectorProps { + asset?: ImportedMediaAsset; + project: AvProjectV1; + clip?: TimelineClip; + onClipUpdate: (changes: Partial) => void; + onMetadataChange: (metadata: ProjectMetadataSettings) => void; + onChaptersChange: (chapters: ProjectChapter[]) => void; + onSubtitlesChange: (tracks: ProjectSubtitleTrack[]) => void; + onSubtitleImport?: (file: File) => void; + onThumbnail: (request: ThumbnailRequest) => void; + currentPreviewTimeSeconds?: number; + disabled?: boolean; + drawerOpen?: boolean; + onDrawerClose?: () => void; +} + +const tabs: Array<{ id: InspectorTab; label: string }> = [ + { id: 'media', label: 'Media' }, + { id: 'transform', label: 'Transform' }, + { id: 'audio', label: 'Audio' }, + { id: 'metadata', label: 'Metadata' }, + { id: 'chapters', label: 'Chapters' }, + { id: 'subtitles', label: 'Subtitles' }, + { id: 'thumbnails', label: 'Frames' }, +]; + +const fadeCurveOptions: ReadonlyArray<{ + value: FadeCurve; + label: string; +}> = [ + { value: 'tri', label: 'Linear' }, + { value: 'qsin', label: 'Quarter sine' }, + { value: 'exp', label: 'Exponential' }, +]; + +export function Inspector({ + asset, + project, + clip, + onClipUpdate, + onMetadataChange, + onChaptersChange, + onSubtitlesChange, + onSubtitleImport, + onThumbnail, + currentPreviewTimeSeconds, + disabled = false, + drawerOpen = false, + onDrawerClose, +}: InspectorProps) { + const [tab, setTab] = useState('media'); + + return ( + + ); +} + +function MediaInspector({ asset }: { asset?: ImportedMediaAsset }) { + if (!asset) return

Select a media source.

; + const probe = asset.probe; + return ( +
+
+ + stream.type === 'video') + ? 'video' + : 'audio' + } + /> + +
+ {asset.file.name} + {formatBytes(asset.file.size)} +
+
+
+
+
Container
+
+ {probe?.formatLongName ?? probe?.formatNames.join(', ') ?? '—'} +
+
+
+
Duration
+
{formatDuration(probe?.durationSeconds)}
+
+
+
Bit rate
+
+ {probe?.bitRate + ? `${Math.round(probe.bitRate / 1_000)} kbit/s` + : '—'} +
+
+
+ {probe && Object.keys(probe.tags).length > 0 ? ( + <> +

Container tags

+
+ {Object.entries(probe.tags).map(([key, value]) => ( +
+
{key}
+
{value}
+
+ ))} +
+ + ) : null} +

Streams

+
    + {probe?.streams.map((stream) => ( +
  1. + {stream.type} +
    + + #{stream.index} · {stream.codecName ?? 'unknown'} + + {stream.type === 'video' ? ( + <> + + Encoded pixels {stream.width ?? '?'}×{stream.height ?? '?'} + {stream.codedWidth !== undefined && + stream.codedHeight !== undefined + ? ` · coded ${stream.codedWidth}×${stream.codedHeight}` + : ''} + {' · '} + {stream.frameRate?.toFixed(3) ?? '?'} fps + + + SAR {stream.sampleAspectRatio ?? '—'} · DAR{' '} + {stream.displayAspectRatio ?? '—'} · rotation{' '} + {stream.rotationDegrees === undefined + ? '—' + : `${stream.rotationDegrees}°`} + + + ) : ( + + {stream.type === 'audio' + ? `${stream.sampleRate ?? '?'} Hz · ${stream.channelLayout ?? `${stream.channels ?? '?'} channels`}` + : (stream.language ?? + stream.title ?? + 'No additional details')} + + )} + {Object.keys(stream.disposition).length > 0 ? ( + + Disposition:{' '} + {Object.entries(stream.disposition) + .filter(([, enabled]) => enabled) + .map(([name]) => name) + .join(', ') || 'none'} + + ) : null} + {Object.keys(stream.tags).length > 0 ? ( +
    + {Object.entries(stream.tags).map(([key, value]) => ( +
    +
    {key}
    +
    {value}
    +
    + ))} +
    + ) : null} +
    +
  2. + )) ??
  3. Waiting for inspection.
  4. } +
+ {probe && probe.chapters.length > 0 ? ( + <> +

Source chapters

+
    + {probe.chapters.map((chapter) => ( +
  1. + {chapter.title ?? `Chapter ${chapter.id + 1}`} + + {formatDuration(chapter.startSeconds)}– + {formatDuration(chapter.endSeconds)} + {chapter.timeBase ? ` · ${chapter.timeBase}` : ''} + +
  2. + ))} +
+ + ) : null} + {probe && probe.warnings.length > 0 ? ( +
+ {probe.warnings.length} probe warning(s) +
    + {probe.warnings.map((warning, index) => ( +
  • {warning.message}
  • + ))} +
+
+ ) : null} + {asset.rawProbe ? ( +
+ Validated raw ffprobe report +
{asset.rawProbe}
+
+ ) : null} +
+ ); +} + +function TransformInspector({ + clip, + asset, + disabled, + onUpdate, +}: { + clip?: TimelineClip; + asset?: ImportedMediaAsset; + disabled: boolean; + onUpdate: (changes: Partial) => void; +}) { + if (!clip) return

Select a timeline clip.

; + const video = asset?.probe?.streams.find((stream) => stream.type === 'video'); + const sourceWidth = video?.width; + const sourceHeight = video?.height; + const canShowVisualCrop = + asset !== undefined && + isPositiveSafeInteger(sourceWidth) && + isPositiveSafeInteger(sourceHeight) && + sourceWidth >= 2 && + sourceHeight >= 2; + + return ( +
+

Crop

+ {canShowVisualCrop ? ( + onUpdate({ crop })} + > + + ) : ( + + )} +

Resize & rotation

+
+ {[ + { label: '720p', width: 1280, height: 720 }, + { label: '1080p', width: 1920, height: 1080 }, + { label: '2160p', width: 3840, height: 2160 }, + ].map((preset) => ( + + ))} +
+
+ + + + +

+ Frame-rate conversion is a timeline-wide output setting. Choose it in + Timeline output below the editor; no frame interpolation is applied. +

+ + +
+ +
+ Video fades +
+ + + +
+
+
+ ); +} + +function NumericCropEditor({ + clip, + sourceWidth, + sourceHeight, + disabled, + onUpdate, +}: { + clip: TimelineClip; + sourceWidth?: number; + sourceHeight?: number; + disabled: boolean; + onUpdate: (changes: Partial) => void; +}) { + return ( + <> +

+ A visual crop preview is unavailable for this source. Enter encoded + source pixels instead. +

+
+ {(['x', 'y', 'width', 'height'] as const).map((field) => ( + + ))} +
+ + ); +} + +function isPositiveSafeInteger(value: number | undefined): value is number { + return ( + value !== undefined && Number.isSafeInteger(value) && Number(value) > 0 + ); +} + +function toNonNegativeEvenInteger(value: number, minimum: number): number { + if (!Number.isFinite(value)) return minimum; + const rounded = Math.max(minimum, Math.round(value)); + return rounded - (rounded % 2); +} + +function AudioInspector({ + clip, + disabled, + onUpdate, +}: { + clip?: TimelineClip; + disabled: boolean; + onUpdate: (changes: Partial) => void; +}) { + if (!clip) return

Select a timeline clip.

; + const audio = clip.audio ?? { enabled: true }; + const update = (changes: Partial) => + onUpdate({ audio: { ...audio, ...changes } }); + return ( +
+ + +
+ + + +
+
+ EBU R128 normalization + + {audio.normalization ? ( +
+ + +
+ ) : null} +
+
+ ); +} + +function MetadataInspector({ + metadata, + disabled, + onChange, +}: { + metadata: ProjectMetadataSettings; + disabled: boolean; + onChange: (metadata: ProjectMetadataSettings) => void; +}) { + const fields = ['title', 'artist', 'album', 'comment'] as const; + return ( +
+

+ Output metadata is plain text and is escaped by the command-plan layer. +

+ {fields.map((field) => ( + + ))} +
+ ); +} + +function ChapterInspector({ + chapters, + disabled, + onChange, +}: { + chapters: ProjectChapter[]; + disabled: boolean; + onChange: (chapters: ProjectChapter[]) => void; +}) { + return ( +
+

+ Chapters use timeline seconds and are written through generated metadata + files. Time base is a positive fraction such as 1/1000. +

+
    + {chapters.map((chapter, index) => ( +
  1. + + onChange( + chapters.map((entry) => + entry.id === chapter.id + ? { ...entry, title: event.currentTarget.value } + : entry + ) + ) + } + /> + + onChange( + chapters.map((entry) => + entry.id === chapter.id + ? { + ...entry, + startSeconds: Number(event.currentTarget.value), + } + : entry + ) + ) + } + /> + + onChange( + chapters.map((entry) => + entry.id === chapter.id + ? { + ...entry, + endSeconds: Number(event.currentTarget.value), + } + : entry + ) + ) + } + /> + + onChange( + chapters.map((entry) => + entry.id === chapter.id + ? { + ...entry, + timeBase: event.currentTarget.value, + } + : entry + ) + ) + } + /> +
    + + + + +
    +
  2. + ))} +
+
+ + +
+
+ ); +} + +function SubtitleInspector({ + tracks, + disabled, + onChange, + onImport, +}: { + tracks: ProjectSubtitleTrack[]; + disabled: boolean; + onChange: (tracks: ProjectSubtitleTrack[]) => void; + onImport?: (file: File) => void; +}) { + return ( +
+

+ SRT, WebVTT and ASS sources can be muxed, converted, or burned in. +

+
    + {tracks.map((track, index) => ( +
  1. +
    + {track.sourceName ?? `Subtitle ${index + 1}`} + + {track.sourceFormat?.toUpperCase() ?? 'Text subtitle'} ·{' '} + {track.sourceStatus ?? 'source not attached'} + +
    + + + onChange( + tracks.map((entry) => + entry.id === track.id + ? { ...entry, language: event.currentTarget.value } + : entry + ) + ) + } + /> + + onChange( + tracks.map((entry) => + entry.id === track.id + ? { ...entry, title: event.currentTarget.value } + : entry + ) + ) + } + /> + + + + +
  2. + ))} +
+ +
+ ); +} + +function ThumbnailInspector({ + duration, + currentPreviewTimeSeconds, + disabled, + onCreate, +}: { + duration?: number; + currentPreviewTimeSeconds?: number; + disabled: boolean; + onCreate: (request: ThumbnailRequest) => void; +}) { + const [timeSeconds, setTimeSeconds] = useState(0); + const [width, setWidth] = useState('1280'); + const [height, setHeight] = useState(''); + const [maintainAspect, setMaintainAspect] = useState(true); + const [format, setFormat] = useState('jpg'); + const [quality, setQuality] = useState(85); + return ( +
+

+ Extract one exact frame. Series and contact sheets use the same typed + plan builders. +

+ + +
+ + + +
+ + + +
+ ); +} diff --git a/src/components/JobPanel.tsx b/src/components/JobPanel.tsx new file mode 100644 index 0000000..909bcf0 --- /dev/null +++ b/src/components/JobPanel.tsx @@ -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>; + 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 + >(() => new Set()); + const knownResultIds = useRef>(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 ( +
+
+
+

Local FFmpeg queue

+

+ {running + ? 'Processing one operation' + : results.length === 1 + ? 'Current output' + : 'Current outputs'} +

+
+ {queuedCount > 0 ? ( + + {queuedCount} waiting + + ) : null} + {running ? ( + + ) : null} +
+
+ {queuedCount > 0 ? ( +

+ Additional operations are retained in memory and start one at a + time. Closing this page discards waiting source handles. +

+ ) : null} + {running ? ( +
+
+ {engineState.operation} + + FFmpeg progress is an estimate; completion requires exit code + zero. + +
+ + {engineState.progress === undefined + ? 'Working…' + : `${Math.round(engineState.progress * 100)}%`} + {engineState.elapsedSeconds !== undefined + ? ` · ${Math.round(engineState.elapsedSeconds)} s` + : ''} + {engineState.speed !== undefined + ? ` · ${engineState.speed.toFixed(2)}×` + : ''} + + +
+ ) : null} + {jobs.length > 0 ? ( +
+
+

Job history

+ {jobs.some((job) => isTerminalJob(job)) ? ( + + ) : null} +
+
+ {jobs.map((job) => ( +
+
+
+ {job.operation} + + {job.status.replace('-', ' ')} + {job.startedAt + ? ` · ${formatJobTime(job.startedAt)}` + : ''} + +
+ {Math.round(job.progress * 100)}% + {job.status === 'queued' ? ( + + ) : null} + {job.status === 'failed' || job.status === 'cancelled' ? ( + + ) : null} +
+ + {job.error ? ( + + {job.error.message} + + ) : null} + {job.steps.length > 1 ? ( +
+ {job.steps.length} local steps +
    + {job.steps.map((step) => ( +
  1. + {step.name} + + {step.status} · {Math.round(step.progress * 100)}% + +
  2. + ))} +
+
+ ) : null} +
+ ))} +
+
+ ) : null} + {error ? ( +
+ +
+ Output failed + {error} +
+
+ ) : null} + {results.length > 0 ? ( + <> + {results.length > 1 ? ( + + ) : null} +
+ {results.map((result) => { + const verification = verifications[result.id]; + const preview = generatedResultPreview( + result, + verification?.probe + ); + return ( +
+ {results.length > 1 ? ( + { + 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} + + + +
+ {result.fileName} + + {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'}` + : ''} + + {result.timeRange ? ( + + Segment range{' '} + {formatTimecode(result.timeRange.startSeconds)}– + {formatTimecode(result.timeRange.endSeconds)} ·{' '} + {formatTimecode( + result.timeRange.endSeconds - + result.timeRange.startSeconds + )}{' '} + duration + + ) : null} + {verification?.warning ? ( + + {verification.warning} + + ) : null} + {!preview.previewable && preview.kind !== 'other' ? ( + + Download only: {preview.reason} + + ) : null} + {verification?.probe ? ( +
+ Round-trip probe details +
+
+
Container tags
+
+ {formatTagSummary(verification.probe.tags)} +
+
+
+
Chapters
+
+ {verification.probe.chapters.length === 0 + ? 'None' + : verification.probe.chapters + .map( + (chapter) => + chapter.title ?? + `Chapter ${chapter.id + 1}` + ) + .join(', ')} +
+
+ {verification.probe.streams.map((stream) => ( +
+
+ {stream.type} #{stream.index} +
+
+ {stream.codecName ?? 'unknown codec'} + {stream.language + ? ` · ${stream.language}` + : ''} + {stream.title ? ` · ${stream.title}` : ''} + {` · ${formatTagSummary(stream.tags)}`} + {` · disposition: ${enabledDispositions( + stream.disposition + )}`} +
+
+ ))} +
+
+ ) : null} +
+ {preview.previewable ? ( + + ) : null} + + +
+ ); + })} +
+
+ {results.length > 1 ? ( + + ) : null} + {report ? ( + + ) : null} + +
+ + ) : null} + {logs.length > 0 ? ( +
+ FFmpeg diagnostics ({logs.length} recent lines) +
{logs.join('\n')}
+
+ ) : null} +
+
+ ); +} + +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>): string { + const entries = Object.entries(tags); + return entries.length === 0 + ? 'None reported' + : entries.map(([key, value]) => `${key}=${value}`).join(' · '); +} + +function enabledDispositions( + dispositions: Readonly> +): string { + const enabled = Object.entries(dispositions) + .filter(([, value]) => value) + .map(([key]) => key); + return enabled.length > 0 ? enabled.join(', ') : 'none'; +} diff --git a/src/components/MediaBin.tsx b/src/components/MediaBin.tsx new file mode 100644 index 0000000..0c291d2 --- /dev/null +++ b/src/components/MediaBin.tsx @@ -0,0 +1,83 @@ +import type { ImportedMediaAsset } from '../app/application-state'; +import { formatBytes, formatDuration } from '../app/application-state'; +import { Icon } from './Icon'; + +export interface MediaBinProps { + assets: readonly ImportedMediaAsset[]; + selectedId?: string; + onSelect: (id: string) => void; + onRemove: (id: string) => void; +} + +export function MediaBin({ + assets, + selectedId, + onSelect, + onRemove, +}: MediaBinProps) { + if (assets.length === 0) { + return ( +
+ +

Your source files will appear here.

+
+ ); + } + + return ( +
    + {assets.map((asset) => { + const video = asset.probe?.streams.find( + (stream) => stream.type === 'video' + ); + const audioCount = + asset.probe?.streams.filter((stream) => stream.type === 'audio') + .length ?? 0; + return ( +
  • + + +
  • + ); + })} +
+ ); +} diff --git a/src/components/MediaPreview.tsx b/src/components/MediaPreview.tsx new file mode 100644 index 0000000..34b2a42 --- /dev/null +++ b/src/components/MediaPreview.tsx @@ -0,0 +1,334 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import type { ImportedMediaAsset } from '../app/application-state'; +import { + assessBrowserPlayback, + assessGeneratedResultPreview, + type MediaProbe, +} from '../media'; +import { renderWaveformToCanvas, type WaveformPeakData } from '../waveform'; +import { Icon } from './Icon'; + +export interface PreviewExportResult { + readonly name: string; + readonly mimeType: string; + readonly objectUrl: string; + readonly probe?: MediaProbe; +} + +export interface PreviewProxySettings { + readonly maxDurationSeconds: number; + readonly maxWidth: number; +} + +export interface MediaPreviewProps { + asset?: ImportedMediaAsset; + result?: PreviewExportResult; + onCreateProxy?: () => void; + proxySettings?: PreviewProxySettings; + onProxySettingsChange?: (settings: PreviewProxySettings) => void; + proxyBusy?: boolean; + waveformPeaks?: WaveformPeakData; + currentTimeSeconds?: number; + onCurrentTimeChange?: (timeSeconds: number) => void; +} + +export function MediaPreview({ + asset, + result, + onCreateProxy, + proxySettings, + onProxySettingsChange, + proxyBusy = false, + waveformPeaks, + currentTimeSeconds, + onCurrentTimeChange, +}: MediaPreviewProps) { + const [failedSourceUrl, setFailedSourceUrl] = useState(); + const mediaElement = useRef(null); + const sourceUrl = result?.objectUrl ?? asset?.objectUrl; + const playbackFailed = sourceUrl === failedSourceUrl; + const hasVideo = result + ? result.mimeType.startsWith('video/') + : asset?.probe?.streams.some( + (stream) => + stream.type === 'video' && stream.disposition.attached_pic !== true + ) === true; + const hasAudio = result + ? result.mimeType.startsWith('audio/') + : asset?.probe?.streams.some((stream) => stream.type === 'audio') === true; + const hasImage = result?.mimeType.startsWith('image/') === true; + const resultPlayback = useMemo(() => { + if (!result) return undefined; + const element = document.createElement( + result.mimeType.startsWith('video/') ? 'video' : 'audio' + ); + return assessGeneratedResultPreview( + { + fileName: result.name, + mimeType: result.mimeType, + ...(result.probe ? { probe: result.probe } : {}), + }, + (mimeType) => element.canPlayType(mimeType) + ); + }, [result]); + + const playback = useMemo(() => { + if (!asset?.probe) return undefined; + const element = document.createElement(hasVideo ? 'video' : 'audio'); + return assessBrowserPlayback( + asset.probe, + (mimeType) => element.canPlayType(mimeType), + { + fileName: asset.file.name, + declaredMimeType: asset.file.type, + } + ); + }, [asset, hasVideo]); + + useEffect(() => { + const element = mediaElement.current; + if ( + element && + currentTimeSeconds !== undefined && + Number.isFinite(currentTimeSeconds) && + Math.abs(element.currentTime - currentTimeSeconds) > 0.05 + ) { + element.currentTime = Math.max(0, currentTimeSeconds); + } + }, [currentTimeSeconds, sourceUrl]); + + const reportCurrentTime = () => { + const value = mediaElement.current?.currentTime; + if (value !== undefined && Number.isFinite(value)) { + onCurrentTimeChange?.(value); + } + }; + + if (!sourceUrl) { + return ( +
+ +

Select a source to inspect and preview it.

+
+ ); + } + + const needsProxy = + !result && + (playbackFailed || playback?.support === 'unsupported') && + onCreateProxy; + + return ( +
+
+ {result && resultPlayback?.previewable === false ? ( +
+ +

Download only: this browser cannot preview this result.

+ {resultPlayback.reason} +
+ ) : hasImage && result ? ( + {`Generated + ) : hasVideo ? ( +
+
+ + + {result + ? resultPlayback?.previewable === false + ? `Download only · ${result.name}` + : `Generated result · ${result.name}` + : (playback?.reason ?? 'Local source preview')} + + {needsProxy && !proxySettings ? ( + + ) : null} +
+ {needsProxy && proxySettings ? ( +
+
+ Create a disposable preview proxy + + {hasVideo + ? 'H.264/AAC MP4, cached locally by these bounded settings.' + : 'AAC/M4A, cached locally by these bounded settings.'} + +
+ + {hasVideo ? ( + + ) : null} + +
+ ) : null} +
+ ); +} + +interface AudioWaveformPreviewProps { + readonly peaks: WaveformPeakData; + readonly currentTimeSeconds?: number; + readonly sourceName: string; +} + +function AudioWaveformPreview({ + peaks, + currentTimeSeconds, + sourceName, +}: AudioWaveformPreviewProps) { + const canvasRef = useRef(null); + const stageRef = useRef(null); + const [renderRevision, setRenderRevision] = useState(0); + + useEffect(() => { + const stage = stageRef.current; + if (stage === null || typeof ResizeObserver === 'undefined') return; + const observer = new ResizeObserver(() => + setRenderRevision((revision) => revision + 1) + ); + observer.observe(stage); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + const canvas = canvasRef.current; + if (canvas === null) return; + const style = getComputedStyle(canvas); + try { + renderWaveformToCanvas(canvas, peaks, { + width: Math.max(1, Math.round(canvas.clientWidth || 640)), + height: 112, + devicePixelRatio: Math.min( + 4, + Math.max(1, window.devicePixelRatio || 1) + ), + waveColor: + style.getPropertyValue('--audio-waveform-wave').trim() || '#6fd7ca', + backgroundColor: + style.getPropertyValue('--audio-waveform-background').trim() || + '#101b22', + centerLineColor: + style.getPropertyValue('--audio-waveform-center').trim() || + 'rgba(255, 255, 255, 0.22)', + accessibleLabel: `Bounded waveform preview for ${sourceName}.`, + }); + } catch { + // Audio controls remain usable if Canvas is unavailable. + } + }, [peaks, renderRevision, sourceName]); + + const playheadFraction = + currentTimeSeconds === undefined || peaks.durationSeconds <= 0 + ? undefined + : Math.min(1, Math.max(0, currentTimeSeconds / peaks.durationSeconds)); + + return ( +
+ + {playheadFraction !== undefined ? ( +
+ ); +} diff --git a/src/components/QuickConvert.tsx b/src/components/QuickConvert.tsx new file mode 100644 index 0000000..9f77ac9 --- /dev/null +++ b/src/components/QuickConvert.tsx @@ -0,0 +1,541 @@ +import { useMemo, useState } from 'react'; +import type { ImportedMediaAsset } from '../app/application-state'; +import type { EngineState } from '../ffmpeg/ffmpeg.types'; +import type { MediaStreamProbe } from '../media'; +import type { StreamSelection } from '../commands/command-utils'; +import { + validateRemuxCompatibility, + type RemuxStream, +} from '../commands/remux'; +import { + requirementsForUserPreset, + type ExportPreset, +} from '../commands/convert'; +import { + BUILT_IN_PRESETS, + presetAvailability, +} from '../presets/preset-registry'; +import { formatBytes, formatDuration } from '../app/application-state'; +import { Icon } from './Icon'; + +export type QuickOperation = 'convert' | 'remux'; + +export interface QuickExportConfiguration { + operation: QuickOperation; + preset?: ExportPreset; + remuxContainer?: 'mp4' | 'webm' | 'matroska' | 'ogg'; + streamSelection: StreamSelection; + removeMetadata: boolean; + removeChapters: boolean; +} + +export interface QuickConvertProps { + asset?: ImportedMediaAsset; + engineState: EngineState; + busy: boolean; + queueing?: boolean; + presets?: readonly ExportPreset[]; + onExport: (configuration: QuickExportConfiguration) => void; +} + +export function QuickConvert({ + asset, + engineState, + busy, + queueing = false, + presets = BUILT_IN_PRESETS, + onExport, +}: QuickConvertProps) { + const [operation, setOperation] = useState('convert'); + const [presetId, setPresetId] = useState('mp4-h264-balanced'); + const [remuxContainer, setRemuxContainer] = + useState('matroska'); + const [removeMetadata, setRemoveMetadata] = useState(false); + const [removeChapters, setRemoveChapters] = useState(false); + const [streamOverride, setStreamOverride] = useState<{ + readonly assetId: string; + readonly indices: ReadonlySet; + }>(); + + const preset = presets.find((entry) => entry.id === presetId); + const streams = useMemo(() => asset?.probe?.streams ?? [], [asset?.probe]); + const requestedStreams = useMemo( + () => + streamOverride !== undefined && + asset !== undefined && + streamOverride.assetId === asset.id + ? streamOverride.indices + : new Set(streams.map((stream) => stream.index)), + [asset, streamOverride, streams] + ); + const streamUnavailableReasons = useMemo( + () => + new Map( + streams.flatMap((stream) => { + const reason = quickStreamUnavailableReason( + stream, + operation, + preset + ); + return reason ? ([[stream.index, reason] as const] as const) : []; + }) + ), + [operation, preset, streams] + ); + const selectedStreams = useMemo( + () => + new Set( + [...requestedStreams].filter( + (index) => !streamUnavailableReasons.has(index) + ) + ), + [requestedStreams, streamUnavailableReasons] + ); + const effectiveSelection = useMemo(() => { + return { + video: streams + .filter( + (stream) => + stream.type === 'video' && selectedStreams.has(stream.index) + ) + .map((stream) => stream.index), + audio: streams + .filter( + (stream) => + stream.type === 'audio' && selectedStreams.has(stream.index) + ) + .map((stream) => stream.index), + subtitles: streams + .filter( + (stream) => + stream.type === 'subtitle' && selectedStreams.has(stream.index) + ) + .map((stream) => stream.index), + attachments: streams + .filter( + (stream) => + stream.type === 'attachment' && selectedStreams.has(stream.index) + ) + .map((stream) => stream.index), + data: streams + .filter( + (stream) => + stream.type === 'data' && selectedStreams.has(stream.index) + ) + .map((stream) => stream.index), + }; + }, [selectedStreams, streams]); + + const presetRequirements = + preset && 'builtIn' in preset + ? preset.requirements + : preset + ? requirementsForUserPreset(preset) + : undefined; + const availability = + presetRequirements && engineState.status === 'ready' + ? presetAvailability( + { requirements: presetRequirements }, + engineState.capabilities + ) + : undefined; + const opusNeedsRegressionTest = preset?.audio?.codec === 'libopus'; + const presetNeedsMissingAudio = + preset?.kind === 'audio' && effectiveSelection.audio.length === 0; + const remuxStreams: readonly RemuxStream[] = streams.flatMap((stream) => + stream.type === 'video' || + stream.type === 'audio' || + stream.type === 'subtitle' || + stream.type === 'attachment' || + stream.type === 'data' + ? [ + { + index: stream.index, + kind: stream.type, + codec: stream.codecName ?? 'unknown', + }, + ] + : [] + ); + const remuxDiagnostics = remuxContainer + ? validateRemuxCompatibility( + { + streams: remuxStreams, + hasChapters: Boolean(asset?.probe?.chapters.length), + }, + remuxContainer, + effectiveSelection + ) + : []; + const remuxErrors = remuxDiagnostics.filter( + (diagnostic) => diagnostic.severity === 'error' + ); + const remuxMuxerMissing = + remuxContainer !== undefined && + engineState.status === 'ready' && + !engineState.capabilities.muxers.has(remuxContainer); + const remuxUnavailableReason = remuxMuxerMissing + ? `The loaded core is missing muxer ${remuxContainer}.` + : remuxErrors.map((diagnostic) => diagnostic.message).join(' '); + const hasSelectedStream = selectedStreams.size > 0; + const canExport = + asset?.phase === 'ready' && + !busy && + hasSelectedStream && + (operation === 'remux' + ? !remuxMuxerMissing && remuxErrors.length === 0 + : preset !== undefined && + availability?.status !== 'unavailable' && + !opusNeedsRegressionTest && + !presetNeedsMissingAudio); + + const presetOption = (entry: ExportPreset) => { + const requirements = + 'builtIn' in entry + ? entry.requirements + : requirementsForUserPreset(entry); + const entryAvailability = + engineState.status === 'ready' + ? presetAvailability({ requirements }, engineState.capabilities) + : undefined; + const reason = + entry.audio?.codec === 'libopus' + ? 'Opus awaits a pinned-core regression test' + : entryAvailability?.status === 'unavailable' + ? entryAvailability.reasons.join(', ') + : undefined; + return { + disabled: reason !== undefined, + label: reason ? `${entry.name} — unavailable: ${reason}` : entry.name, + }; + }; + + return ( +
+
+
+

One source · one output

+

Quick Convert

+
+ + + Local processing + +
+ + {asset ? ( +
+ + stream.type === 'video') + ? 'video' + : 'audio' + } + /> + +
+ {asset.file.name} + + {formatBytes(asset.file.size)} ·{' '} + {formatDuration(asset.probe?.durationSeconds)} + +
+ + {asset.phase === 'ready' + ? 'Ready' + : asset.phase === 'error' + ? 'Could not inspect' + : 'Preparing'} + +
+ ) : ( +

+ Add and select a media source to continue. +

+ )} + +
+ + +
+ +
+ {operation === 'convert' ? ( + + ) : ( + + )} + +
+ Streams + {streams.length > 0 ? ( + streams.map((stream) => { + const checked = selectedStreams.has(stream.index); + const unavailableReason = streamUnavailableReasons.get( + stream.index + ); + return ( + + ); + }) + ) : ( +

Streams appear after local inspection.

+ )} +
+ + + +
+ + {availability?.status === 'unavailable' ? ( +

+ + This core cannot run the selected preset:{' '} + {availability.reasons.join(', ')} +

+ ) : null} + {operation === 'remux' && remuxUnavailableReason ? ( +

+ + {remuxUnavailableReason} +

+ ) : null} + {operation === 'convert' && presetNeedsMissingAudio ? ( +

+ + Select at least one audio stream for an audio-only preset. +

+ ) : null} + {!hasSelectedStream && streams.length > 0 ? ( +

+ + Select at least one output stream. +

+ ) : null} + {opusNeedsRegressionTest ? ( +

+ + Opus export is held back until the pinned core passes its browser + regression test. +

+ ) : null} + +
+
+ + {preset && 'settingsSummary' in preset + ? preset.settingsSummary[0] + : preset + ? 'Validated user preset' + : 'Stream copy'} + + Resource estimates are checked before allocation. +
+ +
+
+ ); +} + +function quickStreamUnavailableReason( + stream: MediaStreamProbe, + operation: QuickOperation, + preset: ExportPreset | undefined +): string | undefined { + if (stream.type === 'unknown') { + return 'unknown stream kinds cannot be mapped safely.'; + } + if (operation === 'remux') { + return undefined; + } + if (!preset) { + return 'choose an export preset first.'; + } + if (stream.type === 'video' && !preset.video) { + return 'the selected preset creates audio only.'; + } + if (stream.type === 'audio' && !preset.audio) { + return 'the selected preset creates video or images without audio.'; + } + if (stream.type === 'subtitle') { + if (preset.subtitlePolicy === 'none') { + return 'the selected preset explicitly omits subtitles.'; + } + if (preset.subtitlePolicy === 'burn-in') { + return 'burn-in requires an explicitly attached subtitle source.'; + } + } + if ( + (stream.type === 'attachment' || stream.type === 'data') && + preset.container !== 'matroska' + ) { + return `${stream.type} streams are only preserved by the reviewed Matroska conversion path.`; + } + return undefined; +} diff --git a/src/components/StoragePanel.tsx b/src/components/StoragePanel.tsx new file mode 100644 index 0000000..de4bbb8 --- /dev/null +++ b/src/components/StoragePanel.tsx @@ -0,0 +1,186 @@ +import { useEffect, useState } from 'react'; +import { formatBytes } from '../app/application-state'; +import { getDerivedCache } from '../app/derived-cache'; +import { + readStorageQuota, + type DerivedCacheEntry, + type StorageQuotaSnapshot, +} from '../storage'; +import { Icon } from './Icon'; + +export function StoragePanel({ projectId }: { readonly projectId?: string }) { + const [quota, setQuota] = useState(); + const [entries, setEntries] = useState([]); + const [mode, setMode] = useState<'opfs' | 'memory'>('memory'); + const [message, setMessage] = useState(); + + const refresh = async () => { + const [nextQuota, cache] = await Promise.all([ + readStorageQuota(), + getDerivedCache(), + ]); + setQuota(nextQuota); + setMode(cache.mode); + setEntries(await cache.list()); + }; + + const runCacheAction = async ( + action: () => Promise, + success: string + ) => { + try { + await action(); + await refresh(); + setMessage(success); + } catch (error) { + setMessage( + error instanceof Error + ? `Cache operation failed: ${error.message}` + : 'Cache operation failed.' + ); + } + }; + + useEffect(() => { + const timer = globalThis.setTimeout(() => { + void refresh().catch((error: unknown) => { + setMessage( + error instanceof Error + ? error.message + : 'Storage status is unavailable.' + ); + }); + }, 0); + return () => globalThis.clearTimeout(timer); + }, []); + + const cachedBytes = entries.reduce( + (total, entry) => total + entry.sizeBytes, + 0 + ); + const projectEntries = projectId + ? entries.filter((entry) => entry.projectId === projectId) + : []; + + return ( +
{ + if (!event.currentTarget.open) return; + void refresh().catch((error: unknown) => { + setMessage( + error instanceof Error + ? error.message + : 'Storage status is unavailable.' + ); + }); + }} + > + + + + Derived cache + + + {formatBytes(cachedBytes)} · {mode.toUpperCase()} + + +
+
+
+
Origin usage
+
+ {quota?.usageBytes === undefined + ? 'Not reported' + : formatBytes(quota.usageBytes)} +
+
+
+
Origin quota
+
+ {quota?.quotaBytes === undefined + ? 'Not reported' + : formatBytes(quota.quotaBytes)} +
+
+
+
Cached items
+
{entries.length}
+
+
+ {entries.length > 0 ? ( +
    + {entries.map((entry) => ( +
  • + {entry.kind} + {formatBytes(entry.sizeBytes)} + +
  • + ))} +
+ ) : ( +

+ Preview proxies and generated frames may be cached here. Original + source media is never added automatically. +

+ )} +
+ + + + {message ? {message} : null} +
+
+
+ ); +} diff --git a/src/components/StructuralOperations.css b/src/components/StructuralOperations.css new file mode 100644 index 0000000..0b29a56 --- /dev/null +++ b/src/components/StructuralOperations.css @@ -0,0 +1,458 @@ +.structural-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%); +} + +.structural-operations *, +.structural-operations *::before, +.structural-operations *::after { + box-sizing: border-box; +} + +.structural-operations h2, +.structural-operations h3, +.structural-operations h4, +.structural-operations p { + margin-block-start: 0; +} + +.structural-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); +} + +.structural-operations__header h2 { + margin-block-end: 0.28rem; + font-size: 1.05rem; +} + +.structural-operations__header p:not(.structural-operations__eyebrow) { + max-width: 50rem; + margin-block-end: 0; + color: var(--toolbox-muted); + font-size: 0.78rem; + line-height: 1.55; +} + +.structural-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; +} + +.structural-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; +} + +.structural-operations__grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: start; + gap: 1rem; + padding: 1rem; +} + +.structural-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) + ); +} + +.structural-operation-card > header { + display: flex; + align-items: flex-start; + gap: 0.7rem; + margin-block-end: 1rem; +} + +.structural-operation-card > header h3 { + margin: 0 0 0.2rem; + font-size: 0.87rem; +} + +.structural-operation-card > header p { + margin-block-end: 0; + color: var(--toolbox-muted); + font-size: 0.72rem; + line-height: 1.45; +} + +.structural-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; +} + +.structural-range-summary { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.5rem; + margin: 0 0 0.85rem; +} + +.structural-range-summary > div, +.structural-timeline-summary { + min-width: 0; + padding: 0.62rem; + border: 1px solid var(--toolbox-border); + border-radius: calc(var(--toolbox-radius) - 0.14rem); + background: var(--toolbox-surface-soft); +} + +.structural-range-summary dt { + margin-block-end: 0.22rem; + color: var(--toolbox-muted); + font-size: 0.61rem; +} + +.structural-range-summary dd { + margin: 0; + overflow-wrap: anywhere; + font-size: 0.68rem; + font-weight: 760; + font-variant-numeric: tabular-nums; +} + +.structural-timeline-summary { + margin-block-end: 0.85rem; +} + +.structural-timeline-summary p { + margin-block-end: 0.18rem; + color: var(--toolbox-muted); + font-size: 0.67rem; + line-height: 1.45; +} + +.structural-timeline-summary p:last-child { + margin-block-end: 0; +} + +.structural-choice-cards, +.structural-policy { + display: grid; + gap: 0.55rem; + margin: 0 0 0.85rem; + padding: 0; + border: 0; +} + +.structural-choice-cards > legend, +.structural-policy > legend { + margin-block-end: 0.35rem; + font-size: 0.7rem; + font-weight: 760; +} + +.structural-choice-cards > label, +.structural-policy > div > 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; +} + +.structural-choice-cards > label:has(input:checked), +.structural-policy > div > label:has(input:checked) { + border-color: color-mix( + in srgb, + var(--toolbox-accent) 48%, + var(--toolbox-border) + ); + background: var(--toolbox-accent-soft); +} + +.structural-choice-cards input, +.structural-policy input { + margin-block-start: 0.15rem; + accent-color: var(--toolbox-accent); +} + +.structural-choice-cards span, +.structural-choice-cards strong, +.structural-choice-cards small, +.structural-policy span, +.structural-policy strong, +.structural-policy small { + display: block; +} + +.structural-choice-cards strong, +.structural-policy strong { + margin-block-end: 0.18rem; + font-size: 0.74rem; +} + +.structural-choice-cards small, +.structural-policy small { + color: var(--toolbox-muted); + font-size: 0.65rem; + line-height: 1.4; +} + +.structural-field { + min-width: 0; + display: grid; + gap: 0.34rem; + margin-block-end: 0.85rem; + font-size: 0.7rem; + font-weight: 740; +} + +.structural-field select { + 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; +} + +.structural-field small { + color: var(--toolbox-muted); + font-size: 0.64rem; + font-weight: 580; + line-height: 1.4; +} + +.structural-policy > p { + margin: -0.2rem 0 0; + color: var(--toolbox-muted); + font-size: 0.65rem; + line-height: 1.4; +} + +.structural-policy > div { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.45rem; +} + +.structural-hint, +.structural-empty { + margin-block-end: 0.85rem; + padding: 0.58rem 0.68rem; + border-radius: calc(var(--toolbox-radius) - 0.15rem); + background: var(--toolbox-surface-soft); + color: var(--toolbox-muted); + font-size: 0.67rem; + line-height: 1.45; +} + +.structural-compatibility { + margin-block-end: 0.85rem; + padding: 0.68rem; + border: 1px solid var(--toolbox-border); + border-radius: calc(var(--toolbox-radius) - 0.15rem); + background: var(--toolbox-surface-soft); +} + +.structural-compatibility h4 { + margin-block-end: 0.28rem; + font-size: 0.7rem; +} + +.structural-compatibility p, +.structural-compatibility li { + margin-block-end: 0; + color: var(--toolbox-muted); + font-size: 0.65rem; + line-height: 1.45; +} + +.structural-compatibility ul { + display: grid; + gap: 0.42rem; + margin: 0.5rem 0 0; + padding: 0; + list-style: none; +} + +.structural-compatibility li { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 0.45rem; +} + +.structural-compatibility li > span:first-child { + align-self: start; + padding: 0.12rem 0.3rem; + border-radius: 999px; + background: var(--toolbox-surface); + font-size: 0.54rem; + font-weight: 800; + text-transform: uppercase; +} + +.structural-compatibility li small { + display: block; + color: var(--toolbox-muted); + font-size: 0.58rem; +} + +.structural-compatibility--ok { + border-color: color-mix( + in srgb, + var(--toolbox-success, #2f8f5b) 28%, + var(--toolbox-border) + ); +} + +.structural-compatibility--warning, +.structural-compatibility--error { + border-color: color-mix( + in srgb, + var(--toolbox-warning, #a96700) 30%, + var(--toolbox-border) + ); +} + +.structural-compatibility--error { + background: color-mix( + in srgb, + var(--toolbox-warning, #a96700) 8%, + var(--toolbox-surface) + ); +} + +.structural-action { + display: grid; + gap: 0.28rem; +} + +.structural-action button { + width: 100%; + min-height: 2.5rem; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.58rem 0.82rem; + border: 1px solid var(--toolbox-accent); + border-radius: var(--toolbox-radius); + background: var(--toolbox-accent); + color: var(--toolbox-accent-contrast); + cursor: pointer; + font: inherit; + font-size: 0.72rem; + font-weight: 780; +} + +.structural-action button:hover:not(:disabled) { + border-color: var(--toolbox-accent-hover); + background: var(--toolbox-accent-hover); +} + +.structural-action button:disabled { + cursor: not-allowed; + opacity: 0.52; +} + +.structural-action small { + color: var(--toolbox-muted); + font-size: 0.61rem; + line-height: 1.35; +} + +.structural-field select:focus-visible, +.structural-operations input:focus-visible, +.structural-operations button:focus-visible { + outline: 3px solid var(--toolbox-focus); + outline-offset: 2px; +} + +.structural-operations__status { + min-height: 1.25rem; + margin: 0; + padding: 0 1rem 0.7rem; + color: var(--toolbox-muted); + font-size: 0.68rem; +} + +@media (max-width: 64rem) { + .structural-operations__grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 40rem) { + .structural-operations__header { + display: grid; + gap: 0.75rem; + } + + .structural-operations__local { + justify-self: start; + } + + .structural-operations__grid { + padding: 0.7rem; + } + + .structural-operation-card { + padding: 0.8rem; + } + + .structural-range-summary, + .structural-policy > div { + grid-template-columns: 1fr; + } +} + +@media (prefers-reduced-motion: reduce) { + .structural-operations *, + .structural-operations *::before, + .structural-operations *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } +} diff --git a/src/components/StructuralOperations.tsx b/src/components/StructuralOperations.tsx new file mode 100644 index 0000000..a4a9425 --- /dev/null +++ b/src/components/StructuralOperations.tsx @@ -0,0 +1,990 @@ +import { useId, useState } from 'react'; +import './StructuralOperations.css'; + +export type StructuralCapabilityKey = + 'trim-fast' | 'trim-accurate' | 'concat-fast' | 'concat-normalized'; + +export interface StructuralCapabilityStatus { + readonly available: boolean; + readonly reason?: string; +} + +export interface StructuralExportPresetOption { + readonly id: string; + readonly name: string; + readonly fileExtension: string; + readonly supportsAudio: boolean; + readonly supportsVideo: boolean; + readonly disabledReason?: string; +} + +export interface StructuralSelectedClip { + readonly id: string; + readonly label: string; + readonly sourceInSeconds: number; + readonly sourceOutSeconds: number; + readonly sourceDurationSeconds?: number; + readonly sourceExtension: string; + readonly hasAudio: boolean; + readonly hasVideo: boolean; +} + +export interface StructuralTimelineClip { + readonly id: string; + readonly label: string; + readonly hasAudio: boolean; + readonly hasVideo: boolean; + readonly hasSubtitles: boolean; +} + +export interface StructuralCompatibilityDiagnostic { + readonly code: string; + readonly severity: 'info' | 'warning' | 'error'; + readonly message: string; + readonly path?: string; +} + +export interface StructuralTimeline { + /** Clips in their exact export order. */ + readonly clips: readonly StructuralTimelineClip[]; + readonly fastTargetExtension: string; + readonly fastTargetMuxer: string; + /** + * `undefined` means the command layer has not checked the inputs. An empty + * array means it checked them and found no compatibility diagnostics. + */ + readonly fastCompatibilityDiagnostics?: readonly StructuralCompatibilityDiagnostic[]; +} + +export type MissingAudioPolicy = 'insert-silence' | 'drop-all' | 'reject'; +export type NormalizedSubtitlePolicy = 'drop-all' | 'reject'; + +export type StructuralTrimRequest = + | { + readonly operation: 'trim'; + readonly mode: 'fast'; + readonly clipId: string; + readonly startSeconds: number; + readonly endSeconds: number; + readonly targetExtension: string; + } + | { + readonly operation: 'trim'; + readonly mode: 'accurate'; + readonly clipId: string; + readonly startSeconds: number; + readonly endSeconds: number; + readonly presetId: string; + }; + +export type StructuralConcatRequest = + | { + readonly operation: 'concat'; + readonly mode: 'fast'; + readonly clipIds: readonly string[]; + readonly targetExtension: string; + readonly targetMuxer: string; + } + | { + readonly operation: 'concat'; + readonly mode: 'normalized'; + readonly clipIds: readonly string[]; + readonly presetId: string; + readonly missingAudioPolicy: MissingAudioPolicy; + readonly subtitlePolicy: NormalizedSubtitlePolicy; + }; + +type RequestHandler = (request: T) => void | Promise; + +export interface StructuralOperationsProps { + readonly selectedClip?: StructuralSelectedClip; + readonly timeline?: StructuralTimeline; + readonly exportPresets?: readonly StructuralExportPresetOption[]; + /** + * Missing entries fail closed. The integration layer must opt in only after + * checking the loaded FFmpeg core and the current browser environment. + */ + readonly availability?: Partial< + Readonly> + >; + readonly busy?: boolean; + readonly onTrim?: RequestHandler; + readonly onConcat?: RequestHandler; +} + +type TrimMode = StructuralTrimRequest['mode']; +type ConcatMode = StructuralConcatRequest['mode']; + +export function StructuralOperations({ + selectedClip, + timeline, + exportPresets = [], + availability = {}, + busy = false, + onTrim, + onConcat, +}: StructuralOperationsProps) { + const componentId = useId(); + const [trimMode, setTrimMode] = useState('fast'); + const [concatMode, setConcatMode] = useState('fast'); + const [trimPresetId, setTrimPresetId] = useState(''); + const [concatPresetId, setConcatPresetId] = useState(''); + const [missingAudioPolicy, setMissingAudioPolicy] = useState< + MissingAudioPolicy | '' + >(''); + const [subtitlePolicy, setSubtitlePolicy] = useState< + NormalizedSubtitlePolicy | '' + >(''); + const [pending, setPending] = useState(false); + const [status, setStatus] = useState(); + + const trimPreset = exportPresets.find((preset) => preset.id === trimPresetId); + const concatPreset = exportPresets.find( + (preset) => preset.id === concatPresetId + ); + const trimReason = reasonForTrim({ + mode: trimMode, + selectedClip, + preset: trimPreset, + availability, + connected: Boolean(onTrim), + busy: busy || pending, + }); + const concatReason = reasonForConcat({ + mode: concatMode, + timeline, + preset: concatPreset, + missingAudioPolicy, + subtitlePolicy, + availability, + connected: Boolean(onConcat), + busy: busy || pending, + }); + + const run = ( + handler: RequestHandler | undefined, + request: T, + successMessage: string + ) => { + if (!handler) { + return; + } + setPending(true); + setStatus(undefined); + void Promise.resolve() + .then(() => handler(request)) + .then(() => setStatus(successMessage)) + .catch((error: unknown) => { + setStatus( + error instanceof Error + ? `The operation could not be queued: ${error.message}` + : 'The operation could not be queued.' + ); + }) + .finally(() => setPending(false)); + }; + + return ( +
+
+
+

Structural exports

+

Trim or join without guesswork

+

+ Choose stream-copy speed or a preset-backed re-encode. Every action + runs locally in this browser. +

+
+ Local only +
+ +
+
+
+ 1 +
+

Trim the selected clip

+

+ The current timeline in/out points are used as-is; this panel + does not change the edit. +

+
+
+ + {selectedClip ? ( +
+
+
Clip
+
{selectedClip.label}
+
+
+
Current range
+
+ {formatTime(selectedClip.sourceInSeconds)}– + {formatTime(selectedClip.sourceOutSeconds)} +
+
+
+
Export duration
+
+ {formatDuration( + selectedClip.sourceOutSeconds - selectedClip.sourceInSeconds + )} +
+
+
+ ) : ( +

+ Select one timeline clip to export its current range. +

+ )} + +
+ Trim method + + +
+ + {trimMode === 'accurate' ? ( + + selectedClip?.hasVideo + ? preset.supportsVideo + : Boolean(selectedClip?.hasAudio && preset.supportsAudio) + } + disabled={busy || pending} + onChange={setTrimPresetId} + /> + ) : ( +

+ The fast export keeps the source streams and writes{' '} + + . + {normalizeExtension(selectedClip?.sourceExtension) || + 'source format'} + + . +

+ )} + + { + if (!selectedClip) { + return; + } + if (trimMode === 'fast') { + run( + onTrim, + { + operation: 'trim', + mode: 'fast', + clipId: selectedClip.id, + startSeconds: selectedClip.sourceInSeconds, + endSeconds: selectedClip.sourceOutSeconds, + targetExtension: normalizeExtension( + selectedClip.sourceExtension + ), + }, + 'Fast trim queued.' + ); + return; + } + if (!trimPreset) { + return; + } + run( + onTrim, + { + operation: 'trim', + mode: 'accurate', + clipId: selectedClip.id, + startSeconds: selectedClip.sourceInSeconds, + endSeconds: selectedClip.sourceOutSeconds, + presetId: trimPreset.id, + }, + 'Accurate trim queued.' + ); + }} + /> +
+ +
+
+ 2 +
+

Join the sequential timeline

+

+ Clips are submitted in their visible timeline order. Fast + concatenation is enabled only after an explicit stream check. +

+
+
+ + {timeline ? ( + + ) : ( +

+ Add at least two clips to the sequential timeline. +

+ )} + +
+ Join method + + +
+ + {concatMode === 'fast' ? ( + + ) : ( + <> + preset.supportsVideo} + disabled={busy || pending} + onChange={setConcatPresetId} + /> + + clip.hasSubtitles) + )} + onChange={setSubtitlePolicy} + /> + + )} + + { + if (!timeline) { + return; + } + const clipIds = timeline.clips.map((clip) => clip.id); + if (concatMode === 'fast') { + run( + onConcat, + { + operation: 'concat', + mode: 'fast', + clipIds, + targetExtension: normalizeExtension( + timeline.fastTargetExtension + ), + targetMuxer: timeline.fastTargetMuxer.trim(), + }, + 'Fast concat queued.' + ); + return; + } + if (!concatPreset || !missingAudioPolicy || !subtitlePolicy) { + return; + } + run( + onConcat, + { + operation: 'concat', + mode: 'normalized', + clipIds, + presetId: concatPreset.id, + missingAudioPolicy, + subtitlePolicy, + }, + 'Normalized concat queued.' + ); + }} + /> +
+
+ +

+ {pending ? 'Queueing local operation…' : status} +

+
+ ); +} + +function TimelineSummary({ + clips, +}: { + readonly clips: readonly StructuralTimelineClip[]; +}) { + const audioCount = clips.filter((clip) => clip.hasAudio).length; + const subtitleCount = clips.filter((clip) => clip.hasSubtitles).length; + const missingAudio = clips.filter((clip) => !clip.hasAudio); + return ( +
+

+ {clips.length} clips in sequence ·{' '} + {audioCount} with audio ·{' '} + {subtitleCount} with subtitles +

+ {missingAudio.length > 0 ? ( +

No audio: {missingAudio.map((clip) => clip.label).join(', ')}

+ ) : ( +

Every clip has an audio stream.

+ )} +
+ ); +} + +function CompatibilityReport({ + diagnostics, + clipCount, +}: { + readonly diagnostics: + readonly StructuralCompatibilityDiagnostic[] | undefined; + readonly clipCount: number; +}) { + if (diagnostics === undefined) { + return ( +
+

Fast compatibility not checked

+

+ Inspect every input stream before enabling stream-copy concatenation. +

+
+ ); + } + if (diagnostics.length === 0) { + return ( +
+

Fast compatibility passed

+

+ All {clipCount} clips have matching stream properties for stream copy. +

+
+ ); + } + const hasErrors = diagnostics.some( + (diagnostic) => diagnostic.severity === 'error' + ); + return ( +
+

+ {hasErrors ? 'Fast compatibility failed' : 'Fast compatibility notes'} +

+
    + {diagnostics.map((diagnostic, index) => ( +
  • + {diagnostic.severity} + + {diagnostic.message} + {diagnostic.path ? ( + Checked field: {diagnostic.path} + ) : null} + +
  • + ))} +
+
+ ); +} + +function PresetField({ + id, + label, + value, + presets, + suitable, + disabled, + onChange, +}: { + readonly id: string; + readonly label: string; + readonly value: string; + readonly presets: readonly StructuralExportPresetOption[]; + readonly suitable: (preset: StructuralExportPresetOption) => boolean; + readonly disabled: boolean; + readonly onChange: (value: string) => void; +}) { + const selectableCount = presets.filter( + (preset) => suitable(preset) && !preset.disabledReason + ).length; + return ( + + ); +} + +function MissingAudioPolicyField({ + componentId, + value, + disabled, + onChange, +}: { + readonly componentId: string; + readonly value: MissingAudioPolicy | ''; + readonly disabled: boolean; + readonly onChange: (value: MissingAudioPolicy) => void; +}) { + const options: readonly { + readonly value: MissingAudioPolicy; + readonly title: string; + readonly detail: string; + }[] = [ + { + value: 'insert-silence', + title: 'Insert silence', + detail: 'Keep an audio track and fill each silent clip to its duration.', + }, + { + value: 'drop-all', + title: 'Drop all audio', + detail: 'Export one video-only sequence, even from clips with audio.', + }, + { + value: 'reject', + title: 'Reject mixed audio', + detail: 'Stop if some clips have audio and others do not.', + }, + ]; + return ( +
+ Missing-audio policy +

+ Required: choose how a normalized sequence handles clips without audio. +

+
+ {options.map((option) => ( + + ))} +
+
+ ); +} + +function SubtitlePolicyField({ + componentId, + value, + disabled, + hasSubtitles, + onChange, +}: { + readonly componentId: string; + readonly value: NormalizedSubtitlePolicy | ''; + readonly disabled: boolean; + readonly hasSubtitles: boolean; + readonly onChange: (value: NormalizedSubtitlePolicy) => void; +}) { + return ( +
+ Subtitle policy +

+ Required: normalized concat has no reviewed, container-safe subtitle + retiming path. The selected preset and loaded FFmpeg capabilities still + gate the final export. +

+
+ + +
+
+ ); +} + +function StructuralAction({ + label, + reason, + onClick, +}: { + readonly label: string; + readonly reason?: string; + readonly onClick: () => void; +}) { + const reasonId = useId(); + return ( +
+ + {reason ? {reason} : null} +
+ ); +} + +function reasonForTrim({ + mode, + selectedClip, + preset, + availability, + connected, + busy, +}: { + readonly mode: TrimMode; + readonly selectedClip: StructuralSelectedClip | undefined; + readonly preset: StructuralExportPresetOption | undefined; + readonly availability: StructuralOperationsProps['availability']; + readonly connected: boolean; + readonly busy: boolean; +}): string | undefined { + if (busy) { + return 'Another local operation is currently running.'; + } + if (!selectedClip) { + return 'Select a timeline clip first.'; + } + if (!validRange(selectedClip)) { + return 'The selected clip must have a finite in/out range inside its source.'; + } + if (mode === 'fast' && !normalizeExtension(selectedClip.sourceExtension)) { + return 'The selected source has no safe output extension.'; + } + if (mode === 'accurate') { + if (!preset) { + return 'Choose an available typed preset for the accurate trim.'; + } + if (preset.disabledReason) { + return preset.disabledReason; + } + if ( + (selectedClip.hasVideo && !preset.supportsVideo) || + (!selectedClip.hasVideo && selectedClip.hasAudio && !preset.supportsAudio) + ) { + return 'The selected preset does not encode this clip type.'; + } + } + if (!connected) { + return 'This action has not been connected to the job runner.'; + } + return capabilityReason( + availability?.[mode === 'fast' ? 'trim-fast' : 'trim-accurate'] + ); +} + +function reasonForConcat({ + mode, + timeline, + preset, + missingAudioPolicy, + subtitlePolicy, + availability, + connected, + busy, +}: { + readonly mode: ConcatMode; + readonly timeline: StructuralTimeline | undefined; + readonly preset: StructuralExportPresetOption | undefined; + readonly missingAudioPolicy: MissingAudioPolicy | ''; + readonly subtitlePolicy: NormalizedSubtitlePolicy | ''; + readonly availability: StructuralOperationsProps['availability']; + readonly connected: boolean; + readonly busy: boolean; +}): string | undefined { + if (busy) { + return 'Another local operation is currently running.'; + } + if (!timeline || timeline.clips.length < 2) { + return 'Add at least two timeline clips in the order to be joined.'; + } + const clipIds = timeline.clips.map((clip) => clip.id); + if ( + clipIds.some((id) => id.trim() === '') || + new Set(clipIds).size !== clipIds.length + ) { + return 'Every timeline clip must have a unique nonempty ID.'; + } + if (mode === 'fast') { + if (!normalizeExtension(timeline.fastTargetExtension)) { + return 'The sequence has no safe fast-concat output extension.'; + } + if (timeline.fastCompatibilityDiagnostics === undefined) { + return 'Fast concat remains disabled until every input stream is checked.'; + } + if ( + timeline.fastCompatibilityDiagnostics.some( + (diagnostic) => diagnostic.severity === 'error' + ) + ) { + return 'Resolve the reported stream incompatibilities or use normalized concat.'; + } + if (!/^[a-z0-9][a-z0-9_-]{0,31}$/u.test(timeline.fastTargetMuxer.trim())) { + return 'The fast-concat output muxer is unknown.'; + } + } else { + if (timeline.clips.some((clip) => !clip.hasVideo)) { + return 'Normalized concat currently requires video in every clip.'; + } + if (!preset) { + return 'Choose an available typed video preset for normalized concat.'; + } + if (preset.disabledReason) { + return preset.disabledReason; + } + if (!preset.supportsVideo) { + return 'Normalized concat requires a video preset.'; + } + if (!missingAudioPolicy) { + return 'Choose an explicit missing-audio policy.'; + } + if (!subtitlePolicy) { + return 'Choose an explicit subtitle policy.'; + } + if ( + subtitlePolicy === 'reject' && + timeline.clips.some((clip) => clip.hasSubtitles) + ) { + return 'This sequence contains subtitles. Explicitly drop all subtitles, or remove them before normalized concat.'; + } + } + if (!connected) { + return 'This action has not been connected to the job runner.'; + } + return capabilityReason( + availability?.[mode === 'fast' ? 'concat-fast' : 'concat-normalized'] + ); +} + +function capabilityReason( + status: StructuralCapabilityStatus | undefined +): string | undefined { + if (!status?.available) { + return ( + status?.reason ?? + 'The required browser and FFmpeg capabilities have not been verified.' + ); + } + return undefined; +} + +function validRange(clip: StructuralSelectedClip): boolean { + if ( + !Number.isFinite(clip.sourceInSeconds) || + !Number.isFinite(clip.sourceOutSeconds) || + clip.sourceInSeconds < 0 || + clip.sourceOutSeconds <= clip.sourceInSeconds + ) { + return false; + } + return ( + clip.sourceDurationSeconds === undefined || + (Number.isFinite(clip.sourceDurationSeconds) && + clip.sourceDurationSeconds > 0 && + clip.sourceOutSeconds <= clip.sourceDurationSeconds) + ); +} + +function normalizeExtension(extension: string | undefined): string { + const normalized = (extension ?? '') + .trim() + .replace(/^\.+/u, '') + .toLowerCase(); + return /^[a-z0-9]{1,10}$/u.test(normalized) ? normalized : ''; +} + +function formatDuration(seconds: number): string { + if (!Number.isFinite(seconds) || seconds < 0) { + return 'Invalid range'; + } + return `${seconds.toFixed(3)} s`; +} + +function formatTime(seconds: number): string { + if (!Number.isFinite(seconds) || seconds < 0) { + return '—'; + } + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const remainder = seconds % 60; + return `${hours.toString().padStart(2, '0')}:${minutes + .toString() + .padStart(2, '0')}:${remainder.toFixed(3).padStart(6, '0')}`; +} diff --git a/src/components/Timeline.tsx b/src/components/Timeline.tsx new file mode 100644 index 0000000..31a7742 --- /dev/null +++ b/src/components/Timeline.tsx @@ -0,0 +1,253 @@ +import { useState, type DragEvent } from 'react'; +import type { + AvProjectV1, + MediaAssetReference, + TimelineClip, +} from '../project'; +import type { WaveformPeakData } from '../waveform'; +import { formatDuration } from '../app/application-state'; +import { Icon } from './Icon'; +import { WaveformEditor, type WaveformSelection } from './WaveformEditor'; + +export interface TimelineProps { + project: AvProjectV1; + selectedClipId?: string; + onSelect: (clipId: string) => void; + onMove: (clipId: string, index: number) => void; + onUpdate: (clipId: string, changes: Partial) => void; + onRemove: (clipId: string) => void; + waveformPeaks?: WaveformPeakData; + currentTimeSeconds?: number; + splitMarkers?: readonly number[]; + onSelectionChange?: (selection: WaveformSelection) => void; + onSeek?: (timeSeconds: number) => void; + waveformDisabled?: boolean; +} + +export function Timeline({ + project, + selectedClipId, + onSelect, + onMove, + onUpdate, + onRemove, + waveformPeaks, + currentTimeSeconds, + splitMarkers = [], + onSelectionChange, + onSeek, + waveformDisabled = false, +}: TimelineProps) { + const [draggedId, setDraggedId] = useState(); + const assetMap = new Map(project.assets.map((asset) => [asset.id, asset])); + const selectedClip = project.timeline.find( + (clip) => clip.id === selectedClipId + ); + + const dropAt = (event: DragEvent, index: number) => { + event.preventDefault(); + if (draggedId) onMove(draggedId, index); + setDraggedId(undefined); + }; + + return ( +
+
+
+

Sequential, non-destructive

+

Timeline

+
+ + {project.timeline.length} clip + {project.timeline.length === 1 ? '' : 's'} + +
+ {project.timeline.length === 0 ? ( +
+ Imported sources with a known duration are added here. +
+ ) : ( +
+ {project.timeline.map((clip, index) => ( + onSelect(clip.id)} + onDragStart={() => setDraggedId(clip.id)} + onDragEnd={() => setDraggedId(undefined)} + onDragOver={(event) => event.preventDefault()} + onDrop={(event) => dropAt(event, index)} + onMove={(offset) => onMove(clip.id, index + offset)} + onUpdate={(changes) => onUpdate(clip.id, changes)} + onRemove={() => onRemove(clip.id)} + /> + ))} +
+ )} + {waveformPeaks && selectedClip ? ( +
+ { + onUpdate(selectedClip.id, { + sourceInSeconds: selection.inSeconds, + sourceOutSeconds: selection.outSeconds, + }); + onSelectionChange?.(selection); + }} + /> +
+ ) : null} +
+ ); +} + +interface TimelineClipCardProps { + clip: TimelineClip; + asset?: MediaAssetReference; + selected: boolean; + first: boolean; + last: boolean; + onSelect: () => void; + onDragStart: () => void; + onDragEnd: () => void; + onDragOver: (event: DragEvent) => void; + onDrop: (event: DragEvent) => void; + onMove: (offset: number) => void; + onUpdate: (changes: Partial) => void; + onRemove: () => void; +} + +function TimelineClipCard({ + clip, + asset, + selected, + first, + last, + onSelect, + onDragStart, + onDragEnd, + onDragOver, + onDrop, + onMove, + onUpdate, + onRemove, +}: TimelineClipCardProps) { + const sourceDurationSeconds = asset?.probe?.durationSeconds; + const canResetTrim = + sourceDurationSeconds !== undefined && + Number.isFinite(sourceDurationSeconds) && + sourceDurationSeconds >= 0.001; + const trimIsFullSource = + canResetTrim && + clip.sourceInSeconds === 0 && + clip.sourceOutSeconds === sourceDurationSeconds; + + return ( +
+ + {selected ? ( +
+ + +
+ + + + +
+
+ ) : null} +
+ ); +} diff --git a/src/components/WaveformEditor.tsx b/src/components/WaveformEditor.tsx new file mode 100644 index 0000000..7e9db24 --- /dev/null +++ b/src/components/WaveformEditor.tsx @@ -0,0 +1,788 @@ +import { + useEffect, + useId, + useMemo, + useRef, + useState, + type ChangeEvent, + type KeyboardEvent, + type PointerEvent, +} from 'react'; +import { formatTimecode } from '../media'; +import { MAX_SPLIT_MARKERS } from '../limits'; +import { + renderWaveformToCanvas, + validateWaveformPeakData, + type WaveformPeakData, +} from '../waveform'; +import './visual-editor.css'; + +type DragTarget = + { kind: 'in' } | { kind: 'out' } | { kind: 'marker'; index: number }; + +interface ActiveDrag { + pointerId: number; + target: DragTarget; +} + +export interface WaveformSelection { + readonly inSeconds: number; + readonly outSeconds: number; + readonly markers: readonly number[]; +} + +export interface WaveformEditorProps { + peaks: WaveformPeakData; + inSeconds: number; + outSeconds: number; + markers: readonly number[]; + onChange: (selection: WaveformSelection) => void; + currentTimeSeconds?: number; + onSeek?: (timeSeconds: number) => void; + disabled?: boolean; + minimumClipDurationSeconds?: number; + keyboardStepSeconds?: number; + maximumZoom?: number; + height?: number; + ariaLabel?: string; +} + +const MINIMUM_MARKER_GAP_SECONDS = 0.001; + +/** + * A single-track waveform editor. It renders bounded peak data to Canvas and + * keeps trim points and split markers as accessible DOM controls above it. + */ +export function WaveformEditor({ + peaks, + inSeconds, + outSeconds, + markers, + onChange, + currentTimeSeconds, + onSeek, + disabled = false, + minimumClipDurationSeconds = 0.001, + keyboardStepSeconds = 0.1, + maximumZoom = 32, + height = 168, + ariaLabel = 'Waveform trim and split editor', +}: WaveformEditorProps) { + const peakData = useMemo(() => validateWaveformPeakData(peaks), [peaks]); + validateEditorOptions( + minimumClipDurationSeconds, + keyboardStepSeconds, + maximumZoom, + height + ); + + const instanceId = useId(); + const descriptionId = `${instanceId}-waveform-description`; + const canvasRef = useRef(null); + const stageRef = useRef(null); + const activeDragRef = useRef(undefined); + const [zoom, setZoom] = useState(1); + const [viewportStartSeconds, setViewportStartSeconds] = useState(0); + const [renderRevision, setRenderRevision] = useState(0); + const duration = peakData.durationSeconds; + const effectiveMinimumClipDuration = Math.min( + minimumClipDurationSeconds, + duration + ); + const effectiveZoom = clamp(zoom, 1, maximumZoom); + const selection = useMemo( + () => + normalizeSelection( + { inSeconds, outSeconds, markers }, + duration, + minimumClipDurationSeconds + ), + [duration, inSeconds, markers, minimumClipDurationSeconds, outSeconds] + ); + const visibleDuration = duration === 0 ? 0 : duration / effectiveZoom; + const maximumViewportStart = Math.max(0, duration - visibleDuration); + const effectiveViewportStart = clamp( + viewportStartSeconds, + 0, + maximumViewportStart + ); + const viewportEnd = effectiveViewportStart + visibleDuration; + const currentTime = + currentTimeSeconds === undefined + ? undefined + : clampFinite(currentTimeSeconds, 0, duration); + + useEffect(() => { + const stage = stageRef.current; + if (stage === null || typeof ResizeObserver === 'undefined') return; + const observer = new ResizeObserver(() => { + setRenderRevision((revision) => revision + 1); + }); + observer.observe(stage); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + const canvas = canvasRef.current; + if (canvas === null || duration === 0) return; + const style = getComputedStyle(canvas); + const width = clamp(Math.round(canvas.clientWidth || 640), 1, 8_192); + const startBucket = clamp( + Math.floor((effectiveViewportStart / duration) * peakData.bucketCount), + 0, + Math.max(0, peakData.bucketCount - 1) + ); + const endBucket = clamp( + Math.ceil((viewportEnd / duration) * peakData.bucketCount), + startBucket + 1, + peakData.bucketCount + ); + renderWaveformToCanvas(canvas, peakData, { + width, + height, + devicePixelRatio: clamp(window.devicePixelRatio || 1, 1, 4), + startBucket, + endBucket, + waveColor: style.getPropertyValue('--waveform-wave').trim() || '#6fd7ca', + backgroundColor: + style.getPropertyValue('--waveform-background').trim() || '#101b22', + centerLineColor: + style.getPropertyValue('--waveform-center').trim() || + 'rgba(255, 255, 255, 0.22)', + selection: { + startFraction: timeToViewportFraction( + selection.inSeconds, + effectiveViewportStart, + visibleDuration + ), + endFraction: timeToViewportFraction( + selection.outSeconds, + effectiveViewportStart, + visibleDuration + ), + color: + style.getPropertyValue('--waveform-selection').trim() || + 'rgba(88, 201, 184, 0.18)', + }, + accessibleLabel: `${ariaLabel}. ${describeSelection(selection)} Visible range ${formatRange( + effectiveViewportStart, + viewportEnd + )}.`, + }); + }, [ + ariaLabel, + duration, + effectiveViewportStart, + height, + peakData, + renderRevision, + selection, + viewportEnd, + visibleDuration, + ]); + + const emit = (next: WaveformSelection) => { + onChange(normalizeSelection(next, duration, minimumClipDurationSeconds)); + }; + + const setTrimPoint = (kind: 'in' | 'out', timeSeconds: number) => { + if (disabled || duration === 0) return; + const next = + kind === 'in' + ? { + ...selection, + inSeconds: clamp( + timeSeconds, + 0, + selection.outSeconds - effectiveMinimumClipDuration + ), + } + : { + ...selection, + outSeconds: clamp( + timeSeconds, + selection.inSeconds + effectiveMinimumClipDuration, + duration + ), + }; + emit(next); + }; + + const setMarker = (index: number, timeSeconds: number) => { + if (disabled) return; + const nextMarkers = [...selection.markers]; + nextMarkers[index] = clamp( + timeSeconds, + selection.inSeconds + MINIMUM_MARKER_GAP_SECONDS, + selection.outSeconds - MINIMUM_MARKER_GAP_SECONDS + ); + emit({ ...selection, markers: nextMarkers }); + }; + + const addMarker = (timeSeconds: number) => { + if ( + disabled || + selection.markers.length >= MAX_SPLIT_MARKERS || + timeSeconds <= selection.inSeconds + MINIMUM_MARKER_GAP_SECONDS / 2 || + timeSeconds >= selection.outSeconds - MINIMUM_MARKER_GAP_SECONDS / 2 + ) { + return; + } + emit({ ...selection, markers: [...selection.markers, timeSeconds] }); + }; + + const removeMarker = (index: number) => { + if (disabled) return; + emit({ + ...selection, + markers: selection.markers.filter( + (_, markerIndex) => markerIndex !== index + ), + }); + }; + + const timeFromPointer = (event: PointerEvent): number => { + const bounds = stageRef.current?.getBoundingClientRect(); + if (bounds === undefined || bounds.width <= 0) { + return effectiveViewportStart; + } + return clamp( + effectiveViewportStart + + ((event.clientX - bounds.left) / bounds.width) * visibleDuration, + effectiveViewportStart, + viewportEnd + ); + }; + + const beginDrag = ( + event: PointerEvent, + target: DragTarget + ) => { + if (disabled) return; + event.preventDefault(); + event.stopPropagation(); + event.currentTarget.setPointerCapture?.(event.pointerId); + activeDragRef.current = { pointerId: event.pointerId, target }; + }; + + const continueDrag = (event: PointerEvent) => { + const active = activeDragRef.current; + if ( + active === undefined || + active.pointerId !== event.pointerId || + disabled + ) { + return; + } + event.preventDefault(); + const time = timeFromPointer(event); + if (active.target.kind === 'marker') { + setMarker(active.target.index, time); + } else { + setTrimPoint(active.target.kind, time); + } + }; + + const endDrag = (event: PointerEvent) => { + if (activeDragRef.current?.pointerId === event.pointerId) { + event.currentTarget.releasePointerCapture?.(event.pointerId); + activeDragRef.current = undefined; + } + }; + + const handleTrimKey = ( + event: KeyboardEvent, + kind: 'in' | 'out' + ) => { + if (disabled) return; + const current = kind === 'in' ? selection.inSeconds : selection.outSeconds; + const next = timeFromKey( + event, + current, + kind === 'in' ? 0 : selection.inSeconds + effectiveMinimumClipDuration, + kind === 'in' + ? selection.outSeconds - effectiveMinimumClipDuration + : duration, + keyboardStepSeconds + ); + if (next === undefined) return; + event.preventDefault(); + setTrimPoint(kind, next); + }; + + const handleMarkerKey = ( + event: KeyboardEvent, + index: number + ) => { + if (disabled) return; + if (event.key === 'Delete' || event.key === 'Backspace') { + event.preventDefault(); + removeMarker(index); + return; + } + const marker = selection.markers[index]; + if (marker === undefined) return; + const next = timeFromKey( + event, + marker, + selection.inSeconds + MINIMUM_MARKER_GAP_SECONDS, + selection.outSeconds - MINIMUM_MARKER_GAP_SECONDS, + keyboardStepSeconds + ); + if (next === undefined) return; + event.preventDefault(); + setMarker(index, next); + }; + + const seekFromStage = (event: PointerEvent) => { + if (disabled || onSeek === undefined || event.button !== 0) return; + onSeek(roundSeconds(timeFromPointer(event))); + }; + + const setZoomLevel = (event: ChangeEvent) => { + const nextZoom = clamp(Number(event.currentTarget.value), 1, maximumZoom); + const center = effectiveViewportStart + visibleDuration / 2; + const nextVisibleDuration = duration / nextZoom; + setZoom(nextZoom); + setViewportStartSeconds( + clamp( + center - nextVisibleDuration / 2, + 0, + Math.max(0, duration - nextVisibleDuration) + ) + ); + }; + + if (duration === 0 || peakData.bucketCount === 0) { + return ( +
+

No waveform samples are available for this source.

+
+ ); + } + + return ( +
+
+
+ Single sequential audio track + + Drag a handle, or focus it and use the arrow keys. Hold Shift for + larger keyboard steps. + +
+ {describeSelection(selection)} +
+ +
+ +
+ {currentTime !== undefined && + isTimeVisible(currentTime, effectiveViewportStart, viewportEnd) ? ( +
+
+ +
+ + +
+ +
+ Trim selection + + + + + +
+ +
+
+ Split markers + + {selection.markers.length}/{MAX_SPLIT_MARKERS} + +
+ {selection.markers.length === 0 ? ( +

No split markers.

+ ) : ( +
    + {selection.markers.map((marker, index) => ( +
  1. + + +
  2. + ))} +
+ )} +
+
+ ); +} + +interface WaveformHandleProps { + kind: 'in' | 'out'; + time: number; + viewportStart: number; + viewportEnd: number; + visibleDuration: number; + minimum: number; + maximum: number; + disabled: boolean; + onPointerDown: (event: PointerEvent) => void; + onPointerMove: (event: PointerEvent) => void; + onPointerEnd: (event: PointerEvent) => void; + onKeyDown: (event: KeyboardEvent) => void; +} + +function WaveformHandle({ + kind, + time, + viewportStart, + viewportEnd, + visibleDuration, + minimum, + maximum, + disabled, + onPointerDown, + onPointerMove, + onPointerEnd, + onKeyDown, +}: WaveformHandleProps) { + if (!isTimeVisible(time, viewportStart, viewportEnd)) return null; + return ( + + ); +} + +function validateEditorOptions( + minimumDuration: number, + keyboardStep: number, + maximumZoom: number, + height: number +): void { + if (!Number.isFinite(minimumDuration) || minimumDuration <= 0) { + throw new RangeError('Minimum clip duration must be positive.'); + } + if (!Number.isFinite(keyboardStep) || keyboardStep <= 0) { + throw new RangeError('Waveform keyboard step must be positive.'); + } + if (!Number.isFinite(maximumZoom) || maximumZoom < 1 || maximumZoom > 128) { + throw new RangeError('Waveform maximum zoom must be between 1 and 128.'); + } + if (!Number.isFinite(height) || height < 80 || height > 1_024) { + throw new RangeError('Waveform height must be between 80 and 1024 pixels.'); + } +} + +function normalizeSelection( + selection: WaveformSelection, + duration: number, + requestedMinimumDuration: number +): WaveformSelection { + if (duration <= 0) { + return Object.freeze({ inSeconds: 0, outSeconds: 0, markers: [] }); + } + const minimumDuration = Math.min(requestedMinimumDuration, duration); + const inPoint = roundSeconds( + clampFinite(selection.inSeconds, 0, duration - minimumDuration) + ); + const outPoint = roundSeconds( + clampFinite(selection.outSeconds, inPoint + minimumDuration, duration) + ); + const normalizedMarkers: number[] = []; + for (const marker of selection.markers) { + if (normalizedMarkers.length >= MAX_SPLIT_MARKERS) break; + if (!Number.isFinite(marker)) continue; + const normalized = roundSeconds(marker); + if ( + normalized <= inPoint + MINIMUM_MARKER_GAP_SECONDS / 2 || + normalized >= outPoint - MINIMUM_MARKER_GAP_SECONDS / 2 || + normalizedMarkers.some( + (existing) => + Math.abs(existing - normalized) < MINIMUM_MARKER_GAP_SECONDS + ) + ) { + continue; + } + normalizedMarkers.push(normalized); + } + normalizedMarkers.sort((left, right) => left - right); + return Object.freeze({ + inSeconds: inPoint, + outSeconds: outPoint, + markers: Object.freeze(normalizedMarkers), + }); +} + +function timeFromKey( + event: KeyboardEvent, + value: number, + minimum: number, + maximum: number, + baseStep: number +): number | undefined { + const step = event.shiftKey ? Math.max(1, baseStep * 10) : baseStep; + switch (event.key) { + case 'ArrowLeft': + case 'ArrowDown': + return clamp(value - step, minimum, maximum); + case 'ArrowRight': + case 'ArrowUp': + return clamp(value + step, minimum, maximum); + case 'Home': + return minimum; + case 'End': + return maximum; + default: + return undefined; + } +} + +function timeToViewportFraction( + time: number, + viewportStart: number, + visibleDuration: number +): number { + if (visibleDuration <= 0) return 0; + return clamp((time - viewportStart) / visibleDuration, 0, 1); +} + +function isTimeVisible(time: number, start: number, end: number): boolean { + return time >= start && time <= end; +} + +function describeSelection(selection: WaveformSelection): string { + return `Selected ${formatRange( + selection.inSeconds, + selection.outSeconds + )}; ${String(selection.markers.length)} split marker${ + selection.markers.length === 1 ? '' : 's' + }.`; +} + +function formatRange(start: number, end: number): string { + return `${formatTimecode(start)}–${formatTimecode(end)}`; +} + +function roundSeconds(value: number): number { + return Math.round(value * 1_000_000) / 1_000_000; +} + +function clampFinite(value: number, minimum: number, maximum: number): number { + return Number.isFinite(value) ? clamp(value, minimum, maximum) : minimum; +} + +function clamp(value: number, minimum: number, maximum: number): number { + return Math.min(Math.max(value, minimum), maximum); +} diff --git a/src/components/visual-editor.css b/src/components/visual-editor.css new file mode 100644 index 0000000..8c2bf11 --- /dev/null +++ b/src/components/visual-editor.css @@ -0,0 +1,577 @@ +.crop-editor, +.waveform-editor { + --visual-editor-border: var(--toolbox-border, #ced7df); + --visual-editor-surface: var(--toolbox-surface, #fff); + --visual-editor-soft: var(--toolbox-surface-soft, #eef3f5); + --visual-editor-text: var(--toolbox-text, #17232b); + --visual-editor-muted: var(--toolbox-muted, #61717c); + --visual-editor-accent: var(--toolbox-accent, #137c72); + --visual-editor-focus: var(--toolbox-focus, #3ba8ff); + min-width: 0; + color: var(--visual-editor-text); +} + +.crop-editor button, +.crop-editor input, +.crop-editor select, +.waveform-editor button, +.waveform-editor input { + font: inherit; +} + +.crop-editor__stage { + position: relative; + width: 100%; + max-height: min(66vh, 46rem); + overflow: hidden; + border: 1px solid var(--visual-editor-border); + border-radius: calc(var(--toolbox-radius, 0.6rem) + 0.2rem); + background: + linear-gradient(45deg, rgb(255 255 255 / 4%) 25%, transparent 25%), + linear-gradient(-45deg, rgb(255 255 255 / 4%) 25%, transparent 25%), #10161a; + background-position: + 0 0, + 8px 8px; + background-size: 16px 16px; + isolation: isolate; + touch-action: none; +} + +.crop-editor__media { + position: absolute; + z-index: 0; + top: 50%; + left: 50%; + transform-origin: center; + pointer-events: none; +} + +.crop-editor__media > img, +.crop-editor__media > video, +.crop-editor__media > canvas, +.crop-editor__media > picture, +.crop-editor__media > picture > img { + width: 100%; + height: 100%; + display: block; + object-fit: contain; +} + +.crop-editor__shade { + position: absolute; + z-index: 1; + inset: 0; + pointer-events: none; +} + +.crop-editor__rectangle { + position: absolute; + z-index: 2; + min-width: 1px; + min-height: 1px; + border: 2px solid #fff; + box-shadow: + 0 0 0 1px rgb(0 0 0 / 70%), + 0 0 0 9999px rgb(4 10 14 / 62%); +} + +.crop-editor__rectangle::before, +.crop-editor__rectangle::after { + position: absolute; + content: ''; + pointer-events: none; +} + +.crop-editor__rectangle::before { + inset: 33.333% 0; + border-block: 1px solid rgb(255 255 255 / 45%); +} + +.crop-editor__rectangle::after { + inset: 0 33.333%; + border-inline: 1px solid rgb(255 255 255 / 45%); +} + +.crop-editor__move { + position: absolute; + z-index: 1; + inset: 1px; + border: 0; + background: transparent; + color: #fff; + cursor: move; + touch-action: none; +} + +.crop-editor__move > span { + position: absolute; + top: 50%; + left: 50%; + padding: 0.22rem 0.4rem; + border-radius: 999px; + background: rgb(0 0 0 / 62%); + font-size: 0.68rem; + font-weight: 800; + letter-spacing: 0.04em; + opacity: 0; + transform: translate(-50%, -50%); + transition: opacity 120ms ease; +} + +.crop-editor__rectangle:hover .crop-editor__move > span, +.crop-editor__move:focus-visible > span { + opacity: 1; +} + +.crop-editor__handle { + position: absolute; + z-index: 3; + width: 1.6rem; + height: 1.6rem; + padding: 0; + border: 0; + background: transparent; + touch-action: none; +} + +.crop-editor__handle::after { + position: absolute; + width: 0.62rem; + height: 0.62rem; + border: 2px solid #102128; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 4px rgb(0 0 0 / 55%); + content: ''; + inset: 50% auto auto 50%; + transform: translate(-50%, -50%); +} + +.crop-editor__handle--n { + top: 0; + left: 50%; + cursor: ns-resize; + transform: translate(-50%, -50%); +} + +.crop-editor__handle--ne { + top: 0; + right: 0; + cursor: nesw-resize; + transform: translate(50%, -50%); +} + +.crop-editor__handle--e { + top: 50%; + right: 0; + cursor: ew-resize; + transform: translate(50%, -50%); +} + +.crop-editor__handle--se { + right: 0; + bottom: 0; + cursor: nwse-resize; + transform: translate(50%, 50%); +} + +.crop-editor__handle--s { + bottom: 0; + left: 50%; + cursor: ns-resize; + transform: translate(-50%, 50%); +} + +.crop-editor__handle--sw { + bottom: 0; + left: 0; + cursor: nesw-resize; + transform: translate(-50%, 50%); +} + +.crop-editor__handle--w { + top: 50%; + left: 0; + cursor: ew-resize; + transform: translate(-50%, -50%); +} + +.crop-editor__handle--nw { + top: 0; + left: 0; + cursor: nwse-resize; + transform: translate(-50%, -50%); +} + +.crop-editor__move:focus-visible, +.crop-editor__handle:focus-visible, +.waveform-editor__trim-handle:focus-visible, +.waveform-editor__marker:focus-visible { + outline: 3px solid var(--visual-editor-focus); + outline-offset: 2px; +} + +.crop-editor__toolbar, +.crop-editor__numeric, +.waveform-editor__navigation, +.waveform-editor__trim { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 10rem), 1fr)); + gap: 0.7rem; + margin-top: 0.75rem; +} + +.crop-editor__toolbar { + align-items: end; +} + +.crop-editor__toolbar > label, +.crop-editor__numeric > label, +.waveform-editor__navigation > label, +.waveform-editor__trim > label { + min-width: 0; + display: grid; + gap: 0.3rem; + color: var(--visual-editor-muted); + font-size: 0.76rem; + font-weight: 720; +} + +.crop-editor__toolbar > label > select, +.crop-editor__numeric input, +.waveform-editor__trim input, +.waveform-editor__markers input { + width: 100%; + min-width: 0; + min-height: 2.35rem; + padding: 0.45rem 0.55rem; + border: 1px solid var(--visual-editor-border); + border-radius: var(--toolbox-radius, 0.6rem); + background: var(--visual-editor-surface); + color: var(--visual-editor-text); +} + +.crop-editor__toolbar > button, +.waveform-editor__trim > button, +.waveform-editor__markers button { + min-height: 2.35rem; + padding: 0.48rem 0.68rem; + border: 1px solid var(--visual-editor-border); + border-radius: var(--toolbox-radius, 0.6rem); + background: var(--visual-editor-surface); + color: var(--visual-editor-text); + cursor: pointer; +} + +.crop-editor__lock { + min-height: 2.35rem; + display: flex !important; + flex-direction: row; + align-items: center; +} + +.crop-editor__lock input { + width: 1.1rem; + height: 1.1rem; + margin: 0; + accent-color: var(--visual-editor-accent); +} + +.crop-editor__numeric, +.waveform-editor__trim { + padding: 0.7rem; + border: 1px solid var(--visual-editor-border); + border-radius: calc(var(--toolbox-radius, 0.6rem) + 0.12rem); +} + +.crop-editor__numeric legend, +.waveform-editor__trim legend { + padding-inline: 0.3rem; + color: var(--visual-editor-muted); + font-size: 0.72rem; + font-weight: 800; +} + +.crop-editor__hint, +.crop-editor__errors { + margin: 0.55rem 0 0; + color: var(--visual-editor-muted); + font-size: 0.72rem; + line-height: 1.45; +} + +.crop-editor__errors { + color: var(--toolbox-danger, #b22f3e); +} + +.waveform-editor { + --waveform-background: color-mix( + in srgb, + var(--toolbox-text, #17232b) 96%, + #000 + ); + --waveform-wave: color-mix(in srgb, var(--visual-editor-accent) 76%, #fff); + --waveform-center: rgb(255 255 255 / 22%); + --waveform-selection: color-mix( + in srgb, + var(--visual-editor-accent) 22%, + transparent + ); + display: grid; + gap: 0.75rem; +} + +.waveform-editor__header { + display: flex; + align-items: end; + justify-content: space-between; + gap: 1rem; +} + +.waveform-editor__header > div { + min-width: 0; + display: grid; + gap: 0.2rem; +} + +.waveform-editor__header strong { + font-size: 0.84rem; +} + +.waveform-editor__header span, +.waveform-editor__header output, +.waveform-editor__markers p, +.waveform-editor__markers > div > span { + color: var(--visual-editor-muted); + font-size: 0.72rem; + line-height: 1.4; +} + +.waveform-editor__header output { + flex: none; + text-align: right; +} + +.waveform-editor__stage { + position: relative; + min-height: 5rem; + overflow: hidden; + border: 1px solid var(--visual-editor-border); + border-radius: calc(var(--toolbox-radius, 0.6rem) + 0.18rem); + background: var(--waveform-background); + cursor: crosshair; + isolation: isolate; + touch-action: none; +} + +.waveform-editor__canvas, +.waveform-editor__overlay { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.waveform-editor__canvas { + display: block; + color: var(--waveform-wave); +} + +.waveform-editor__overlay { + pointer-events: none; +} + +.waveform-editor__playhead { + position: absolute; + z-index: 2; + top: 0; + bottom: 0; + width: 2px; + background: #ffcd57; + box-shadow: 0 0 0 1px rgb(0 0 0 / 35%); + transform: translateX(-1px); +} + +.waveform-editor__trim-handle, +.waveform-editor__marker { + position: absolute; + z-index: 4; + top: 0; + bottom: 0; + width: 1.5rem; + padding: 0; + border: 0; + background: transparent; + pointer-events: auto; + touch-action: none; + transform: translateX(-50%); +} + +.waveform-editor__trim-handle { + color: #fff; + cursor: ew-resize; +} + +.waveform-editor__trim-handle::before, +.waveform-editor__marker::before { + position: absolute; + top: 0; + bottom: 0; + left: calc(50% - 1px); + width: 2px; + background: currentColor; + content: ''; +} + +.waveform-editor__trim-handle > span { + position: absolute; + top: 0.3rem; + left: 50%; + min-width: 1.7rem; + padding: 0.16rem 0.25rem; + border-radius: 0.28rem; + background: var(--visual-editor-accent); + color: var(--toolbox-accent-contrast, #fff); + font-size: 0.56rem; + font-weight: 900; + line-height: 1; + transform: translateX(-50%); +} + +.waveform-editor__trim-handle--in { + color: #89ebdd; +} + +.waveform-editor__trim-handle--out { + color: #ffb49d; +} + +.waveform-editor__marker { + z-index: 3; + color: #ffd36f; + cursor: col-resize; +} + +.waveform-editor__marker > span { + position: absolute; + top: 0.25rem; + left: 50%; + width: 0.72rem; + height: 0.72rem; + border: 2px solid #342a00; + background: currentColor; + clip-path: polygon(50% 100%, 0 0, 100% 0); + transform: translateX(-50%); +} + +.waveform-editor__navigation { + margin-top: 0; +} + +.waveform-editor__navigation > label { + grid-template-columns: auto minmax(5rem, 1fr) auto; + align-items: center; +} + +.waveform-editor__navigation input[type='range'] { + width: 100%; + accent-color: var(--visual-editor-accent); +} + +.waveform-editor__navigation output { + min-width: 5rem; + color: var(--visual-editor-muted); + font-size: 0.7rem; + text-align: right; +} + +.waveform-editor__trim { + align-items: end; + margin-top: 0; +} + +.waveform-editor__markers { + min-width: 0; +} + +.waveform-editor__markers > div { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.waveform-editor__markers p { + margin: 0.5rem 0 0; +} + +.waveform-editor__markers ol { + display: grid; + gap: 0.45rem; + margin: 0.5rem 0 0; + padding: 0; + list-style: none; +} + +.waveform-editor__markers li { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + gap: 0.55rem; +} + +.waveform-editor__markers label { + min-width: 0; + display: grid; + grid-template-columns: 5rem minmax(0, 1fr); + align-items: center; + gap: 0.5rem; + color: var(--visual-editor-muted); + font-size: 0.72rem; + font-weight: 720; +} + +.waveform-editor--empty { + min-height: 7rem; + place-items: center; + padding: 1rem; + border: 1px dashed var(--visual-editor-border); + border-radius: var(--toolbox-radius, 0.6rem); + background: var(--visual-editor-soft); + color: var(--visual-editor-muted); + text-align: center; +} + +.waveform-editor--empty p { + margin: 0; +} + +@media (max-width: 43rem) { + .waveform-editor__header { + align-items: start; + flex-direction: column; + } + + .waveform-editor__header output { + text-align: left; + } + + .waveform-editor__navigation > label { + grid-template-columns: 3rem minmax(4rem, 1fr); + } + + .waveform-editor__navigation output { + grid-column: 1 / -1; + text-align: left; + } + + .waveform-editor__markers label { + grid-template-columns: 1fr; + } +} + +@media (prefers-reduced-motion: reduce) { + .crop-editor__move > span { + transition: none; + } +} diff --git a/src/export/browser-save.ts b/src/export/browser-save.ts new file mode 100644 index 0000000..059dab9 --- /dev/null +++ b/src/export/browser-save.ts @@ -0,0 +1,145 @@ +import { + exportReportBlob, + exportReportFileName, + type ExportReport, +} from './export-report'; +import { saveBlob } from './save-result'; +import { safeExportFileName } from './output-name'; +import { type ExportResult, ExportResultCollection } from './result-collection'; +import { + createResultZip, + resultEntries, + type CreateResultZipOptions, + type ResultZip, +} from './zip-results'; + +export type SaveOutcome = 'saved' | 'downloaded' | 'cancelled'; + +export type BlobSaver = ( + blob: Blob, + fileName: string, + mimeType?: string +) => Promise; + +export interface SaveExportResultOptions { + readonly saver?: BlobSaver; + readonly fileName?: string; +} + +export interface SaveCollectionResultOptions extends SaveExportResultOptions { + /** + * Defaults to true so large result Blobs and their preview URLs are released + * after a successful save or download. + */ + readonly releaseAfterSave?: boolean; +} + +export interface SaveResultsZipOptions extends Omit< + CreateResultZipOptions, + 'fileName' +> { + readonly fileName?: string; + readonly report?: ExportReport; + readonly saver?: BlobSaver; +} + +export interface SavedResultsZip { + readonly outcome: SaveOutcome; + readonly archive: ResultZip; +} + +export async function saveExportResult( + result: ExportResult, + options: SaveExportResultOptions = {} +): Promise { + const fileName = safeExportFileName(options.fileName ?? result.fileName, { + fallback: 'output', + }); + return (options.saver ?? saveBlob)(result.blob, fileName, result.mimeType); +} + +export async function saveCollectionResult( + collection: ExportResultCollection, + resultId: string, + options: SaveCollectionResultOptions = {} +): Promise { + const result = collection.get(resultId); + if (!result) { + throw new RangeError(`Unknown result ID "${resultId}".`); + } + const outcome = await saveExportResult(result, options); + if (outcome !== 'cancelled' && (options.releaseAfterSave ?? true)) { + collection.remove(resultId); + } + return outcome; +} + +export async function saveResultsZip( + results: readonly ExportResult[], + options: SaveResultsZipOptions = {} +): Promise { + const entries = [...resultEntries(results)]; + if (options.report) { + entries.push({ + fileName: exportReportFileName(options.report.plan.operation), + blob: exportReportBlob(options.report), + }); + } + const { saver, fileName } = options; + const archive = await createResultZip(entries, { + ...(options.limits ? { limits: options.limits } : {}), + ...(options.signal ? { signal: options.signal } : {}), + ...(options.compressionLevel === undefined + ? {} + : { compressionLevel: options.compressionLevel }), + fileName, + }); + const outcome = await (saver ?? saveBlob)( + archive.blob, + archive.fileName, + archive.blob.type + ); + return Object.freeze({ outcome, archive }); +} + +export async function saveCollectionZip( + collection: ExportResultCollection, + options: SaveResultsZipOptions & { + readonly releaseAfterSave?: boolean; + } = {} +): Promise { + const results = collection.list(); + const saved = await saveResultsZip(results, options); + if (saved.outcome !== 'cancelled' && (options.releaseAfterSave ?? true)) { + collection.clear(); + } + return saved; +} + +export async function saveExportReport( + report: ExportReport, + options: { + readonly fileName?: string; + readonly saver?: BlobSaver; + } = {} +): Promise { + const blob = exportReportBlob(report); + return (options.saver ?? saveBlob)( + blob, + safeExportFileName( + options.fileName ?? exportReportFileName(report.plan.operation), + { extension: 'json', fallback: 'export-report' } + ), + blob.type + ); +} + +export function supportsSaveFilePicker(): boolean { + return ( + typeof ( + globalThis as typeof globalThis & { + showSaveFilePicker?: unknown; + } + ).showSaveFilePicker === 'function' + ); +} diff --git a/src/export/export-report.ts b/src/export/export-report.ts new file mode 100644 index 0000000..da56b74 --- /dev/null +++ b/src/export/export-report.ts @@ -0,0 +1,258 @@ +import type { + CommandDiagnostic, + FFmpegCommandPlan, +} from '../commands/command-plan'; +import { JSON_MIME_TYPE } from './mime'; +import { safeExportFileName } from './output-name'; +import type { ExportResult } from './result-collection'; + +const REPORT_SCHEMA_VERSION = 1; +const MAX_DIAGNOSTICS = 100; +const MAX_TEXT_LENGTH = 2_000; + +export type ExportReportStatus = + 'completed' | 'failed' | 'cancelled' | 'partial'; + +export interface ExportExecutionSummary { + readonly status?: ExportReportStatus; + readonly engineMode?: 'single-thread' | 'multi-thread'; + readonly ffmpegVersion?: string; + readonly coreVersion?: string; + readonly elapsedSeconds?: number; + readonly conversionMilliseconds?: number; + readonly outputReadMilliseconds?: number; + readonly cleanupMilliseconds?: number; + readonly exitCode?: number; +} + +export interface ExportReportInput { + readonly plan: Pick; + readonly results: readonly Pick< + ExportResult, + 'id' | 'outputId' | 'fileName' | 'mimeType' | 'role' | 'size' + >[]; + readonly execution?: ExportExecutionSummary; + readonly diagnostics?: readonly CommandDiagnostic[]; + readonly generatedAt?: string; +} + +export interface ExportReport { + readonly schemaVersion: 1; + readonly generatedAt: string; + readonly plan: { + readonly id: string; + readonly operation: string; + }; + readonly execution?: ExportExecutionSummary; + readonly outputs: readonly { + readonly id: string; + readonly outputId: string; + readonly fileName: string; + readonly mimeType: string; + readonly role: ExportResult['role']; + readonly sizeBytes: number; + }[]; + readonly diagnostics: readonly CommandDiagnostic[]; + readonly privacy: { + readonly includesSourceMedia: false; + readonly includesCommandArguments: false; + readonly privatePathsRedacted: true; + }; +} + +/** + * Builds an intentionally narrow report. Source records, command arguments, + * Blobs, object URLs and browser storage paths are not copied into the output. + */ +export function createExportReport(input: ExportReportInput): ExportReport { + const generatedAt = normalizeTimestamp( + input.generatedAt ?? new Date().toISOString() + ); + const diagnostics = [...input.plan.diagnostics, ...(input.diagnostics ?? [])] + .slice(0, MAX_DIAGNOSTICS) + .map(sanitizeDiagnostic); + const outputs = [...input.results] + .map((result) => + Object.freeze({ + id: sanitizeText(result.id, 256), + outputId: sanitizeText(result.outputId, 256), + fileName: safeExportFileName(result.fileName, { + fallback: 'output', + }), + mimeType: sanitizeText(result.mimeType, 128), + role: result.role, + sizeBytes: safeByteSize(result.size), + }) + ) + .sort((left, right) => compareText(left.id, right.id)); + const execution = sanitizeExecution(input.execution); + + return Object.freeze({ + schemaVersion: REPORT_SCHEMA_VERSION, + generatedAt, + plan: Object.freeze({ + id: sanitizeText(input.plan.id, 256), + operation: sanitizeText(input.plan.operation, 128), + }), + ...(execution ? { execution } : {}), + outputs: Object.freeze(outputs), + diagnostics: Object.freeze(diagnostics), + privacy: Object.freeze({ + includesSourceMedia: false, + includesCommandArguments: false, + privatePathsRedacted: true, + }), + }); +} + +export function serializeExportReport(report: ExportReport): string { + return `${JSON.stringify(report, undefined, 2)}\n`; +} + +export function exportReportBlob(report: ExportReport): Blob { + return new Blob([serializeExportReport(report)], { + type: JSON_MIME_TYPE, + }); +} + +export function exportReportFileName(operation: string): string { + return safeExportFileName(`${operation}-export-report`, { + extension: 'json', + fallback: 'export-report', + }); +} + +export function redactPrivatePaths(value: string): string { + return value + .replace(/\bblob:(?:https?:\/\/)?[^\s"'<>]+/giu, '[object URL]') + .replace(/\bfile:\/\/\/?[^\s"'<>]+/giu, '[local path]') + .replace( + /(["'])\b[a-z]:[\\/](?:users|documents and settings|fakepath|temp)[\\/][^\r\n"'<>]*\1/giu, + '[local path]' + ) + .replace( + /\b[a-z]:[\\/](?:users|documents and settings|fakepath|temp)[\\/][^\s"'<>]*/giu, + '[local path]' + ) + .replace( + /(["'])\/(?:home|users|mnt|media|volumes|tmp|var\/folders|private\/var)\/[^\r\n"'<>]*\1/giu, + '[local path]' + ) + .replace( + /\/(?:home|users|mnt|media|volumes|tmp|var\/folders|private\/var)\/[^\s"'<>]+/giu, + '[local path]' + ); +} + +function sanitizeDiagnostic(diagnostic: CommandDiagnostic): CommandDiagnostic { + return Object.freeze({ + code: sanitizeText(diagnostic.code, 128), + severity: diagnostic.severity, + message: sanitizeText(diagnostic.message, MAX_TEXT_LENGTH), + ...(diagnostic.field ? { field: sanitizeText(diagnostic.field, 256) } : {}), + }); +} + +function sanitizeExecution( + execution: ExportExecutionSummary | undefined +): ExportExecutionSummary | undefined { + if (!execution) { + return undefined; + } + const elapsedSeconds = + execution.elapsedSeconds === undefined + ? undefined + : safeNonNegativeNumber(execution.elapsedSeconds, 'Elapsed seconds'); + const exitCode = + execution.exitCode === undefined + ? undefined + : safeInteger(execution.exitCode, 'Exit code'); + const conversionMilliseconds = optionalNonNegativeNumber( + execution.conversionMilliseconds, + 'Conversion milliseconds' + ); + const outputReadMilliseconds = optionalNonNegativeNumber( + execution.outputReadMilliseconds, + 'Output-read milliseconds' + ); + const cleanupMilliseconds = optionalNonNegativeNumber( + execution.cleanupMilliseconds, + 'Cleanup milliseconds' + ); + return Object.freeze({ + ...(execution.status ? { status: execution.status } : {}), + ...(execution.engineMode ? { engineMode: execution.engineMode } : {}), + ...(execution.ffmpegVersion + ? { ffmpegVersion: sanitizeText(execution.ffmpegVersion, 128) } + : {}), + ...(execution.coreVersion + ? { coreVersion: sanitizeText(execution.coreVersion, 128) } + : {}), + ...(elapsedSeconds === undefined ? {} : { elapsedSeconds }), + ...(conversionMilliseconds === undefined ? {} : { conversionMilliseconds }), + ...(outputReadMilliseconds === undefined ? {} : { outputReadMilliseconds }), + ...(cleanupMilliseconds === undefined ? {} : { cleanupMilliseconds }), + ...(exitCode === undefined ? {} : { exitCode }), + }); +} + +function optionalNonNegativeNumber( + value: number | undefined, + label: string +): number | undefined { + return value === undefined ? undefined : safeNonNegativeNumber(value, label); +} + +function sanitizeText(value: string, maximumLength: number): string { + return removeUnsafeControlCharacters(redactPrivatePaths(value)).slice( + 0, + maximumLength + ); +} + +function removeUnsafeControlCharacters(value: string): string { + return Array.from(value) + .filter((character) => { + const codePoint = character.codePointAt(0) ?? 0; + return ( + codePoint === 9 || + codePoint === 10 || + codePoint === 13 || + (codePoint >= 32 && codePoint !== 127) + ); + }) + .join(''); +} + +function normalizeTimestamp(value: string): string { + const timestamp = new Date(value); + if (!Number.isFinite(timestamp.valueOf())) { + throw new RangeError('Report generation time must be a valid timestamp.'); + } + return timestamp.toISOString(); +} + +function safeByteSize(value: number): number { + if (!Number.isSafeInteger(value) || value < 0) { + throw new RangeError('Output size must be a non-negative safe integer.'); + } + return value; +} + +function safeNonNegativeNumber(value: number, label: string): number { + if (!Number.isFinite(value) || value < 0) { + throw new RangeError(`${label} must be a non-negative finite number.`); + } + return value; +} + +function safeInteger(value: number, label: string): number { + if (!Number.isSafeInteger(value)) { + throw new RangeError(`${label} must be a safe integer.`); + } + return value; +} + +function compareText(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} diff --git a/src/export/index.ts b/src/export/index.ts new file mode 100644 index 0000000..372b5a1 --- /dev/null +++ b/src/export/index.ts @@ -0,0 +1,69 @@ +export { + saveCollectionResult, + saveCollectionZip, + saveExportReport, + saveExportResult, + saveResultsZip, + supportsSaveFilePicker, + type BlobSaver, + type SaveCollectionResultOptions, + type SavedResultsZip, + type SaveExportResultOptions, + type SaveOutcome, + type SaveResultsZipOptions, +} from './browser-save'; +export { + createExportReport, + exportReportBlob, + exportReportFileName, + redactPrivatePaths, + serializeExportReport, + type ExportExecutionSummary, + type ExportReport, + type ExportReportInput, + type ExportReportStatus, +} from './export-report'; +export { + BINARY_MIME_TYPE, + JSON_MIME_TYPE, + ZIP_MIME_TYPE, + blobWithOutputMimeType, + isBrowserPreviewMimeType, + mimeTypeFromOutputName, + normalizeOutputMimeType, + preferredExtensionForMimeType, +} from './mime'; +export { + deriveOutputFileName, + isSafeArchiveLeafName, + safeExportFileName, + uniqueExportFileNames, + type DerivedOutputNameOptions, + type OutputNameOptions, +} from './output-name'; +export { + collectCommandOutputs, + DEFAULT_RESULT_COLLECTION_LIMITS, + ExportResultCollection, + ExportResultLimitError, + MissingCommandOutputError, + type CommandOutputData, + type ExportResult, + type ExportResultInput, + type ExportResultLimit, + type ObjectUrlApi, + type ResultCollectionLimits, + type ResultCollectionOptions, +} from './result-collection'; +export { downloadBlob, saveBlob } from './save-result'; +export { + createResultZip, + DEFAULT_RESULT_ZIP_LIMITS, + resultEntries, + ResultZipLimitError, + type CreateResultZipOptions, + type ResultZip, + type ResultZipEntry, + type ResultZipLimit, + type ResultZipLimits, +} from './zip-results'; diff --git a/src/export/mime.ts b/src/export/mime.ts new file mode 100644 index 0000000..75d61ce --- /dev/null +++ b/src/export/mime.ts @@ -0,0 +1,128 @@ +import { splitFileName } from '../media/safe-file-name'; + +const MIME_BY_EXTENSION: Readonly> = Object.freeze({ + '.3gp': 'video/3gpp', + '.aac': 'audio/aac', + '.ass': 'text/x-ssa', + '.avi': 'video/x-msvideo', + '.avif': 'image/avif', + '.bmp': 'image/bmp', + '.flac': 'audio/flac', + '.gif': 'image/gif', + '.jpeg': 'image/jpeg', + '.jpg': 'image/jpeg', + '.json': 'application/json', + '.m2ts': 'video/mp2t', + '.m4a': 'audio/mp4', + '.m4v': 'video/mp4', + '.mkv': 'video/x-matroska', + '.mov': 'video/quicktime', + '.mp3': 'audio/mpeg', + '.mp4': 'video/mp4', + '.mpeg': 'video/mpeg', + '.mpg': 'video/mpeg', + '.oga': 'audio/ogg', + '.ogg': 'audio/ogg', + '.ogv': 'video/ogg', + '.opus': 'audio/ogg', + '.png': 'image/png', + '.srt': 'application/x-subrip', + '.tif': 'image/tiff', + '.tiff': 'image/tiff', + '.ts': 'video/mp2t', + '.txt': 'text/plain', + '.vtt': 'text/vtt', + '.wav': 'audio/wav', + '.webm': 'video/webm', + '.webp': 'image/webp', + '.zip': 'application/zip', +}); + +const EXTENSION_BY_MIME: Readonly> = Object.freeze({ + 'application/json': '.json', + 'application/zip': '.zip', + 'application/x-subrip': '.srt', + 'audio/aac': '.aac', + 'audio/flac': '.flac', + 'audio/mp4': '.m4a', + 'audio/mpeg': '.mp3', + 'audio/ogg': '.ogg', + 'audio/wav': '.wav', + 'image/avif': '.avif', + 'image/bmp': '.bmp', + 'image/gif': '.gif', + 'image/jpeg': '.jpg', + 'image/png': '.png', + 'image/tiff': '.tiff', + 'image/webp': '.webp', + 'text/plain': '.txt', + 'text/vtt': '.vtt', + 'text/x-ssa': '.ass', + 'video/3gpp': '.3gp', + 'video/mp2t': '.ts', + 'video/mp4': '.mp4', + 'video/mpeg': '.mpeg', + 'video/ogg': '.ogv', + 'video/quicktime': '.mov', + 'video/webm': '.webm', + 'video/x-matroska': '.mkv', + 'video/x-msvideo': '.avi', +}); + +const MIME_TYPE_PATTERN = /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/iu; + +export const BINARY_MIME_TYPE = 'application/octet-stream'; +export const ZIP_MIME_TYPE = 'application/zip'; +export const JSON_MIME_TYPE = 'application/json'; + +export function mimeTypeFromOutputName(fileName: string): string | undefined { + const extension = splitFileName(fileName).extension.toLowerCase(); + return MIME_BY_EXTENSION[extension]; +} + +/** + * Returns a picker- and Blob-safe base MIME type. Parameters such as codecs + * are deliberately removed because the File System Access API accept map + * expects media types, not Content-Type header values. + */ +export function normalizeOutputMimeType( + candidate: string | undefined, + fileName?: string +): string { + const baseType = candidate?.split(';', 1)[0]?.trim().toLowerCase(); + if (baseType && MIME_TYPE_PATTERN.test(baseType)) { + return baseType; + } + return ( + (fileName ? mimeTypeFromOutputName(fileName) : undefined) ?? + BINARY_MIME_TYPE + ); +} + +export function preferredExtensionForMimeType( + mimeType: string +): string | undefined { + return EXTENSION_BY_MIME[normalizeOutputMimeType(mimeType)]; +} + +export function isBrowserPreviewMimeType(mimeType: string): boolean { + const normalized = normalizeOutputMimeType(mimeType); + return ( + normalized.startsWith('audio/') || + normalized.startsWith('video/') || + normalized.startsWith('image/') + ); +} + +export function blobWithOutputMimeType( + data: Blob | Uint8Array, + mimeType: string, + fileName?: string +): Blob { + const normalized = normalizeOutputMimeType(mimeType, fileName); + if (data instanceof Blob && data.type === normalized) { + return data; + } + const part: BlobPart = data instanceof Blob ? data : Uint8Array.from(data); + return new Blob([part], { type: normalized }); +} diff --git a/src/export/output-name.ts b/src/export/output-name.ts new file mode 100644 index 0000000..b8d55ae --- /dev/null +++ b/src/export/output-name.ts @@ -0,0 +1,168 @@ +import { + safeFileName, + safeOutputFileName, + splitFileName, +} from '../media/safe-file-name'; + +const DEFAULT_OUTPUT_NAME = 'output'; +const MAX_OUTPUT_NAME_LENGTH = 120; + +export interface OutputNameOptions { + /** + * When provided, this extension replaces an extension in `requestedName`. + * Both `mp4` and `.mp4` are accepted. + */ + readonly extension?: string; + readonly fallback?: string; +} + +export interface DerivedOutputNameOptions extends OutputNameOptions { + readonly sourceName: string; + readonly operation: string; + readonly partIndex?: number; + readonly partCount?: number; +} + +/** + * Converts an untrusted display name into a portable leaf filename. Directory + * components, control characters, reserved device names and unsafe punctuation + * are removed by `safeFileName`. + */ +export function safeExportFileName( + requestedName: string, + options: OutputNameOptions = {} +): string { + const leafName = requestedName.split(/[\\/]/u).at(-1) ?? ''; + const fallback = safeFileName(options.fallback ?? DEFAULT_OUTPUT_NAME, { + fallback: DEFAULT_OUTPUT_NAME, + maxLength: 48, + preserveExtension: false, + }); + + if (options.extension !== undefined) { + const { stem } = splitFileName(leafName); + return safeOutputFileName(stem, options.extension, fallback); + } + + return safeFileName(leafName, { + fallback, + maxLength: MAX_OUTPUT_NAME_LENGTH, + }); +} + +/** + * Derives a predictable name from a source and operation. A numbered suffix is + * included only for multi-output operations. + */ +export function deriveOutputFileName( + options: DerivedOutputNameOptions +): string { + validatePartOptions(options.partIndex, options.partCount); + const sourceLeaf = safeExportFileName(options.sourceName, { + fallback: 'source', + }); + const sourceStem = splitFileName(sourceLeaf).stem; + const operation = safeFileName(options.operation, { + fallback: 'export', + maxLength: 40, + preserveExtension: false, + }); + const partSuffix = + options.partIndex === undefined + ? '' + : `-${String(options.partIndex + 1).padStart( + Math.max(3, String(options.partCount ?? 1).length), + '0' + )}`; + + return safeExportFileName(`${sourceStem}-${operation}${partSuffix}`, { + extension: + options.extension ?? splitFileName(sourceLeaf).extension.slice(1), + fallback: options.fallback, + }); +} + +/** + * Produces case-insensitively unique leaf filenames while retaining input + * order. This avoids silent overwrites when an archive is extracted on a + * case-insensitive filesystem. + */ +export function uniqueExportFileNames( + requestedNames: readonly string[], + fallback = DEFAULT_OUTPUT_NAME +): readonly string[] { + const used = new Set(); + const nextSuffix = new Map(); + const names: string[] = []; + + for (const requestedName of requestedNames) { + const safeName = safeExportFileName(requestedName, { fallback }); + const key = comparisonKey(safeName); + if (!used.has(key)) { + used.add(key); + nextSuffix.set(key, 2); + names.push(safeName); + continue; + } + + const { stem, extension } = splitFileName(safeName); + let suffix = nextSuffix.get(key) ?? 2; + let candidate: string; + do { + candidate = safeExportFileName(`${stem}-${suffix}${extension}`, { + fallback, + }); + suffix += 1; + } while (used.has(comparisonKey(candidate))); + + nextSuffix.set(key, suffix); + used.add(comparisonKey(candidate)); + names.push(candidate); + } + + return Object.freeze(names); +} + +export function isSafeArchiveLeafName(fileName: string): boolean { + return ( + fileName.length > 0 && + fileName.length <= MAX_OUTPUT_NAME_LENGTH && + fileName !== '.' && + fileName !== '..' && + !fileName.includes('/') && + !fileName.includes('\\') && + !hasControlCharacters(fileName) && + safeExportFileName(fileName) === fileName + ); +} + +function comparisonKey(fileName: string): string { + return fileName.normalize('NFKC').toLocaleLowerCase('en-US'); +} + +function hasControlCharacters(value: string): boolean { + return Array.from(value).some((character) => { + const codePoint = character.codePointAt(0) ?? 0; + return codePoint < 32 || codePoint === 127; + }); +} + +function validatePartOptions( + partIndex: number | undefined, + partCount: number | undefined +): void { + if ( + partCount !== undefined && + (!Number.isSafeInteger(partCount) || partCount < 1) + ) { + throw new RangeError('Part count must be a positive safe integer.'); + } + if ( + partIndex !== undefined && + (!Number.isSafeInteger(partIndex) || + partIndex < 0 || + (partCount !== undefined && partIndex >= partCount)) + ) { + throw new RangeError('Part index must be within the output part count.'); + } +} diff --git a/src/export/result-collection.ts b/src/export/result-collection.ts new file mode 100644 index 0000000..034ff33 --- /dev/null +++ b/src/export/result-collection.ts @@ -0,0 +1,458 @@ +import type { + FFmpegCommandPlan, + PlannedOutput, +} from '../commands/command-plan'; +import { MAX_GENERATED_OUTPUT_FILES } from '../limits'; +import { blobWithOutputMimeType, normalizeOutputMimeType } from './mime'; +import { safeExportFileName, uniqueExportFileNames } from './output-name'; + +export const DEFAULT_RESULT_COLLECTION_LIMITS = Object.freeze({ + maxResults: MAX_GENERATED_OUTPUT_FILES, + maxSingleResultBytes: 1024 * 1024 * 1024, + maxTotalBytes: 1024 * 1024 * 1024, +}); + +export interface ResultCollectionLimits { + readonly maxResults: number; + readonly maxSingleResultBytes: number; + readonly maxTotalBytes: number; +} + +export interface ExportResultInput { + readonly id: string; + readonly planId: string; + readonly outputId: string; + readonly fileName: string; + readonly blob: Blob; + readonly mimeType?: string; + readonly role?: PlannedOutput['role']; + readonly timeRange?: PlannedOutput['timeRange']; + readonly createdAt?: string; +} + +export interface ExportResult { + readonly id: string; + readonly planId: string; + readonly outputId: string; + readonly fileName: string; + readonly blob: Blob; + readonly mimeType: string; + readonly role: PlannedOutput['role']; + readonly timeRange?: PlannedOutput['timeRange']; + readonly size: number; + readonly createdAt: string; +} + +export interface ObjectUrlApi { + createObjectURL(blob: Blob): string; + revokeObjectURL(url: string): void; +} + +export interface ResultCollectionOptions { + readonly limits?: Partial; + readonly objectUrlApi?: ObjectUrlApi; + readonly now?: () => Date; +} + +export type CommandOutputData = Blob | Uint8Array; + +/** + * Owns browser-memory output Blobs and every object URL created for them. + * Removing, replacing, or clearing a result always revokes its preview URL. + */ +export class ExportResultCollection { + readonly #limits: ResultCollectionLimits; + readonly #urlApi: ObjectUrlApi; + readonly #now: () => Date; + readonly #results = new Map(); + readonly #objectUrls = new Map(); + #totalBytes = 0; + #disposed = false; + + constructor(options: ResultCollectionOptions = {}) { + this.#limits = resolveLimits(options.limits); + this.#urlApi = options.objectUrlApi ?? defaultObjectUrlApi(); + this.#now = options.now ?? (() => new Date()); + } + + get size(): number { + return this.#results.size; + } + + get totalBytes(): number { + return this.#totalBytes; + } + + get limits(): ResultCollectionLimits { + return this.#limits; + } + + list(): readonly ExportResult[] { + this.#assertActive(); + return Object.freeze([...this.#results.values()]); + } + + get(id: string): ExportResult | undefined { + this.#assertActive(); + return this.#results.get(id); + } + + add(input: ExportResultInput): ExportResult { + const result = this.addMany([input])[0]; + if (!result) { + throw new Error('The result could not be added.'); + } + return result; + } + + /** + * Adds a batch atomically: every ID, timestamp and limit is validated before + * the collection retains the first Blob. + */ + addMany(inputs: readonly ExportResultInput[]): readonly ExportResult[] { + this.#assertActive(); + if (inputs.length === 0) { + return Object.freeze([]); + } + const inputIds = new Set(); + let addedBytes = 0; + for (const input of inputs) { + validateIdentifier(input.id, 'Result ID'); + validateTimeRange(input.timeRange); + if (this.#results.has(input.id) || inputIds.has(input.id)) { + throw new RangeError(`A result with ID "${input.id}" already exists.`); + } + inputIds.add(input.id); + this.#assertSingleResultSize(input.blob.size); + addedBytes += input.blob.size; + if (!Number.isSafeInteger(addedBytes)) { + throw new RangeError( + 'Combined result size exceeds safe integer range.' + ); + } + } + this.#assertCountAndTotalCapacity(inputs.length, addedBytes, 0); + + const existingNames = [...this.#results.values()].map( + (result) => result.fileName + ); + const uniqueNames = uniqueExportFileNames([ + ...existingNames, + ...inputs.map((input) => + safeExportFileName(input.fileName, { fallback: 'output' }) + ), + ]).slice(existingNames.length); + const results = inputs.map((input, index) => + this.#createResult(input, undefined, uniqueNames[index]) + ); + for (const result of results) { + this.#results.set(result.id, result); + } + this.#totalBytes += addedBytes; + return Object.freeze(results); + } + + replace(input: ExportResultInput): ExportResult { + this.#assertActive(); + validateIdentifier(input.id, 'Result ID'); + validateTimeRange(input.timeRange); + const existing = this.#results.get(input.id); + this.#assertSingleResultSize(input.blob.size); + this.#assertCountAndTotalCapacity( + existing ? 0 : 1, + input.blob.size, + existing?.size ?? 0 + ); + const result = this.#createResult(input, input.id); + this.#revokeObjectUrl(input.id); + this.#results.set(input.id, result); + this.#totalBytes = this.#totalBytes - (existing?.size ?? 0) + result.size; + return result; + } + + objectUrl(id: string): string { + this.#assertActive(); + const result = this.#results.get(id); + if (!result) { + throw new RangeError(`Unknown result ID "${id}".`); + } + const existing = this.#objectUrls.get(id); + if (existing) { + return existing; + } + const url = this.#urlApi.createObjectURL(result.blob); + this.#objectUrls.set(id, url); + return url; + } + + revokeObjectUrl(id: string): boolean { + this.#assertActive(); + return this.#revokeObjectUrl(id); + } + + remove(id: string): boolean { + this.#assertActive(); + const result = this.#results.get(id); + if (!result) { + return false; + } + this.#revokeObjectUrl(id); + this.#results.delete(id); + this.#totalBytes -= result.size; + return true; + } + + clear(): void { + this.#assertActive(); + this.#clear(); + } + + /** + * Permanently releases the collection. Calling read or mutation methods + * afterwards throws, making use-after-cleanup visible during development. + */ + dispose(): void { + if (this.#disposed) { + return; + } + this.#clear(); + this.#disposed = true; + } + + #createResult( + input: ExportResultInput, + replacedId?: string, + assignedFileName?: string + ): ExportResult { + validateIdentifier(input.planId, 'Plan ID'); + validateIdentifier(input.outputId, 'Output ID'); + const existingNames = [...this.#results.values()] + .filter((result) => result.id !== replacedId) + .map((result) => result.fileName); + const [generatedFileName] = uniqueExportFileNames([ + ...existingNames, + safeExportFileName(input.fileName, { fallback: 'output' }), + ]).slice(-1); + const fileName = assignedFileName ?? generatedFileName; + const createdAt = normalizeTimestamp( + input.createdAt ?? this.#now().toISOString() + ); + const mimeType = normalizeOutputMimeType( + input.mimeType ?? input.blob.type, + fileName + ); + + return Object.freeze({ + id: input.id, + planId: input.planId, + outputId: input.outputId, + fileName: fileName ?? 'output', + blob: + input.blob.type === mimeType + ? input.blob + : blobWithOutputMimeType(input.blob, mimeType, fileName), + mimeType, + role: input.role ?? 'media', + ...(input.timeRange + ? { timeRange: Object.freeze({ ...input.timeRange }) } + : {}), + size: input.blob.size, + createdAt, + }); + } + + #assertSingleResultSize(addedBytes: number): void { + if (!Number.isSafeInteger(addedBytes) || addedBytes < 0) { + throw new RangeError('Result size must be a non-negative safe integer.'); + } + if (addedBytes > this.#limits.maxSingleResultBytes) { + throw new ExportResultLimitError( + 'single-result-bytes', + addedBytes, + this.#limits.maxSingleResultBytes + ); + } + } + + #assertCountAndTotalCapacity( + addedCount: number, + addedBytes: number, + replacedBytes: number + ): void { + if (this.#results.size + addedCount > this.#limits.maxResults) { + throw new ExportResultLimitError( + 'result-count', + this.#results.size + addedCount, + this.#limits.maxResults + ); + } + const nextTotal = this.#totalBytes - replacedBytes + addedBytes; + if (nextTotal > this.#limits.maxTotalBytes) { + throw new ExportResultLimitError( + 'total-result-bytes', + nextTotal, + this.#limits.maxTotalBytes + ); + } + } + + #revokeObjectUrl(id: string): boolean { + const url = this.#objectUrls.get(id); + if (!url) { + return false; + } + this.#urlApi.revokeObjectURL(url); + this.#objectUrls.delete(id); + return true; + } + + #clear(): void { + for (const url of this.#objectUrls.values()) { + this.#urlApi.revokeObjectURL(url); + } + this.#objectUrls.clear(); + this.#results.clear(); + this.#totalBytes = 0; + } + + #assertActive(): void { + if (this.#disposed) { + throw new Error('This export result collection has been disposed.'); + } + } +} + +export type ExportResultLimit = + 'result-count' | 'single-result-bytes' | 'total-result-bytes'; + +export class ExportResultLimitError extends RangeError { + readonly limit: ExportResultLimit; + readonly actual: number; + readonly maximum: number; + + constructor(limit: ExportResultLimit, actual: number, maximum: number) { + super( + `Export result limit exceeded: ${limit} is ${actual}, max ${maximum}.` + ); + this.name = 'ExportResultLimitError'; + this.limit = limit; + this.actual = actual; + this.maximum = maximum; + } +} + +/** + * Converts the output map returned by the job queue into managed browser + * results. Keys may be the planned output ID or its virtual output path. + */ +export function collectCommandOutputs( + plan: Pick, + outputs: ReadonlyMap, + collection: ExportResultCollection +): readonly ExportResult[] { + const inputs: ExportResultInput[] = []; + for (const planned of plan.outputs) { + const data = outputs.get(planned.id) ?? outputs.get(planned.path); + if (!data) { + throw new MissingCommandOutputError(planned.id, planned.path); + } + const blob = blobWithOutputMimeType( + data instanceof Blob ? data : Uint8Array.from(data), + planned.mediaType, + planned.fileName + ); + inputs.push({ + id: `${plan.id}:${planned.id}`, + planId: plan.id, + outputId: planned.id, + fileName: planned.fileName, + blob, + mimeType: planned.mediaType, + role: planned.role, + ...(planned.timeRange ? { timeRange: planned.timeRange } : {}), + }); + } + return collection.addMany(inputs); +} + +export class MissingCommandOutputError extends Error { + readonly outputId: string; + readonly virtualPath: string; + + constructor(outputId: string, virtualPath: string) { + super(`Expected output "${outputId}" was not returned by the media job.`); + this.name = 'MissingCommandOutputError'; + this.outputId = outputId; + this.virtualPath = virtualPath; + } +} + +function resolveLimits( + overrides: Partial | undefined +): ResultCollectionLimits { + const limits = { + ...DEFAULT_RESULT_COLLECTION_LIMITS, + ...overrides, + }; + for (const [name, value] of Object.entries(limits)) { + if (!Number.isSafeInteger(value) || value < 1) { + throw new RangeError(`${name} must be a positive safe integer.`); + } + } + if (limits.maxSingleResultBytes > limits.maxTotalBytes) { + throw new RangeError('maxSingleResultBytes cannot exceed maxTotalBytes.'); + } + return Object.freeze(limits); +} + +function validateTimeRange(range: PlannedOutput['timeRange']): void { + if ( + range && + (!Number.isFinite(range.startSeconds) || + range.startSeconds < 0 || + !Number.isFinite(range.endSeconds) || + range.endSeconds <= range.startSeconds) + ) { + throw new RangeError( + 'Result time range must have a finite start before end.' + ); + } +} + +function defaultObjectUrlApi(): ObjectUrlApi { + if ( + typeof URL.createObjectURL !== 'function' || + typeof URL.revokeObjectURL !== 'function' + ) { + return { + createObjectURL: () => { + throw new Error('Object URLs are not supported in this environment.'); + }, + revokeObjectURL: () => undefined, + }; + } + return URL; +} + +function validateIdentifier(value: string, label: string): void { + if (value.length < 1 || value.length > 256 || hasControlCharacters(value)) { + throw new RangeError( + `${label} must contain 1 to 256 printable characters.` + ); + } +} + +function hasControlCharacters(value: string): boolean { + return Array.from(value).some((character) => { + const codePoint = character.codePointAt(0) ?? 0; + return codePoint < 32 || codePoint === 127; + }); +} + +function normalizeTimestamp(value: string): string { + const timestamp = new Date(value); + if (!Number.isFinite(timestamp.valueOf())) { + throw new RangeError('Result creation time must be a valid timestamp.'); + } + return timestamp.toISOString(); +} diff --git a/src/export/save-result.ts b/src/export/save-result.ts new file mode 100644 index 0000000..0e8122b --- /dev/null +++ b/src/export/save-result.ts @@ -0,0 +1,73 @@ +interface WritableFileStream { + write(data: Blob): Promise; + close(): Promise; +} + +interface SaveFileHandle { + createWritable(): Promise; +} + +interface SavePickerOptions { + suggestedName?: string; + types?: Array<{ + description?: string; + accept: Record; + }>; +} + +type SaveFilePicker = (options?: SavePickerOptions) => Promise; + +function getSaveFilePicker(): SaveFilePicker | undefined { + const value = ( + globalThis as typeof globalThis & { + showSaveFilePicker?: SaveFilePicker; + } + ).showSaveFilePicker; + return typeof value === 'function' ? value.bind(globalThis) : undefined; +} + +export async function saveBlob( + blob: Blob, + fileName: string, + mimeType = blob.type || 'application/octet-stream' +): Promise<'saved' | 'downloaded' | 'cancelled'> { + const picker = getSaveFilePicker(); + if (picker) { + try { + const extension = fileName.includes('.') + ? `.${fileName.split('.').at(-1)}` + : ''; + const handle = await picker({ + suggestedName: fileName, + types: [ + { + description: 'Media output', + accept: { [mimeType]: extension ? [extension] : [] }, + }, + ], + }); + const writable = await handle.createWritable(); + await writable.write(blob); + await writable.close(); + return 'saved'; + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { + return 'cancelled'; + } + throw error; + } + } + + downloadBlob(blob, fileName); + return 'downloaded'; +} + +export function downloadBlob(blob: Blob, fileName: string): void { + const temporaryUrl = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = temporaryUrl; + link.download = fileName; + link.rel = 'noopener'; + link.click(); + setTimeout(() => URL.revokeObjectURL(temporaryUrl), 1_000); +} diff --git a/src/export/zip-results.ts b/src/export/zip-results.ts new file mode 100644 index 0000000..6e64638 --- /dev/null +++ b/src/export/zip-results.ts @@ -0,0 +1,267 @@ +import { + zip, + type AsyncTerminable, + type AsyncZippable, + type AsyncZipOptions, +} from 'fflate'; +import { ZIP_MIME_TYPE } from './mime'; +import { + isSafeArchiveLeafName, + safeExportFileName, + uniqueExportFileNames, +} from './output-name'; +import type { ExportResult } from './result-collection'; +import { MAX_RESULT_ZIP_ENTRIES } from '../limits'; + +const MEBIBYTE = 1024 * 1024; + +export const DEFAULT_RESULT_ZIP_LIMITS = Object.freeze({ + maxEntries: MAX_RESULT_ZIP_ENTRIES, + maxEntryBytes: 512 * MEBIBYTE, + maxTotalInputBytes: 512 * MEBIBYTE, + maxArchiveBytes: 550 * MEBIBYTE, +}); + +export interface ResultZipLimits { + readonly maxEntries: number; + readonly maxEntryBytes: number; + readonly maxTotalInputBytes: number; + readonly maxArchiveBytes: number; +} + +export interface ResultZipEntry { + readonly fileName: string; + readonly blob: Blob; +} + +export interface CreateResultZipOptions { + readonly fileName?: string; + readonly limits?: Partial; + readonly signal?: AbortSignal; + readonly compressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; +} + +export interface ResultZip { + readonly blob: Blob; + readonly fileName: string; + readonly entryNames: readonly string[]; + readonly totalInputBytes: number; + readonly archiveBytes: number; +} + +export type ResultZipLimit = + 'entry-count' | 'entry-bytes' | 'total-input-bytes' | 'archive-bytes'; + +export class ResultZipLimitError extends RangeError { + readonly limit: ResultZipLimit; + readonly actual: number; + readonly maximum: number; + + constructor(limit: ResultZipLimit, actual: number, maximum: number) { + super(`ZIP limit exceeded: ${limit} is ${actual}, max ${maximum}.`); + this.name = 'ResultZipLimitError'; + this.limit = limit; + this.actual = actual; + this.maximum = maximum; + } +} + +/** + * Creates an archive with sorted, safe paths, fixed local DOS timestamps, + * normalized attributes and a fixed compression level. fflate's asynchronous + * API moves compression away from the browser rendering path. + */ +export async function createResultZip( + entries: readonly ResultZipEntry[], + options: CreateResultZipOptions = {} +): Promise { + const limits = resolveZipLimits(options.limits); + throwIfAborted(options.signal); + if (entries.length < 1) { + throw new RangeError('At least one result is required to create a ZIP.'); + } + if (entries.length > limits.maxEntries) { + throw new ResultZipLimitError( + 'entry-count', + entries.length, + limits.maxEntries + ); + } + + const names = uniqueExportFileNames( + entries.map((entry) => entry.fileName), + 'result' + ); + const normalized = entries + .map((entry, index) => ({ + blob: entry.blob, + fileName: names[index] ?? `result-${index + 1}`, + })) + .sort((left, right) => comparePaths(left.fileName, right.fileName)); + + let totalInputBytes = 0; + for (const entry of normalized) { + if (!isSafeArchiveLeafName(entry.fileName)) { + throw new RangeError(`Unsafe ZIP entry name: "${entry.fileName}".`); + } + if (entry.blob.size > limits.maxEntryBytes) { + throw new ResultZipLimitError( + 'entry-bytes', + entry.blob.size, + limits.maxEntryBytes + ); + } + totalInputBytes += entry.blob.size; + if ( + !Number.isSafeInteger(totalInputBytes) || + totalInputBytes > limits.maxTotalInputBytes + ) { + throw new ResultZipLimitError( + 'total-input-bytes', + totalInputBytes, + limits.maxTotalInputBytes + ); + } + } + + const zippable: AsyncZippable = {}; + for (const entry of normalized) { + throwIfAborted(options.signal); + const bytes = new Uint8Array(await entry.blob.arrayBuffer()); + zippable[entry.fileName] = [ + bytes, + { + attrs: 0o644 << 16, + mtime: fixedZipTimestamp(), + os: 3, + }, + ]; + } + + const bytes = await compressZip(zippable, { + attrs: 0o644 << 16, + level: options.compressionLevel ?? 6, + mtime: fixedZipTimestamp(), + os: 3, + signal: options.signal, + }); + if (bytes.byteLength > limits.maxArchiveBytes) { + throw new ResultZipLimitError( + 'archive-bytes', + bytes.byteLength, + limits.maxArchiveBytes + ); + } + + const blob = new Blob([Uint8Array.from(bytes)], { + type: ZIP_MIME_TYPE, + }); + return Object.freeze({ + blob, + fileName: safeExportFileName(options.fileName ?? 'av-tools-results.zip', { + extension: 'zip', + fallback: 'av-tools-results', + }), + entryNames: Object.freeze(normalized.map((entry) => entry.fileName)), + totalInputBytes, + archiveBytes: blob.size, + }); +} + +export function resultEntries( + results: readonly ExportResult[] +): readonly ResultZipEntry[] { + return Object.freeze( + results.map((result) => + Object.freeze({ + fileName: result.fileName, + blob: result.blob, + }) + ) + ); +} + +interface CompressionOptions extends AsyncZipOptions { + readonly signal?: AbortSignal; +} + +function compressZip( + zippable: AsyncZippable, + options: CompressionOptions +): Promise { + throwIfAborted(options.signal); + return new Promise((resolve, reject) => { + let settled = false; + let terminate: AsyncTerminable = () => undefined; + const onAbort = () => { + if (settled) { + return; + } + settled = true; + terminate(); + reject(abortError()); + }; + options.signal?.addEventListener('abort', onAbort, { once: true }); + + const zipOptions: AsyncZipOptions = { + ...(options.attrs === undefined ? {} : { attrs: options.attrs }), + ...(options.level === undefined ? {} : { level: options.level }), + ...(options.mtime === undefined ? {} : { mtime: options.mtime }), + ...(options.os === undefined ? {} : { os: options.os }), + }; + terminate = zip(zippable, zipOptions, (error, bytes) => { + if (settled) { + return; + } + settled = true; + options.signal?.removeEventListener('abort', onAbort); + if (error) { + reject(error); + return; + } + resolve(bytes); + }); + + if (options.signal?.aborted) { + onAbort(); + } + }); +} + +function resolveZipLimits( + overrides: Partial | undefined +): ResultZipLimits { + const limits = { + ...DEFAULT_RESULT_ZIP_LIMITS, + ...overrides, + }; + for (const [name, value] of Object.entries(limits)) { + if (!Number.isSafeInteger(value) || value < 1) { + throw new RangeError(`${name} must be a positive safe integer.`); + } + } + if (limits.maxEntryBytes > limits.maxTotalInputBytes) { + throw new RangeError('maxEntryBytes cannot exceed maxTotalInputBytes.'); + } + return Object.freeze(limits); +} + +function fixedZipTimestamp(): Date { + // fflate writes DOS local-time fields. Constructing the Date in local time + // makes those encoded fields identical across time zones. + return new Date(1980, 0, 1, 0, 0, 0, 0); +} + +function comparePaths(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function throwIfAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) { + throw abortError(); + } +} + +function abortError(): DOMException { + return new DOMException('ZIP creation was cancelled.', 'AbortError'); +} diff --git a/src/ffmpeg/EngineManager.ts b/src/ffmpeg/EngineManager.ts new file mode 100644 index 0000000..ff06e9d --- /dev/null +++ b/src/ffmpeg/EngineManager.ts @@ -0,0 +1,863 @@ +import type { LogEvent, ProgressEvent } from '@ffmpeg/ffmpeg'; +import { queryCapabilities } from './capabilities'; +import { + detectEngineEnvironment, + selectEngineMode, + supportsMultithread, +} from './engine-mode'; +import { EngineError, toEngineError } from './ffmpeg-error'; +import type { + EngineJobRequest, + EngineJobResult, + EngineJobContext, + EngineLogListener, + EngineMode, + EnginePreference, + EngineProgressListener, + EngineState, + EngineStateListener, + FFmpegAdapter, + FFmpegCapabilities, + ProbeResult, +} from './ffmpeg.types'; +import { createLoadConfig } from './load-config'; +import { LogBuffer } from './log-buffer'; +import { normalizeProgress } from './progress-parser'; +import { describeMountedInputAccess, mountJobFiles } from './virtual-fs'; +import { + parseFFmpegProgress, + progressUpdateFromFFmpeg, +} from '../jobs/progress'; + +type FFmpegFactory = () => Promise; + +export const DEFAULT_JOB_TIMEOUT_MILLISECONDS = 4 * 60 * 60 * 1_000; +export const MULTITHREAD_CODEC_THREAD_LIMIT = 2; +/** + * The pinned Emscripten core can retain native heap state across heterogeneous + * FFmpeg invocations even after every virtual file has been removed. Recycling + * the idle worker before this many media jobs bounds that accumulation without + * interrupting an active command or discarding application-level results. + */ +export const MAX_EXECUTIONS_PER_ENGINE = 16; +/** The pinned tile filter aborts on a fourth consecutive contact-sheet job. */ +export const MAX_CONTACT_SHEETS_PER_ENGINE = 3; + +export function shouldRecycleExecutionCore( + completedExecutions: number, + nextOperation?: string, + completedContactSheets = 0 +): boolean { + return ( + completedExecutions >= MAX_EXECUTIONS_PER_ENGINE || + (nextOperation === 'contact-sheet' && + completedContactSheets >= MAX_CONTACT_SHEETS_PER_ENGINE) + ); +} + +async function defaultFFmpegFactory(): Promise { + const { FFmpeg } = await import('@ffmpeg/ffmpeg'); + return new FFmpeg(); +} + +function textFromFileData(data: Uint8Array | string): string { + return typeof data === 'string' ? data : new TextDecoder().decode(data); +} + +function bytesFromFileData(data: Uint8Array | string): Uint8Array { + return typeof data === 'string' ? new TextEncoder().encode(data) : data; +} + +export class EngineManager { + readonly #factory: FFmpegFactory; + readonly #stateListeners = new Set(); + readonly #logListeners = new Set(); + readonly #progressListeners = new Set(); + readonly #logs = new LogBuffer(); + #engine: FFmpegAdapter | null = null; + #state: EngineState = { status: 'idle' }; + #mode: EngineMode | null = null; + #preference: EnginePreference = 'automatic'; + #capabilities: FFmpegCapabilities | null = null; + #warning: Extract['warning']; + #loadPromise: Promise | null = null; + #activeJobId: string | null = null; + #activeExpectedDurationSeconds: number | undefined; + #activeStartedAtMilliseconds = 0; + #machineProgressLines: string[] = []; + #machineProgressSeen = false; + #cancelledJobIds = new Set(); + #executionsSinceLoad = 0; + #contactSheetsSinceLoad = 0; + #generation = 0; + + readonly #handleLog = (event: LogEvent): void => { + this.#logs.append(event); + if (this.#state.status === 'running') { + for (const line of event.message.split(/\r?\n/gu)) { + if (/^(?:frame|fps|out_time_us|out_time|speed|progress)=/u.test(line)) { + this.#machineProgressLines.push(line); + } + if (/^progress=(?:continue|end)$/u.test(line)) { + const parsed = parseFFmpegProgress( + this.#machineProgressLines.join('\n') + ); + const update = progressUpdateFromFFmpeg( + parsed, + this.#activeExpectedDurationSeconds + ); + this.#machineProgressLines = []; + this.#machineProgressSeen = true; + this.#setState({ + ...this.#state, + ...(update.progress !== undefined + ? { progress: update.progress } + : {}), + ...(update.speed !== undefined ? { speed: update.speed } : {}), + ...(update.frame !== undefined ? { frame: update.frame } : {}), + elapsedSeconds: Math.max( + 0, + (performance.now() - this.#activeStartedAtMilliseconds) / 1_000 + ), + }); + } + } + } + for (const listener of this.#logListeners) listener(event); + }; + + readonly #handleProgress = (event: ProgressEvent): void => { + const normalized = normalizeProgress(event); + if ( + this.#state.status === 'running' && + !this.#machineProgressSeen && + normalized.progress !== this.#state.progress + ) { + this.#setState({ + ...this.#state, + progress: Math.min(0.99, normalized.progress), + elapsedSeconds: Math.max( + 0, + (performance.now() - this.#activeStartedAtMilliseconds) / 1_000 + ), + }); + } + for (const listener of this.#progressListeners) listener(normalized); + }; + + constructor(factory: FFmpegFactory = defaultFFmpegFactory) { + this.#factory = factory; + } + + get state(): EngineState { + return this.#state; + } + + get preference(): EnginePreference { + return this.#preference; + } + + setPreference(preference: EnginePreference): void { + if (this.#state.status === 'running') { + throw new EngineError( + 'busy', + 'Engine mode cannot be changed while a job is running.' + ); + } + if (preference !== this.#preference) { + this.#preference = preference; + this.dispose(); + } + } + + subscribe(listener: EngineStateListener): () => void { + this.#stateListeners.add(listener); + listener(this.#state); + return () => this.#stateListeners.delete(listener); + } + + onLog(listener: EngineLogListener): () => void { + this.#logListeners.add(listener); + return () => this.#logListeners.delete(listener); + } + + onProgress(listener: EngineProgressListener): () => void { + this.#progressListeners.add(listener); + return () => this.#progressListeners.delete(listener); + } + + async initialize(): Promise { + if (this.#activeJobId || this.#state.status === 'running') { + throw new EngineError( + 'busy', + 'The FFmpeg engine cannot be initialized while another operation is active.' + ); + } + if (this.#state.status === 'ready') return; + if (this.#loadPromise) return this.#loadPromise; + this.#loadPromise = this.#initializeInternal().finally(() => { + this.#loadPromise = null; + }); + return this.#loadPromise; + } + + async #initializeInternal(): Promise { + const environment = detectEngineEnvironment(); + if (!environment.secureContext || typeof Worker === 'undefined') { + const error = new EngineError( + 'unsupported-environment', + 'FFmpeg requires a secure browser context with Web Worker support.' + ); + this.#setState({ status: 'error', error }); + throw error; + } + + const requestedMode = selectEngineMode(environment, this.#preference); + const unsupportedMultithread = + this.#preference === 'prefer-multithread' && + !supportsMultithread(environment); + const initialWarning = unsupportedMultithread + ? { + code: 'multithread-unavailable' as const, + message: + 'Multithreading is unavailable in this browser context; using the single-thread compatibility core.', + } + : undefined; + + try { + await this.#loadMode(requestedMode, initialWarning); + } catch (firstError) { + if (requestedMode !== 'multithread') { + const details = describeInitializationFailure(firstError); + this.#terminateEngine(); + const error = new EngineError( + 'load-failed', + 'The FFmpeg compatibility core could not be loaded.', + { cause: firstError, details } + ); + this.#setState({ status: 'error', error }); + throw error; + } + + this.#terminateEngine(); + const warning = { + code: 'multithread-load-failed' as const, + message: + 'The multithread core failed to load. The app recovered with the single-thread compatibility core.', + }; + this.#setState({ + status: 'recovering', + previousMode: 'multithread', + warning, + }); + try { + await this.#loadMode('single-thread', warning); + } catch (fallbackError) { + const details = [ + `Multithread core: ${describeInitializationFailure(firstError)}`, + `Single-thread core: ${describeInitializationFailure(fallbackError)}`, + ].join('\n'); + this.#terminateEngine(); + const error = new EngineError( + 'load-failed', + 'Neither FFmpeg execution core could be loaded.', + { + cause: fallbackError, + details, + } + ); + this.#setState({ status: 'error', error }); + throw error; + } + } + } + + async #loadMode( + mode: EngineMode, + warning?: Extract['warning'] + ): Promise { + const generation = ++this.#generation; + this.#setState({ + status: 'loading', + mode, + progress: 0.05, + stage: 'Loading FFmpeg wrapper', + }); + const engine = await this.#factory(); + if (generation !== this.#generation) return; + this.#engine = engine; + this.#mode = mode; + engine.on('log', this.#handleLog); + engine.on('progress', this.#handleProgress); + this.#setState({ + status: 'loading', + mode, + progress: 0.2, + stage: 'Loading local WebAssembly core', + }); + await engine.load(createLoadConfig(mode)); + if (generation !== this.#generation) return; + this.#setState({ + status: 'loading', + mode, + progress: 0.75, + stage: 'Inspecting codec capabilities', + }); + const capabilities = await queryCapabilities(engine); + if (generation !== this.#generation) return; + this.#capabilities = capabilities; + this.#executionsSinceLoad = 0; + this.#contactSheetsSinceLoad = 0; + this.#warning = warning; + this.#setState({ status: 'ready', mode, capabilities, warning }); + } + + async runJob(request: EngineJobRequest): Promise { + if (this.#activeJobId) { + throw new EngineError( + 'busy', + 'Another FFmpeg operation is already running.' + ); + } + await this.initialize(); + const coreWorkload = request.coreWorkload ?? request.operation; + await this.#recycleExecutionCoreIfNeeded(coreWorkload); + const engine = this.#engine; + const mode = this.#mode; + if (!engine || !mode || this.#state.status !== 'ready') { + throw new EngineError('load-failed', 'The FFmpeg engine is not ready.'); + } + + const mounted = await mountJobFiles( + engine, + request.id, + request.inputs, + request.outputs.map(({ name }) => name) + ); + const generation = this.#generation; + const temporaryPaths = (request.temporaryFiles ?? []).map( + (temporary, index) => { + const extensionMatch = /\.([a-zA-Z0-9]{1,10})$/.exec(temporary.name); + const extension = extensionMatch?.[1]?.toLowerCase() ?? 'tmp'; + return `${mounted.workDirectory}/temporary-${index}.${extension}`; + } + ); + this.#activeJobId = request.id; + this.#activeExpectedDurationSeconds = request.expectedDurationSeconds; + this.#activeStartedAtMilliseconds = performance.now(); + this.#machineProgressLines = []; + this.#machineProgressSeen = false; + this.#logs.clear(); + this.#setState({ + status: 'running', + mode, + jobId: request.id, + operation: request.operation, + inputMode: mounted.inputMode, + inputBytes: mounted.inputBytes, + copiedInputBytes: mounted.copiedInputBytes, + progress: 0, + }); + this.#appendInternalLog({ + type: mounted.inputMode === 'workerfs' ? 'stdout' : 'stderr', + message: describeMountedInputAccess(mounted), + }); + + let completedResult: Omit | undefined; + let conversionMilliseconds: number; + let outputReadMilliseconds: number; + let cleanupMilliseconds: number; + let coreAborted = false; + try { + const context: EngineJobContext = { + inputPaths: mounted.inputPaths, + temporaryPaths, + outputPaths: mounted.outputPaths, + workDirectory: mounted.workDirectory, + }; + for (const [index, temporary] of ( + request.temporaryFiles ?? [] + ).entries()) { + const path = temporaryPaths[index]; + if (path) { + const content = + typeof temporary.content === 'function' + ? temporary.content(context) + : temporary.content; + await engine.writeFile( + path, + typeof content === 'string' ? content : new Uint8Array(content) + ); + } + } + const args = prepareEngineArguments( + request.buildArguments(context), + mode, + mounted.inputPaths, + mounted.outputPaths + ); + const conversionStartedAt = performance.now(); + const exitCode = await engine.exec( + args, + engineJobTimeoutMilliseconds(request) + ); + if (generation === this.#generation && this.#engine === engine) { + this.#executionsSinceLoad += 1; + if (coreWorkload === 'contact-sheet') { + this.#contactSheetsSinceLoad += 1; + } + } + conversionMilliseconds = performance.now() - conversionStartedAt; + if (this.#cancelledJobIds.delete(request.id)) { + throw new EngineError('cancelled', 'The FFmpeg job was cancelled.'); + } + if (exitCode !== 0) { + coreAborted = /(?:^|\n)Aborted\(\)(?:\n|$)/u.test(this.#logs.text()); + throw new EngineError( + 'execution-failed', + `FFmpeg exited with code ${exitCode}.`, + { + exitCode, + details: this.#logs.text(), + } + ); + } + const outputReadStartedAt = performance.now(); + const outputs = await Promise.all( + mounted.outputPaths.map(async (path, index) => { + const bytes = bytesFromFileData(await engine.readFile(path)); + if (bytes.byteLength === 0) { + throw new EngineError( + 'execution-failed', + `FFmpeg produced an empty expected output: ${request.outputs[index]?.name ?? `output-${index}`}.`, + { details: this.#logs.text() } + ); + } + return { + name: request.outputs[index]?.name ?? `output-${index}`, + mimeType: + request.outputs[index]?.mimeType ?? 'application/octet-stream', + bytes, + }; + }) + ); + outputReadMilliseconds = performance.now() - outputReadStartedAt; + completedResult = { + id: request.id, + exitCode: 0, + inputMode: mounted.inputMode, + inputBytes: mounted.inputBytes, + copiedInputBytes: mounted.copiedInputBytes, + outputs, + logs: this.#logs.snapshot(), + }; + } catch (error) { + if (this.#cancelledJobIds.delete(request.id)) { + throw new EngineError('cancelled', 'The FFmpeg job was cancelled.', { + cause: error, + }); + } + throw toEngineError(error); + } finally { + const cleanupStartedAt = performance.now(); + this.#activeJobId = null; + this.#activeExpectedDurationSeconds = undefined; + this.#machineProgressLines = []; + this.#machineProgressSeen = false; + if (generation === this.#generation && this.#engine === engine) { + for (const path of temporaryPaths) { + try { + await engine.deleteFile(path); + } catch { + // Cleanup below will recover or surface a later filesystem fault. + } + } + const cleanupSucceeded = await mounted.cleanup(); + if ( + (!cleanupSucceeded || coreAborted) && + generation === this.#generation && + this.#engine === engine + ) { + this.#logs.append({ + type: 'stderr', + message: coreAborted + ? 'The FFmpeg WebAssembly core aborted; recreating it before the next job.' + : 'Filesystem cleanup could not be verified; recreating the FFmpeg engine.', + }); + this.#terminateEngine(); + this.#setState({ status: 'recovering', previousMode: mode }); + try { + await this.initialize(); + } catch { + // initialize() publishes the controlled error state. + } + } else if (this.#isRunning()) { + const capabilities = this.#capabilities; + if (capabilities) { + this.#setState({ + status: 'ready', + mode, + capabilities, + warning: this.#warning, + }); + } + } + } + cleanupMilliseconds = performance.now() - cleanupStartedAt; + } + if (!completedResult) { + throw new EngineError( + 'execution-failed', + 'FFmpeg completed without returning an execution result.' + ); + } + return { + ...completedResult, + timings: Object.freeze({ + conversionMilliseconds, + outputReadMilliseconds, + cleanupMilliseconds, + }), + }; + } + + async probe(file: File, timeoutMilliseconds = 30_000): Promise { + await this.initialize(); + const engine = this.#engine; + const mode = this.#mode; + if (!engine || !mode || this.#state.status !== 'ready') { + throw new EngineError('load-failed', 'The FFmpeg engine is not ready.'); + } + if (this.#activeJobId) { + throw new EngineError( + 'busy', + 'Another FFmpeg operation is already running.' + ); + } + + const id = crypto.randomUUID(); + const mounted = await mountJobFiles(engine, id, [file], ['probe.json']); + const probeOutput = mounted.outputPaths[0]; + const inputPath = mounted.inputPaths[0]; + if (!probeOutput || !inputPath) { + throw new EngineError( + 'filesystem-failed', + 'Could not allocate probe paths.' + ); + } + const activeProbeId = `probe-${id}`; + this.#activeJobId = activeProbeId; + this.#activeExpectedDurationSeconds = undefined; + this.#activeStartedAtMilliseconds = performance.now(); + this.#machineProgressLines = []; + this.#machineProgressSeen = false; + this.#logs.clear(); + this.#setState({ + status: 'running', + mode, + jobId: this.#activeJobId, + operation: 'Inspect media', + inputMode: mounted.inputMode, + inputBytes: mounted.inputBytes, + copiedInputBytes: mounted.copiedInputBytes, + }); + this.#appendInternalLog({ + type: mounted.inputMode === 'workerfs' ? 'stdout' : 'stderr', + message: describeMountedInputAccess(mounted), + }); + const watchdog = setTimeout(() => this.cancelActive(), timeoutMilliseconds); + try { + const exitCode = await engine.ffprobe([ + '-v', + 'error', + '-show_format', + '-show_streams', + '-show_chapters', + '-of', + 'json', + inputPath, + '-o', + probeOutput, + ]); + let parsed: unknown; + try { + parsed = JSON.parse( + textFromFileData(await engine.readFile(probeOutput)) + ) as unknown; + } catch (readError) { + throw new EngineError( + 'probe-failed', + exitCode === 0 + ? 'ffprobe did not produce valid JSON.' + : `ffprobe exited with code ${exitCode} without a valid report.`, + { + cause: readError, + exitCode, + details: this.#logs.text(), + } + ); + } + if (!isProbeReportShape(parsed)) { + throw new EngineError( + 'probe-failed', + 'ffprobe produced JSON that does not have a media report shape.', + { exitCode, details: this.#logs.text() } + ); + } + if (!probeReportHasStreams(parsed)) { + throw new EngineError( + 'probe-failed', + 'ffprobe did not find any media streams.', + { exitCode, details: this.#logs.text() } + ); + } + if (exitCode !== 0) { + // The pinned 0.12.10 browser core is observed to return -1 after + // successfully writing a complete report. The file is the source of + // truth only in this narrow, validated case; absent/malformed output + // above still fails. + this.#logs.append({ + type: 'stderr', + message: `Compatibility note: ffprobe returned ${exitCode}, but its complete JSON report was validated and accepted.`, + }); + } + return { + json: parsed, + inputMode: mounted.inputMode, + inputBytes: mounted.inputBytes, + copiedInputBytes: mounted.copiedInputBytes, + logs: this.#logs.snapshot(), + }; + } catch (error) { + if (this.#cancelledJobIds.delete(activeProbeId)) { + throw new EngineError('cancelled', 'The media probe was cancelled.', { + cause: error, + }); + } + throw error instanceof EngineError + ? error + : new EngineError('probe-failed', 'The media could not be inspected.', { + cause: error, + details: this.#logs.text(), + }); + } finally { + clearTimeout(watchdog); + this.#activeJobId = null; + this.#machineProgressLines = []; + this.#machineProgressSeen = false; + if (this.#engine === engine) { + const cleanupSucceeded = await mounted.cleanup(); + if (!cleanupSucceeded && this.#engine === engine) { + this.#logs.append({ + type: 'stderr', + message: + 'Probe filesystem cleanup could not be verified; recreating the FFmpeg engine.', + }); + this.#terminateEngine(); + this.#setState({ status: 'recovering', previousMode: mode }); + try { + await this.initialize(); + } catch { + // initialize() publishes the controlled error state. + } + } else if (this.#isRunning()) { + const capabilities = this.#capabilities; + if (capabilities) { + this.#setState({ + status: 'ready', + mode, + capabilities, + warning: this.#warning, + }); + } + } + } + } + } + + async cancelActive(): Promise { + const activeJobId = this.#activeJobId; + const previousMode = this.#mode; + if (!activeJobId || !previousMode) return; + this.#cancelledJobIds.add(activeJobId); + this.#terminateEngine(); + this.#activeJobId = null; + this.#activeExpectedDurationSeconds = undefined; + this.#machineProgressLines = []; + this.#machineProgressSeen = false; + this.#setState({ status: 'recovering', previousMode }); + try { + await this.initialize(); + } catch { + // initialize() already publishes the controlled error state. + } + } + + dispose(): void { + this.#terminateEngine(); + this.#activeJobId = null; + this.#setState({ status: 'idle' }); + } + + #terminateEngine(): void { + this.#generation += 1; + if (this.#engine) { + this.#engine.off('log', this.#handleLog); + this.#engine.off('progress', this.#handleProgress); + this.#engine.terminate(); + } + this.#engine = null; + this.#mode = null; + this.#capabilities = null; + this.#warning = undefined; + this.#executionsSinceLoad = 0; + this.#contactSheetsSinceLoad = 0; + } + + async #recycleExecutionCoreIfNeeded(nextOperation: string): Promise { + if ( + !shouldRecycleExecutionCore( + this.#executionsSinceLoad, + nextOperation, + this.#contactSheetsSinceLoad + ) + ) { + return; + } + const previousMode = this.#mode; + if ( + !previousMode || + this.#activeJobId || + this.#state.status === 'running' + ) { + return; + } + const contactSheetGuard = + nextOperation === 'contact-sheet' && + this.#contactSheetsSinceLoad >= MAX_CONTACT_SHEETS_PER_ENGINE; + this.#appendInternalLog({ + type: 'stderr', + message: contactSheetGuard + ? `Refreshing the idle FFmpeg WebAssembly core before contact sheet ${this.#contactSheetsSinceLoad + 1} to release retained tile-filter state.` + : `Refreshing the idle FFmpeg WebAssembly core after ${this.#executionsSinceLoad} media jobs to release retained native heap state.`, + }); + this.#terminateEngine(); + this.#setState({ status: 'recovering', previousMode }); + await this.initialize(); + } + + #setState(state: EngineState): void { + this.#state = state; + for (const listener of this.#stateListeners) listener(state); + } + + #appendInternalLog(event: LogEvent): void { + this.#logs.append(event); + for (const listener of this.#logListeners) listener(event); + } + + #isRunning(): boolean { + return this.#state.status === 'running'; + } +} + +/** + * The pinned core-mt build can stall on Chromium when FFmpeg auto-selects a + * large codec thread count. Keep the WASM pthread core, but bound decoder and + * encoder codec threads to the smallest upstream-reported stable setting. + */ +export function prepareEngineArguments( + original: readonly string[], + mode: EngineMode, + inputPaths: readonly string[], + outputPaths: readonly string[] +): string[] { + const args = [...original]; + if (mode !== 'multithread' || args.length === 0) return args; + + const constrained: string[] = []; + const inputPathSet = new Set(inputPaths); + const outputPathSet = new Set(outputPaths); + let constrainedOutput = false; + + for (const [index, argument] of args.entries()) { + if (argument === '-i' && inputPathSet.has(args[index + 1] ?? '')) { + constrained.push( + '-threads', + String(MULTITHREAD_CODEC_THREAD_LIMIT), + '-i' + ); + continue; + } + if (outputPathSet.has(argument)) { + constrained.push( + '-threads', + String(MULTITHREAD_CODEC_THREAD_LIMIT), + argument + ); + constrainedOutput = true; + continue; + } + constrained.push(argument); + } + + // Segment/image sequence plans can use a pattern instead of an exact + // expected output path. Every typed command plan keeps its output URL last. + if (!constrainedOutput && constrained.length > 0) { + constrained.splice( + constrained.length - 1, + 0, + '-threads', + String(MULTITHREAD_CODEC_THREAD_LIMIT) + ); + } + return constrained; +} + +export function engineJobTimeoutMilliseconds( + request: Pick< + EngineJobRequest, + 'expectedDurationSeconds' | 'timeoutMilliseconds' + > +): number { + const inferred = + request.expectedDurationSeconds === undefined + ? DEFAULT_JOB_TIMEOUT_MILLISECONDS + : Math.max(120_000, request.expectedDurationSeconds * 30_000); + return Math.min( + request.timeoutMilliseconds ?? inferred, + DEFAULT_JOB_TIMEOUT_MILLISECONDS + ); +} + +export const engineManager = new EngineManager(); + +function describeInitializationFailure(error: unknown): string { + if (error instanceof EngineError) { + return error.details ? `${error.message}\n${error.details}` : error.message; + } + if (error instanceof Error) { + return error.stack ?? error.message; + } + return String(error); +} + +function isProbeReportShape(value: unknown): boolean { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false; + } + const report = value as Record; + return ( + (Array.isArray(report.streams) || report.format !== undefined) && + (report.format === undefined || + (typeof report.format === 'object' && report.format !== null)) + ); +} + +function probeReportHasStreams(value: unknown): boolean { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false; + } + const streams = (value as Record).streams; + return Array.isArray(streams) && streams.length > 0; +} diff --git a/src/ffmpeg/asset-urls.ts b/src/ffmpeg/asset-urls.ts new file mode 100644 index 0000000..370ca56 --- /dev/null +++ b/src/ffmpeg/asset-urls.ts @@ -0,0 +1,36 @@ +import type { EngineMode } from './ffmpeg.types'; +import { FFMPEG_CORE_VERSION } from '../version'; + +export function resolveFromAppBase( + relativePath: string, + locationHref = window.location.href, + viteBase = import.meta.env.BASE_URL +): string { + const appBase = new URL(viteBase, locationHref); + return new URL(relativePath, appBase).href; +} + +export interface CoreAssetUrls { + coreURL: string; + wasmURL: string; + workerURL?: string; +} + +export function getCoreAssetUrls( + mode: EngineMode, + resolver: (path: string) => string = resolveFromAppBase +): CoreAssetUrls { + const directory = + mode === 'multithread' + ? `vendor/ffmpeg/${FFMPEG_CORE_VERSION}/mt/` + : `vendor/ffmpeg/${FFMPEG_CORE_VERSION}/st/`; + const base = resolver(directory); + const urls: CoreAssetUrls = { + coreURL: new URL('ffmpeg-core.js', base).href, + wasmURL: new URL('ffmpeg-core.wasm', base).href, + }; + if (mode === 'multithread') { + urls.workerURL = new URL('ffmpeg-core.worker.js', base).href; + } + return urls; +} diff --git a/src/ffmpeg/capabilities.ts b/src/ffmpeg/capabilities.ts new file mode 100644 index 0000000..ea17c3f --- /dev/null +++ b/src/ffmpeg/capabilities.ts @@ -0,0 +1,74 @@ +import type { LogEvent } from '@ffmpeg/ffmpeg'; +import { + parseBuildConfiguration, + parseCodecs, + parseFilters, + parseFormats, + parseNamedComponents, +} from './capability-parser'; +import { EngineError } from './ffmpeg-error'; +import type { FFmpegAdapter, FFmpegCapabilities } from './ffmpeg.types'; + +async function captureCommand( + ffmpeg: FFmpegAdapter, + args: string[] +): Promise { + const messages: string[] = []; + const listener = ({ message }: LogEvent) => messages.push(message); + ffmpeg.on('log', listener); + try { + const exitCode = await ffmpeg.exec(args); + if (exitCode !== 0) { + throw new EngineError( + 'execution-failed', + `FFmpeg capability query failed with exit code ${exitCode}`, + { exitCode, details: messages.join('\n') } + ); + } + } finally { + ffmpeg.off('log', listener); + } + return messages.join('\n'); +} + +export async function queryCapabilities( + ffmpeg: FFmpegAdapter +): Promise { + // A core is a single process and must not receive overlapping executions. + const versionText = await captureCommand(ffmpeg, ['-version']); + const buildText = await captureCommand(ffmpeg, ['-buildconf']); + const formatsText = await captureCommand(ffmpeg, [ + '-hide_banner', + '-formats', + ]); + const codecsText = await captureCommand(ffmpeg, ['-hide_banner', '-codecs']); + const encodersText = await captureCommand(ffmpeg, [ + '-hide_banner', + '-encoders', + ]); + const decodersText = await captureCommand(ffmpeg, [ + '-hide_banner', + '-decoders', + ]); + const filtersText = await captureCommand(ffmpeg, [ + '-hide_banner', + '-filters', + ]); + + const formats = parseFormats(formatsText); + const codecs = parseCodecs(codecsText); + const explicitEncoders = parseNamedComponents(encodersText); + for (const encoder of explicitEncoders) codecs.encoders.add(encoder); + const explicitDecoders = parseNamedComponents(decodersText); + for (const decoder of explicitDecoders) codecs.decoders.add(decoder); + + return { + versionText, + buildConfiguration: parseBuildConfiguration(`${versionText}\n${buildText}`), + demuxers: formats.demuxers, + muxers: formats.muxers, + decoders: codecs.decoders, + encoders: codecs.encoders, + filters: parseFilters(filtersText), + }; +} diff --git a/src/ffmpeg/capability-parser.ts b/src/ffmpeg/capability-parser.ts new file mode 100644 index 0000000..f1b4996 --- /dev/null +++ b/src/ffmpeg/capability-parser.ts @@ -0,0 +1,109 @@ +import type { + FFmpegCapabilities, + SerializableFFmpegCapabilities, +} from './ffmpeg.types'; + +function normalizedLines(text: string): string[] { + return text + .replaceAll('\r', '') + .split('\n') + .map((line) => line.trimEnd()); +} + +export function parseFormats(text: string): { + demuxers: Set; + muxers: Set; +} { + const demuxers = new Set(); + const muxers = new Set(); + + for (const line of normalizedLines(text)) { + const match = /^ ([D ])([E ])\s+([^\s]+)\s/.exec(line); + if (!match) continue; + const names = (match[3] ?? '') + .split(',') + .filter((name) => /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(name)); + for (const name of names) { + if (match[1] === 'D') demuxers.add(name); + if (match[2] === 'E') muxers.add(name); + } + } + return { demuxers, muxers }; +} + +export function parseCodecs(text: string): { + decoders: Set; + encoders: Set; +} { + const decoders = new Set(); + const encoders = new Set(); + for (const line of normalizedLines(text)) { + const match = /^ ([D.])([E.])[VASD.TILX]{4}\s+([^\s]+)\s/.exec(line); + if (!match) continue; + const name = match[3]; + if (!name || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/u.test(name)) continue; + if (match[1] === 'D') decoders.add(name); + if (match[2] === 'E') encoders.add(name); + } + return { decoders, encoders }; +} + +export function parseNamedComponents(text: string): Set { + const names = new Set(); + for (const line of normalizedLines(text)) { + const match = /^ [A-Z.]{1,8}\s+([^\s=]+)\s/.exec(line); + if (match?.[1]) names.add(match[1]); + } + return names; +} + +export function parseFilters(text: string): Set { + const names = new Set(); + for (const line of normalizedLines(text)) { + const match = /^ [TSC.]{3}\s+([^\s]+)\s+[AVN|]+->[AVN|]+/.exec(line); + if (match?.[1]) names.add(match[1]); + } + return names; +} + +export function parseBuildConfiguration(text: string): string[] { + const line = normalizedLines(text).find((entry) => + entry.trimStart().startsWith('configuration:') + ); + if (!line) return []; + return line + .slice(line.indexOf(':') + 1) + .trim() + .split(/\s+/) + .filter(Boolean); +} + +export function serializeCapabilities( + capabilities: FFmpegCapabilities +): SerializableFFmpegCapabilities { + const sorted = (values: Set) => + [...values].sort((left, right) => left.localeCompare(right, 'en')); + return { + versionText: capabilities.versionText, + buildConfiguration: [...capabilities.buildConfiguration], + demuxers: sorted(capabilities.demuxers), + muxers: sorted(capabilities.muxers), + decoders: sorted(capabilities.decoders), + encoders: sorted(capabilities.encoders), + filters: sorted(capabilities.filters), + }; +} + +export function deserializeCapabilities( + capabilities: SerializableFFmpegCapabilities +): FFmpegCapabilities { + return { + versionText: capabilities.versionText, + buildConfiguration: [...capabilities.buildConfiguration], + demuxers: new Set(capabilities.demuxers), + muxers: new Set(capabilities.muxers), + decoders: new Set(capabilities.decoders), + encoders: new Set(capabilities.encoders), + filters: new Set(capabilities.filters), + }; +} diff --git a/src/ffmpeg/engine-lifecycle.ts b/src/ffmpeg/engine-lifecycle.ts new file mode 100644 index 0000000..e122937 --- /dev/null +++ b/src/ffmpeg/engine-lifecycle.ts @@ -0,0 +1,55 @@ +import { engineManager } from './EngineManager'; + +export interface DisposableRuntimeResource { + dispose(): void; +} + +type DeferredCallback = (callback: () => void) => void; + +/** + * Reference-counts an application-wide runtime resource. Final disposal is + * deferred by one microtask so React StrictMode's development cleanup/remount + * cycle can reacquire the same singleton without terminating a healthy engine. + */ +export class DeferredResourceLifecycle { + readonly #resource: DisposableRuntimeResource; + readonly #defer: DeferredCallback; + #leaseCount = 0; + #generation = 0; + + constructor( + resource: DisposableRuntimeResource, + defer: DeferredCallback = globalThis.queueMicrotask.bind(globalThis) + ) { + this.#resource = resource; + this.#defer = defer; + } + + get leaseCount(): number { + return this.#leaseCount; + } + + acquire(): () => void { + this.#leaseCount += 1; + this.#generation += 1; + let active = true; + + return () => { + if (!active) return; + active = false; + this.#leaseCount -= 1; + const releaseGeneration = ++this.#generation; + if (this.#leaseCount !== 0) return; + + this.#defer(() => { + if (this.#leaseCount === 0 && this.#generation === releaseGeneration) { + this.#resource.dispose(); + } + }); + }; + } +} + +export const engineManagerLifecycle = new DeferredResourceLifecycle( + engineManager +); diff --git a/src/ffmpeg/engine-mode.ts b/src/ffmpeg/engine-mode.ts new file mode 100644 index 0000000..72efbc5 --- /dev/null +++ b/src/ffmpeg/engine-mode.ts @@ -0,0 +1,40 @@ +import type { + EngineEnvironment, + EngineMode, + EnginePreference, +} from './ffmpeg.types'; + +export function detectEngineEnvironment( + scope: Window = window +): EngineEnvironment { + return { + secureContext: scope.isSecureContext === true, + crossOriginIsolated: scope.crossOriginIsolated === true, + sharedArrayBuffer: typeof globalThis.SharedArrayBuffer !== 'undefined', + hardwareConcurrency: scope.navigator.hardwareConcurrency, + }; +} + +export function supportsMultithread(environment: EngineEnvironment): boolean { + return ( + environment.secureContext && + environment.crossOriginIsolated && + environment.sharedArrayBuffer + ); +} + +export function selectEngineMode( + environment: EngineEnvironment, + preference: EnginePreference +): EngineMode { + if (preference === 'force-single-thread') { + return 'single-thread'; + } + return supportsMultithread(environment) ? 'multithread' : 'single-thread'; +} + +export function describeEngineMode(mode: EngineMode): string { + return mode === 'multithread' + ? 'Multithreaded' + : 'Single-threaded compatibility mode'; +} diff --git a/src/ffmpeg/ffmpeg-error.ts b/src/ffmpeg/ffmpeg-error.ts new file mode 100644 index 0000000..01acce8 --- /dev/null +++ b/src/ffmpeg/ffmpeg-error.ts @@ -0,0 +1,38 @@ +import type { EngineErrorShape } from './ffmpeg.types'; + +export class EngineError extends Error implements EngineErrorShape { + readonly code: EngineErrorShape['code']; + readonly details?: string; + readonly exitCode?: number; + + constructor( + code: EngineErrorShape['code'], + message: string, + options: { + cause?: unknown; + details?: string; + exitCode?: number; + } = {} + ) { + super(message, { cause: options.cause }); + this.name = 'EngineError'; + this.code = code; + this.details = options.details; + this.exitCode = options.exitCode; + } +} + +export function toEngineError(error: unknown): EngineError { + if (error instanceof EngineError) { + return error; + } + if (error instanceof Error) { + return new EngineError('execution-failed', error.message, { + cause: error, + details: error.stack, + }); + } + return new EngineError('execution-failed', 'Unknown FFmpeg error', { + details: String(error), + }); +} diff --git a/src/ffmpeg/ffmpeg.types.ts b/src/ffmpeg/ffmpeg.types.ts new file mode 100644 index 0000000..abc8c9f --- /dev/null +++ b/src/ffmpeg/ffmpeg.types.ts @@ -0,0 +1,177 @@ +import type { FFmpeg, FileData, LogEvent, ProgressEvent } from '@ffmpeg/ffmpeg'; + +export type EngineMode = 'multithread' | 'single-thread'; +export type EngineInputMode = 'workerfs' | 'memory-copy'; +export type EnginePreference = + 'automatic' | 'prefer-multithread' | 'force-single-thread'; + +export interface EngineEnvironment { + secureContext: boolean; + crossOriginIsolated: boolean; + sharedArrayBuffer: boolean; + hardwareConcurrency?: number; +} + +export interface FFmpegCapabilities { + versionText: string; + buildConfiguration: string[]; + demuxers: Set; + muxers: Set; + decoders: Set; + encoders: Set; + filters: Set; +} + +export interface SerializableFFmpegCapabilities { + versionText: string; + buildConfiguration: string[]; + demuxers: string[]; + muxers: string[]; + decoders: string[]; + encoders: string[]; + filters: string[]; +} + +export interface EngineWarning { + code: 'multithread-unavailable' | 'multithread-load-failed'; + message: string; +} + +export type EngineState = + | { status: 'idle' } + | { + status: 'loading'; + mode: EngineMode; + progress?: number; + stage?: string; + } + | { + status: 'ready'; + mode: EngineMode; + capabilities: FFmpegCapabilities; + warning?: EngineWarning; + } + | { + status: 'running'; + mode: EngineMode; + jobId: string; + operation: string; + inputMode: EngineInputMode; + inputBytes: number; + copiedInputBytes: number; + progress?: number; + elapsedSeconds?: number; + speed?: number; + frame?: number; + } + | { + status: 'recovering'; + previousMode: EngineMode; + warning?: EngineWarning; + } + | { status: 'error'; error: EngineErrorShape }; + +export interface EngineErrorShape { + code: + | 'unsupported-environment' + | 'load-failed' + | 'execution-failed' + | 'probe-failed' + | 'cancelled' + | 'busy' + | 'filesystem-failed'; + message: string; + details?: string; + exitCode?: number; +} + +export interface EngineOutputRequest { + name: string; + mimeType: string; +} + +export interface EngineJobContext { + inputPaths: readonly string[]; + temporaryPaths: readonly string[]; + outputPaths: readonly string[]; + workDirectory: string; +} + +export interface EngineTemporaryFileRequest { + name: string; + content: + string | Uint8Array | ((context: EngineJobContext) => string | Uint8Array); +} + +export interface EngineJobRequest { + id: string; + /** Human-readable operation shown in the engine state. */ + operation: string; + /** Stable typed plan operation used for core-lifecycle workload policy. */ + coreWorkload?: string; + inputs: readonly File[]; + temporaryFiles?: readonly EngineTemporaryFileRequest[]; + outputs: readonly EngineOutputRequest[]; + buildArguments: (context: EngineJobContext) => readonly string[]; + expectedDurationSeconds?: number; + timeoutMilliseconds?: number; +} + +export interface EngineJobOutput { + name: string; + mimeType: string; + bytes: Uint8Array; +} + +export interface EngineJobTimings { + /** Time spent inside FFmpeg's exec call. */ + conversionMilliseconds: number; + /** Time spent copying expected outputs out of the WebAssembly filesystem. */ + outputReadMilliseconds: number; + /** Time spent deleting temporary paths and unmounting the job filesystem. */ + cleanupMilliseconds: number; +} + +export interface EngineJobResult { + id: string; + exitCode: 0; + inputMode: EngineInputMode; + inputBytes: number; + copiedInputBytes: number; + outputs: readonly EngineJobOutput[]; + logs: readonly LogEvent[]; + timings: EngineJobTimings; +} + +export interface ProbeResult { + json: unknown; + inputMode: EngineInputMode; + inputBytes: number; + copiedInputBytes: number; + logs: readonly LogEvent[]; +} + +export interface FFmpegAdapter extends Pick< + FFmpeg, + | 'loaded' + | 'load' + | 'exec' + | 'ffprobe' + | 'terminate' + | 'on' + | 'off' + | 'writeFile' + | 'mount' + | 'unmount' + | 'readFile' + | 'listDir' + | 'deleteFile' + | 'createDir' + | 'deleteDir' +> { + readFile(path: string, encoding?: string): Promise; +} + +export type EngineStateListener = (state: EngineState) => void; +export type EngineLogListener = (event: LogEvent) => void; +export type EngineProgressListener = (event: ProgressEvent) => void; diff --git a/src/ffmpeg/load-config.ts b/src/ffmpeg/load-config.ts new file mode 100644 index 0000000..3956615 --- /dev/null +++ b/src/ffmpeg/load-config.ts @@ -0,0 +1,7 @@ +import type { FFMessageLoadConfig } from '@ffmpeg/ffmpeg'; +import { getCoreAssetUrls } from './asset-urls'; +import type { EngineMode } from './ffmpeg.types'; + +export function createLoadConfig(mode: EngineMode): FFMessageLoadConfig { + return getCoreAssetUrls(mode); +} diff --git a/src/ffmpeg/log-buffer.ts b/src/ffmpeg/log-buffer.ts new file mode 100644 index 0000000..e8365c5 --- /dev/null +++ b/src/ffmpeg/log-buffer.ts @@ -0,0 +1,43 @@ +import type { LogEvent } from '@ffmpeg/ffmpeg'; + +export class LogBuffer { + readonly #maximumEntries: number; + readonly #maximumCharacters: number; + #entries: LogEvent[] = []; + #characters = 0; + + constructor(maximumEntries = 2_000, maximumCharacters = 500_000) { + this.#maximumEntries = maximumEntries; + this.#maximumCharacters = maximumCharacters; + } + + append(event: LogEvent): void { + const safeEvent = { + type: event.type, + message: event.message.replaceAll('\u0000', ''), + }; + this.#entries.push(safeEvent); + this.#characters += safeEvent.message.length; + + while ( + this.#entries.length > this.#maximumEntries || + this.#characters > this.#maximumCharacters + ) { + const removed = this.#entries.shift(); + this.#characters -= removed?.message.length ?? 0; + } + } + + clear(): void { + this.#entries = []; + this.#characters = 0; + } + + snapshot(): readonly LogEvent[] { + return this.#entries.map((event) => ({ ...event })); + } + + text(): string { + return this.#entries.map(({ message }) => message).join('\n'); + } +} diff --git a/src/ffmpeg/progress-parser.ts b/src/ffmpeg/progress-parser.ts new file mode 100644 index 0000000..d72bc59 --- /dev/null +++ b/src/ffmpeg/progress-parser.ts @@ -0,0 +1,14 @@ +import type { ProgressEvent } from '@ffmpeg/ffmpeg'; + +export function normalizeProgress(event: ProgressEvent): ProgressEvent { + const finiteProgress = Number.isFinite(event.progress) ? event.progress : 0; + const finiteTime = Number.isFinite(event.time) ? event.time : 0; + return { + progress: Math.min(1, Math.max(0, finiteProgress)), + time: Math.max(0, finiteTime), + }; +} + +export function progressPercent(event: ProgressEvent): number { + return Math.round(normalizeProgress(event).progress * 100); +} diff --git a/src/ffmpeg/virtual-fs.ts b/src/ffmpeg/virtual-fs.ts new file mode 100644 index 0000000..99fed25 --- /dev/null +++ b/src/ffmpeg/virtual-fs.ts @@ -0,0 +1,191 @@ +import type { FFFSType } from '@ffmpeg/ffmpeg'; +import { EngineError } from './ffmpeg-error'; +import type { EngineInputMode, FFmpegAdapter } from './ffmpeg.types'; + +const WORKER_FS = 'WORKERFS' as FFFSType; +const DEFAULT_COPY_LIMIT_BYTES = 256 * 1024 * 1024; + +function safeJobId(jobId: string): string { + const normalized = jobId + .replaceAll(/[^a-zA-Z0-9_-]/g, '-') + .replaceAll(/-+/g, '-') + .replaceAll(/^[-_]+|[-_]+$/g, '') + .slice(0, 80); + return normalized || 'anonymous'; +} + +function safeExtension(fileName: string): string { + const match = /\.([a-zA-Z0-9]{1,10})$/.exec(fileName); + return match?.[1]?.toLowerCase() ?? 'bin'; +} + +async function createDirIfNeeded( + ffmpeg: FFmpegAdapter, + path: string +): Promise { + try { + await ffmpeg.createDir(path); + } catch { + // Emscripten exposes no portable "exists" primitive. Creating an existing + // parent is harmless, and a later mount/write will surface real failures. + } +} + +async function deleteFileIfPresent( + ffmpeg: FFmpegAdapter, + path: string +): Promise { + try { + await ffmpeg.deleteFile(path); + } catch { + // Cleanup is intentionally idempotent. + } +} + +async function deleteDirIfEmpty( + ffmpeg: FFmpegAdapter, + path: string +): Promise { + try { + await ffmpeg.deleteDir(path); + } catch { + // Parent or a terminated filesystem may already be gone. + } +} + +export interface MountedJobFiles { + inputPaths: readonly string[]; + outputPaths: readonly string[]; + workDirectory: string; + inputMode: EngineInputMode; + /** Total size of every mounted source. */ + inputBytes: number; + /** Exact bytes copied into MEMFS; always zero for WORKERFS. */ + copiedInputBytes: number; + /** + * Returns false when the per-job directories can still be observed after + * cleanup. Callers must discard that engine instance in that case. + */ + cleanup: () => Promise; +} + +export async function mountJobFiles( + ffmpeg: FFmpegAdapter, + jobId: string, + files: readonly File[], + outputNames: readonly string[], + copyLimitBytes = DEFAULT_COPY_LIMIT_BYTES +): Promise { + const id = safeJobId(jobId); + const inputDirectory = `/input/job-${id}`; + const workDirectory = `/work/job-${id}`; + const inputPaths = files.map( + (file, index) => + `${inputDirectory}/source-${index}.${safeExtension(file.name)}` + ); + const outputPaths = outputNames.map((name, index) => { + const extension = safeExtension(name); + return `${workDirectory}/output-${index}.${extension}`; + }); + const inputBytes = files.reduce((total, file) => total + file.size, 0); + + await createDirIfNeeded(ffmpeg, '/input'); + await createDirIfNeeded(ffmpeg, '/work'); + await createDirIfNeeded(ffmpeg, inputDirectory); + await createDirIfNeeded(ffmpeg, workDirectory); + + let mounted = false; + let inputMode: MountedJobFiles['inputMode'] = 'workerfs'; + try { + await ffmpeg.mount( + WORKER_FS, + { + blobs: files.map((file, index) => ({ + name: inputPaths[index]?.split('/').at(-1) ?? `source-${index}.bin`, + data: file, + })), + }, + inputDirectory + ); + mounted = true; + } catch (mountError) { + inputMode = 'memory-copy'; + if (inputBytes > copyLimitBytes) { + throw new EngineError( + 'filesystem-failed', + 'This browser could not mount the source file without copying it, and the file exceeds the compatibility-mode memory limit.', + { + cause: mountError, + details: `${inputBytes} bytes exceeds ${copyLimitBytes} bytes`, + } + ); + } + try { + for (const [index, file] of files.entries()) { + const path = inputPaths[index]; + if (path) { + await ffmpeg.writeFile( + path, + new Uint8Array(await file.arrayBuffer()) + ); + } + } + } catch (copyError) { + throw new EngineError( + 'filesystem-failed', + 'The input could not be copied into the FFmpeg virtual filesystem.', + { cause: copyError } + ); + } + } + + return { + inputPaths, + outputPaths, + workDirectory, + inputMode, + inputBytes, + copiedInputBytes: inputMode === 'memory-copy' ? inputBytes : 0, + cleanup: async () => { + for (const path of outputPaths) await deleteFileIfPresent(ffmpeg, path); + if (mounted) { + try { + await ffmpeg.unmount(inputDirectory); + } catch { + // The engine may have been terminated by cancellation. + } + } else { + for (const path of inputPaths) await deleteFileIfPresent(ffmpeg, path); + } + await deleteDirIfEmpty(ffmpeg, inputDirectory); + await deleteDirIfEmpty(ffmpeg, workDirectory); + try { + const [inputEntries, workEntries] = await Promise.all([ + ffmpeg.listDir('/input'), + ffmpeg.listDir('/work'), + ]); + const inputName = inputDirectory.split('/').at(-1); + const workName = workDirectory.split('/').at(-1); + return ( + !inputEntries.some((entry) => entry.name === inputName) && + !workEntries.some((entry) => entry.name === workName) + ); + } catch { + // A terminated engine has no inspectable filesystem. Its caller owns + // recovery; a live caller conservatively recreates the instance. + return false; + } + }, + }; +} + +export function describeMountedInputAccess( + mounted: Pick< + MountedJobFiles, + 'inputMode' | 'inputBytes' | 'copiedInputBytes' + > +): string { + return mounted.inputMode === 'workerfs' + ? `Input access: ${mounted.inputBytes} bytes mounted read-only through WORKERFS; 0 bytes copied into engine memory.` + : `Input compatibility fallback: WORKERFS mounting was unavailable, so ${mounted.copiedInputBytes} bytes were copied into engine memory.`; +} diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..c1b1bbe --- /dev/null +++ b/src/index.css @@ -0,0 +1,2740 @@ +:root { + font-family: var(--toolbox-font, Inter, ui-sans-serif, system-ui, sans-serif); + font-synthesis: none; + text-rendering: optimizeLegibility; +} + +* { + box-sizing: border-box; +} + +html { + min-width: 320px; + min-height: 100%; + background: var(--toolbox-background); +} + +body { + min-width: 320px; + min-height: 100vh; + margin: 0; +} + +button, +input, +select, +textarea { + color: inherit; + font: inherit; +} + +button, +select, +input[type='checkbox'], +input[type='file'] { + accent-color: var(--toolbox-accent); +} + +button:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible, +summary:focus-visible, +a:focus-visible { + outline: 3px solid var(--toolbox-focus); + outline-offset: 2px; +} + +button:disabled, +input:disabled, +select:disabled { + cursor: not-allowed; + opacity: 0.52; +} + +.toolbox-shell__main { + min-width: 0; +} + +.av-app { + width: 100%; + display: grid; + gap: 1rem; + padding-block: 1rem 2.75rem; + color: var(--toolbox-text); +} + +.av-app h2, +.av-app h3, +.av-app p { + margin-block-start: 0; +} + +.av-app h2 { + margin-block-end: 0; + font-size: 1.05rem; + line-height: 1.2; +} + +.av-app h3 { + margin: 1.1rem 0 0.65rem; + font-size: 0.82rem; + letter-spacing: 0.01em; +} + +.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; +} + +.eyebrow { + margin: 0 0 0.22rem; + color: var(--toolbox-muted); + font-size: 0.65rem; + font-weight: 800; + letter-spacing: 0.11em; + line-height: 1.2; + text-transform: uppercase; +} + +.panel { + min-width: 0; + border: 1px solid var(--toolbox-border); + border-radius: calc(var(--toolbox-radius) + 0.25rem); + background: var(--toolbox-surface); + box-shadow: 0 8px 25px rgb(24 34 68 / 5%); +} + +.panel__header { + min-height: 3.75rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.88rem 1rem; + border-bottom: 1px solid var(--toolbox-border); +} + +.panel__header > span:not(.privacy-badge) { + min-width: 1.75rem; + min-height: 1.75rem; + display: inline-grid; + place-items: center; + padding-inline: 0.45rem; + border-radius: 999px; + background: var(--toolbox-surface-soft); + color: var(--toolbox-muted); + font-size: 0.72rem; + font-weight: 800; +} + +.button, +.icon-action, +.text-button, +.segmented button, +.inspector__tabs button, +.timeline-clip__main, +.timeline-clip__actions button { + border: 0; + cursor: pointer; +} + +.button { + min-height: 2.5rem; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.45rem; + padding: 0.58rem 0.86rem; + border: 1px solid transparent; + border-radius: var(--toolbox-radius); + font-size: 0.78rem; + font-weight: 750; + line-height: 1; + text-decoration: none; +} + +.button svg { + width: 1rem; + height: 1rem; +} + +.button--primary { + border-color: var(--toolbox-accent); + background: var(--toolbox-accent); + color: var(--toolbox-accent-contrast); +} + +.button--primary:hover:not(:disabled) { + border-color: var(--toolbox-accent-hover); + background: var(--toolbox-accent-hover); +} + +.button--secondary { + border-color: var(--toolbox-border); + background: var(--toolbox-surface); + color: var(--toolbox-accent); +} + +.button--secondary:hover:not(:disabled), +.button--quiet:hover:not(:disabled) { + border-color: color-mix( + in srgb, + var(--toolbox-accent) 35%, + var(--toolbox-border) + ); + background: var(--toolbox-accent-soft); +} + +.button--quiet { + min-height: 2.25rem; + border-color: transparent; + background: transparent; + color: var(--toolbox-muted); +} + +.button--large { + min-height: 2.9rem; + padding-inline: 1.1rem; + font-size: 0.84rem; +} + +.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); +} + +.icon-action { + width: 2.25rem; + height: 2.25rem; + display: inline-grid; + flex: none; + place-items: center; + border: 1px solid transparent; + border-radius: 0.58rem; + background: transparent; + color: var(--toolbox-muted); +} + +.icon-action svg { + width: 1rem; + height: 1rem; +} + +.icon-action:hover:not(:disabled) { + border-color: var(--toolbox-border); + background: var(--toolbox-surface-soft); + color: var(--toolbox-danger); +} + +.workspace-toolbar { + min-height: 3.4rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.48rem; + border: 1px solid var(--toolbox-border); + border-radius: calc(var(--toolbox-radius) + 0.12rem); + background: color-mix( + in srgb, + var(--toolbox-surface) 88%, + var(--toolbox-background) + ); +} + +.segmented { + display: inline-flex; + gap: 0.2rem; + padding: 0.18rem; + border: 1px solid var(--toolbox-border); + border-radius: var(--toolbox-radius); + background: var(--toolbox-surface-soft); +} + +.segmented button { + min-height: 2.25rem; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + padding: 0.42rem 0.82rem; + border-radius: calc(var(--toolbox-radius) - 0.16rem); + background: transparent; + color: var(--toolbox-muted); + font-size: 0.75rem; + font-weight: 750; +} + +.segmented button svg { + width: 0.95rem; + height: 0.95rem; +} + +.segmented button.is-active { + background: var(--toolbox-surface); + color: var(--toolbox-accent); + box-shadow: 0 2px 8px rgb(24 34 68 / 9%); +} + +.segmented--compact { + width: calc(100% - 2rem); + margin: 1rem 1rem 0; +} + +.segmented--compact button { + flex: 1; +} + +.project-actions { + min-width: 0; + display: flex; + align-items: center; + gap: 0.15rem; +} + +.project-actions > span { + max-width: 15rem; + padding-inline: 0.65rem; + overflow: hidden; + color: var(--toolbox-muted); + font-size: 0.76rem; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.project-notice { + display: flex; + align-items: center; + gap: 0.55rem; + margin: 0; + padding: 0.65rem 0.85rem; + border: 1px solid + color-mix(in srgb, var(--toolbox-accent) 20%, var(--toolbox-border)); + border-radius: var(--toolbox-radius); + background: var(--toolbox-accent-soft); + color: var(--toolbox-accent); + font-size: 0.76rem; +} + +.project-notice svg { + width: 1rem; + height: 1rem; + flex: none; +} + +.waveform-progress { + display: grid; + grid-template-columns: auto minmax(10rem, auto) minmax(8rem, 1fr) auto; +} + +.waveform-progress progress { + width: 100%; +} + +.quick-workspace { + display: grid; + grid-template-columns: + minmax(15rem, 0.78fr) + minmax(21rem, 1.25fr) + minmax(18rem, 0.92fr); + align-items: start; + gap: 1rem; +} + +.quick-workspace__source { + min-width: 0; + display: grid; + gap: 1rem; +} + +.drop-zone { + min-height: 13.3rem; + display: grid; + align-content: center; + justify-items: center; + gap: 0.8rem; + padding: 1.3rem; + border: 1.5px dashed + color-mix(in srgb, var(--toolbox-accent) 28%, var(--toolbox-border)); + border-radius: calc(var(--toolbox-radius) + 0.25rem); + background: + radial-gradient( + circle at 50% 15%, + color-mix(in srgb, var(--toolbox-accent-soft) 70%, transparent), + transparent 56% + ), + var(--toolbox-surface); + text-align: center; + transition: + border-color 160ms ease, + background-color 160ms ease, + transform 160ms ease; +} + +.drop-zone--active { + border-color: var(--toolbox-accent); + background-color: var(--toolbox-accent-soft); + transform: translateY(-2px); +} + +.drop-zone__icon { + width: 3.1rem; + height: 3.1rem; + display: grid; + place-items: center; + border-radius: 1rem; + background: var(--toolbox-accent-soft); + color: var(--toolbox-accent); +} + +.drop-zone__icon svg { + width: 1.35rem; + height: 1.35rem; +} + +.drop-zone strong, +.drop-zone span { + display: block; +} + +.drop-zone strong { + font-size: 0.9rem; +} + +.drop-zone span { + max-width: 24rem; + margin-top: 0.25rem; + color: var(--toolbox-muted); + font-size: 0.7rem; + line-height: 1.5; +} + +.drop-zone--compact { + min-height: auto; + grid-template-columns: auto minmax(0, 1fr); + align-content: initial; + justify-items: start; + gap: 0.6rem 0.75rem; + margin: 0.75rem; + padding: 0.75rem; + text-align: left; +} + +.drop-zone--compact .drop-zone__icon { + width: 2.5rem; + height: 2.5rem; + grid-row: 1 / 3; + border-radius: 0.72rem; +} + +.drop-zone--compact .button { + grid-column: 2; +} + +.quick-media .media-list, +.media-panel .media-list { + max-height: 22rem; + overflow: auto; +} + +.media-list { + display: grid; + gap: 0.28rem; + margin: 0; + padding: 0.55rem; + list-style: none; +} + +.media-list li { + min-width: 0; + display: flex; + align-items: center; + gap: 0.2rem; +} + +.media-card { + min-width: 0; + min-height: 4.25rem; + display: flex; + flex: 1; + align-items: center; + gap: 0.65rem; + padding: 0.55rem; + border: 1px solid transparent; + border-radius: 0.7rem; + background: transparent; + color: var(--toolbox-text); + text-align: left; + cursor: pointer; +} + +.media-card:hover, +.media-card--selected { + border-color: var(--toolbox-border); + background: var(--toolbox-surface-soft); +} + +.media-card--selected { + border-color: color-mix( + in srgb, + var(--toolbox-accent) 34%, + var(--toolbox-border) + ); + box-shadow: inset 3px 0 0 var(--toolbox-accent); +} + +.media-card__type, +.source-summary__icon { + width: 2.45rem; + height: 2.45rem; + display: grid; + flex: none; + place-items: center; + border-radius: 0.65rem; + background: var(--toolbox-accent-soft); + color: var(--toolbox-accent); +} + +.media-card__type svg, +.source-summary__icon svg { + width: 1.1rem; + height: 1.1rem; +} + +.media-card__content { + min-width: 0; + display: grid; + gap: 0.08rem; +} + +.media-card__content strong, +.media-card__content small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.media-card__content strong { + font-size: 0.75rem; +} + +.media-card__content small { + color: var(--toolbox-muted); + font-size: 0.63rem; +} + +.phase { + display: inline-flex; + align-items: center; + gap: 0.3rem; + color: var(--toolbox-muted); +} + +.phase::before { + width: 0.42rem; + height: 0.42rem; + flex: none; + border-radius: 50%; + background: currentColor; + content: ''; +} + +.phase--ready { + color: #2c7d63 !important; +} + +.phase--probing { + color: #8b6a20 !important; +} + +.phase--error { + color: var(--toolbox-danger) !important; +} + +.media-bin__empty { + min-height: 9rem; + display: grid; + align-content: center; + justify-items: center; + gap: 0.55rem; + padding: 1rem; + color: var(--toolbox-muted); + font-size: 0.74rem; + text-align: center; +} + +.media-bin__empty svg { + width: 1.5rem; + height: 1.5rem; + opacity: 0.6; +} + +.quick-panel { + overflow: hidden; +} + +.privacy-badge { + display: inline-flex; + align-items: center; + gap: 0.35rem; + color: var(--toolbox-muted); + font-size: 0.64rem; + font-weight: 700; + white-space: nowrap; +} + +.privacy-badge > span, +.status-dot { + width: 0.48rem; + height: 0.48rem; + border-radius: 50%; + background: #2f9b75; + box-shadow: 0 0 0 3px rgb(47 155 117 / 13%); +} + +.source-summary { + display: flex; + align-items: center; + gap: 0.7rem; + margin: 1rem 1rem 0; + padding: 0.65rem; + border: 1px solid var(--toolbox-border); + border-radius: var(--toolbox-radius); + background: var(--toolbox-surface-soft); +} + +.source-summary > div { + min-width: 0; + display: grid; + flex: 1; + gap: 0.12rem; +} + +.source-summary strong, +.source-summary span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.source-summary strong { + font-size: 0.75rem; +} + +.source-summary span { + color: var(--toolbox-muted); + font-size: 0.64rem; +} + +.panel-empty { + min-height: 6rem; + display: grid; + place-items: center; + margin: 1rem; + padding: 1rem; + border: 1px dashed var(--toolbox-border); + border-radius: var(--toolbox-radius); + color: var(--toolbox-muted); + font-size: 0.75rem; + text-align: center; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; + padding: 1rem; +} + +.field { + min-width: 0; + display: grid; + align-content: start; + gap: 0.3rem; +} + +.field > span, +.stream-picker legend, +.subpanel legend { + color: var(--toolbox-muted); + font-size: 0.65rem; + font-weight: 800; + letter-spacing: 0.02em; +} + +.field input, +.field select, +.chapter-list input, +.subtitle-list select, +.subtitle-list input:not([type='checkbox']) { + width: 100%; + min-width: 0; + min-height: 2.45rem; + border: 1px solid var(--toolbox-border); + border-radius: 0.55rem; + padding: 0.5rem 0.62rem; + background: var(--toolbox-surface); + color: var(--toolbox-text); + font-size: 0.75rem; +} + +.field input:hover, +.field select:hover { + border-color: color-mix( + in srgb, + var(--toolbox-accent) 35%, + var(--toolbox-border) + ); +} + +.field small { + min-height: 1.2em; + color: var(--toolbox-muted); + font-size: 0.62rem; + line-height: 1.4; +} + +.field--wide { + grid-column: 1 / -1; +} + +.check-field, +.switch-field { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--toolbox-text); + font-size: 0.7rem; + font-weight: 650; +} + +.check-field input, +.switch-field input { + width: 1rem; + height: 1rem; + flex: none; + margin: 0; +} + +.stream-picker, +.subpanel { + min-width: 0; + display: grid; + gap: 0.3rem; + margin: 0; + padding: 0.7rem; + border: 1px solid var(--toolbox-border); + border-radius: 0.65rem; +} + +.stream-picker label { + min-width: 0; + display: grid; + grid-template-columns: auto auto minmax(0, 1fr); + align-items: center; + gap: 0.4rem; + padding: 0.35rem 0.42rem; + border-radius: 0.45rem; + font-size: 0.67rem; +} + +.stream-picker label:hover { + background: var(--toolbox-surface-soft); +} + +.stream-picker label:has(input:disabled) { + cursor: not-allowed; + opacity: 0.72; +} + +.stream-picker label small { + display: block; + margin-block-start: 0.12rem; + color: var(--toolbox-warning, #a96700); + font-size: 0.58rem; + line-height: 1.35; +} + +.stream-picker p { + margin: 0; + color: var(--toolbox-muted); + font-size: 0.68rem; +} + +.stream-kind { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.14rem 0.35rem; + border-radius: 999px; + background: var(--toolbox-accent-soft); + color: var(--toolbox-accent); + font-size: 0.55rem; + font-weight: 850; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.notice { + display: flex; + align-items: flex-start; + gap: 0.5rem; + margin: 0.65rem 1rem; + padding: 0.65rem 0.72rem; + border: 1px solid var(--toolbox-border); + border-radius: 0.6rem; + font-size: 0.68rem; + line-height: 1.45; +} + +.notice svg { + width: 1rem; + height: 1rem; + flex: none; + margin-top: 0.02rem; +} + +.notice--warning { + border-color: #d5bc78; + background: color-mix(in srgb, #d5bc78 12%, var(--toolbox-surface)); + color: color-mix(in srgb, #9b6d00 76%, var(--toolbox-text)); +} + +.notice--error { + border-color: color-mix( + in srgb, + var(--toolbox-danger) 35%, + var(--toolbox-border) + ); + background: color-mix( + in srgb, + var(--toolbox-danger) 7%, + var(--toolbox-surface) + ); + color: var(--toolbox-danger); +} + +.notice > div { + display: grid; + gap: 0.15rem; +} + +.notice > div span { + color: inherit; +} + +.quick-panel__footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.8rem 1rem; + border-top: 1px solid var(--toolbox-border); + background: var(--toolbox-surface-soft); +} + +.quick-panel__footer > div { + min-width: 0; + display: grid; + gap: 0.12rem; +} + +.quick-panel__footer strong { + overflow: hidden; + font-size: 0.68rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.quick-panel__footer span { + color: var(--toolbox-muted); + font-size: 0.6rem; +} + +.quick-preview, +.preview-panel { + overflow: hidden; +} + +.preview { + min-width: 0; +} + +.preview__stage { + min-height: 16rem; + display: grid; + place-items: center; + overflow: hidden; + background: + linear-gradient(45deg, rgb(255 255 255 / 2%) 25%, transparent 25%), + linear-gradient(-45deg, rgb(255 255 255 / 2%) 25%, transparent 25%), #111722; + background-position: + 0 0, + 8px 8px; + background-size: 16px 16px; +} + +.preview__stage video, +.preview__stage img { + width: 100%; + max-height: 25rem; + display: block; + object-fit: contain; +} + +.preview__caption { + min-height: 2.75rem; + display: flex; + align-items: center; + gap: 0.55rem; + padding: 0.6rem 0.75rem; + border-top: 1px solid var(--toolbox-border); + color: var(--toolbox-muted); + font-size: 0.62rem; + line-height: 1.4; +} + +.preview__caption > span:nth-child(2) { + min-width: 0; + flex: 1; +} + +.preview-proxy-controls { + display: grid; + grid-template-columns: minmax(12rem, 1fr) repeat(2, minmax(7rem, auto)) auto; + align-items: end; + gap: 0.55rem; + padding: 0.65rem 0.75rem; + border-top: 1px solid var(--toolbox-border); + background: var(--toolbox-surface-soft); +} + +.preview-proxy-controls--audio { + grid-template-columns: minmax(12rem, 1fr) minmax(7rem, auto) auto; +} + +.preview-proxy-controls > div, +.preview-proxy-controls label { + min-width: 0; + display: grid; + gap: 0.18rem; +} + +.preview-proxy-controls strong { + font-size: 0.67rem; +} + +.preview-proxy-controls span { + color: var(--toolbox-muted); + font-size: 0.58rem; + line-height: 1.35; +} + +.preview-proxy-controls input, +.preview-proxy-controls select { + min-height: 2.2rem; + padding: 0.38rem 0.5rem; + border: 1px solid var(--toolbox-border); + border-radius: calc(var(--toolbox-radius) - 0.2rem); + background: var(--toolbox-surface); + color: var(--toolbox-text); + font: inherit; + font-size: 0.65rem; +} + +.text-button { + padding: 0; + background: transparent; + color: var(--toolbox-accent); + font-size: inherit; + font-weight: 750; + text-align: right; +} + +.preview-empty { + min-height: 18.75rem; + display: grid; + align-content: center; + justify-items: center; + gap: 1rem; + padding: 1.5rem; + color: var(--toolbox-muted); + font-size: 0.72rem; + text-align: center; +} + +.preview-empty__graphic { + position: relative; + width: 7rem; + height: 4.4rem; + display: grid; + place-items: center; + border: 1px solid var(--toolbox-border); + border-radius: 0.8rem; + background: var(--toolbox-surface-soft); +} + +.preview-empty__graphic::before, +.preview-empty__graphic::after, +.preview-empty__graphic span { + position: absolute; + inset: 0.45rem; + border: 1px solid var(--toolbox-border); + border-radius: 0.5rem; + content: ''; +} + +.preview-empty__graphic::before { + transform: rotate(-5deg) translate(-0.45rem, -0.25rem); +} + +.preview-empty__graphic::after { + transform: rotate(4deg) translate(0.45rem, -0.15rem); +} + +.preview-empty__graphic svg { + z-index: 1; + width: 1.35rem; + height: 1.35rem; + color: var(--toolbox-accent); +} + +.audio-preview { + width: 100%; + display: grid; + justify-items: center; + gap: 1.5rem; + padding: 2rem 1rem; +} + +.audio-preview__disc { + width: 7rem; + height: 7rem; + display: grid; + place-items: center; + border: 1px solid rgb(255 255 255 / 12%); + border-radius: 50%; + background: + radial-gradient(circle, #111722 0 13%, #5a6a87 14% 16%, transparent 17%), + repeating-radial-gradient(circle, #303b4e 0 3px, #1c2432 4px 7px); + color: #dfe7fa; + box-shadow: 0 18px 30px rgb(0 0 0 / 28%); +} + +.audio-preview__disc svg { + width: 1.5rem; + height: 1.5rem; +} + +.audio-preview__waveform { + --audio-waveform-background: #101b22; + --audio-waveform-wave: #6fd7ca; + --audio-waveform-center: rgb(255 255 255 / 22%); + position: relative; + width: min(100%, 32rem); + height: 7rem; + overflow: hidden; + border: 1px solid rgb(255 255 255 / 12%); + border-radius: calc(var(--toolbox-radius) - 0.15rem); + background: var(--audio-waveform-background); + box-shadow: 0 18px 30px rgb(0 0 0 / 20%); +} + +.audio-preview__waveform canvas { + width: 100%; + height: 7rem; + display: block; +} + +.audio-preview__playhead { + position: absolute; + inset-block: 0; + width: 2px; + transform: translateX(-1px); + background: var(--toolbox-accent); + box-shadow: 0 0 0 1px rgb(0 0 0 / 24%); + pointer-events: none; +} + +.audio-preview audio { + width: min(100%, 32rem); +} + +.preview-unsupported { + display: grid; + justify-items: center; + gap: 0.6rem; + color: #d7deec; + font-size: 0.72rem; +} + +.preview-unsupported svg { + width: 2rem; + height: 2rem; +} + +.preview-unsupported small { + max-width: 30rem; + color: #aeb9cb; + line-height: 1.45; + text-align: center; +} + +.job-panel { + overflow: hidden; +} + +.job-panel__queue-count { + flex: none; + padding: 0.36rem 0.55rem; + border: 1px solid var(--toolbox-border); + border-radius: 999px; + color: var(--toolbox-accent); + font-size: 0.64rem; + font-weight: 800; +} + +.job-panel__body { + display: grid; + gap: 0.75rem; + padding: 0.85rem 1rem; +} + +.job-queue-note { + margin: 0; + padding: 0.55rem 0.65rem; + border: 1px solid var(--toolbox-border); + border-radius: calc(var(--toolbox-radius) - 0.15rem); + background: var(--toolbox-surface-soft); + color: var(--toolbox-muted); + font-size: 0.63rem; + line-height: 1.45; +} + +.job-progress { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 0.35rem 1rem; +} + +.job-progress > div { + display: grid; + gap: 0.12rem; +} + +.job-progress strong { + font-size: 0.76rem; +} + +.job-progress span { + color: var(--toolbox-muted); + font-size: 0.63rem; +} + +.job-progress > span { + color: var(--toolbox-accent); + font-size: 0.75rem; + font-weight: 800; +} + +.job-progress progress, +.job-record progress, +.load-stage progress { + width: 100%; + height: 0.48rem; + grid-column: 1 / -1; + overflow: hidden; + border: 0; + border-radius: 999px; + background: var(--toolbox-surface-soft); +} + +.job-history { + display: grid; + gap: 0.55rem; +} + +.job-history > header, +.job-record__summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.6rem; +} + +.job-history h3 { + margin: 0; + font-size: 0.72rem; +} + +.job-history__list { + display: grid; + gap: 0.45rem; +} + +.job-record { + display: grid; + gap: 0.4rem; + padding: 0.55rem 0.65rem; + border: 1px solid var(--toolbox-border); + border-radius: calc(var(--toolbox-radius) - 0.15rem); + background: var(--toolbox-surface-soft); +} + +.job-record__summary > div { + min-width: 0; + display: grid; + gap: 0.1rem; +} + +.job-record__summary strong, +.job-record__summary span, +.job-record details, +.job-record__error { + font-size: 0.62rem; +} + +.job-record__summary span, +.job-record details { + color: var(--toolbox-muted); +} + +.job-record__summary b { + margin-left: auto; + color: var(--toolbox-accent); + font-size: 0.68rem; +} + +.job-record progress { + width: 100%; + height: 0.35rem; +} + +.job-record__error { + color: var(--toolbox-danger); +} + +.job-record details ol { + display: grid; + gap: 0.25rem; + margin: 0.4rem 0 0; + padding-left: 1.1rem; +} + +.job-record details li { + padding-left: 0.1rem; +} + +.job-record details li span:last-child { + float: right; +} + +progress::-webkit-progress-bar { + border-radius: 999px; + background: var(--toolbox-surface-soft); +} + +progress::-webkit-progress-value { + border-radius: 999px; + background: var(--toolbox-accent); +} + +progress::-moz-progress-bar { + border-radius: 999px; + background: var(--toolbox-accent); +} + +.result-card { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.65rem; + border: 1px solid color-mix(in srgb, #2f9b75 30%, var(--toolbox-border)); + border-radius: var(--toolbox-radius); + background: color-mix(in srgb, #2f9b75 6%, var(--toolbox-surface)); +} + +.result-list { + display: grid; + gap: 0.55rem; +} + +.result-select-all { + display: flex; + align-items: center; + gap: 0.45rem; + color: var(--toolbox-muted); + font-size: 0.63rem; + font-weight: 700; +} + +.result-card__select { + flex: none; +} + +.result-card.is-selected { + box-shadow: 0 0 0 2px + color-mix(in srgb, var(--toolbox-accent) 30%, transparent); +} + +.result-card__warning { + color: var(--toolbox-warning, #a96700); + font-size: 0.62rem; + line-height: 1.4; +} + +.result-card__range { + color: var(--toolbox-text); + font-size: 0.62rem; + font-variant-numeric: tabular-nums; + line-height: 1.4; +} + +.result-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.result-card__icon { + width: 2.4rem; + height: 2.4rem; + display: grid; + flex: none; + place-items: center; + border-radius: 50%; + background: #2f9b75; + color: white; +} + +.result-card__icon svg { + width: 1.05rem; + height: 1.05rem; +} + +.result-card > div { + min-width: 0; + display: grid; + flex: 1; + gap: 0.1rem; +} + +.result-card strong { + overflow: hidden; + font-size: 0.76rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.result-card span { + color: var(--toolbox-muted); + font-size: 0.63rem; +} + +.result-verification { + margin-top: 0.35rem; + color: var(--toolbox-muted); + font-size: 0.6rem; +} + +.result-verification summary { + cursor: pointer; + font-weight: 750; +} + +.result-verification dl { + display: grid; + gap: 0.25rem; + margin: 0.35rem 0 0; +} + +.result-verification dl > div { + display: grid; + grid-template-columns: minmax(5rem, 0.3fr) minmax(0, 1fr); + gap: 0.35rem; +} + +.result-verification dt { + font-weight: 750; +} + +.result-verification dd { + margin: 0; + overflow-wrap: anywhere; +} + +.diagnostics, +.inline-details { + min-width: 0; + border: 1px solid var(--toolbox-border); + border-radius: 0.55rem; + background: var(--toolbox-surface-soft); +} + +.diagnostics summary, +.inline-details summary { + padding: 0.58rem 0.68rem; + color: var(--toolbox-muted); + font-size: 0.65rem; + font-weight: 750; + cursor: pointer; +} + +.diagnostics pre { + max-height: 18rem; + margin: 0; + padding: 0.7rem; + overflow: auto; + border-top: 1px solid var(--toolbox-border); + color: var(--toolbox-text); + font: + 0.62rem/1.55 ui-monospace, + SFMono-Regular, + Consolas, + monospace; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.capability-panel { + border: 1px solid var(--toolbox-border); + border-radius: var(--toolbox-radius); + background: var(--toolbox-surface); +} + +.capability-panel > summary { + min-height: 3.25rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.65rem 0.85rem; + cursor: pointer; +} + +.capability-panel > summary > span:first-child { + display: inline-flex; + align-items: center; + gap: 0.5rem; + font-size: 0.72rem; + font-weight: 750; +} + +.capability-panel summary svg { + width: 1rem; + height: 1rem; + color: var(--toolbox-accent); +} + +.engine-pill { + padding: 0.24rem 0.5rem; + border-radius: 999px; + background: var(--toolbox-surface-soft); + color: var(--toolbox-muted); + font-size: 0.6rem; + font-weight: 750; +} + +.engine-pill--ready { + background: color-mix(in srgb, #2f9b75 12%, var(--toolbox-surface)); + color: #2f8064; +} + +.engine-pill--running, +.engine-pill--loading { + background: var(--toolbox-accent-soft); + color: var(--toolbox-accent); +} + +.engine-pill--error { + color: var(--toolbox-danger); +} + +.capability-panel__body { + display: grid; + grid-template-columns: minmax(12rem, 0.5fr) minmax(0, 2fr); + gap: 0.85rem 1rem; + padding: 0.85rem; + border-top: 1px solid var(--toolbox-border); +} + +.capability-panel__body > label { + display: grid; + gap: 0.3rem; + color: var(--toolbox-muted); + font-size: 0.65rem; + font-weight: 750; +} + +.capability-panel__body select { + min-height: 2.4rem; + border: 1px solid var(--toolbox-border); + border-radius: 0.55rem; + padding: 0.45rem 0.6rem; + background: var(--toolbox-surface); +} + +.capability-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.55rem; + margin: 0; +} + +.capability-grid > div { + min-width: 0; + display: grid; + gap: 0.12rem; + padding: 0.55rem; + border-radius: 0.55rem; + background: var(--toolbox-surface-soft); +} + +.capability-grid dt { + color: var(--toolbox-muted); + font-size: 0.57rem; + font-weight: 750; +} + +.capability-grid dd { + margin: 0; + overflow: hidden; + font-size: 0.66rem; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.capability-panel__body > .notice, +.load-stage { + grid-column: 1 / -1; + margin: 0; +} + +.capability-limits, +.capability-build, +.capability-error { + grid-column: 1 / -1; +} + +.capability-limits { + display: grid; + gap: 0.55rem; + padding-top: 0.15rem; +} + +.capability-limits__heading { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.75rem; +} + +.capability-limits__heading h3 { + margin: 0; + font-size: 0.68rem; +} + +.capability-limits__heading span, +.capability-limits > p { + margin: 0; + color: var(--toolbox-muted); + font-size: 0.6rem; +} + +.capability-grid--limits { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.capability-build, +.capability-error > details { + border: 1px solid var(--toolbox-border); + border-radius: 0.55rem; + background: var(--toolbox-surface-soft); +} + +.capability-build > summary, +.capability-error > details > summary { + padding: 0.55rem; + color: var(--toolbox-muted); + font-size: 0.62rem; + font-weight: 750; + cursor: pointer; +} + +.capability-build pre, +.capability-error pre { + max-height: 14rem; + margin: 0; + padding: 0.65rem; + overflow: auto; + border-top: 1px solid var(--toolbox-border); + color: var(--toolbox-text); + font: + 0.6rem/1.5 ui-monospace, + SFMono-Regular, + Consolas, + monospace; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.capability-error { + display: grid; + justify-items: start; + gap: 0.6rem; +} + +.capability-error > .notice { + width: 100%; + margin: 0; +} + +.capability-error > .notice span { + display: grid; + gap: 0.12rem; +} + +.capability-error > dl { + display: flex; + flex-wrap: wrap; + gap: 0.45rem 1rem; + margin: 0; +} + +.capability-error > dl > div { + display: flex; + gap: 0.35rem; + font-size: 0.62rem; +} + +.capability-error dt { + color: var(--toolbox-muted); + font-weight: 750; +} + +.capability-error dd { + margin: 0; + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; +} + +.storage-panel__body { + display: grid; + gap: 0.8rem; + padding: 0.85rem; + border-top: 1px solid var(--toolbox-border); +} + +.cache-list { + display: grid; + gap: 0.3rem; + margin: 0; + padding: 0; + list-style: none; +} + +.cache-list li { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + gap: 0.6rem; + padding: 0.4rem 0.5rem; + border: 1px solid var(--toolbox-border); + border-radius: 0.55rem; + color: var(--toolbox-muted); + font-size: 0.65rem; +} + +.storage-panel__actions { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.storage-panel__actions > span { + color: var(--toolbox-muted); + font-size: 0.65rem; +} + +.load-stage { + display: grid; + gap: 0.35rem; + color: var(--toolbox-muted); + font-size: 0.65rem; +} + +.edit-grid { + min-width: 0; + display: grid; + grid-template-columns: + minmax(13rem, 0.75fr) + minmax(22rem, 1.7fr) + minmax(18rem, 1fr); + align-items: start; + gap: 1rem; +} + +.mobile-edit-drawer-controls, +.mobile-edit-drawer__close { + display: none; +} + +.mobile-edit-drawer__header-actions { + display: flex; + align-items: center; + gap: 0.45rem; +} + +.media-panel, +.preview-panel, +.inspector { + min-width: 0; + min-height: 34rem; +} + +.media-panel { + overflow: hidden; +} + +.preview-panel .preview__stage { + min-height: 28rem; +} + +.inspector { + overflow: hidden; +} + +.inspector__tabs { + display: flex; + gap: 0.1rem; + padding: 0.45rem; + overflow-x: auto; + border-bottom: 1px solid var(--toolbox-border); + scrollbar-width: thin; +} + +.inspector__tabs button { + flex: none; + padding: 0.42rem 0.5rem; + border-radius: 0.42rem; + background: transparent; + color: var(--toolbox-muted); + font-size: 0.59rem; + font-weight: 750; +} + +.inspector__tabs button:hover, +.inspector__tabs button.is-active { + background: var(--toolbox-accent-soft); + color: var(--toolbox-accent); +} + +.inspector__body { + max-height: 29rem; + overflow: auto; +} + +.inspector-section { + display: grid; + gap: 0.65rem; + padding: 0.8rem; +} + +.inspector-section h3 { + margin: 0.45rem 0 0; +} + +.inspector-file { + display: flex; + align-items: center; + gap: 0.65rem; + padding: 0.65rem; + border-radius: 0.65rem; + background: var(--toolbox-surface-soft); +} + +.inspector-file > span { + width: 2.35rem; + height: 2.35rem; + display: grid; + flex: none; + place-items: center; + border-radius: 0.6rem; + background: var(--toolbox-accent-soft); + color: var(--toolbox-accent); +} + +.inspector-file svg { + width: 1.05rem; + height: 1.05rem; +} + +.inspector-file > div { + min-width: 0; + display: grid; + gap: 0.12rem; +} + +.inspector-file strong, +.inspector-file small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.inspector-file strong { + font-size: 0.7rem; +} + +.inspector-file small { + color: var(--toolbox-muted); + font-size: 0.6rem; +} + +.detail-list { + display: grid; + gap: 0; + margin: 0; +} + +.detail-list > div { + display: grid; + grid-template-columns: 5.5rem minmax(0, 1fr); + gap: 0.5rem; + padding: 0.45rem 0; + border-bottom: 1px solid var(--toolbox-border); + font-size: 0.65rem; +} + +.detail-list dt { + color: var(--toolbox-muted); +} + +.detail-list dd { + margin: 0; + overflow-wrap: anywhere; + text-align: right; +} + +.inspector-streams, +.chapter-list, +.subtitle-list { + display: grid; + gap: 0.4rem; + margin: 0; + padding: 0; + list-style: none; +} + +.inspector-streams li { + display: flex; + align-items: center; + gap: 0.55rem; + padding: 0.5rem; + border: 1px solid var(--toolbox-border); + border-radius: 0.55rem; +} + +.inspector-streams li > div { + min-width: 0; + display: grid; + gap: 0.1rem; +} + +.inspector-streams strong { + font-size: 0.64rem; +} + +.inspector-streams small { + color: var(--toolbox-muted); + font-size: 0.58rem; + overflow-wrap: anywhere; +} + +.inline-details ul { + margin: 0; + padding: 0.2rem 1.8rem 0.65rem; + color: var(--toolbox-muted); + font-size: 0.62rem; +} + +.mini-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.6rem; +} + +.section-intro { + margin: 0; + color: var(--toolbox-muted); + font-size: 0.64rem; + line-height: 1.5; +} + +.chapter-list li { + display: grid; + grid-template-columns: minmax(0, 1fr) repeat(3, 4.5rem); + gap: 0.35rem; +} + +.chapter-list__actions { + grid-column: 1 / -1; + display: flex; + justify-content: flex-end; + gap: 0.2rem; +} + +.chapter-editor-actions { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.stream-tag-list { + display: grid; + gap: 0.1rem; + margin: 0.25rem 0 0; +} + +.stream-tag-list > div { + display: grid; + grid-template-columns: minmax(3rem, 0.4fr) minmax(0, 1fr); + gap: 0.35rem; + font-size: 0.56rem; +} + +.stream-tag-list dt { + color: var(--toolbox-muted); +} + +.stream-tag-list dd { + margin: 0; + overflow-wrap: anywhere; +} + +.source-chapter-list { + display: grid; + gap: 0.3rem; + margin: 0; + padding-left: 1.25rem; +} + +.source-chapter-list li { + padding-left: 0.2rem; + font-size: 0.62rem; +} + +.source-chapter-list small { + display: block; + color: var(--toolbox-muted); +} + +.chapter-list input, +.subtitle-list select, +.subtitle-list input:not([type='checkbox']) { + min-height: 2.2rem; + font-size: 0.65rem; +} + +.subtitle-list li { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(7rem, auto); + align-items: center; + gap: 0.5rem; + padding: 0.5rem; + border: 1px solid var(--toolbox-border); + border-radius: 0.55rem; +} + +.subtitle-list li > div { + grid-column: 1 / -1; +} + +.subtitle-list li > input:not([type='checkbox']), +.subtitle-list li > label:not(.check-field) { + grid-column: 1 / -1; +} + +.subtitle-list li > div { + min-width: 0; + display: grid; + gap: 0.12rem; +} + +.subtitle-list strong { + overflow: hidden; + font-size: 0.64rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.subtitle-list small { + color: var(--toolbox-muted); + font-size: 0.57rem; +} + +.file-button { + position: relative; + width: fit-content; +} + +.timeline-panel { + min-width: 0; + overflow: hidden; + border: 1px solid var(--toolbox-border); + border-radius: calc(var(--toolbox-radius) + 0.2rem); + background: var(--toolbox-surface); +} + +.timeline-panel > header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.75rem 0.9rem; + border-bottom: 1px solid var(--toolbox-border); +} + +.timeline-panel > header > span { + color: var(--toolbox-muted); + font-size: 0.65rem; +} + +.timeline-track { + min-height: 8.7rem; + display: flex; + align-items: flex-start; + gap: 0.4rem; + padding: 0.75rem; + overflow-x: auto; + background: + linear-gradient( + to bottom, + transparent 49%, + color-mix(in srgb, var(--toolbox-border) 65%, transparent) 50%, + transparent 51% + ), + var(--toolbox-surface-soft); +} + +.timeline-empty { + min-height: 7rem; + display: grid; + place-items: center; + padding: 1rem; + color: var(--toolbox-muted); + font-size: 0.7rem; +} + +.timeline-clip { + width: 11.5rem; + min-width: 11.5rem; + display: grid; + overflow: hidden; + border: 1px solid var(--toolbox-border); + border-radius: 0.65rem; + background: var(--toolbox-surface); + box-shadow: 0 3px 12px rgb(24 34 68 / 7%); +} + +.timeline-clip.is-selected { + border-color: var(--toolbox-accent); + box-shadow: 0 0 0 2px var(--toolbox-accent-soft); +} + +.timeline-clip__main { + min-width: 0; + display: grid; + grid-template-columns: 2.25rem minmax(0, 1fr); + align-items: center; + gap: 0.5rem; + padding: 0.5rem; + background: transparent; + color: var(--toolbox-text); + text-align: left; +} + +.timeline-clip__thumb { + height: 2.25rem; + display: grid; + place-items: center; + border-radius: 0.45rem; + background: #192231; + color: #dfe6f5; +} + +.timeline-clip__thumb svg { + width: 1rem; + height: 1rem; +} + +.timeline-clip__main > span:last-child { + min-width: 0; + display: grid; + gap: 0.1rem; +} + +.timeline-clip__main strong, +.timeline-clip__main small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.timeline-clip__main strong { + font-size: 0.63rem; +} + +.timeline-clip__main small { + color: var(--toolbox-muted); + font-size: 0.56rem; +} + +.timeline-clip__controls { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.35rem; + padding: 0.45rem; + border-top: 1px solid var(--toolbox-border); + background: var(--toolbox-surface-soft); +} + +.timeline-clip__controls label { + display: grid; + gap: 0.15rem; + color: var(--toolbox-muted); + font-size: 0.52rem; + font-weight: 750; +} + +.timeline-clip__controls input { + width: 100%; + min-width: 0; + height: 1.8rem; + border: 1px solid var(--toolbox-border); + border-radius: 0.35rem; + padding: 0.25rem; + background: var(--toolbox-surface); + font-size: 0.58rem; +} + +.timeline-clip__actions { + display: flex; + grid-column: 1 / -1; + justify-content: flex-end; + gap: 0.2rem; +} + +.timeline-clip__actions button { + width: 1.75rem; + height: 1.75rem; + display: grid; + place-items: center; + border-radius: 0.35rem; + background: var(--toolbox-surface); + color: var(--toolbox-muted); +} + +.timeline-clip__actions .timeline-clip__reset { + width: auto; + padding-inline: 0.45rem; + font-size: 0.55rem; + font-weight: 750; +} + +.timeline-clip__actions button:hover:not(:disabled) { + color: var(--toolbox-accent); +} + +.timeline-clip__actions svg { + width: 0.78rem; + height: 0.78rem; +} + +.edit-export { + display: grid; + overflow: hidden; +} + +.edit-export > header, +.edit-export > footer { + padding: 1rem; +} + +.edit-export > header { + border-bottom: 1px solid var(--toolbox-border); +} + +.edit-export > header h2 { + margin-block-start: 0.2rem; +} + +.edit-export > header p:last-child, +.edit-export > footer p { + max-width: 48rem; + margin: 0.35rem 0 0; + color: var(--toolbox-muted); + font-size: 0.68rem; + line-height: 1.5; +} + +.edit-export__settings { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.8rem; + padding: 1rem; + background: var(--toolbox-surface-soft); +} + +.edit-export__settings .field:first-child, +.edit-export__settings .field:nth-child(2) { + grid-column: span 2; +} + +.edit-export__audio-toggle { + align-self: end; + min-height: 2.55rem; +} + +.edit-export > footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + border-top: 1px solid var(--toolbox-border); +} + +.edit-export > footer > div { + min-width: min(100%, 16rem); + display: grid; + gap: 0.35rem; +} + +.edit-export > footer small { + color: var(--toolbox-danger); + font-size: 0.62rem; + line-height: 1.35; +} + +.modal { + width: min(44rem, calc(100% - 2rem)); + max-height: calc(100vh - 2rem); + padding: 0; + overflow: visible; + border: 1px solid var(--toolbox-border); + border-radius: 1rem; + background: var(--toolbox-surface); + color: var(--toolbox-text); + box-shadow: 0 30px 90px rgb(0 0 0 / 35%); +} + +.modal::backdrop { + background: rgb(10 14 25 / 58%); + backdrop-filter: blur(6px); +} + +.modal__panel { + max-height: calc(100vh - 2rem); + display: grid; + overflow: auto; + border-radius: inherit; +} + +.modal__panel > header, +.modal__panel > footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1rem 1.15rem; +} + +.modal__panel > header { + border-bottom: 1px solid var(--toolbox-border); +} + +.modal__panel > footer { + border-top: 1px solid var(--toolbox-border); +} + +.modal__panel > footer a { + color: var(--toolbox-accent); + font-size: 0.72rem; + font-weight: 750; +} + +.help-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.8rem; + padding: 1.15rem; +} + +.help-grid section { + position: relative; + min-width: 0; + padding: 1rem; + border: 1px solid var(--toolbox-border); + border-radius: 0.75rem; + background: var(--toolbox-surface-soft); +} + +.help-grid h3 { + margin: 0.75rem 0 0.35rem; + font-size: 0.78rem; +} + +.help-grid p { + margin: 0; + color: var(--toolbox-muted); + font-size: 0.66rem; + line-height: 1.55; +} + +.about-licenses { + display: grid; + gap: 0.85rem; + margin: 0 1.15rem 1.15rem; + padding: 1rem; + border: 1px solid var(--toolbox-border); + border-radius: 0.8rem; + background: var(--toolbox-surface-soft); +} + +.about-licenses > header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.about-licenses > header h3 { + margin: 0.1rem 0 0; + font-size: 0.88rem; +} + +.about-licenses > header > span { + flex: none; + padding: 0.3rem 0.48rem; + border: 1px solid var(--toolbox-border); + border-radius: 999px; + color: var(--toolbox-muted); + font-size: 0.58rem; + font-weight: 750; +} + +.about-version-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.45rem; + margin: 0; +} + +.about-version-grid > div { + min-width: 0; + display: grid; + gap: 0.14rem; + padding: 0.58rem; + border: 1px solid var(--toolbox-border); + border-radius: 0.55rem; + background: var(--toolbox-surface); +} + +.about-version-grid dt { + color: var(--toolbox-muted); + font-size: 0.54rem; +} + +.about-version-grid dd { + margin: 0; + overflow-wrap: anywhere; + font-size: 0.65rem; + font-weight: 750; +} + +.about-license-grid { + display: grid; + grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr); + gap: 0.55rem; +} + +.about-license-grid > section { + min-width: 0; + padding: 0.7rem; + border: 1px solid var(--toolbox-border); + border-radius: 0.6rem; + background: var(--toolbox-surface); +} + +.about-license-grid h4 { + margin: 0 0 0.35rem; + font-size: 0.67rem; +} + +.about-license-grid p, +.about-license-grid li { + color: var(--toolbox-muted); + font-size: 0.59rem; + line-height: 1.5; +} + +.about-license-grid p { + margin: 0; +} + +.about-license-grid p strong { + color: var(--toolbox-text); +} + +.about-license-grid ul { + display: grid; + gap: 0.35rem; + margin: 0; + padding-left: 1.1rem; +} + +.about-license-grid li a { + display: inline-block; + margin-left: 0.35rem; +} + +.about-link-list { + display: flex; + flex-wrap: wrap; + gap: 0.35rem 0.7rem; + margin-top: 0.55rem; +} + +.about-license-grid a, +.about-link-list a { + color: var(--toolbox-accent); + font-size: 0.58rem; + font-weight: 750; +} + +.step-number { + width: 1.8rem; + height: 1.8rem; + display: grid; + place-items: center; + border-radius: 0.55rem; + background: var(--toolbox-accent); + color: var(--toolbox-accent-contrast); + font-size: 0.68rem; + font-weight: 850; +} + +.privacy-callout { + display: flex; + align-items: flex-start; + gap: 0.7rem; + margin: 0 1.15rem 1.15rem; + padding: 0.85rem; + border: 1px solid color-mix(in srgb, #2f9b75 28%, var(--toolbox-border)); + border-radius: 0.7rem; + background: color-mix(in srgb, #2f9b75 7%, var(--toolbox-surface)); +} + +.privacy-callout > svg { + width: 1.1rem; + height: 1.1rem; + flex: none; + color: #2f9b75; +} + +.privacy-callout > div { + display: grid; + gap: 0.2rem; +} + +.privacy-callout strong { + font-size: 0.72rem; +} + +.privacy-callout p { + margin: 0; + color: var(--toolbox-muted); + font-size: 0.64rem; + line-height: 1.45; +} + +.fatal-error { + width: min(40rem, calc(100% - 2rem)); + margin: 3rem auto; + padding: 2rem; + border: 1px solid var(--toolbox-border); + border-radius: 1rem; + background: var(--toolbox-surface); + color: var(--toolbox-text); + box-shadow: var(--toolbox-shadow); +} + +.fatal-error h1 { + font-size: 1.5rem; +} + +.fatal-error > p:not(.eyebrow) { + color: var(--toolbox-muted); +} + +.fatal-error button { + min-height: 2.5rem; + padding: 0.5rem 0.8rem; + border: 0; + border-radius: var(--toolbox-radius); + background: var(--toolbox-accent); + color: var(--toolbox-accent-contrast); + font-weight: 750; +} + +@media (max-width: 76rem) { + .quick-workspace { + grid-template-columns: minmax(14rem, 0.65fr) minmax(21rem, 1fr); + } + + .quick-preview { + grid-column: 1 / -1; + } + + .quick-preview .preview__stage { + min-height: 24rem; + } + + .edit-grid { + grid-template-columns: minmax(13rem, 0.7fr) minmax(22rem, 1.5fr); + } + + .inspector { + min-height: auto; + grid-column: 1 / -1; + } + + .inspector__body { + max-height: none; + } +} + +@media (max-width: 48rem) { + .av-app { + padding-block-start: 0.65rem; + } + + .workspace-toolbar { + align-items: stretch; + flex-direction: column; + } + + .segmented { + display: grid; + grid-template-columns: 1fr 1fr; + } + + .project-actions { + display: grid; + grid-template-columns: repeat(3, 1fr); + } + + .project-actions > span { + max-width: none; + grid-column: 1 / -1; + padding-block: 0.35rem; + } + + .quick-workspace, + .edit-grid { + display: grid; + grid-template-columns: minmax(0, 1fr); + } + + .mobile-edit-drawer-controls { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.6rem; + } + + .mobile-edit-drawer-controls .button { + width: 100%; + } + + .mobile-edit-drawer__close { + display: grid; + } + + .edit-grid > .mobile-edit-drawer { + display: none; + } + + .edit-grid > .mobile-edit-drawer.is-open { + position: fixed; + z-index: 120; + inset: 0.75rem; + width: auto; + min-height: 0; + max-height: calc(100dvh - 1.5rem); + display: block; + overflow: auto; + overscroll-behavior: contain; + box-shadow: 0 24px 80px rgb(5 10 24 / 52%); + } + + .edit-grid > .inspector.is-open { + display: grid; + grid-template-rows: auto auto minmax(0, 1fr); + } + + .edit-grid > .inspector.is-open .inspector__body { + min-height: 0; + max-height: none; + } + + .quick-preview, + .inspector { + grid-column: auto; + } + + .preview-panel { + min-height: auto; + grid-row: 1; + } + + .preview-panel .preview__stage, + .quick-preview .preview__stage { + min-height: 14rem; + } + + .media-panel { + min-height: auto; + } + + .quick-panel__footer, + .edit-export > footer, + .result-card { + align-items: stretch; + flex-direction: column; + } + + .preview-proxy-controls { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + } + + .preview-proxy-controls > div, + .preview-proxy-controls > button { + grid-column: 1 / -1; + } + + .preview-proxy-controls > button { + width: 100%; + } + + .quick-panel__footer .button, + .edit-export > footer .button, + .result-card .button { + width: 100%; + } + + .result-card { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + } + + .result-card .button { + grid-column: 1 / -1; + } + + .capability-panel__body, + .capability-grid { + grid-template-columns: minmax(0, 1fr); + } + + .help-grid, + .about-license-grid { + grid-template-columns: 1fr; + } + + .about-version-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .form-grid { + grid-template-columns: minmax(0, 1fr); + } + + .form-grid > * { + grid-column: auto; + } + + .edit-export__settings { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + } + + .edit-export__settings .field:first-child, + .edit-export__settings .field:nth-child(2) { + grid-column: 1 / -1; + } +} + +@media (max-width: 30rem) { + .panel__header { + padding-inline: 0.75rem; + } + + .segmented--compact { + width: calc(100% - 1.5rem); + margin-inline: 0.75rem; + } + + .form-grid { + padding-inline: 0.75rem; + } + + .mini-grid { + grid-template-columns: minmax(0, 1fr); + } + + .about-version-grid { + grid-template-columns: minmax(0, 1fr); + } + + .edit-export__settings { + grid-template-columns: minmax(0, 1fr); + } + + .edit-export__settings > * { + grid-column: auto !important; + } + + .chapter-list li, + .subtitle-list li { + grid-template-columns: minmax(0, 1fr); + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } +} diff --git a/src/jobs/durable-store.ts b/src/jobs/durable-store.ts new file mode 100644 index 0000000..f105946 --- /dev/null +++ b/src/jobs/durable-store.ts @@ -0,0 +1,121 @@ +import type { JobMetadataStore, MediaJob } from './job.types'; + +export class MemoryJobMetadataStore implements JobMetadataStore { + readonly #jobs = new Map(); + + async list(): Promise { + return structuredClone( + [...this.#jobs.values()].sort((left, right) => + left.createdAt.localeCompare(right.createdAt) + ) + ); + } + + async put(job: MediaJob): Promise { + this.#jobs.set(job.id, structuredClone(job)); + } + + async delete(id: string): Promise { + this.#jobs.delete(id); + } + + async clear(): Promise { + this.#jobs.clear(); + } +} + +export interface IndexedDbJobStoreOptions { + readonly databaseName?: string; + readonly storeName?: string; + readonly indexedDB?: IDBFactory; +} + +export class IndexedDbJobMetadataStore implements JobMetadataStore { + readonly #databaseName: string; + readonly #storeName: string; + readonly #factory: IDBFactory; + #database?: Promise; + + constructor(options: IndexedDbJobStoreOptions = {}) { + const factory = options.indexedDB ?? globalThis.indexedDB; + if (!factory) { + throw new Error('IndexedDB is unavailable'); + } + this.#factory = factory; + this.#databaseName = options.databaseName ?? 'av-tools'; + this.#storeName = options.storeName ?? 'media-jobs'; + } + + async list(): Promise { + const request = (await this.#store('readonly')).getAll(); + const result = await requestResult(request); + return Object.freeze( + result.sort((left, right) => + left.createdAt.localeCompare(right.createdAt) + ) + ); + } + + async put(job: MediaJob): Promise { + const store = await this.#store('readwrite'); + const completion = transactionComplete(store.transaction); + const request = store.put(structuredClone(job)); + await requestResult(request); + await completion; + } + + async delete(id: string): Promise { + const store = await this.#store('readwrite'); + const completion = transactionComplete(store.transaction); + await requestResult(store.delete(id)); + await completion; + } + + async clear(): Promise { + const store = await this.#store('readwrite'); + const completion = transactionComplete(store.transaction); + await requestResult(store.clear()); + await completion; + } + + async #store(mode: IDBTransactionMode): Promise { + const database = await (this.#database ??= this.#open()); + return database + .transaction(this.#storeName, mode) + .objectStore(this.#storeName); + } + + #open(): Promise { + return new Promise((resolve, reject) => { + const request = this.#factory.open(this.#databaseName, 1); + request.onupgradeneeded = () => { + if (!request.result.objectStoreNames.contains(this.#storeName)) { + request.result.createObjectStore(this.#storeName, { keyPath: 'id' }); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => + reject(request.error ?? new Error('Could not open IndexedDB')); + request.onblocked = () => + reject(new Error('IndexedDB upgrade is blocked')); + }); + } +} + +function requestResult(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => + reject(request.error ?? new Error('IndexedDB request failed')); + }); +} + +function transactionComplete(transaction: IDBTransaction): Promise { + return new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve(); + transaction.onerror = () => + reject(transaction.error ?? new Error('IndexedDB transaction failed')); + transaction.onabort = () => + reject(transaction.error ?? new Error('IndexedDB transaction aborted')); + }); +} diff --git a/src/jobs/index.ts b/src/jobs/index.ts new file mode 100644 index 0000000..c95ee6a --- /dev/null +++ b/src/jobs/index.ts @@ -0,0 +1,5 @@ +export * from './durable-store'; +export * from './job-queue'; +export * from './job.types'; +export * from './object-url-registry'; +export * from './progress'; diff --git a/src/jobs/job-queue.ts b/src/jobs/job-queue.ts new file mode 100644 index 0000000..9ae58c7 --- /dev/null +++ b/src/jobs/job-queue.ts @@ -0,0 +1,669 @@ +import { weightedJobProgress, validateStepWeights } from './progress'; +import type { + JobMetadataStore, + JobExecutionResult, + JobQueueListener, + JobRecoveryProvider, + MediaJob, + MediaJobDefinition, + MediaJobError, + MediaJobExecutor, + MediaJobStep, + MediaJobStatus, +} from './job.types'; + +export interface MediaJobQueueOptions { + readonly executor: MediaJobExecutor; + readonly store: JobMetadataStore; + readonly recoveryProvider?: JobRecoveryProvider; + readonly now?: () => Date; +} + +export class MediaJobQueue { + readonly #executor: MediaJobExecutor; + readonly #store: JobMetadataStore; + readonly #recoveryProvider?: JobRecoveryProvider; + readonly #now: () => Date; + readonly #listeners = new Set(); + readonly #definitions = new Map(); + readonly #results = new Map(); + readonly #cancellationTasks = new Map>(); + #jobs = new Map(); + #activeJobId?: string; + #activeAbort?: AbortController; + #scheduled = false; + #initialized = false; + #persistenceTail: Promise = Promise.resolve(); + + constructor(options: MediaJobQueueOptions) { + this.#executor = options.executor; + this.#store = options.store; + this.#recoveryProvider = options.recoveryProvider; + this.#now = options.now ?? (() => new Date()); + } + + async initialize(): Promise { + if (this.#initialized) { + return; + } + const stored = await this.#store.list(); + this.#jobs = new Map(stored.map((job) => [job.id, freezeJob(job)])); + for (const job of stored) { + if (isInterruptedStatus(job.status)) { + await this.#recoverPersistedJob(job); + } + } + this.#initialized = true; + this.#emit(); + this.#schedule(); + } + + list(): readonly MediaJob[] { + return Object.freeze( + [...this.#jobs.values()].sort((left, right) => + left.createdAt.localeCompare(right.createdAt) + ) + ); + } + + get(id: string): MediaJob | undefined { + return this.#jobs.get(id); + } + + get activeJobId(): string | undefined { + return this.#activeJobId; + } + + getResults(id: string): readonly JobExecutionResult[] | undefined { + return this.#results.get(id); + } + + releaseResults(id: string): boolean { + return this.#results.delete(id); + } + + subscribe(listener: JobQueueListener): () => void { + this.#listeners.add(listener); + listener(this.list(), this.#activeJobId); + return () => this.#listeners.delete(listener); + } + + async enqueue(definition: MediaJobDefinition): Promise { + this.#assertInitialized(); + validateDefinition(definition); + if (this.#jobs.has(definition.id)) { + throw new RangeError(`Job already exists: ${definition.id}`); + } + this.#definitions.set(definition.id, freezeDefinition(definition)); + const timestamp = this.#timestamp(); + const job = freezeJob({ + id: definition.id, + operation: definition.operation, + status: 'queued', + steps: definition.steps.map((step) => ({ + id: step.id, + name: step.name, + weight: step.weight, + status: 'queued', + progress: 0, + planId: step.plan.id, + })), + progress: 0, + createdAt: timestamp, + updatedAt: timestamp, + retryCount: 0, + }); + await this.#replace(job); + this.#schedule(); + return job; + } + + async cancel(id: string): Promise { + this.#assertInitialized(); + const job = this.#jobs.get(id); + if (!job || isTerminalStatus(job.status)) { + return false; + } + if (job.status === 'queued') { + await this.#update(id, { + status: 'cancelled', + completedAt: this.#timestamp(), + steps: job.steps.map((step) => + step.status === 'queued' ? { ...step, status: 'cancelled' } : step + ), + }); + return true; + } + if (id !== this.#activeJobId) { + return false; + } + const existing = this.#cancellationTasks.get(id); + if (existing) { + await existing; + return true; + } + const task = this.#cancelActive(id); + this.#cancellationTasks.set(id, task); + try { + await task; + } finally { + this.#cancellationTasks.delete(id); + } + return true; + } + + async retry( + id: string, + replacementDefinition?: MediaJobDefinition + ): Promise { + this.#assertInitialized(); + const job = this.#jobs.get(id); + if (!job || (job.status !== 'failed' && job.status !== 'cancelled')) { + throw new RangeError('Only failed or cancelled jobs can be retried'); + } + const definition = replacementDefinition ?? this.#definitions.get(id); + if (!definition) { + throw new Error('The command plans for this job are no longer available'); + } + validateDefinition(definition); + if (definition.id !== id) { + throw new TypeError('Replacement definition must retain the job ID'); + } + this.#definitions.set(id, freezeDefinition(definition)); + this.#results.delete(id); + const retried = await this.#update(id, { + status: 'queued', + progress: 0, + startedAt: undefined, + completedAt: undefined, + error: undefined, + recoveryNote: undefined, + retryCount: job.retryCount + 1, + steps: definition.steps.map((step) => ({ + id: step.id, + name: step.name, + weight: step.weight, + status: 'queued', + progress: 0, + planId: step.plan.id, + })), + }); + this.#schedule(); + return retried; + } + + async remove(id: string): Promise { + this.#assertInitialized(); + const job = this.#jobs.get(id); + if (!job) { + return false; + } + if (!isTerminalStatus(job.status)) { + throw new Error('Active or queued jobs cannot be removed'); + } + this.#jobs.delete(id); + this.#definitions.delete(id); + this.#results.delete(id); + await this.#deletePersisted(id); + this.#emit(); + return true; + } + + async clearCompleted(): Promise { + const removable = this.list().filter((job) => isTerminalStatus(job.status)); + for (const job of removable) { + this.#jobs.delete(job.id); + this.#definitions.delete(job.id); + this.#results.delete(job.id); + await this.#deletePersisted(job.id); + } + this.#emit(); + } + + async waitForIdle(): Promise { + while ( + this.#activeJobId !== undefined || + this.list().some((job) => job.status === 'queued') + ) { + await new Promise((resolve) => { + const unsubscribe = this.subscribe((jobs, activeJobId) => { + if ( + activeJobId === undefined && + !jobs.some((job) => job.status === 'queued') + ) { + unsubscribe(); + resolve(); + } + }); + }); + } + } + + async #recoverPersistedJob(job: MediaJob): Promise { + const recovering = freezeJob({ + ...job, + status: 'recovering', + updatedAt: this.#timestamp(), + recoveryNote: + 'The previous browser session ended before this job reached a terminal state.', + }); + this.#jobs.set(job.id, recovering); + await this.#persist(recovering); + const definition = await this.#recoveryProvider?.restore(recovering); + if (definition) { + validateDefinition(definition); + if (definition.id !== job.id) { + throw new TypeError('Recovered job definition has a mismatched ID'); + } + this.#definitions.set(job.id, freezeDefinition(definition)); + await this.#update(job.id, { + status: 'queued', + progress: 0, + startedAt: undefined, + completedAt: undefined, + error: undefined, + steps: definition.steps.map((step) => ({ + id: step.id, + name: step.name, + weight: step.weight, + status: 'queued', + progress: 0, + planId: step.plan.id, + })), + }); + return; + } + await this.#update(job.id, { + status: 'failed', + completedAt: this.#timestamp(), + error: { + code: 'session-interrupted', + message: + 'The job was interrupted and its source handles or command plans could not be restored.', + recoverable: true, + }, + steps: job.steps.map((step) => + step.status === 'completed' + ? step + : { + ...step, + status: 'failed', + progress: Math.min(step.progress, 0.999), + } + ), + }); + } + + async #run(definition: MediaJobDefinition): Promise { + const id = definition.id; + this.#activeJobId = id; + this.#activeAbort = new AbortController(); + const completedResults: JobExecutionResult[] = []; + this.#emit(); + try { + const existing = this.#requireJob(id); + await this.#update(id, { + status: 'preparing', + startedAt: existing.startedAt ?? this.#timestamp(), + }); + await this.#executor.prepare?.( + this.#requireJob(id), + this.#activeAbort.signal + ); + for (let index = 0; index < definition.steps.length; index += 1) { + this.#throwIfCancelling(id); + const definitionStep = definition.steps[index]; + if (!definitionStep) { + continue; + } + await this.#setStep(id, index, { status: 'running', progress: 0 }); + await this.#update(id, { status: 'running' }); + let result = await this.#executor.execute(definitionStep.plan, { + signal: this.#activeAbort.signal, + onProgress: (update) => { + if (update.progress !== undefined) { + this.#setStepProgress(id, index, update.progress); + } + }, + onLog: () => { + // Engine-specific log buffers own full logs. The queue deliberately + // persists only bounded job metadata. + }, + }); + this.#throwIfCancelling(id); + if (this.#executor.readOutputs) { + await this.#update(id, { status: 'reading-output' }); + result = await this.#executor.readOutputs( + definitionStep.plan, + result, + this.#activeAbort.signal + ); + } + completedResults.push(result); + await this.#setStep(id, index, { status: 'completed', progress: 1 }); + } + await this.#executor.cleanup(id); + this.#results.set(id, Object.freeze(completedResults)); + await this.#update(id, { + status: 'completed', + progress: 1, + completedAt: this.#timestamp(), + }); + } catch (error) { + const cancellation = this.#cancellationTasks.get(id); + if (cancellation) { + await cancellation; + } else { + await this.#handleFailure(id, error); + } + } finally { + if (this.#activeJobId === id) { + this.#activeJobId = undefined; + this.#activeAbort = undefined; + } + this.#emit(); + this.#schedule(); + } + } + + async #cancelActive(id: string): Promise { + this.#results.delete(id); + await this.#update(id, { + status: 'cancelling', + steps: this.#requireJob(id).steps.map((step) => + step.status === 'queued' || step.status === 'running' + ? { + ...step, + status: 'cancelled', + progress: Math.min(step.progress, 0.999), + } + : step + ), + }); + this.#activeAbort?.abort(new DOMException('Job cancelled', 'AbortError')); + try { + await this.#executor.cancelCurrentJob(); + try { + await this.#executor.cleanup(id); + } catch { + // Termination may already remove the virtual filesystem. Recovery below + // is authoritative before another job may start. + } + await this.#update(id, { status: 'recovering' }); + await this.#executor.recoverAfterCancellation(); + await this.#update(id, { + status: 'cancelled', + completedAt: this.#timestamp(), + }); + this.#results.delete(id); + } catch (error) { + await this.#update(id, { + status: 'failed', + completedAt: this.#timestamp(), + error: toMediaJobError(error, 'cancellation-recovery-failed', false), + }); + } + } + + async #handleFailure(id: string, error: unknown): Promise { + this.#results.delete(id); + try { + await this.#executor.cleanup(id); + } catch (cleanupError) { + await this.#update(id, { + status: 'recovering', + recoveryNote: + 'Cleanup failed. The execution engine is being recreated before the queue continues.', + }); + try { + await this.#executor.cancelCurrentJob(); + await this.#executor.recoverAfterCancellation(); + } catch { + error = new AggregateError( + [error, cleanupError], + 'Job and engine cleanup both failed' + ); + } + } + const job = this.#requireJob(id); + await this.#update(id, { + status: 'failed', + completedAt: this.#timestamp(), + error: toMediaJobError(error, 'execution-failed', true), + steps: job.steps.map((step) => + step.status === 'running' + ? { + ...step, + status: 'failed', + progress: Math.min(step.progress, 0.999), + } + : step + ), + }); + } + + #setStepProgress(id: string, index: number, progress: number): void { + const job = this.#jobs.get(id); + if (!job || job.status !== 'running') { + return; + } + const steps = job.steps.map((step, stepIndex) => + stepIndex === index + ? { ...step, progress: Math.min(0.99, Math.max(0, progress)) } + : step + ); + const updated = freezeJob({ + ...job, + steps, + progress: weightedJobProgress(steps), + updatedAt: this.#timestamp(), + }); + this.#jobs.set(id, updated); + void this.#persist(updated).catch(() => undefined); + this.#emit(); + } + + async #setStep( + id: string, + index: number, + patch: Pick + ): Promise { + const job = this.#requireJob(id); + const steps = job.steps.map((step, stepIndex) => + stepIndex === index ? { ...step, ...patch } : step + ); + await this.#update(id, { + steps, + progress: weightedJobProgress(steps), + }); + } + + #throwIfCancelling(id: string): void { + const status = this.#requireJob(id).status; + if ( + status === 'cancelling' || + status === 'recovering' || + status === 'cancelled' + ) { + throw new DOMException('Job cancelled', 'AbortError'); + } + this.#activeAbort?.signal.throwIfAborted(); + } + + #schedule(): void { + if (this.#scheduled || this.#activeJobId || !this.#initialized) { + return; + } + this.#scheduled = true; + queueMicrotask(() => { + this.#scheduled = false; + if (this.#activeJobId) { + return; + } + const next = this.list().find( + (job) => job.status === 'queued' && this.#definitions.has(job.id) + ); + const definition = next ? this.#definitions.get(next.id) : undefined; + if (definition) { + void this.#run(definition); + } + }); + } + + async #update( + id: string, + patch: Partial> + ): Promise { + const current = this.#requireJob(id); + const withOptionalFields = { ...current, ...patch }; + if (patch.startedAt === undefined && 'startedAt' in patch) { + delete (withOptionalFields as { startedAt?: string }).startedAt; + } + if (patch.completedAt === undefined && 'completedAt' in patch) { + delete (withOptionalFields as { completedAt?: string }).completedAt; + } + if (patch.error === undefined && 'error' in patch) { + delete (withOptionalFields as { error?: MediaJobError }).error; + } + if (patch.recoveryNote === undefined && 'recoveryNote' in patch) { + delete (withOptionalFields as { recoveryNote?: string }).recoveryNote; + } + return this.#replace( + freezeJob({ ...withOptionalFields, updatedAt: this.#timestamp() }) + ); + } + + async #replace(job: MediaJob): Promise { + this.#jobs.set(job.id, job); + await this.#persist(job); + this.#emit(); + return job; + } + + #requireJob(id: string): MediaJob { + const job = this.#jobs.get(id); + if (!job) { + throw new RangeError(`Unknown job: ${id}`); + } + return job; + } + + #timestamp(): string { + return this.#now().toISOString(); + } + + #emit(): void { + const jobs = this.list(); + for (const listener of this.#listeners) { + try { + listener(jobs, this.#activeJobId); + } catch { + // A view listener must not interrupt execution or durable state updates. + } + } + } + + #persist(job: MediaJob): Promise { + const operation = this.#persistenceTail.then(() => this.#store.put(job)); + this.#persistenceTail = operation.catch(() => undefined); + return operation; + } + + #deletePersisted(id: string): Promise { + const operation = this.#persistenceTail.then(() => this.#store.delete(id)); + this.#persistenceTail = operation.catch(() => undefined); + return operation; + } + + #assertInitialized(): void { + if (!this.#initialized) { + throw new Error('MediaJobQueue.initialize() must be called first'); + } + } +} + +export function toMediaJobError( + error: unknown, + fallbackCode: string, + recoverable: boolean +): MediaJobError { + if (isMediaJobError(error)) { + return Object.freeze({ ...error }); + } + if (error instanceof Error) { + return Object.freeze({ + code: fallbackCode, + message: error.message || error.name, + recoverable, + ...(error.stack ? { details: error.stack.slice(0, 4_000) } : {}), + }); + } + return Object.freeze({ + code: fallbackCode, + message: typeof error === 'string' ? error : 'Unknown media job error', + recoverable, + }); +} + +function validateDefinition(definition: MediaJobDefinition): void { + if (!definition.id.trim() || !definition.operation.trim()) { + throw new TypeError('Job id and operation are required'); + } + validateStepWeights(definition.steps); + if ( + definition.steps.some( + (step) => + !step.name.trim() || + !step.plan.id.trim() || + step.plan.id !== step.plan.id.trim() + ) + ) { + throw new TypeError('Job step names and command plan IDs are required'); + } +} + +function freezeDefinition(definition: MediaJobDefinition): MediaJobDefinition { + return Object.freeze({ + ...definition, + steps: Object.freeze( + definition.steps.map((step) => Object.freeze({ ...step })) + ), + }); +} + +function freezeJob(job: MediaJob): MediaJob { + return Object.freeze({ + ...job, + steps: Object.freeze(job.steps.map((step) => Object.freeze({ ...step }))), + ...(job.error ? { error: Object.freeze({ ...job.error }) } : {}), + }); +} + +function isTerminalStatus(status: MediaJobStatus): boolean { + return ( + status === 'completed' || status === 'failed' || status === 'cancelled' + ); +} + +function isInterruptedStatus(status: MediaJobStatus): boolean { + return ( + status === 'queued' || + status === 'preparing' || + status === 'running' || + status === 'reading-output' || + status === 'cancelling' || + status === 'recovering' + ); +} + +function isMediaJobError(value: unknown): value is MediaJobError { + if (typeof value !== 'object' || value === null) { + return false; + } + const candidate = value as Partial; + return ( + typeof candidate.code === 'string' && + typeof candidate.message === 'string' && + typeof candidate.recoverable === 'boolean' + ); +} diff --git a/src/jobs/job.types.ts b/src/jobs/job.types.ts new file mode 100644 index 0000000..33384fc --- /dev/null +++ b/src/jobs/job.types.ts @@ -0,0 +1,113 @@ +import type { FFmpegCommandPlan } from '../commands/command-plan'; + +export type MediaJobStatus = + | 'queued' + | 'preparing' + | 'running' + | 'reading-output' + | 'cancelling' + | 'recovering' + | 'completed' + | 'failed' + | 'cancelled'; + +export type MediaJobStepStatus = + 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'; + +export interface MediaJobError { + readonly code: string; + readonly message: string; + readonly stepId?: string; + readonly recoverable: boolean; + readonly details?: string; +} + +export interface MediaJobStep { + readonly id: string; + readonly name: string; + readonly weight: number; + readonly status: MediaJobStepStatus; + readonly progress: number; + readonly planId: string; +} + +export interface MediaJob { + readonly id: string; + readonly operation: string; + readonly status: MediaJobStatus; + readonly steps: readonly MediaJobStep[]; + readonly progress: number; + readonly createdAt: string; + readonly updatedAt: string; + readonly startedAt?: string; + readonly completedAt?: string; + readonly retryCount: number; + readonly error?: MediaJobError; + readonly recoveryNote?: string; +} + +export interface MediaJobDefinition { + readonly id: string; + readonly operation: string; + readonly steps: readonly { + readonly id: string; + readonly name: string; + readonly weight: number; + readonly plan: FFmpegCommandPlan; + }[]; +} + +export interface JobExecutionResult { + readonly outputs: ReadonlyMap; + readonly diagnostics?: readonly string[]; +} + +export interface JobProgressUpdate { + readonly progress?: number; + readonly elapsedSeconds?: number; + readonly speed?: number; + readonly frame?: number; + readonly message?: string; +} + +export interface JobExecutionContext { + readonly signal: AbortSignal; + readonly onProgress: (update: JobProgressUpdate) => void; + readonly onLog: (message: string) => void; +} + +/** + * Engine-facing interface. Cancellation must terminate the active FFmpeg + * worker; aborting the signal alone is deliberately insufficient. + */ +export interface MediaJobExecutor { + prepare?(job: MediaJob, signal: AbortSignal): Promise; + execute( + plan: FFmpegCommandPlan, + context: JobExecutionContext + ): Promise; + readOutputs?( + plan: FFmpegCommandPlan, + result: JobExecutionResult, + signal: AbortSignal + ): Promise; + cleanup(jobId: string): Promise; + cancelCurrentJob(): Promise; + recoverAfterCancellation(): Promise; +} + +export interface JobMetadataStore { + list(): Promise; + put(job: MediaJob): Promise; + delete(id: string): Promise; + clear(): Promise; +} + +export interface JobRecoveryProvider { + restore(job: MediaJob): Promise; +} + +export type JobQueueListener = ( + jobs: readonly MediaJob[], + activeJobId: string | undefined +) => void; diff --git a/src/jobs/object-url-registry.ts b/src/jobs/object-url-registry.ts new file mode 100644 index 0000000..be027bd --- /dev/null +++ b/src/jobs/object-url-registry.ts @@ -0,0 +1,45 @@ +export interface ObjectUrlApi { + createObjectURL(blob: Blob): string; + revokeObjectURL(url: string): void; +} + +export class ObjectUrlRegistry { + readonly #api: ObjectUrlApi; + readonly #urls = new Map(); + + constructor(api: ObjectUrlApi = URL) { + this.#api = api; + } + + replace(key: string, blob: Blob): string { + this.revoke(key); + const url = this.#api.createObjectURL(blob); + this.#urls.set(key, url); + return url; + } + + get(key: string): string | undefined { + return this.#urls.get(key); + } + + revoke(key: string): boolean { + const url = this.#urls.get(key); + if (!url) { + return false; + } + this.#api.revokeObjectURL(url); + this.#urls.delete(key); + return true; + } + + clear(): void { + for (const url of this.#urls.values()) { + this.#api.revokeObjectURL(url); + } + this.#urls.clear(); + } + + get size(): number { + return this.#urls.size; + } +} diff --git a/src/jobs/progress.ts b/src/jobs/progress.ts new file mode 100644 index 0000000..b367f33 --- /dev/null +++ b/src/jobs/progress.ts @@ -0,0 +1,124 @@ +import type { JobProgressUpdate, MediaJobStep } from './job.types'; + +export interface ParsedFFmpegProgress { + readonly frame?: number; + readonly fps?: number; + readonly outTimeSeconds?: number; + readonly speed?: number; + readonly terminal: boolean; +} + +export function parseFFmpegProgress(text: string): ParsedFFmpegProgress { + const values = new Map(); + for (const line of text.split(/\r?\n/gu)) { + const separator = line.indexOf('='); + if (separator > 0) { + values.set( + line.slice(0, separator).trim(), + line.slice(separator + 1).trim() + ); + } + } + const frame = parseOptionalFinite(values.get('frame')); + const fps = parseOptionalFinite(values.get('fps')); + const outTimeSeconds = + parseMicroseconds(values.get('out_time_us')) ?? + parseProgressTime(values.get('out_time')); + const speed = parseSpeed(values.get('speed')); + return Object.freeze({ + ...(frame !== undefined ? { frame } : {}), + ...(fps !== undefined ? { fps } : {}), + ...(outTimeSeconds !== undefined ? { outTimeSeconds } : {}), + ...(speed !== undefined ? { speed } : {}), + terminal: values.get('progress') === 'end', + }); +} + +export function progressUpdateFromFFmpeg( + parsed: ParsedFFmpegProgress, + expectedDurationSeconds?: number +): JobProgressUpdate { + const progress = parsed.terminal + ? 0.99 + : parsed.outTimeSeconds !== undefined && + expectedDurationSeconds !== undefined && + expectedDurationSeconds > 0 + ? Math.min( + 0.99, + Math.max(0, parsed.outTimeSeconds / expectedDurationSeconds) + ) + : undefined; + return Object.freeze({ + ...(progress !== undefined ? { progress } : {}), + ...(parsed.speed !== undefined ? { speed: parsed.speed } : {}), + ...(parsed.frame !== undefined ? { frame: parsed.frame } : {}), + }); +} + +export function weightedJobProgress(steps: readonly MediaJobStep[]): number { + const totalWeight = steps.reduce((sum, step) => sum + step.weight, 0); + if (totalWeight <= 0) { + return 0; + } + const completedWeight = steps.reduce( + (sum, step) => sum + step.weight * clampProgress(step.progress), + 0 + ); + return Math.min(0.999, completedWeight / totalWeight); +} + +export function validateStepWeights( + steps: readonly { readonly weight: number; readonly id: string }[] +): void { + if (steps.length === 0) { + throw new RangeError('A media job requires at least one step'); + } + const ids = new Set(); + for (const step of steps) { + if ( + !step.id || + ids.has(step.id) || + !Number.isFinite(step.weight) || + step.weight <= 0 + ) { + throw new RangeError( + 'Job step IDs must be unique and weights must be positive' + ); + } + ids.add(step.id); + } +} + +function parseMicroseconds(value: string | undefined): number | undefined { + const microseconds = parseOptionalFinite(value); + return microseconds === undefined ? undefined : microseconds / 1_000_000; +} + +function parseProgressTime(value: string | undefined): number | undefined { + if (!value) { + return undefined; + } + const match = /^(\d+):([0-5]\d):([0-5]\d(?:\.\d+)?)$/u.exec(value); + if (!match) { + return undefined; + } + return Number(match[1]) * 3600 + Number(match[2]) * 60 + Number(match[3]); +} + +function parseSpeed(value: string | undefined): number | undefined { + return value?.endsWith('x') + ? parseOptionalFinite(value.slice(0, -1)) + : undefined; +} + +function parseOptionalFinite(value: string | undefined): number | undefined { + if (value === undefined || value === 'N/A') { + return undefined; + } + const result = Number(value); + return Number.isFinite(result) ? result : undefined; +} + +function clampProgress(value: number): number { + return Math.min(1, Math.max(0, Number.isFinite(value) ? value : 0)); +} diff --git a/src/limits.ts b/src/limits.ts new file mode 100644 index 0000000..8ac944b --- /dev/null +++ b/src/limits.ts @@ -0,0 +1,11 @@ +/** + * A managed result batch must remain saveable as one ZIP together with its + * optional export report. The archive therefore reserves one of its 256 + * entries for that report. + */ +export const MAX_GENERATED_OUTPUT_FILES = 255; +export const MAX_RESULT_ZIP_ENTRIES = MAX_GENERATED_OUTPUT_FILES + 1; +export const MAX_SPLIT_MARKERS = MAX_GENERATED_OUTPUT_FILES - 1; + +export const GENERATED_OUTPUT_LIMIT_EXPLANATION = + 'The 255-file limit keeps the managed result collection exportable as one ZIP with an optional report.'; diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..738877c --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,9 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { ApplicationRuntime } from './app/ApplicationRuntime'; + +createRoot(document.getElementById('root')!).render( + + + +); diff --git a/src/media/browser-playback.ts b/src/media/browser-playback.ts new file mode 100644 index 0000000..534d585 --- /dev/null +++ b/src/media/browser-playback.ts @@ -0,0 +1,308 @@ +import type { MediaProbe, MediaStreamProbe } from './media.types'; +import { mimeTypeFromFileName } from './mime-types'; + +export type BrowserPlaybackSupport = + 'probably' | 'maybe' | 'unsupported' | 'unknown'; + +export interface BrowserPlaybackAssessment { + support: BrowserPlaybackSupport; + candidates: string[]; + selectedMimeType?: string; + reason: string; + needsPreviewProxy: boolean; +} + +export type CanPlayType = (mimeType: string) => CanPlayTypeResult; + +export interface PlaybackCandidateOptions { + fileName?: string; + declaredMimeType?: string; +} + +export type GeneratedPreviewKind = 'audio' | 'video' | 'image' | 'other'; + +export interface GeneratedPreviewInput { + readonly fileName: string; + readonly mimeType: string; + readonly probe?: MediaProbe; +} + +export interface GeneratedPreviewAssessment { + readonly kind: GeneratedPreviewKind; + readonly previewable: boolean; + readonly support: BrowserPlaybackSupport; + readonly candidates: readonly string[]; + readonly reason: string; +} + +const BROWSER_IMAGE_PREVIEW_TYPES = new Set([ + 'image/avif', + 'image/gif', + 'image/jpeg', + 'image/png', + 'image/svg+xml', + 'image/webp', +]); + +export function buildPlaybackMimeCandidates( + probe: MediaProbe, + options: PlaybackCandidateOptions = {} +): string[] { + const hasVideo = probe.streams.some( + (stream) => + stream.type === 'video' && stream.disposition.attached_pic !== true + ); + const baseMimeType = + normalizeMimeType(options.declaredMimeType) ?? + (options.fileName ? mimeTypeFromFileName(options.fileName) : undefined) ?? + mimeTypeFromFormats(probe.formatNames, hasVideo); + + if (baseMimeType === undefined) { + return []; + } + + const codecNames = playbackCodecNames(probe.streams); + const withCodecs = + codecNames.length > 0 + ? `${baseMimeType}; codecs="${codecNames.join(', ')}"` + : undefined; + return [...new Set([withCodecs, baseMimeType].filter(isString))]; +} + +export function assessBrowserPlayback( + probe: MediaProbe, + canPlayType: CanPlayType, + options: PlaybackCandidateOptions = {} +): BrowserPlaybackAssessment { + const candidates = buildPlaybackMimeCandidates(probe, options); + if (candidates.length === 0) { + return { + support: 'unknown', + candidates, + reason: 'The source container or MIME type could not be identified.', + needsPreviewProxy: false, + }; + } + + let maybeCandidate: string | undefined; + for (const candidate of candidates) { + const result = canPlayType(candidate); + if (result === 'probably') { + return { + support: 'probably', + candidates, + selectedMimeType: candidate, + reason: + 'The browser reports that it can probably decode this container and codec combination.', + needsPreviewProxy: false, + }; + } + if (result === 'maybe' && maybeCandidate === undefined) { + maybeCandidate = candidate; + } + } + + if (maybeCandidate !== undefined) { + return { + support: 'maybe', + candidates, + selectedMimeType: maybeCandidate, + reason: + 'The browser may decode this source; actual playback can still fail.', + needsPreviewProxy: false, + }; + } + + return { + support: 'unsupported', + candidates, + reason: + 'The browser reports no native support. FFmpeg may still be able to read the source.', + needsPreviewProxy: true, + }; +} + +/** + * Checks a generated result against the browser before a Preview action is + * offered. A broad audio/video MIME prefix is not enough: when a round-trip + * probe is available, its container and codecs are included in the query. + */ +export function assessGeneratedResultPreview( + input: GeneratedPreviewInput, + canPlayType: CanPlayType +): GeneratedPreviewAssessment { + const mimeType = input.mimeType.trim().toLowerCase().split(';')[0]?.trim(); + const kind = previewKind(mimeType); + + if (kind === 'image') { + const previewable = + mimeType !== undefined && BROWSER_IMAGE_PREVIEW_TYPES.has(mimeType); + return { + kind, + previewable, + support: previewable ? 'maybe' : 'unsupported', + candidates: mimeType ? [mimeType] : [], + reason: previewable + ? 'The result uses a commonly supported browser image format; an actual decode error will still be reported by the preview.' + : 'This generated image format is not in the browser-preview allowlist.', + }; + } + if (kind !== 'audio' && kind !== 'video') { + return { + kind, + previewable: false, + support: 'unknown', + candidates: [], + reason: 'This result type has no in-browser media preview.', + }; + } + + const playback = input.probe + ? assessBrowserPlayback(input.probe, canPlayType, { + fileName: input.fileName, + declaredMimeType: mimeType, + }) + : assessDeclaredPlayback(mimeType, canPlayType); + return { + kind, + previewable: + playback.support === 'probably' || playback.support === 'maybe', + support: playback.support, + candidates: playback.candidates, + reason: playback.reason, + }; +} + +function assessDeclaredPlayback( + mimeType: string | undefined, + canPlayType: CanPlayType +): BrowserPlaybackAssessment { + if ( + mimeType === undefined || + !/^(?:audio|video)\/[a-z0-9.+-]+$/.test(mimeType) + ) { + return { + support: 'unknown', + candidates: [], + reason: 'The generated media type could not be identified.', + needsPreviewProxy: false, + }; + } + const result = canPlayType(mimeType); + if (result === 'probably' || result === 'maybe') { + return { + support: result, + candidates: [mimeType], + selectedMimeType: mimeType, + reason: + result === 'probably' + ? 'The browser reports that it can probably decode this generated result.' + : 'The browser may decode this generated result; actual playback can still fail.', + needsPreviewProxy: false, + }; + } + return { + support: 'unsupported', + candidates: [mimeType], + reason: + 'The browser reports no native playback support for this generated result.', + needsPreviewProxy: false, + }; +} + +function previewKind(mimeType: string | undefined): GeneratedPreviewKind { + if (mimeType?.startsWith('audio/')) return 'audio'; + if (mimeType?.startsWith('video/')) return 'video'; + if (mimeType?.startsWith('image/')) return 'image'; + return 'other'; +} + +function mimeTypeFromFormats( + formats: readonly string[], + hasVideo: boolean +): string | undefined { + const normalized = new Set(formats.map((format) => format.toLowerCase())); + if ( + normalized.has('mp4') || + normalized.has('mov') || + normalized.has('m4a') || + normalized.has('3gp') || + normalized.has('3g2') || + normalized.has('mj2') + ) { + return hasVideo ? 'video/mp4' : 'audio/mp4'; + } + if (normalized.has('webm')) { + return hasVideo ? 'video/webm' : 'audio/webm'; + } + if (normalized.has('ogg')) { + return hasVideo ? 'video/ogg' : 'audio/ogg'; + } + if (normalized.has('matroska')) { + return hasVideo ? 'video/x-matroska' : 'audio/x-matroska'; + } + if (normalized.has('mp3')) { + return 'audio/mpeg'; + } + if (normalized.has('wav')) { + return 'audio/wav'; + } + if (normalized.has('flac')) { + return 'audio/flac'; + } + return undefined; +} + +function playbackCodecNames(streams: readonly MediaStreamProbe[]): string[] { + const codecs: string[] = []; + for (const stream of streams) { + if ( + (stream.type !== 'video' && stream.type !== 'audio') || + stream.disposition.attached_pic === true + ) { + continue; + } + const codec = browserCodecName(stream.codecName); + if (codec && !codecs.includes(codec)) { + codecs.push(codec); + } + } + return codecs; +} + +function browserCodecName(codecName: string | undefined): string | undefined { + switch (codecName?.toLowerCase()) { + case 'h264': + return 'avc1.42E01E'; + case 'hevc': + case 'h265': + return 'hvc1'; + case 'av1': + return 'av01.0.05M.08'; + case 'vp8': + return 'vp8'; + case 'vp9': + return 'vp09.00.10.08'; + case 'aac': + return 'mp4a.40.2'; + case 'mp3': + return 'mp3'; + case 'opus': + return 'opus'; + case 'vorbis': + return 'vorbis'; + default: + return undefined; + } +} + +function normalizeMimeType(value: string | undefined): string | undefined { + const mimeType = value?.trim().toLowerCase().split(';')[0]?.trim(); + return mimeType && /^(?:audio|video)\/[a-z0-9.+-]+$/.test(mimeType) + ? mimeType + : undefined; +} + +function isString(value: string | undefined): value is string { + return value !== undefined; +} diff --git a/src/media/chapter-time-base.ts b/src/media/chapter-time-base.ts new file mode 100644 index 0000000..2351201 --- /dev/null +++ b/src/media/chapter-time-base.ts @@ -0,0 +1,55 @@ +export const DEFAULT_CHAPTER_TIME_BASE = '1/1000' as const; + +const TIME_BASE_PATTERN = /^([1-9]\d*)\/([1-9]\d*)$/u; +const MAX_TIME_BASE_COMPONENT = 2_147_483_647n; + +export interface ParsedChapterTimeBase { + readonly numerator: number; + readonly denominator: number; + readonly normalized: string; + readonly secondsPerTick: number; +} + +/** + * Parses the positive integer rational accepted by FFMETADATA TIMEBASE. + * Components are bounded to FFmpeg's signed 32-bit AVRational range. + */ +export function parseChapterTimeBase( + value: unknown +): ParsedChapterTimeBase | undefined { + if (typeof value !== 'string') return undefined; + const match = TIME_BASE_PATTERN.exec(value.trim()); + if (!match) return undefined; + const numeratorValue = match[1]; + const denominatorValue = match[2]; + if (numeratorValue === undefined || denominatorValue === undefined) { + return undefined; + } + const numerator = BigInt(numeratorValue); + const denominator = BigInt(denominatorValue); + if ( + numerator > MAX_TIME_BASE_COMPONENT || + denominator > MAX_TIME_BASE_COMPONENT + ) { + return undefined; + } + const numericNumerator = Number(numerator); + const numericDenominator = Number(denominator); + return Object.freeze({ + numerator: numericNumerator, + denominator: numericDenominator, + normalized: `${numericNumerator}/${numericDenominator}`, + secondsPerTick: numericNumerator / numericDenominator, + }); +} + +export function chapterSecondsToTicks( + seconds: number, + timeBase: ParsedChapterTimeBase +): number | undefined { + if (!Number.isFinite(seconds) || seconds < 0) return undefined; + const ticks = Math.round( + (seconds * timeBase.denominator) / timeBase.numerator + ); + return Number.isSafeInteger(ticks) ? ticks : undefined; +} diff --git a/src/media/duration.ts b/src/media/duration.ts new file mode 100644 index 0000000..a3cd4b7 --- /dev/null +++ b/src/media/duration.ts @@ -0,0 +1,152 @@ +export const DEFAULT_MINIMUM_CLIP_DURATION_SECONDS = 0.001; +export const TIME_COMPARISON_EPSILON_SECONDS = 1e-6; + +export type SourceRangeIssueCode = + | 'invalid-source-in' + | 'invalid-source-out' + | 'source-out-before-in' + | 'clip-too-short' + | 'source-out-after-duration'; + +export interface SourceRangeIssue { + code: SourceRangeIssueCode; + message: string; +} + +export interface SourceRangeValidation { + valid: boolean; + durationSeconds?: number; + issues: SourceRangeIssue[]; +} + +export function isValidDuration(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0; +} + +export function roundSeconds(seconds: number, decimalPlaces = 6): number { + if (!Number.isFinite(seconds)) { + throw new RangeError('Seconds must be finite.'); + } + if ( + !Number.isInteger(decimalPlaces) || + decimalPlaces < 0 || + decimalPlaces > 9 + ) { + throw new RangeError('Decimal places must be an integer from 0 to 9.'); + } + + const factor = 10 ** decimalPlaces; + const rounded = + Math.round((seconds + Math.sign(seconds) * Number.EPSILON) * factor) / + factor; + return Object.is(rounded, -0) ? 0 : rounded; +} + +export function addDurations( + durations: readonly number[], + decimalPlaces = 6 +): number { + let total = 0; + let compensation = 0; + + for (const duration of durations) { + if (!isValidDuration(duration)) { + throw new RangeError('Every duration must be finite and non-negative.'); + } + const adjusted = duration - compensation; + const next = total + adjusted; + compensation = next - total - adjusted; + total = next; + } + + return roundSeconds(total, decimalPlaces); +} + +export function subtractDurations( + endSeconds: number, + startSeconds: number, + decimalPlaces = 6 +): number { + if (!isValidDuration(startSeconds) || !isValidDuration(endSeconds)) { + throw new RangeError( + 'Start and end times must be finite and non-negative.' + ); + } + return roundSeconds(endSeconds - startSeconds, decimalPlaces); +} + +export function clampTime(seconds: number, durationSeconds: number): number { + if (!Number.isFinite(seconds) || !isValidDuration(durationSeconds)) { + throw new RangeError('Time and duration must be finite.'); + } + return Math.min(Math.max(seconds, 0), durationSeconds); +} + +export function validateSourceRange( + sourceInSeconds: unknown, + sourceOutSeconds: unknown, + sourceDurationSeconds?: unknown, + minimumDurationSeconds = DEFAULT_MINIMUM_CLIP_DURATION_SECONDS +): SourceRangeValidation { + const issues: SourceRangeIssue[] = []; + const sourceIn = isValidDuration(sourceInSeconds) + ? sourceInSeconds + : undefined; + const sourceOut = isValidDuration(sourceOutSeconds) + ? sourceOutSeconds + : undefined; + + if (sourceIn === undefined) { + issues.push({ + code: 'invalid-source-in', + message: 'The source in-point must be finite and non-negative.', + }); + } + if (sourceOut === undefined) { + issues.push({ + code: 'invalid-source-out', + message: 'The source out-point must be finite and non-negative.', + }); + } + if (!Number.isFinite(minimumDurationSeconds) || minimumDurationSeconds <= 0) { + throw new RangeError('Minimum duration must be finite and positive.'); + } + + let durationSeconds: number | undefined; + if (sourceIn !== undefined && sourceOut !== undefined) { + if (sourceOut < sourceIn) { + issues.push({ + code: 'source-out-before-in', + message: 'The source out-point must not be before the in-point.', + }); + } else { + durationSeconds = subtractDurations(sourceOut, sourceIn); + if ( + durationSeconds + TIME_COMPARISON_EPSILON_SECONDS < + minimumDurationSeconds + ) { + issues.push({ + code: 'clip-too-short', + message: `The clip must be at least ${minimumDurationSeconds} seconds long.`, + }); + } + } + } + + if ( + sourceOut !== undefined && + isValidDuration(sourceDurationSeconds) && + sourceOut > sourceDurationSeconds + TIME_COMPARISON_EPSILON_SECONDS + ) { + issues.push({ + code: 'source-out-after-duration', + message: 'The source out-point exceeds the source duration.', + }); + } + + return { + valid: issues.length === 0, + ...(durationSeconds === undefined ? {} : { durationSeconds }), + issues, + }; +} diff --git a/src/media/index.ts b/src/media/index.ts new file mode 100644 index 0000000..785825d --- /dev/null +++ b/src/media/index.ts @@ -0,0 +1,9 @@ +export * from './browser-playback'; +export * from './chapter-time-base'; +export * from './duration'; +export * from './media.types'; +export * from './mime-types'; +export * from './probe-parser'; +export * from './safe-file-name'; +export * from './stream-selection'; +export * from './timecode'; diff --git a/src/media/media.types.ts b/src/media/media.types.ts new file mode 100644 index 0000000..7f81dfa --- /dev/null +++ b/src/media/media.types.ts @@ -0,0 +1,107 @@ +export type MediaStreamType = + 'video' | 'audio' | 'subtitle' | 'attachment' | 'data' | 'unknown'; + +export type ProbeWarningCode = + | 'invalid-field' + | 'invalid-fraction' + | 'missing-stream-index' + | 'duplicate-stream-index' + | 'unknown-stream-type' + | 'invalid-stream' + | 'invalid-chapter'; + +export interface ProbeWarning { + code: ProbeWarningCode; + path: string; + message: string; + received?: string; +} + +export interface MediaStreamProbe { + index: number; + type: MediaStreamType; + codecName?: string; + codecLongName?: string; + profile?: string; + durationSeconds?: number; + bitRate?: number; + language?: string; + title?: string; + disposition: Record; + + width?: number; + height?: number; + codedWidth?: number; + codedHeight?: number; + pixelFormat?: string; + timeBase?: string; + sampleAspectRatio?: string; + displayAspectRatio?: string; + /** + * Display-matrix or legacy rotate-tag metadata normalized to the signed + * range (-180, 180]. The encoded width and height are not swapped. + */ + rotationDegrees?: number; + frameRate?: number; + + sampleRate?: number; + channels?: number; + channelLayout?: string; + sampleFormat?: string; + + tags: Record; +} + +export interface ChapterProbe { + id: number; + startSeconds: number; + endSeconds: number; + timeBase?: string; + title?: string; + tags: Record; +} + +export interface MediaProbe { + durationSeconds?: number; + startTimeSeconds?: number; + bitRate?: number; + formatNames: string[]; + formatLongName?: string; + tags: Record; + streams: MediaStreamProbe[]; + chapters: ChapterProbe[]; + warnings: ProbeWarning[]; +} + +export interface ParsedProbeReport { + probe: MediaProbe; + /** + * The exact report emitted by ffprobe. It is kept separate from the + * normalized model so application views cannot accidentally depend on + * unvalidated fields. + */ + rawReport: string; +} + +export interface FractionParseOptions { + /** Maximum absolute numerator or denominator accepted. */ + maxComponent?: number; + /** Maximum absolute result accepted. */ + maxAbsoluteValue?: number; + allowNegative?: boolean; + allowZero?: boolean; +} + +export type FractionParseFailure = + | 'not-a-string-or-number' + | 'empty' + | 'invalid-syntax' + | 'division-by-zero' + | 'component-too-large' + | 'result-too-large' + | 'negative-not-allowed' + | 'zero-not-allowed' + | 'not-finite'; + +export type FractionParseResult = + { ok: true; value: number } | { ok: false; reason: FractionParseFailure }; diff --git a/src/media/mime-types.ts b/src/media/mime-types.ts new file mode 100644 index 0000000..ed91c7b --- /dev/null +++ b/src/media/mime-types.ts @@ -0,0 +1,30 @@ +import { splitFileName } from './safe-file-name'; + +const EXTENSION_MIME_TYPES: Readonly> = { + '.aac': 'audio/aac', + '.flac': 'audio/flac', + '.m4a': 'audio/mp4', + '.m4v': 'video/mp4', + '.mkv': 'video/x-matroska', + '.mov': 'video/quicktime', + '.mp3': 'audio/mpeg', + '.mp4': 'video/mp4', + '.oga': 'audio/ogg', + '.ogg': 'audio/ogg', + '.ogv': 'video/ogg', + '.opus': 'audio/ogg', + '.wav': 'audio/wav', + '.webm': 'video/webm', +}; + +export function mimeTypeFromFileName(fileName: string): string | undefined { + return EXTENSION_MIME_TYPES[splitFileName(fileName).extension.toLowerCase()]; +} + +export function isAudioMimeType(mimeType: string): boolean { + return mimeType.toLowerCase().startsWith('audio/'); +} + +export function isVideoMimeType(mimeType: string): boolean { + return mimeType.toLowerCase().startsWith('video/'); +} diff --git a/src/media/probe-model.ts b/src/media/probe-model.ts new file mode 100644 index 0000000..9f7df29 --- /dev/null +++ b/src/media/probe-model.ts @@ -0,0 +1,9 @@ +export type { + ChapterProbe, + MediaProbe, + MediaStreamProbe, + MediaStreamType, + ParsedProbeReport, + ProbeWarning, + ProbeWarningCode, +} from './media.types'; diff --git a/src/media/probe-parser.ts b/src/media/probe-parser.ts new file mode 100644 index 0000000..ec597ca --- /dev/null +++ b/src/media/probe-parser.ts @@ -0,0 +1,971 @@ +import type { + ChapterProbe, + FractionParseOptions, + FractionParseResult, + MediaProbe, + MediaStreamProbe, + MediaStreamType, + ParsedProbeReport, + ProbeWarning, + ProbeWarningCode, +} from './media.types'; + +const INTEGER_PATTERN = /^[+-]?\d+$/; +const DECIMAL_PATTERN = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/; +const FINITE_NUMBER_PATTERN = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/; +const DEFAULT_MAX_COMPONENT = 1_000_000_000_000; +const DEFAULT_MAX_ABSOLUTE_VALUE = 1_000_000; + +export class ProbeParseError extends Error { + readonly causeValue?: unknown; + + constructor(message: string, causeValue?: unknown) { + super(message); + this.name = 'ProbeParseError'; + this.causeValue = causeValue; + } +} + +export function parseFractionDetailed( + input: unknown, + options: FractionParseOptions = {} +): FractionParseResult { + if (typeof input !== 'string' && typeof input !== 'number') { + return { ok: false, reason: 'not-a-string-or-number' }; + } + + const text = String(input).trim(); + if (!text) { + return { ok: false, reason: 'empty' }; + } + + const maxComponent = normalizePositiveLimit( + options.maxComponent, + DEFAULT_MAX_COMPONENT + ); + const maxAbsoluteValue = normalizePositiveLimit( + options.maxAbsoluteValue, + DEFAULT_MAX_ABSOLUTE_VALUE + ); + + let value: number; + const slashIndex = text.indexOf('/'); + if (slashIndex >= 0) { + if ( + text.indexOf('/', slashIndex + 1) >= 0 || + slashIndex === 0 || + slashIndex === text.length - 1 + ) { + return { ok: false, reason: 'invalid-syntax' }; + } + + const numeratorText = text.slice(0, slashIndex).trim(); + const denominatorText = text.slice(slashIndex + 1).trim(); + if ( + !INTEGER_PATTERN.test(numeratorText) || + !INTEGER_PATTERN.test(denominatorText) + ) { + return { ok: false, reason: 'invalid-syntax' }; + } + + const numerator = BigInt(numeratorText); + const denominator = BigInt(denominatorText); + if (denominator === 0n) { + return { ok: false, reason: 'division-by-zero' }; + } + + const componentLimit = BigInt(Math.floor(maxComponent)); + if ( + absoluteBigInt(numerator) > componentLimit || + absoluteBigInt(denominator) > componentLimit + ) { + return { ok: false, reason: 'component-too-large' }; + } + + value = Number(numerator) / Number(denominator); + } else { + if (!DECIMAL_PATTERN.test(text)) { + return { ok: false, reason: 'invalid-syntax' }; + } + value = Number(text); + } + + if (!Number.isFinite(value)) { + return { ok: false, reason: 'not-finite' }; + } + if (Math.abs(value) > maxAbsoluteValue) { + return { ok: false, reason: 'result-too-large' }; + } + if (value < 0 && options.allowNegative === false) { + return { ok: false, reason: 'negative-not-allowed' }; + } + if (value === 0 && options.allowZero === false) { + return { ok: false, reason: 'zero-not-allowed' }; + } + + return { ok: true, value }; +} + +export function parseFraction( + input: unknown, + options?: FractionParseOptions +): number | undefined { + const result = parseFractionDetailed(input, options); + return result.ok ? result.value : undefined; +} + +export function parseFrameRate(input: unknown): number | undefined { + return parseFraction(input, { + allowNegative: false, + allowZero: false, + maxComponent: DEFAULT_MAX_COMPONENT, + maxAbsoluteValue: 1_000, + }); +} + +export function parseFfprobeJson(rawReport: string): ParsedProbeReport { + let raw: unknown; + try { + raw = JSON.parse(rawReport) as unknown; + } catch (error) { + throw new ProbeParseError('ffprobe returned invalid JSON.', error); + } + + return { + probe: normalizeFfprobeReport(raw), + rawReport, + }; +} + +export function normalizeFfprobeReport(raw: unknown): MediaProbe { + if (!isRecord(raw)) { + throw new ProbeParseError('The ffprobe report must be a JSON object.', raw); + } + + const warnings: ProbeWarning[] = []; + const format = isRecord(raw.format) ? raw.format : {}; + if (raw.format !== undefined && !isRecord(raw.format)) { + warn( + warnings, + 'invalid-field', + 'format', + 'Expected the format section to be an object.', + raw.format + ); + } + + const formatName = optionalString( + format.format_name, + 'format.format_name', + warnings + ); + const streams = normalizeStreams(raw.streams, warnings); + const chapters = normalizeChapters(raw.chapters, warnings); + + return compactObject({ + durationSeconds: optionalNonNegativeNumber( + format.duration, + 'format.duration', + warnings + ), + startTimeSeconds: optionalFiniteNumber( + format.start_time, + 'format.start_time', + warnings + ), + bitRate: optionalNonNegativeInteger( + format.bit_rate, + 'format.bit_rate', + warnings + ), + formatNames: formatName + ? [ + ...new Set( + formatName + .split(',') + .map((name) => name.trim()) + .filter(Boolean) + ), + ] + : [], + formatLongName: optionalString( + format.format_long_name, + 'format.format_long_name', + warnings + ), + tags: normalizeTags(format.tags, 'format.tags', warnings), + streams, + chapters, + warnings, + }); +} + +function normalizeStreams( + value: unknown, + warnings: ProbeWarning[] +): MediaStreamProbe[] { + if (value === undefined) { + return []; + } + if (!Array.isArray(value)) { + warn( + warnings, + 'invalid-field', + 'streams', + 'Expected streams to be an array.', + value + ); + return []; + } + + const streams: MediaStreamProbe[] = []; + const streamIndexes = new Set(); + value.forEach((entry, arrayIndex) => { + const path = `streams[${arrayIndex}]`; + if (!isRecord(entry)) { + warn( + warnings, + 'invalid-stream', + path, + 'Ignored a stream entry that was not an object.', + entry + ); + return; + } + + const index = optionalNonNegativeInteger( + entry.index, + `${path}.index`, + warnings + ); + if (index === undefined) { + warn( + warnings, + 'missing-stream-index', + `${path}.index`, + 'Ignored a stream without a valid non-negative index.', + entry.index + ); + return; + } + if (streamIndexes.has(index)) { + warn( + warnings, + 'duplicate-stream-index', + `${path}.index`, + `Ignored duplicate stream index ${index}.`, + index + ); + return; + } + streamIndexes.add(index); + + const tags = normalizeTags(entry.tags, `${path}.tags`, warnings); + const type = normalizeStreamType(entry.codec_type, path, warnings); + const frameRate = normalizeFrameRate(entry, path, warnings); + const timeBase = normalizeStreamTimeBase(entry.time_base, path, warnings); + const rotationDegrees = normalizeRotation(entry, tags, path, warnings); + + streams.push( + compactObject({ + index, + type, + codecName: optionalString( + entry.codec_name, + `${path}.codec_name`, + warnings + ), + codecLongName: optionalString( + entry.codec_long_name, + `${path}.codec_long_name`, + warnings + ), + profile: optionalString(entry.profile, `${path}.profile`, warnings), + durationSeconds: optionalNonNegativeNumber( + entry.duration, + `${path}.duration`, + warnings + ), + bitRate: optionalNonNegativeInteger( + entry.bit_rate, + `${path}.bit_rate`, + warnings + ), + language: tags.language, + title: tags.title, + disposition: normalizeDisposition( + entry.disposition, + `${path}.disposition`, + warnings + ), + width: optionalPositiveInteger(entry.width, `${path}.width`, warnings), + height: optionalPositiveInteger( + entry.height, + `${path}.height`, + warnings + ), + codedWidth: optionalPositiveInteger( + entry.coded_width, + `${path}.coded_width`, + warnings + ), + codedHeight: optionalPositiveInteger( + entry.coded_height, + `${path}.coded_height`, + warnings + ), + pixelFormat: optionalString(entry.pix_fmt, `${path}.pix_fmt`, warnings), + timeBase, + sampleAspectRatio: normalizeAspectRatio( + entry.sample_aspect_ratio, + `${path}.sample_aspect_ratio`, + warnings + ), + displayAspectRatio: normalizeAspectRatio( + entry.display_aspect_ratio, + `${path}.display_aspect_ratio`, + warnings + ), + rotationDegrees, + frameRate, + sampleRate: optionalPositiveInteger( + entry.sample_rate, + `${path}.sample_rate`, + warnings + ), + channels: optionalPositiveInteger( + entry.channels, + `${path}.channels`, + warnings + ), + channelLayout: optionalString( + entry.channel_layout, + `${path}.channel_layout`, + warnings + ), + sampleFormat: optionalString( + entry.sample_fmt, + `${path}.sample_fmt`, + warnings + ), + tags, + }) + ); + }); + + return streams.sort((left, right) => left.index - right.index); +} + +function normalizeStreamTimeBase( + value: unknown, + path: string, + warnings: ProbeWarning[] +): string | undefined { + const timeBase = optionalString(value, `${path}.time_base`, warnings); + if (timeBase === undefined) { + return undefined; + } + if ( + parseFraction(timeBase, { + allowNegative: false, + allowZero: false, + maxAbsoluteValue: 1, + }) === undefined + ) { + warn( + warnings, + 'invalid-fraction', + `${path}.time_base`, + 'Ignored an invalid stream time base.', + value + ); + return undefined; + } + return timeBase; +} + +function normalizeChapters( + value: unknown, + warnings: ProbeWarning[] +): ChapterProbe[] { + if (value === undefined) { + return []; + } + if (!Array.isArray(value)) { + warn( + warnings, + 'invalid-field', + 'chapters', + 'Expected chapters to be an array.', + value + ); + return []; + } + + const chapters: ChapterProbe[] = []; + value.forEach((entry, arrayIndex) => { + const path = `chapters[${arrayIndex}]`; + if (!isRecord(entry)) { + warn( + warnings, + 'invalid-chapter', + path, + 'Ignored a chapter entry that was not an object.', + entry + ); + return; + } + + const id = + optionalNonNegativeInteger(entry.id, `${path}.id`, warnings) ?? + arrayIndex; + const timeBase = optionalString( + entry.time_base, + `${path}.time_base`, + warnings + ); + const timeBaseValue = + timeBase === undefined + ? undefined + : parseFraction(timeBase, { + allowNegative: false, + allowZero: false, + maxAbsoluteValue: 1, + }); + + if (timeBase !== undefined && timeBaseValue === undefined) { + warn( + warnings, + 'invalid-fraction', + `${path}.time_base`, + 'Ignored an invalid chapter time base.', + timeBase + ); + } + + const startSeconds = + optionalNonNegativeNumber( + entry.start_time, + `${path}.start_time`, + warnings + ) ?? + ticksToSeconds(entry.start, timeBaseValue, `${path}.start`, warnings); + const endSeconds = + optionalNonNegativeNumber(entry.end_time, `${path}.end_time`, warnings) ?? + ticksToSeconds(entry.end, timeBaseValue, `${path}.end`, warnings); + + if ( + startSeconds === undefined || + endSeconds === undefined || + endSeconds < startSeconds + ) { + warn( + warnings, + 'invalid-chapter', + path, + 'Ignored a chapter without a valid start and end time.', + entry + ); + return; + } + + const tags = normalizeTags(entry.tags, `${path}.tags`, warnings); + chapters.push( + compactObject({ + id, + startSeconds, + endSeconds, + timeBase, + title: tags.title, + tags, + }) + ); + }); + + return chapters.sort( + (left, right) => + left.startSeconds - right.startSeconds || left.id - right.id + ); +} + +function normalizeFrameRate( + stream: Record, + path: string, + warnings: ProbeWarning[] +): number | undefined { + const candidates: Array<[string, unknown]> = [ + ['avg_frame_rate', stream.avg_frame_rate], + ['r_frame_rate', stream.r_frame_rate], + ]; + + for (const [field, value] of candidates) { + if (value === undefined || value === null || value === '') { + continue; + } + const frameRate = parseFrameRate(value); + if (frameRate !== undefined) { + return frameRate; + } + warn( + warnings, + 'invalid-fraction', + `${path}.${field}`, + 'Ignored an invalid or implausible frame-rate fraction.', + value + ); + } + return undefined; +} + +function normalizeAspectRatio( + value: unknown, + path: string, + warnings: ProbeWarning[] +): string | undefined { + if (value === undefined || value === null || value === '') { + return undefined; + } + if (typeof value !== 'string') { + warn(warnings, 'invalid-field', path, 'Expected an aspect ratio.', value); + return undefined; + } + + if (value.length > 64) { + warn( + warnings, + 'invalid-fraction', + path, + 'Ignored an implausibly long aspect ratio.', + value + ); + return undefined; + } + const match = /^\s*(\d+)\s*[:/]\s*(\d+)\s*$/u.exec(value); + if (!match?.[1] || !match[2]) { + warn( + warnings, + 'invalid-fraction', + path, + 'Ignored an invalid aspect ratio.', + value + ); + return undefined; + } + + const numerator = BigInt(match[1]); + const denominator = BigInt(match[2]); + const componentLimit = BigInt(DEFAULT_MAX_COMPONENT); + if ( + numerator === 0n || + denominator === 0n || + numerator > componentLimit || + denominator > componentLimit + ) { + warn( + warnings, + 'invalid-fraction', + path, + 'Ignored a zero or implausibly large aspect ratio.', + value + ); + return undefined; + } + + const divisor = greatestCommonDivisor(numerator, denominator); + return `${numerator / divisor}:${denominator / divisor}`; +} + +function normalizeRotation( + stream: Record, + tags: Record, + path: string, + warnings: ProbeWarning[] +): number | undefined { + const sideData = stream.side_data_list; + if (sideData !== undefined && !Array.isArray(sideData)) { + warn( + warnings, + 'invalid-field', + `${path}.side_data_list`, + 'Expected stream side data to be an array.', + sideData + ); + } else if (Array.isArray(sideData)) { + for (let index = 0; index < sideData.length; index += 1) { + const entry = sideData[index]; + if (!isRecord(entry)) { + warn( + warnings, + 'invalid-field', + `${path}.side_data_list[${index}]`, + 'Ignored a side-data entry that was not an object.', + entry + ); + continue; + } + if (entry.rotation === undefined) continue; + const rotation = normalizeRotationValue( + entry.rotation, + `${path}.side_data_list[${index}].rotation`, + warnings + ); + if (rotation !== undefined) return rotation; + } + } + + const legacyRotation = Object.entries(tags).find( + ([key]) => key.toLowerCase() === 'rotate' + )?.[1]; + return legacyRotation === undefined + ? undefined + : normalizeRotationValue(legacyRotation, `${path}.tags.rotate`, warnings); +} + +function normalizeRotationValue( + value: unknown, + path: string, + warnings: ProbeWarning[] +): number | undefined { + const rotation = parseFiniteNumericValue(value); + if (!Number.isFinite(rotation) || Math.abs(rotation) > 1_000_000) { + warn( + warnings, + 'invalid-field', + path, + 'Ignored invalid or implausible rotation metadata.', + value + ); + return undefined; + } + + const normalized = ((((rotation + 180) % 360) + 360) % 360) - 180; + const signed = normalized === -180 ? 180 : normalized; + return Object.is(signed, -0) ? 0 : signed; +} + +function normalizeStreamType( + value: unknown, + path: string, + warnings: ProbeWarning[] +): MediaStreamType { + if ( + value === 'video' || + value === 'audio' || + value === 'subtitle' || + value === 'attachment' || + value === 'data' + ) { + return value; + } + + if (value !== undefined && value !== 'unknown') { + warn( + warnings, + 'unknown-stream-type', + `${path}.codec_type`, + 'Mapped an unrecognized stream type to "unknown".', + value + ); + } + return 'unknown'; +} + +function normalizeTags( + value: unknown, + path: string, + warnings: ProbeWarning[] +): Record { + if (value === undefined) { + return {}; + } + if (!isRecord(value)) { + warn( + warnings, + 'invalid-field', + path, + 'Expected tags to be an object.', + value + ); + return {}; + } + + const tags: Record = {}; + for (const [key, tagValue] of Object.entries(value)) { + if (!isSafeRecordKey(key)) { + warn( + warnings, + 'invalid-field', + `${path}.${key}`, + 'Ignored an unsafe tag key.', + key + ); + } else if (typeof tagValue === 'string') { + tags[key] = tagValue; + } else { + warn( + warnings, + 'invalid-field', + `${path}.${key}`, + 'Ignored a tag whose value was not a string.', + tagValue + ); + } + } + return tags; +} + +function normalizeDisposition( + value: unknown, + path: string, + warnings: ProbeWarning[] +): Record { + if (value === undefined) { + return {}; + } + if (!isRecord(value)) { + warn( + warnings, + 'invalid-field', + path, + 'Expected stream disposition to be an object.', + value + ); + return {}; + } + + const disposition: Record = {}; + for (const [key, flag] of Object.entries(value)) { + if (!isSafeRecordKey(key)) { + warn( + warnings, + 'invalid-field', + `${path}.${key}`, + 'Ignored an unsafe disposition key.', + key + ); + } else if (flag === true || flag === 1 || flag === '1') { + disposition[key] = true; + } else if (flag === false || flag === 0 || flag === '0') { + disposition[key] = false; + } else { + warn( + warnings, + 'invalid-field', + `${path}.${key}`, + 'Ignored a disposition flag that was not boolean-like.', + flag + ); + } + } + return disposition; +} + +function ticksToSeconds( + ticks: unknown, + timeBase: number | undefined, + path: string, + warnings: ProbeWarning[] +): number | undefined { + if (ticks === undefined || timeBase === undefined) { + return undefined; + } + const tickValue = optionalNonNegativeInteger(ticks, path, warnings); + return tickValue === undefined ? undefined : tickValue * timeBase; +} + +function optionalString( + value: unknown, + path: string, + warnings: ProbeWarning[] +): string | undefined { + if (value === undefined || value === null || value === '') { + return undefined; + } + if (typeof value !== 'string') { + warn(warnings, 'invalid-field', path, 'Expected a string value.', value); + return undefined; + } + return value; +} + +function optionalFiniteNumber( + value: unknown, + path: string, + warnings: ProbeWarning[] +): number | undefined { + if (value === undefined || value === null || value === '') { + return undefined; + } + const number = parseFiniteNumericValue(value); + if (!Number.isFinite(number)) { + warn( + warnings, + 'invalid-field', + path, + 'Expected a finite numeric value.', + value + ); + return undefined; + } + return number; +} + +function optionalNonNegativeNumber( + value: unknown, + path: string, + warnings: ProbeWarning[] +): number | undefined { + const number = optionalFiniteNumber(value, path, warnings); + if (number === undefined) { + return undefined; + } + if (number < 0) { + warn( + warnings, + 'invalid-field', + path, + 'Expected a non-negative numeric value.', + value + ); + return undefined; + } + return number; +} + +function optionalPositiveInteger( + value: unknown, + path: string, + warnings: ProbeWarning[] +): number | undefined { + const integer = optionalInteger(value, path, warnings); + if (integer === undefined) { + return undefined; + } + if (integer <= 0) { + warn( + warnings, + 'invalid-field', + path, + 'Expected a positive integer.', + value + ); + return undefined; + } + return integer; +} + +function optionalNonNegativeInteger( + value: unknown, + path: string, + warnings: ProbeWarning[] +): number | undefined { + const integer = optionalInteger(value, path, warnings); + if (integer === undefined) { + return undefined; + } + if (integer < 0) { + warn( + warnings, + 'invalid-field', + path, + 'Expected a non-negative integer.', + value + ); + return undefined; + } + return integer; +} + +function optionalInteger( + value: unknown, + path: string, + warnings: ProbeWarning[] +): number | undefined { + if (value === undefined || value === null || value === '') { + return undefined; + } + const number = parseFiniteNumericValue(value); + if (!Number.isSafeInteger(number)) { + warn(warnings, 'invalid-field', path, 'Expected a safe integer.', value); + return undefined; + } + return number; +} + +function warn( + warnings: ProbeWarning[], + code: ProbeWarningCode, + path: string, + message: string, + received: unknown +): void { + warnings.push({ + code, + path, + message, + received: describe(received), + }); +} + +function describe(value: unknown): string { + if (typeof value === 'string') { + return value.length > 120 ? `${value.slice(0, 117)}...` : value; + } + try { + const serialized = JSON.stringify(value); + return serialized === undefined ? String(value) : serialized.slice(0, 120); + } catch { + return String(value); + } +} + +function compactObject(value: T): T { + for (const key of Object.keys(value) as Array) { + if (value[key] === undefined) { + delete value[key]; + } + } + return value; +} + +function normalizePositiveLimit( + value: number | undefined, + fallback: number +): number { + return value !== undefined && Number.isFinite(value) && value > 0 + ? value + : fallback; +} + +function absoluteBigInt(value: bigint): bigint { + return value < 0n ? -value : value; +} + +function greatestCommonDivisor(left: bigint, right: bigint): bigint { + let dividend = left; + let divisor = right; + while (divisor !== 0n) { + const remainder = dividend % divisor; + dividend = divisor; + divisor = remainder; + } + return dividend; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function parseFiniteNumericValue(value: unknown): number { + if (typeof value === 'number') { + return value; + } + if (typeof value !== 'string' || !FINITE_NUMBER_PATTERN.test(value.trim())) { + return Number.NaN; + } + return Number(value); +} + +function isSafeRecordKey(key: string): boolean { + return key !== '__proto__' && key !== 'prototype' && key !== 'constructor'; +} diff --git a/src/media/safe-file-name.ts b/src/media/safe-file-name.ts new file mode 100644 index 0000000..80857e1 --- /dev/null +++ b/src/media/safe-file-name.ts @@ -0,0 +1,127 @@ +const WINDOWS_RESERVED_NAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i; +const COMBINING_MARKS = /[\u0300-\u036f]/g; +const UNSAFE_CHARACTERS = /[^A-Za-z0-9._-]+/g; +const REPEATED_SEPARATORS = /[-_]{2,}/g; + +export interface SafeFileNameOptions { + fallback?: string; + maxLength?: number; + preserveExtension?: boolean; +} + +export function safeFileName( + originalName: string, + options: SafeFileNameOptions = {} +): string { + const fallback = sanitizePart(options.fallback ?? 'file') || 'file'; + const maxLength = options.maxLength ?? 120; + if (!Number.isInteger(maxLength) || maxLength < 8 || maxLength > 255) { + throw new RangeError('Maximum filename length must be from 8 to 255.'); + } + + const basename = originalName.split(/[\\/]/).at(-1) ?? ''; + const normalized = removeControlCharacters( + basename.normalize('NFKD').replace(COMBINING_MARKS, '') + ).trim(); + const { stem, extension } = + options.preserveExtension === false + ? { stem: normalized, extension: '' } + : splitFileName(normalized); + const safeStem = + sanitizePart(stem) + .replace(/\.{2,}/g, '.') + .replace(/^\.+|\.+$/g, '') || fallback; + const safeExtension = + options.preserveExtension === false ? '' : sanitizeExtension(extension); + const reservedStem = WINDOWS_RESERVED_NAME.test(safeStem) + ? `_${safeStem}` + : safeStem; + + const availableStemLength = Math.max(1, maxLength - safeExtension.length); + const truncatedStem = + reservedStem.slice(0, availableStemLength).replace(/[._-]+$/g, '') || + fallback.slice(0, availableStemLength); + return `${truncatedStem}${safeExtension}`.slice(0, maxLength); +} + +export function createSafeVirtualFileName( + originalName: string, + index: number, + prefix = 'source' +): string { + if (!Number.isSafeInteger(index) || index < 0) { + throw new RangeError('File index must be a non-negative safe integer.'); + } + const sanitized = safeFileName(originalName, { + fallback: prefix, + maxLength: 96, + }); + const { extension } = splitFileName(sanitized); + return `${safeFileName(prefix, { + preserveExtension: false, + maxLength: 40, + })}-${index}${extension}`; +} + +export function safeOutputFileName( + requestedStem: string, + extension: string, + fallback = 'output' +): string { + const normalizedExtension = sanitizeExtension( + extension.startsWith('.') ? extension : `.${extension}` + ); + const stem = safeFileName(requestedStem, { + fallback, + preserveExtension: false, + maxLength: Math.max(8, 120 - normalizedExtension.length), + }); + return `${stem}${normalizedExtension}`; +} + +export function splitFileName(name: string): { + stem: string; + extension: string; +} { + const lastDot = name.lastIndexOf('.'); + if (lastDot <= 0 || lastDot === name.length - 1) { + return { stem: name, extension: '' }; + } + const extension = name.slice(lastDot); + if (extension.length > 17) { + return { stem: name, extension: '' }; + } + return { stem: name.slice(0, lastDot), extension }; +} + +function sanitizePart(value: string): string { + return value + .normalize('NFKD') + .replace(COMBINING_MARKS, '') + .replace(UNSAFE_CHARACTERS, '-') + .replace(REPEATED_SEPARATORS, (separator) => separator[0] ?? '-') + .replace(/^[._-]+|[._-]+$/g, ''); +} + +function sanitizeExtension(extension: string): string { + if (!extension) { + return ''; + } + const parts = extension + .replace(/^\.+/, '') + .normalize('NFKD') + .replace(COMBINING_MARKS, '') + .split('.') + .map((part) => part.replace(/[^A-Za-z0-9]/g, '').slice(0, 16)) + .filter(Boolean); + return parts.length > 0 ? `.${parts.join('.').toLowerCase()}` : ''; +} + +function removeControlCharacters(value: string): string { + return Array.from(value) + .filter((character) => { + const codePoint = character.codePointAt(0) ?? 0; + return codePoint >= 32 && codePoint !== 127; + }) + .join(''); +} diff --git a/src/media/stream-selection.ts b/src/media/stream-selection.ts new file mode 100644 index 0000000..f7c190c --- /dev/null +++ b/src/media/stream-selection.ts @@ -0,0 +1,233 @@ +import type { + MediaProbe, + MediaStreamProbe, + MediaStreamType, +} from './media.types'; + +export interface StreamSelection { + videoStreamIndex?: number; + audioStreamIndexes: number[]; + subtitleStreamIndexes: number[]; +} + +export interface DefaultStreamSelectionOptions { + preferredAudioLanguages?: readonly string[]; + preferredSubtitleLanguages?: readonly string[]; + includeSubtitles?: boolean; + includeAllAudioStreams?: boolean; +} + +export type StreamSelectionIssueCode = + 'stream-not-found' | 'wrong-stream-type' | 'duplicate-stream'; + +export interface StreamSelectionIssue { + code: StreamSelectionIssueCode; + field: 'videoStreamIndex' | 'audioStreamIndexes' | 'subtitleStreamIndexes'; + streamIndex: number; + message: string; +} + +export interface StreamSelectionValidation { + valid: boolean; + issues: StreamSelectionIssue[]; +} + +export function streamsOfType( + probe: MediaProbe, + type: MediaStreamType +): MediaStreamProbe[] { + return probe.streams.filter((stream) => stream.type === type); +} + +export function findStream( + probe: MediaProbe, + index: number +): MediaStreamProbe | undefined { + return probe.streams.find((stream) => stream.index === index); +} + +export function selectDefaultStreams( + probe: MediaProbe, + options: DefaultStreamSelectionOptions = {} +): StreamSelection { + const videoCandidates = streamsOfType(probe, 'video').filter( + (stream) => stream.disposition.attached_pic !== true + ); + const audioCandidates = streamsOfType(probe, 'audio'); + const subtitleCandidates = streamsOfType(probe, 'subtitle'); + const defaultVideo = choosePreferredStream(videoCandidates); + const preferredAudio = choosePreferredStream( + audioCandidates, + options.preferredAudioLanguages + ); + const preferredSubtitle = choosePreferredStream( + subtitleCandidates, + options.preferredSubtitleLanguages + ); + + return { + ...(defaultVideo === undefined + ? {} + : { videoStreamIndex: defaultVideo.index }), + audioStreamIndexes: + options.includeAllAudioStreams === true + ? orderByPreference( + audioCandidates, + options.preferredAudioLanguages + ).map((stream) => stream.index) + : preferredAudio === undefined + ? [] + : [preferredAudio.index], + subtitleStreamIndexes: + options.includeSubtitles === true && preferredSubtitle !== undefined + ? [preferredSubtitle.index] + : [], + }; +} + +export function validateStreamSelection( + probe: MediaProbe, + selection: StreamSelection +): StreamSelectionValidation { + const issues: StreamSelectionIssue[] = []; + const seen = new Set(); + + if (selection.videoStreamIndex !== undefined) { + validateSelectedIndex( + probe, + selection.videoStreamIndex, + 'video', + 'videoStreamIndex', + seen, + issues + ); + } + for (const streamIndex of selection.audioStreamIndexes) { + validateSelectedIndex( + probe, + streamIndex, + 'audio', + 'audioStreamIndexes', + seen, + issues + ); + } + for (const streamIndex of selection.subtitleStreamIndexes) { + validateSelectedIndex( + probe, + streamIndex, + 'subtitle', + 'subtitleStreamIndexes', + seen, + issues + ); + } + + return { valid: issues.length === 0, issues }; +} + +export function streamSelectionMapArgs( + selection: StreamSelection, + inputIndex = 0 +): string[] { + if (!Number.isSafeInteger(inputIndex) || inputIndex < 0) { + throw new RangeError('Input index must be a non-negative safe integer.'); + } + + const args: string[] = []; + const append = (streamIndex: number): void => { + if (!Number.isSafeInteger(streamIndex) || streamIndex < 0) { + throw new RangeError( + 'Stream indexes must be non-negative safe integers.' + ); + } + args.push('-map', `${inputIndex}:${streamIndex}`); + }; + + if (selection.videoStreamIndex !== undefined) { + append(selection.videoStreamIndex); + } + selection.audioStreamIndexes.forEach(append); + selection.subtitleStreamIndexes.forEach(append); + return args; +} + +function choosePreferredStream( + streams: readonly MediaStreamProbe[], + preferredLanguages?: readonly string[] +): MediaStreamProbe | undefined { + return orderByPreference(streams, preferredLanguages)[0]; +} + +function orderByPreference( + streams: readonly MediaStreamProbe[], + preferredLanguages?: readonly string[] +): MediaStreamProbe[] { + const languageRank = new Map( + (preferredLanguages ?? []).map((language, index) => [ + normalizeLanguage(language), + index, + ]) + ); + + return [...streams].sort((left, right) => { + const leftLanguage = left.language + ? languageRank.get(normalizeLanguage(left.language)) + : undefined; + const rightLanguage = right.language + ? languageRank.get(normalizeLanguage(right.language)) + : undefined; + const leftRank = leftLanguage ?? Number.POSITIVE_INFINITY; + const rightRank = rightLanguage ?? Number.POSITIVE_INFINITY; + if (leftRank !== rightRank) { + return leftRank - rightRank; + } + const defaultDifference = + Number(right.disposition.default === true) - + Number(left.disposition.default === true); + if (defaultDifference !== 0) { + return defaultDifference; + } + return left.index - right.index; + }); +} + +function validateSelectedIndex( + probe: MediaProbe, + streamIndex: number, + expectedType: MediaStreamType, + field: StreamSelectionIssue['field'], + seen: Set, + issues: StreamSelectionIssue[] +): void { + if (seen.has(streamIndex)) { + issues.push({ + code: 'duplicate-stream', + field, + streamIndex, + message: `Stream ${streamIndex} is selected more than once.`, + }); + } + seen.add(streamIndex); + + const stream = findStream(probe, streamIndex); + if (stream === undefined) { + issues.push({ + code: 'stream-not-found', + field, + streamIndex, + message: `Stream ${streamIndex} does not exist in the source.`, + }); + } else if (stream.type !== expectedType) { + issues.push({ + code: 'wrong-stream-type', + field, + streamIndex, + message: `Stream ${streamIndex} is ${stream.type}, not ${expectedType}.`, + }); + } +} + +function normalizeLanguage(language: string): string { + return language.trim().toLowerCase().split(/[-_]/)[0] ?? ''; +} diff --git a/src/media/timecode.ts b/src/media/timecode.ts new file mode 100644 index 0000000..23e606c --- /dev/null +++ b/src/media/timecode.ts @@ -0,0 +1,199 @@ +import { roundSeconds } from './duration'; + +export type TimecodeParseFailure = + | 'empty' + | 'invalid-syntax' + | 'negative-not-allowed' + | 'minutes-out-of-range' + | 'seconds-out-of-range' + | 'frames-out-of-range' + | 'frame-rate-required' + | 'invalid-frame-rate' + | 'not-finite'; + +export type TimecodeParseResult = + { ok: true; seconds: number } | { ok: false; reason: TimecodeParseFailure }; + +export interface ParseTimecodeOptions { + allowNegative?: boolean; + /** + * Enables HH:MM:SS:FF input. This is nominal, non-drop-frame timecode. + */ + frameRate?: number; +} + +export interface FormatTimecodeOptions { + decimalPlaces?: number; + alwaysShowHours?: boolean; +} + +const DECIMAL_COMPONENT = /^\d+(?:\.\d+)?$/; +const INTEGER_COMPONENT = /^\d+$/; + +export function parseTimecodeDetailed( + input: string | number, + options: ParseTimecodeOptions = {} +): TimecodeParseResult { + const text = String(input).trim(); + if (!text) { + return { ok: false, reason: 'empty' }; + } + + const negative = text.startsWith('-'); + if (negative && options.allowNegative !== true) { + return { ok: false, reason: 'negative-not-allowed' }; + } + + const unsigned = negative || text.startsWith('+') ? text.slice(1) : text; + const parts = unsigned.split(':'); + if (parts.length > 4 || parts.some((part) => part === '')) { + return { ok: false, reason: 'invalid-syntax' }; + } + + let seconds: number; + if (parts.length === 4) { + if (options.frameRate === undefined) { + return { ok: false, reason: 'frame-rate-required' }; + } + if (!isValidFrameRate(options.frameRate)) { + return { ok: false, reason: 'invalid-frame-rate' }; + } + if (!parts.every((part) => INTEGER_COMPONENT.test(part))) { + return { ok: false, reason: 'invalid-syntax' }; + } + + const hours = Number(parts[0]); + const minutes = Number(parts[1]); + const wholeSeconds = Number(parts[2]); + const frames = Number(parts[3]); + if (minutes >= 60) { + return { ok: false, reason: 'minutes-out-of-range' }; + } + if (wholeSeconds >= 60) { + return { ok: false, reason: 'seconds-out-of-range' }; + } + if (frames >= Math.ceil(options.frameRate)) { + return { ok: false, reason: 'frames-out-of-range' }; + } + seconds = + hours * 3_600 + minutes * 60 + wholeSeconds + frames / options.frameRate; + } else { + if ( + !parts.every((part, index) => + index === parts.length - 1 + ? DECIMAL_COMPONENT.test(part) + : INTEGER_COMPONENT.test(part) + ) + ) { + return { ok: false, reason: 'invalid-syntax' }; + } + const values = parts.map(Number); + const last = values.at(-1); + if (last === undefined) { + return { ok: false, reason: 'invalid-syntax' }; + } + if (parts.length >= 2 && last >= 60) { + return { ok: false, reason: 'seconds-out-of-range' }; + } + if (parts.length === 3 && (values[1] ?? 0) >= 60) { + return { ok: false, reason: 'minutes-out-of-range' }; + } + + seconds = + parts.length === 1 + ? last + : parts.length === 2 + ? (values[0] ?? 0) * 60 + last + : (values[0] ?? 0) * 3_600 + (values[1] ?? 0) * 60 + last; + } + + seconds *= negative ? -1 : 1; + if (!Number.isFinite(seconds)) { + return { ok: false, reason: 'not-finite' }; + } + return { ok: true, seconds: roundSeconds(seconds, 9) }; +} + +export function parseTimecode( + input: string | number, + options?: ParseTimecodeOptions +): number | undefined { + const result = parseTimecodeDetailed(input, options); + return result.ok ? result.seconds : undefined; +} + +export function formatTimecode( + seconds: number, + options: FormatTimecodeOptions = {} +): string { + if (!Number.isFinite(seconds)) { + throw new RangeError('Timecode seconds must be finite.'); + } + const decimalPlaces = options.decimalPlaces ?? 3; + if ( + !Number.isInteger(decimalPlaces) || + decimalPlaces < 0 || + decimalPlaces > 9 + ) { + throw new RangeError('Decimal places must be an integer from 0 to 9.'); + } + + const negative = seconds < 0; + const unitsPerSecond = 10 ** decimalPlaces; + const totalUnits = Math.round(Math.abs(seconds) * unitsPerSecond); + const unitsPerMinute = 60 * unitsPerSecond; + const unitsPerHour = 60 * unitsPerMinute; + const hours = Math.floor(totalUnits / unitsPerHour); + const minuteRemainder = totalUnits % unitsPerHour; + const minutes = Math.floor(minuteRemainder / unitsPerMinute); + const secondUnits = minuteRemainder % unitsPerMinute; + const wholeSeconds = Math.floor(secondUnits / unitsPerSecond); + const fractionalUnits = secondUnits % unitsPerSecond; + const sign = negative && totalUnits !== 0 ? '-' : ''; + const secondText = + decimalPlaces === 0 + ? padTwo(wholeSeconds) + : `${padTwo(wholeSeconds)}.${String(fractionalUnits).padStart( + decimalPlaces, + '0' + )}`; + + if (hours > 0 || options.alwaysShowHours !== false) { + return `${sign}${String(hours).padStart(2, '0')}:${padTwo(minutes)}:${secondText}`; + } + return `${sign}${padTwo(minutes)}:${secondText}`; +} + +export function formatFrameTimecode( + seconds: number, + frameRate: number +): string { + if (!Number.isFinite(seconds)) { + throw new RangeError('Timecode seconds must be finite.'); + } + if (!isValidFrameRate(frameRate)) { + throw new RangeError('Frame rate must be finite and positive.'); + } + + const nominalFrameRate = Math.round(frameRate); + const totalFrames = Math.round(Math.abs(seconds) * frameRate); + const frames = totalFrames % nominalFrameRate; + const totalWholeSeconds = Math.floor(totalFrames / nominalFrameRate); + const wholeSeconds = totalWholeSeconds % 60; + const totalMinutes = Math.floor(totalWholeSeconds / 60); + const minutes = totalMinutes % 60; + const hours = Math.floor(totalMinutes / 60); + const sign = seconds < 0 && totalFrames !== 0 ? '-' : ''; + + return `${sign}${String(hours).padStart(2, '0')}:${padTwo(minutes)}:${padTwo( + wholeSeconds + )}:${String(frames).padStart(2, '0')}`; +} + +function isValidFrameRate(frameRate: number): boolean { + return Number.isFinite(frameRate) && frameRate >= 1 && frameRate <= 1_000; +} + +function padTwo(value: number): string { + return String(value).padStart(2, '0'); +} diff --git a/src/presets/audio-presets.ts b/src/presets/audio-presets.ts new file mode 100644 index 0000000..2a1ec18 --- /dev/null +++ b/src/presets/audio-presets.ts @@ -0,0 +1,113 @@ +import type { BuiltInExportPreset } from './preset.types'; + +function audioPreset( + preset: Omit +): BuiltInExportPreset { + return Object.freeze({ + ...preset, + kind: 'audio', + builtIn: true, + requirements: Object.freeze({ + ...preset.requirements, + ...(preset.requirements.muxers + ? { muxers: Object.freeze([...preset.requirements.muxers]) } + : {}), + ...(preset.requirements.encoders + ? { encoders: Object.freeze([...preset.requirements.encoders]) } + : {}), + }), + ...(preset.audio ? { audio: Object.freeze({ ...preset.audio }) } : {}), + settingsSummary: Object.freeze([...preset.settingsSummary]), + }); +} + +const COMMON_POLICY = { + metadataPolicy: 'copy', + chapterPolicy: 'remove', + subtitlePolicy: 'none', +} as const; + +export const AUDIO_PRESETS: readonly BuiltInExportPreset[] = Object.freeze([ + audioPreset({ + id: 'audio-mp3', + name: 'MP3', + description: 'Widely compatible compressed audio.', + container: 'mp3', + fileExtension: 'mp3', + audio: { + codec: 'libmp3lame', + bitrateKbps: 192, + sampleRate: 44_100, + channels: 2, + }, + requirements: { muxers: ['mp3'], encoders: ['libmp3lame'] }, + settingsSummary: ['libmp3lame 192 kbit/s stereo at 44.1 kHz'], + ...COMMON_POLICY, + }), + audioPreset({ + id: 'audio-m4a-aac', + name: 'M4A · AAC', + description: 'Browser-friendly compressed audio.', + container: 'ipod', + fileExtension: 'm4a', + audio: { codec: 'aac', bitrateKbps: 160, sampleRate: 48_000, channels: 2 }, + requirements: { muxers: ['ipod'], encoders: ['aac'] }, + settingsSummary: ['AAC 160 kbit/s stereo at 48 kHz'], + fastStart: true, + ...COMMON_POLICY, + }), + audioPreset({ + id: 'audio-ogg-vorbis', + name: 'Ogg Vorbis', + description: 'Open compressed audio.', + container: 'ogg', + fileExtension: 'ogg', + audio: { + codec: 'libvorbis', + bitrateKbps: 160, + sampleRate: 48_000, + channels: 2, + }, + requirements: { muxers: ['ogg'], encoders: ['libvorbis'] }, + settingsSummary: ['libvorbis 160 kbit/s stereo at 48 kHz'], + ...COMMON_POLICY, + }), + audioPreset({ + id: 'audio-opus', + name: 'Opus', + description: 'Efficient speech and music audio.', + container: 'opus', + fileExtension: 'opus', + audio: { + codec: 'libopus', + bitrateKbps: 128, + sampleRate: 48_000, + channels: 2, + }, + requirements: { muxers: ['opus'], encoders: ['libopus'] }, + settingsSummary: ['libopus 128 kbit/s stereo at 48 kHz'], + ...COMMON_POLICY, + }), + audioPreset({ + id: 'audio-wav-pcm', + name: 'WAV · PCM', + description: 'Uncompressed 16-bit PCM audio.', + container: 'wav', + fileExtension: 'wav', + audio: { codec: 'pcm_s16le', sampleRate: 48_000, channels: 2 }, + requirements: { muxers: ['wav'], encoders: ['pcm_s16le'] }, + settingsSummary: ['16-bit little-endian PCM stereo at 48 kHz'], + ...COMMON_POLICY, + }), + audioPreset({ + id: 'audio-flac', + name: 'FLAC', + description: 'Lossless compressed audio.', + container: 'flac', + fileExtension: 'flac', + audio: { codec: 'flac', sampleRate: 48_000, channels: 2 }, + requirements: { muxers: ['flac'], encoders: ['flac'] }, + settingsSummary: ['FLAC stereo at 48 kHz'], + ...COMMON_POLICY, + }), +]); diff --git a/src/presets/index.ts b/src/presets/index.ts new file mode 100644 index 0000000..a6c7e11 --- /dev/null +++ b/src/presets/index.ts @@ -0,0 +1,6 @@ +export * from './audio-presets'; +export * from './preset-registry'; +export * from './preset-validation'; +export * from './preset.types'; +export * from './user-presets'; +export * from './video-presets'; diff --git a/src/presets/preset-registry.ts b/src/presets/preset-registry.ts new file mode 100644 index 0000000..99c7782 --- /dev/null +++ b/src/presets/preset-registry.ts @@ -0,0 +1,121 @@ +import type { PresetRequirements } from '../commands/command-plan'; +import { AUDIO_PRESETS } from './audio-presets'; +import type { + BuiltInExportPreset, + ExportPresetBase, + FFmpegCapabilitySnapshot, + PresetAvailability, + UserExportPreset, +} from './preset.types'; +import { VIDEO_PRESETS } from './video-presets'; + +export const BUILT_IN_PRESETS: readonly BuiltInExportPreset[] = Object.freeze([ + ...VIDEO_PRESETS, + ...AUDIO_PRESETS, +]); + +export function findBuiltInPreset(id: string): BuiltInExportPreset | undefined { + return BUILT_IN_PRESETS.find((preset) => preset.id === id); +} + +export function presetAvailability( + preset: Pick, + capabilities: FFmpegCapabilitySnapshot, + degradedWhenMissing: PresetRequirements = {} +): PresetAvailability { + const missing = missingRequirements(preset.requirements, capabilities); + if (missing.length > 0) { + return { + status: 'unavailable', + reasons: Object.freeze( + missing.map(({ kind, name }) => `Missing ${kind}: ${name}`) + ), + }; + } + const degraded = missingRequirements(degradedWhenMissing, capabilities); + if (degraded.length > 0) { + return { + status: 'degraded', + reasons: Object.freeze( + degraded.map( + ({ kind, name }) => `Optional ${kind} unavailable: ${name}` + ) + ), + }; + } + return { status: 'available', reasons: Object.freeze([]) }; +} + +export function missingRequirements( + requirements: PresetRequirements, + capabilities: FFmpegCapabilitySnapshot +): readonly { + readonly kind: keyof PresetRequirements; + readonly name: string; +}[] { + const result: { kind: keyof PresetRequirements; name: string }[] = []; + const kinds = ['muxers', 'encoders', 'decoders', 'filters'] as const; + for (const kind of kinds) { + for (const name of requirements[kind] ?? []) { + if (!capabilities[kind].has(name)) { + result.push({ kind, name }); + } + } + } + return Object.freeze(result); +} + +export function presetEncodingArguments( + preset: Pick +): readonly string[] { + const args: string[] = []; + if (preset.video) { + args.push('-c:v', preset.video.codec); + if (preset.video.crf !== undefined) { + if (preset.video.codec === 'libwebp_anim') { + args.push('-quality', String(preset.video.crf)); + } else if (preset.video.codec !== 'gif') { + args.push('-crf', String(preset.video.crf)); + } + } + if ( + preset.video.bitrateKbps !== undefined && + preset.video.bitrateKbps > 0 + ) { + args.push('-b:v', `${preset.video.bitrateKbps}k`); + } + if (preset.video.pixelFormat) { + args.push('-pix_fmt', preset.video.pixelFormat); + } + if (preset.video.encoderPreset && preset.video.codec === 'libx264') { + args.push('-preset', preset.video.encoderPreset); + } + if (preset.video.frameRate) { + args.push('-r', String(preset.video.frameRate)); + } + if (preset.video.codec === 'libwebp_anim') { + args.push('-loop', '0'); + } + } else { + args.push('-vn'); + } + + if (preset.audio) { + args.push('-c:a', preset.audio.codec); + if (preset.audio.bitrateKbps !== undefined) { + args.push('-b:a', `${preset.audio.bitrateKbps}k`); + } + if (preset.audio.sampleRate !== undefined) { + args.push('-ar', String(preset.audio.sampleRate)); + } + if (preset.audio.channels !== undefined) { + args.push('-ac', String(preset.audio.channels)); + } + } else { + args.push('-an'); + } + if (preset.fastStart) { + args.push('-movflags', '+faststart'); + } + return Object.freeze(args); +} diff --git a/src/presets/preset-validation.ts b/src/presets/preset-validation.ts new file mode 100644 index 0000000..d0d5603 --- /dev/null +++ b/src/presets/preset-validation.ts @@ -0,0 +1,734 @@ +import type { + AudioCodec, + AudioEncodingSettings, + ChapterPolicy, + MetadataPolicy, + PresetKind, + QualityMode, + SubtitlePolicy, + UserExportPreset, + VideoCodec, + VideoEncodingSettings, +} from './preset.types'; + +export interface PresetValidationResult { + readonly valid: boolean; + readonly errors: readonly string[]; + readonly preset?: UserExportPreset; +} + +const ROOT_FIELDS = new Set([ + 'schemaVersion', + 'id', + 'name', + 'description', + 'kind', + 'container', + 'fileExtension', + 'video', + 'audio', + 'metadataPolicy', + 'chapterPolicy', + 'subtitlePolicy', + 'fastStart', +]); +const VIDEO_FIELDS = new Set([ + 'codec', + 'qualityMode', + 'crf', + 'bitrateKbps', + 'width', + 'height', + 'frameRate', + 'pixelFormat', + 'encoderPreset', +]); +const AUDIO_FIELDS = new Set([ + 'codec', + 'bitrateKbps', + 'sampleRate', + 'channels', +]); +const LEGACY_ROOT_FIELDS = new Set([ + 'schemaVersion', + 'id', + 'name', + 'description', + 'kind', + 'format', + 'fileExtension', + 'video', + 'audio', + 'metadataPolicy', + 'includeChapters', + 'subtitlePolicy', + 'fastStart', +]); + +type UserPresetKind = Exclude; + +interface UserPresetContainerCompatibility { + readonly extension: string; + readonly kinds: readonly UserPresetKind[]; + readonly videoCodecs: readonly VideoCodec[]; + readonly audioCodecs: readonly AudioCodec[]; + readonly requiresVideo?: boolean; + readonly requiresAudio?: boolean; + readonly forbidsVideo?: boolean; + readonly forbidsAudio?: boolean; + readonly supportsFastStart?: boolean; +} + +/** + * This is deliberately narrower than everything FFmpeg may happen to accept. + * Each entry represents a reviewed path through the typed command builder. + * New combinations must be added together with a command/runtime regression. + */ +const USER_PRESET_CONTAINER_COMPATIBILITY: Readonly< + Record +> = Object.freeze({ + mp4: { + extension: 'mp4', + kinds: ['video'], + videoCodecs: ['libx264'], + audioCodecs: ['aac'], + requiresVideo: true, + supportsFastStart: true, + }, + webm: { + extension: 'webm', + kinds: ['video'], + videoCodecs: ['libvpx-vp9', 'libvpx'], + audioCodecs: ['libopus', 'libvorbis'], + requiresVideo: true, + }, + matroska: { + extension: 'mkv', + kinds: ['video'], + videoCodecs: ['libx264', 'libvpx-vp9', 'libvpx'], + audioCodecs: [ + 'aac', + 'libmp3lame', + 'libvorbis', + 'libopus', + 'pcm_s16le', + 'flac', + ], + requiresVideo: true, + }, + webp: { + extension: 'webp', + kinds: ['derivative'], + videoCodecs: ['libwebp_anim'], + audioCodecs: [], + requiresVideo: true, + forbidsAudio: true, + }, + gif: { + extension: 'gif', + kinds: ['derivative'], + videoCodecs: ['gif'], + audioCodecs: [], + requiresVideo: true, + forbidsAudio: true, + }, + mp3: { + extension: 'mp3', + kinds: ['audio'], + videoCodecs: [], + audioCodecs: ['libmp3lame'], + requiresAudio: true, + forbidsVideo: true, + }, + ipod: { + extension: 'm4a', + kinds: ['audio'], + videoCodecs: [], + audioCodecs: ['aac'], + requiresAudio: true, + forbidsVideo: true, + supportsFastStart: true, + }, + ogg: { + extension: 'ogg', + kinds: ['audio'], + videoCodecs: [], + audioCodecs: ['libvorbis'], + requiresAudio: true, + forbidsVideo: true, + }, + opus: { + extension: 'opus', + kinds: ['audio'], + videoCodecs: [], + audioCodecs: ['libopus'], + requiresAudio: true, + forbidsVideo: true, + }, + wav: { + extension: 'wav', + kinds: ['audio'], + videoCodecs: [], + audioCodecs: ['pcm_s16le'], + requiresAudio: true, + forbidsVideo: true, + }, + flac: { + extension: 'flac', + kinds: ['audio'], + videoCodecs: [], + audioCodecs: ['flac'], + requiresAudio: true, + forbidsVideo: true, + }, +}); + +export const USER_PRESET_CONTAINERS: readonly string[] = Object.freeze( + Object.keys(USER_PRESET_CONTAINER_COMPATIBILITY) +); + +export function validateUserPreset(value: unknown): PresetValidationResult { + const errors: string[] = []; + if (!isRecord(value)) { + return { valid: false, errors: ['Preset must be a JSON object'] }; + } + rejectUnknownFields(value, ROOT_FIELDS, 'preset', errors); + if (value.schemaVersion !== 1) { + errors.push('schemaVersion must be 1'); + } + const id = readIdentifier(value.id, 'id', errors); + const name = readText(value.name, 'name', 1, 80, errors); + const description = readOptionalText( + value.description, + 'description', + 400, + errors + ); + const kind = readUnion( + value.kind, + ['video', 'audio', 'derivative'], + 'kind', + errors + ); + const container = readIdentifier(value.container, 'container', errors); + const fileExtension = readExtension(value.fileExtension, errors); + const metadataPolicy = readUnion( + value.metadataPolicy, + ['copy', 'edit', 'remove'] satisfies readonly MetadataPolicy[], + 'metadataPolicy', + errors + ); + const chapterPolicy = readUnion( + value.chapterPolicy, + ['keep', 'remove', 'replace'] satisfies readonly ChapterPolicy[], + 'chapterPolicy', + errors + ); + const subtitlePolicy = readUnion( + value.subtitlePolicy, + [ + 'none', + 'copy-compatible', + 'convert', + 'burn-in', + ] satisfies readonly SubtitlePolicy[], + 'subtitlePolicy', + errors + ); + const video = + value.video === undefined ? undefined : parseVideo(value.video, errors); + const audio = + value.audio === undefined ? undefined : parseAudio(value.audio, errors); + if (!video && !audio) { + errors.push('At least one of video or audio is required'); + } + if (kind === 'audio' && video) { + errors.push('Audio presets cannot include video settings'); + } + if (value.fastStart !== undefined && typeof value.fastStart !== 'boolean') { + errors.push('fastStart must be a boolean'); + } + validateQuickPresetPolicies( + metadataPolicy, + chapterPolicy, + subtitlePolicy, + errors + ); + validateContainerCompatibility( + { + kind, + container, + fileExtension, + video, + audio, + fastStart: value.fastStart, + }, + errors + ); + if ( + errors.length > 0 || + !id || + !name || + !kind || + !container || + !fileExtension || + !metadataPolicy || + !chapterPolicy || + !subtitlePolicy + ) { + return { valid: false, errors: Object.freeze(errors) }; + } + + const preset: UserExportPreset = { + schemaVersion: 1, + id, + name, + kind, + container, + fileExtension, + metadataPolicy, + chapterPolicy, + subtitlePolicy, + ...(description ? { description } : {}), + ...(video ? { video } : {}), + ...(audio ? { audio } : {}), + ...(typeof value.fastStart === 'boolean' + ? { fastStart: value.fastStart } + : {}), + }; + return { + valid: true, + errors: Object.freeze([]), + preset: deepFreezePreset(preset), + }; +} + +function validateQuickPresetPolicies( + metadataPolicy: MetadataPolicy | undefined, + chapterPolicy: ChapterPolicy | undefined, + subtitlePolicy: SubtitlePolicy | undefined, + errors: string[] +): void { + if (metadataPolicy === 'edit') { + errors.push( + 'metadataPolicy "edit" is not supported by Quick presets; use "copy" or "remove"' + ); + } + if (chapterPolicy === 'replace') { + errors.push( + 'chapterPolicy "replace" is not supported by Quick presets; use "keep" or "remove"' + ); + } + if (subtitlePolicy === 'burn-in') { + errors.push( + 'subtitlePolicy "burn-in" requires an explicit subtitle source and is not supported by Quick presets' + ); + } +} + +function validateContainerCompatibility( + fields: { + readonly kind: UserPresetKind | undefined; + readonly container: string | undefined; + readonly fileExtension: string | undefined; + readonly video: VideoEncodingSettings | undefined; + readonly audio: AudioEncodingSettings | undefined; + readonly fastStart: unknown; + }, + errors: string[] +): void { + if (!fields.container) { + return; + } + const compatibility = USER_PRESET_CONTAINER_COMPATIBILITY[fields.container]; + if (!compatibility) { + errors.push( + `container "${fields.container}" is not in the reviewed user-preset allowlist (allowed: ${USER_PRESET_CONTAINERS.join(', ')})` + ); + return; + } + + if ( + fields.fileExtension && + fields.fileExtension !== compatibility.extension + ) { + errors.push( + `fileExtension must be "${compatibility.extension}" for container "${fields.container}"` + ); + } + if (fields.kind && !compatibility.kinds.includes(fields.kind)) { + errors.push( + `container "${fields.container}" is not compatible with preset kind "${fields.kind}"` + ); + } + if (compatibility.requiresVideo && !fields.video) { + errors.push(`container "${fields.container}" requires video settings`); + } + if (compatibility.requiresAudio && !fields.audio) { + errors.push(`container "${fields.container}" requires audio settings`); + } + if (compatibility.forbidsVideo && fields.video) { + errors.push( + `container "${fields.container}" does not support video settings` + ); + } + if (compatibility.forbidsAudio && fields.audio) { + errors.push( + `container "${fields.container}" does not support audio settings` + ); + } + if (fields.video && !compatibility.videoCodecs.includes(fields.video.codec)) { + errors.push( + `video codec "${fields.video.codec}" is not supported in container "${fields.container}" (allowed: ${formatAllowlist(compatibility.videoCodecs)})` + ); + } + if (fields.audio && !compatibility.audioCodecs.includes(fields.audio.codec)) { + errors.push( + `audio codec "${fields.audio.codec}" is not supported in container "${fields.container}" (allowed: ${formatAllowlist(compatibility.audioCodecs)})` + ); + } + if (fields.fastStart === true && !compatibility.supportsFastStart) { + errors.push( + `fastStart is only supported for reviewed MP4/M4A containers, not "${fields.container}"` + ); + } +} + +function formatAllowlist(values: readonly string[]): string { + return values.length > 0 ? values.join(', ') : 'none'; +} + +export function parseUserPresetJson(json: string): UserExportPreset { + if (new TextEncoder().encode(json).byteLength > 64 * 1024) { + throw new RangeError('Preset JSON exceeds the 64 KiB import limit'); + } + let value: unknown; + try { + value = JSON.parse(json) as unknown; + } catch { + throw new TypeError('Preset JSON is malformed'); + } + const result = validateUserPreset(migrateUserPreset(value)); + if (!result.valid || !result.preset) { + throw new TypeError(`Invalid preset: ${result.errors.join('; ')}`); + } + return result.preset; +} + +/** + * Migrates the historical development schema into schema 1. Unknown legacy + * fields are rejected before migration, so they cannot smuggle executable + * arguments or filter expressions into the allowlisted model. + */ +export function migrateUserPreset(value: unknown): unknown { + if (!isRecord(value) || value.schemaVersion !== 0) { + return value; + } + const errors: string[] = []; + rejectUnknownFields(value, LEGACY_ROOT_FIELDS, 'legacy preset', errors); + if (errors.length > 0) { + throw new TypeError(errors.join('; ')); + } + if (typeof value.format !== 'string') { + throw new TypeError('Legacy preset format is required'); + } + const extension = + typeof value.fileExtension === 'string' + ? value.fileExtension + : value.format === 'matroska' + ? 'mkv' + : value.format === 'ipod' + ? 'm4a' + : value.format; + return { + schemaVersion: 1, + id: value.id, + name: value.name, + ...(value.description !== undefined + ? { description: value.description } + : {}), + kind: value.kind, + container: value.format, + fileExtension: extension, + ...(value.video !== undefined ? { video: value.video } : {}), + ...(value.audio !== undefined ? { audio: value.audio } : {}), + metadataPolicy: value.metadataPolicy ?? 'copy', + chapterPolicy: value.includeChapters === true ? 'keep' : 'remove', + subtitlePolicy: value.subtitlePolicy ?? 'none', + ...(value.fastStart !== undefined ? { fastStart: value.fastStart } : {}), + }; +} + +export function serializeUserPreset(preset: UserExportPreset): string { + const result = validateUserPreset(preset); + if (!result.valid || !result.preset) { + throw new TypeError(`Invalid preset: ${result.errors.join('; ')}`); + } + return `${JSON.stringify(result.preset, null, 2)}\n`; +} + +function parseVideo( + value: unknown, + errors: string[] +): VideoEncodingSettings | undefined { + if (!isRecord(value)) { + errors.push('video must be an object'); + return undefined; + } + rejectUnknownFields(value, VIDEO_FIELDS, 'video', errors); + const codec = readUnion( + value.codec, + ['libx264', 'libvpx-vp9', 'libvpx', 'libwebp_anim', 'gif'], + 'video.codec', + errors + ); + const qualityMode = readUnion( + value.qualityMode, + [ + 'compatibility', + 'balanced', + 'smaller-file', + 'higher-quality', + 'custom', + ] satisfies readonly QualityMode[], + 'video.qualityMode', + errors + ); + const crf = readOptionalNumber(value.crf, 'video.crf', 0, 63, errors); + const bitrateKbps = readOptionalNumber( + value.bitrateKbps, + 'video.bitrateKbps', + 0, + 1_000_000, + errors + ); + const width = readOptionalInteger( + value.width, + 'video.width', + 2, + 8192, + errors + ); + const height = readOptionalInteger( + value.height, + 'video.height', + 2, + 8192, + errors + ); + const frameRate = readOptionalNumber( + value.frameRate, + 'video.frameRate', + 0.01, + 240, + errors + ); + const pixelFormat = + value.pixelFormat === undefined + ? undefined + : readUnion( + value.pixelFormat, + ['yuv420p', 'yuv422p', 'yuv444p', 'rgba'], + 'video.pixelFormat', + errors + ); + const encoderPreset = + value.encoderPreset === undefined + ? undefined + : readUnion( + value.encoderPreset, + ['ultrafast', 'veryfast', 'fast', 'medium', 'slow', 'veryslow'], + 'video.encoderPreset', + errors + ); + if (!codec || !qualityMode) { + return undefined; + } + return { + codec, + qualityMode, + ...(crf !== undefined ? { crf } : {}), + ...(bitrateKbps !== undefined ? { bitrateKbps } : {}), + ...(width !== undefined ? { width } : {}), + ...(height !== undefined ? { height } : {}), + ...(frameRate !== undefined ? { frameRate } : {}), + ...(pixelFormat ? { pixelFormat } : {}), + ...(encoderPreset ? { encoderPreset } : {}), + }; +} + +function parseAudio( + value: unknown, + errors: string[] +): AudioEncodingSettings | undefined { + if (!isRecord(value)) { + errors.push('audio must be an object'); + return undefined; + } + rejectUnknownFields(value, AUDIO_FIELDS, 'audio', errors); + const codec = readUnion( + value.codec, + ['aac', 'libmp3lame', 'libvorbis', 'libopus', 'pcm_s16le', 'flac'], + 'audio.codec', + errors + ); + const bitrateKbps = readOptionalNumber( + value.bitrateKbps, + 'audio.bitrateKbps', + 8, + 2_000, + errors + ); + const sampleRate = + value.sampleRate === undefined + ? undefined + : readUnion( + value.sampleRate, + [22_050, 32_000, 44_100, 48_000, 96_000], + 'audio.sampleRate', + errors + ); + const channels = + value.channels === undefined + ? undefined + : readUnion(value.channels, [1, 2, 6], 'audio.channels', errors); + if (!codec) { + return undefined; + } + return { + codec, + ...(bitrateKbps !== undefined ? { bitrateKbps } : {}), + ...(sampleRate ? { sampleRate } : {}), + ...(channels ? { channels } : {}), + }; +} + +function rejectUnknownFields( + value: Record, + allowlist: ReadonlySet, + label: string, + errors: string[] +): void { + for (const key of Object.keys(value)) { + if (!allowlist.has(key)) { + errors.push(`${label} contains unsupported field "${key}"`); + } + } +} + +function readIdentifier( + value: unknown, + label: string, + errors: string[] +): string | undefined { + if ( + typeof value !== 'string' || + !/^[a-z0-9][a-z0-9._-]{0,79}$/iu.test(value) + ) { + errors.push(`${label} must be a safe identifier`); + return undefined; + } + return value; +} + +function readText( + value: unknown, + label: string, + minimum: number, + maximum: number, + errors: string[] +): string | undefined { + if ( + typeof value !== 'string' || + value.trim().length < minimum || + value.trim().length > maximum || + // eslint-disable-next-line no-control-regex -- imported preset text must reject non-printing control characters. + /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(value) + ) { + errors.push(`${label} must contain ${minimum}–${maximum} safe characters`); + return undefined; + } + return value.trim(); +} + +function readOptionalText( + value: unknown, + label: string, + maximum: number, + errors: string[] +): string | undefined { + if (value === undefined) { + return undefined; + } + return readText(value, label, 1, maximum, errors); +} + +function readExtension(value: unknown, errors: string[]): string | undefined { + if (typeof value !== 'string' || !/^[a-z0-9]{1,10}$/u.test(value)) { + errors.push('fileExtension must contain 1–10 lowercase letters or digits'); + return undefined; + } + return value; +} + +function readUnion( + value: unknown, + values: readonly T[], + label: string, + errors: string[] +): T | undefined { + const match = values.find((entry) => entry === value); + if (match === undefined) { + errors.push(`${label} has an unsupported value`); + } + return match; +} + +function readOptionalNumber( + value: unknown, + label: string, + minimum: number, + maximum: number, + errors: string[] +): number | undefined { + if (value === undefined) { + return undefined; + } + if ( + typeof value !== 'number' || + !Number.isFinite(value) || + value < minimum || + value > maximum + ) { + errors.push(`${label} must be between ${minimum} and ${maximum}`); + return undefined; + } + return value; +} + +function readOptionalInteger( + value: unknown, + label: string, + minimum: number, + maximum: number, + errors: string[] +): number | undefined { + const result = readOptionalNumber(value, label, minimum, maximum, errors); + if (result !== undefined && !Number.isSafeInteger(result)) { + errors.push(`${label} must be an integer`); + return undefined; + } + return result; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function deepFreezePreset(preset: UserExportPreset): UserExportPreset { + return Object.freeze({ + ...preset, + ...(preset.video ? { video: Object.freeze({ ...preset.video }) } : {}), + ...(preset.audio ? { audio: Object.freeze({ ...preset.audio }) } : {}), + }); +} diff --git a/src/presets/preset.types.ts b/src/presets/preset.types.ts new file mode 100644 index 0000000..3820f6c --- /dev/null +++ b/src/presets/preset.types.ts @@ -0,0 +1,84 @@ +import type { PresetRequirements } from '../commands/command-plan'; + +export type MetadataPolicy = 'copy' | 'edit' | 'remove'; +export type ChapterPolicy = 'keep' | 'remove' | 'replace'; +export type SubtitlePolicy = 'none' | 'copy-compatible' | 'convert' | 'burn-in'; +export type PresetKind = 'video' | 'audio' | 'derivative' | 'remux'; +export type QualityMode = + 'compatibility' | 'balanced' | 'smaller-file' | 'higher-quality' | 'custom'; + +export type VideoCodec = + 'libx264' | 'libvpx-vp9' | 'libvpx' | 'libwebp_anim' | 'gif'; + +export type AudioCodec = + 'aac' | 'libmp3lame' | 'libvorbis' | 'libopus' | 'pcm_s16le' | 'flac'; + +export interface VideoEncodingSettings { + readonly codec: VideoCodec; + readonly qualityMode: QualityMode; + readonly crf?: number; + readonly bitrateKbps?: number; + readonly width?: number; + readonly height?: number; + readonly frameRate?: number; + readonly pixelFormat?: 'yuv420p' | 'yuv422p' | 'yuv444p' | 'rgba'; + readonly encoderPreset?: + 'ultrafast' | 'veryfast' | 'fast' | 'medium' | 'slow' | 'veryslow'; +} + +export interface AudioEncodingSettings { + readonly codec: AudioCodec; + readonly bitrateKbps?: number; + readonly sampleRate?: 22_050 | 32_000 | 44_100 | 48_000 | 96_000; + readonly channels?: 1 | 2 | 6; +} + +export interface ExportPresetBase { + readonly id: string; + readonly name: string; + readonly description: string; + readonly kind: PresetKind; + readonly container: string; + readonly fileExtension: string; + readonly requirements: PresetRequirements; + readonly video?: VideoEncodingSettings; + readonly audio?: AudioEncodingSettings; + readonly metadataPolicy: MetadataPolicy; + readonly chapterPolicy: ChapterPolicy; + readonly subtitlePolicy: SubtitlePolicy; + readonly fastStart?: boolean; + /** Concise documentation of the fixed encoding decisions in the preset. */ + readonly settingsSummary: readonly string[]; +} + +export interface BuiltInExportPreset extends ExportPresetBase { + readonly builtIn: true; +} + +export interface UserExportPreset { + readonly schemaVersion: 1; + readonly id: string; + readonly name: string; + readonly description?: string; + readonly kind: Exclude; + readonly container: string; + readonly fileExtension: string; + readonly video?: VideoEncodingSettings; + readonly audio?: AudioEncodingSettings; + readonly metadataPolicy: MetadataPolicy; + readonly chapterPolicy: ChapterPolicy; + readonly subtitlePolicy: SubtitlePolicy; + readonly fastStart?: boolean; +} + +export interface FFmpegCapabilitySnapshot { + readonly muxers: ReadonlySet; + readonly encoders: ReadonlySet; + readonly decoders: ReadonlySet; + readonly filters: ReadonlySet; +} + +export interface PresetAvailability { + readonly status: 'available' | 'degraded' | 'unavailable'; + readonly reasons: readonly string[]; +} diff --git a/src/presets/user-presets.ts b/src/presets/user-presets.ts new file mode 100644 index 0000000..515440f --- /dev/null +++ b/src/presets/user-presets.ts @@ -0,0 +1,228 @@ +import type { UserExportPreset } from './preset.types'; +import { + parseUserPresetJson, + serializeUserPreset, + validateUserPreset, +} from './preset-validation'; + +export interface UserPresetPersistence { + load(): Promise; + save(presets: readonly UserExportPreset[]): Promise; +} + +export class UserPresetRegistry { + readonly #persistence: UserPresetPersistence; + #presets = new Map(); + + constructor(persistence: UserPresetPersistence) { + this.#persistence = persistence; + } + + async initialize(): Promise { + const loaded = await this.#persistence.load(); + const validated = loaded.map((preset) => { + const result = validateUserPreset(preset); + if (!result.valid || !result.preset) { + throw new TypeError( + `Stored preset is invalid: ${result.errors.join('; ')}` + ); + } + return result.preset; + }); + this.#presets = new Map(validated.map((preset) => [preset.id, preset])); + } + + list(): readonly UserExportPreset[] { + return Object.freeze( + [...this.#presets.values()].sort((left, right) => + left.name.localeCompare(right.name) + ) + ); + } + + get(id: string): UserExportPreset | undefined { + return this.#presets.get(id); + } + + async save(preset: UserExportPreset): Promise { + const result = validateUserPreset(preset); + if (!result.valid || !result.preset) { + throw new TypeError(`Invalid preset: ${result.errors.join('; ')}`); + } + this.#presets.set(result.preset.id, result.preset); + await this.#persist(); + } + + async clone( + id: string, + newId: string, + newName: string + ): Promise { + const original = this.#presets.get(id); + if (!original) { + throw new RangeError(`Unknown preset: ${id}`); + } + const cloned = { ...original, id: newId, name: newName }; + await this.save(cloned); + return this.#presets.get(newId) as UserExportPreset; + } + + async rename(id: string, name: string): Promise { + const preset = this.#presets.get(id); + if (!preset) { + throw new RangeError(`Unknown preset: ${id}`); + } + await this.save({ ...preset, name }); + } + + async delete(id: string): Promise { + const deleted = this.#presets.delete(id); + if (deleted) { + await this.#persist(); + } + return deleted; + } + + async reset(): Promise { + this.#presets.clear(); + await this.#persist(); + } + + export(id: string): string { + const preset = this.#presets.get(id); + if (!preset) { + throw new RangeError(`Unknown preset: ${id}`); + } + return serializeUserPreset(preset); + } + + async import(json: string): Promise { + const preset = parseUserPresetJson(json); + await this.save(preset); + return preset; + } + + async #persist(): Promise { + await this.#persistence.save(this.list()); + } +} + +export class MemoryUserPresetPersistence implements UserPresetPersistence { + #presets: readonly UserExportPreset[] = []; + + async load(): Promise { + return this.#presets; + } + + async save(presets: readonly UserExportPreset[]): Promise { + this.#presets = structuredClone(presets); + } +} + +export interface IndexedDbUserPresetPersistenceOptions { + readonly databaseName?: string; + readonly storeName?: string; + readonly indexedDB?: IDBFactory; +} + +/** + * Stores only validated typed preset documents. A dedicated database avoids + * version races with durable job metadata stores that may be opened first. + */ +export class IndexedDbUserPresetPersistence implements UserPresetPersistence { + readonly #databaseName: string; + readonly #storeName: string; + readonly #factory: IDBFactory; + #database?: Promise; + + constructor(options: IndexedDbUserPresetPersistenceOptions = {}) { + const factory = options.indexedDB ?? globalThis.indexedDB; + if (!factory) { + throw new Error('IndexedDB is unavailable'); + } + this.#factory = factory; + this.#databaseName = options.databaseName ?? 'av-tools-user-presets-v1'; + this.#storeName = options.storeName ?? 'presets'; + } + + async load(): Promise { + const request = (await this.#store('readonly')).get('all'); + const value = await requestResult(request); + if (value === undefined) { + return Object.freeze([]); + } + if (!Array.isArray(value)) { + throw new TypeError('Stored user presets are invalid'); + } + return Object.freeze( + value.map((preset) => { + const result = validateUserPreset(preset); + if (!result.valid || !result.preset) { + throw new TypeError( + `Stored preset is invalid: ${result.errors.join('; ')}` + ); + } + return result.preset; + }) + ); + } + + async save(presets: readonly UserExportPreset[]): Promise { + const validated = presets.map((preset) => { + const result = validateUserPreset(preset); + if (!result.valid || !result.preset) { + throw new TypeError(`Invalid preset: ${result.errors.join('; ')}`); + } + return result.preset; + }); + const store = await this.#store('readwrite'); + const completion = transactionComplete(store.transaction); + await requestResult( + store.put(structuredClone(validated), 'all') + ); + await completion; + } + + async #store(mode: IDBTransactionMode): Promise { + const database = await (this.#database ??= this.#open()); + return database + .transaction(this.#storeName, mode) + .objectStore(this.#storeName); + } + + #open(): Promise { + return new Promise((resolve, reject) => { + const request = this.#factory.open(this.#databaseName, 1); + request.onupgradeneeded = () => { + if (!request.result.objectStoreNames.contains(this.#storeName)) { + request.result.createObjectStore(this.#storeName); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => + reject(request.error ?? new Error('Could not open preset storage')); + request.onblocked = () => + reject(new Error('Preset storage upgrade is blocked')); + }); + } +} + +function requestResult(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => + reject(request.error ?? new Error('Preset storage request failed')); + }); +} + +function transactionComplete(transaction: IDBTransaction): Promise { + return new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve(); + transaction.onerror = () => + reject( + transaction.error ?? new Error('Preset storage transaction failed') + ); + transaction.onabort = () => + reject(transaction.error ?? new Error('Preset storage was aborted')); + }); +} diff --git a/src/presets/video-presets.ts b/src/presets/video-presets.ts new file mode 100644 index 0000000..44a9eea --- /dev/null +++ b/src/presets/video-presets.ts @@ -0,0 +1,201 @@ +import type { BuiltInExportPreset } from './preset.types'; + +function immutable(preset: T): Readonly { + return Object.freeze({ + ...preset, + requirements: Object.freeze({ + ...preset.requirements, + ...(preset.requirements.muxers + ? { muxers: Object.freeze([...preset.requirements.muxers]) } + : {}), + ...(preset.requirements.encoders + ? { encoders: Object.freeze([...preset.requirements.encoders]) } + : {}), + ...(preset.requirements.filters + ? { filters: Object.freeze([...preset.requirements.filters]) } + : {}), + }), + ...(preset.video ? { video: Object.freeze({ ...preset.video }) } : {}), + ...(preset.audio ? { audio: Object.freeze({ ...preset.audio }) } : {}), + settingsSummary: Object.freeze([...preset.settingsSummary]), + }); +} + +export const VIDEO_PRESETS: readonly BuiltInExportPreset[] = Object.freeze([ + immutable({ + id: 'mp4-h264-balanced', + name: 'MP4 · H.264 + AAC', + description: 'Balanced browser-friendly video.', + kind: 'video', + container: 'mp4', + fileExtension: 'mp4', + video: { + codec: 'libx264', + qualityMode: 'balanced', + crf: 23, + pixelFormat: 'yuv420p', + encoderPreset: 'medium', + }, + audio: { codec: 'aac', bitrateKbps: 160, sampleRate: 48_000, channels: 2 }, + fastStart: true, + metadataPolicy: 'copy', + chapterPolicy: 'keep', + subtitlePolicy: 'convert', + requirements: { + muxers: ['mp4'], + encoders: ['libx264', 'aac'], + }, + settingsSummary: [ + 'libx264 CRF 23, medium preset', + 'yuv420p pixel format', + 'AAC 160 kbit/s stereo at 48 kHz', + 'fast-start metadata', + ], + builtIn: true, + }), + immutable({ + id: 'mp4-h264-compatibility', + name: 'MP4 · H.264 compatibility', + description: 'Conservative settings for broad playback support.', + kind: 'video', + container: 'mp4', + fileExtension: 'mp4', + video: { + codec: 'libx264', + qualityMode: 'compatibility', + crf: 24, + pixelFormat: 'yuv420p', + encoderPreset: 'fast', + }, + audio: { codec: 'aac', bitrateKbps: 128, sampleRate: 44_100, channels: 2 }, + fastStart: true, + metadataPolicy: 'copy', + chapterPolicy: 'keep', + subtitlePolicy: 'convert', + requirements: { muxers: ['mp4'], encoders: ['libx264', 'aac'] }, + settingsSummary: [ + 'libx264 CRF 24, fast preset', + 'yuv420p pixel format', + 'AAC 128 kbit/s stereo at 44.1 kHz', + ], + builtIn: true, + }), + immutable({ + id: 'webm-vp9-opus', + name: 'WebM · VP9 + Opus', + description: 'Efficient open web video when both encoders are available.', + kind: 'video', + container: 'webm', + fileExtension: 'webm', + video: { + codec: 'libvpx-vp9', + qualityMode: 'smaller-file', + crf: 32, + bitrateKbps: 0, + pixelFormat: 'yuv420p', + }, + audio: { + codec: 'libopus', + bitrateKbps: 128, + sampleRate: 48_000, + channels: 2, + }, + metadataPolicy: 'copy', + chapterPolicy: 'keep', + subtitlePolicy: 'convert', + requirements: { + muxers: ['webm'], + encoders: ['libvpx-vp9', 'libopus'], + }, + settingsSummary: ['libvpx-vp9 CRF 32', 'Opus 128 kbit/s stereo at 48 kHz'], + builtIn: true, + }), + immutable({ + id: 'webm-vp8-vorbis', + name: 'WebM · VP8 + Vorbis', + description: 'Compatibility-oriented open web video.', + kind: 'video', + container: 'webm', + fileExtension: 'webm', + video: { + codec: 'libvpx', + qualityMode: 'compatibility', + crf: 18, + bitrateKbps: 1_500, + pixelFormat: 'yuv420p', + }, + audio: { + codec: 'libvorbis', + bitrateKbps: 128, + sampleRate: 48_000, + channels: 2, + }, + metadataPolicy: 'copy', + chapterPolicy: 'keep', + subtitlePolicy: 'convert', + requirements: { muxers: ['webm'], encoders: ['libvpx', 'libvorbis'] }, + settingsSummary: ['libvpx CRF 18, 1.5 Mbit/s target', 'Vorbis 128 kbit/s'], + builtIn: true, + }), + immutable({ + id: 'animated-webp', + name: 'Animated WebP', + description: 'Animated image derivative without audio.', + kind: 'derivative', + container: 'webp', + fileExtension: 'webp', + video: { + codec: 'libwebp_anim', + qualityMode: 'balanced', + crf: 60, + pixelFormat: 'rgba', + }, + metadataPolicy: 'remove', + chapterPolicy: 'remove', + subtitlePolicy: 'none', + requirements: { muxers: ['webp'], encoders: ['libwebp_anim'] }, + settingsSummary: ['libwebp_anim quality 60', 'audio omitted'], + builtIn: true, + }), + immutable({ + id: 'animated-gif', + name: 'Animated GIF', + description: 'Advanced compatibility derivative with limited colours.', + kind: 'derivative', + container: 'gif', + fileExtension: 'gif', + video: { codec: 'gif', qualityMode: 'compatibility' }, + metadataPolicy: 'remove', + chapterPolicy: 'remove', + subtitlePolicy: 'none', + requirements: { + muxers: ['gif'], + encoders: ['gif'], + filters: ['palettegen', 'paletteuse'], + }, + settingsSummary: ['palettegen/paletteuse filter workflow', 'audio omitted'], + builtIn: true, + }), + immutable({ + id: 'matroska-h264-aac', + name: 'Matroska · H.264 + AAC', + description: 'Flexible Matroska output with selected compatible streams.', + kind: 'video', + container: 'matroska', + fileExtension: 'mkv', + video: { + codec: 'libx264', + qualityMode: 'higher-quality', + crf: 20, + pixelFormat: 'yuv420p', + encoderPreset: 'medium', + }, + audio: { codec: 'aac', bitrateKbps: 192, sampleRate: 48_000, channels: 2 }, + metadataPolicy: 'copy', + chapterPolicy: 'keep', + subtitlePolicy: 'copy-compatible', + requirements: { muxers: ['matroska'], encoders: ['libx264', 'aac'] }, + settingsSummary: ['libx264 CRF 20', 'AAC 192 kbit/s', 'Matroska container'], + builtIn: true, + }), +]); diff --git a/src/project/index.ts b/src/project/index.ts new file mode 100644 index 0000000..8cd741e --- /dev/null +++ b/src/project/index.ts @@ -0,0 +1,8 @@ +export * from './migrations'; +export * from './project.reducer'; +export * from './project.schema'; +export * from './project.selectors'; +export * from './project.serialization'; +export * from './project.types'; +export * from './project.validation'; +export * from './source-reattachment'; diff --git a/src/project/migrations/index.ts b/src/project/migrations/index.ts new file mode 100644 index 0000000..412c64d --- /dev/null +++ b/src/project/migrations/index.ts @@ -0,0 +1,67 @@ +import type { AvProjectV1, LegacyAvProjectV0 } from '../project.types'; +import { migrateV0ToV1, type ProjectMigrationWarning } from './v0-to-v1'; + +export { migrateV0ToV1 }; +export type { ProjectMigrationWarning }; + +export interface ProjectMigrationResult { + project: AvProjectV1; + fromVersion: 0 | 1; + toVersion: 1; + warnings: ProjectMigrationWarning[]; +} + +export class ProjectMigrationError extends Error { + readonly schemaVersion: unknown; + + constructor(message: string, schemaVersion: unknown) { + super(message); + this.name = 'ProjectMigrationError'; + this.schemaVersion = schemaVersion; + } +} + +export function migrateProjectDocument(value: unknown): ProjectMigrationResult { + if (!isRecord(value)) { + throw new ProjectMigrationError( + 'A project document must be a JSON object.', + undefined + ); + } + if (value.schemaVersion === 1) { + let project: AvProjectV1; + try { + project = structuredClone(value) as unknown as AvProjectV1; + } catch { + throw new ProjectMigrationError( + 'The project contains a value that cannot be migrated.', + value.schemaVersion + ); + } + return { + project, + fromVersion: 1, + toVersion: 1, + warnings: [], + }; + } + if (value.schemaVersion === 0) { + const migration = migrateV0ToV1(value as unknown as LegacyAvProjectV0); + return { + project: migration.project, + fromVersion: 0, + toVersion: 1, + warnings: migration.warnings, + }; + } + throw new ProjectMigrationError( + value.schemaVersion === undefined + ? 'The project document does not declare a schema version.' + : `Project schema version ${String(value.schemaVersion)} is not supported.`, + value.schemaVersion + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/src/project/migrations/v0-to-v1.ts b/src/project/migrations/v0-to-v1.ts new file mode 100644 index 0000000..67a8fdf --- /dev/null +++ b/src/project/migrations/v0-to-v1.ts @@ -0,0 +1,298 @@ +import type { AvProjectV1, LegacyAvProjectV0 } from '../project.types'; + +export interface ProjectMigrationWarning { + code: 'renamed-field' | 'defaulted-field' | 'source-reattachment-required'; + path: string; + message: string; +} + +export interface V0MigrationResult { + project: AvProjectV1; + warnings: ProjectMigrationWarning[]; +} + +export function migrateV0ToV1(input: LegacyAvProjectV0): V0MigrationResult { + const warnings: ProjectMigrationWarning[] = []; + const rawAssets = Array.isArray(input.assets) + ? input.assets + : Array.isArray(input.media) + ? input.media + : []; + const rawTimeline = Array.isArray(input.timeline) + ? input.timeline + : Array.isArray(input.clips) + ? input.clips + : []; + if (input.title !== undefined && input.name === undefined) { + warnings.push({ + code: 'renamed-field', + path: 'title', + message: 'Migrated the legacy "title" field to "name".', + }); + } + if (input.media !== undefined && input.assets === undefined) { + warnings.push({ + code: 'renamed-field', + path: 'media', + message: 'Migrated the legacy "media" collection to "assets".', + }); + } + if (input.clips !== undefined && input.timeline === undefined) { + warnings.push({ + code: 'renamed-field', + path: 'clips', + message: 'Migrated the legacy "clips" collection to "timeline".', + }); + } + + const project: AvProjectV1 = { + schemaVersion: 1, + id: stringValue(input.id), + name: stringValue(input.name ?? input.title ?? 'Untitled project'), + createdAt: stringValue(input.createdAt), + updatedAt: stringValue(input.updatedAt), + assets: rawAssets.map((asset, index) => + migrateAsset(asset, index, warnings) + ), + timeline: rawTimeline.map((clip, index) => migrateClip(clip, index)), + output: migrateOutput(input.output, warnings), + metadata: migrateMetadata(input.metadata), + chapters: (Array.isArray(input.chapters) ? input.chapters : []).map( + migrateChapter + ), + subtitles: (Array.isArray(input.subtitles) ? input.subtitles : []).map( + migrateSubtitle + ), + ...(isRecord(input.ui) ? { ui: input.ui as AvProjectV1['ui'] } : {}), + }; + + return { project, warnings }; +} + +function migrateAsset( + value: unknown, + index: number, + warnings: ProjectMigrationWarning[] +): AvProjectV1['assets'][number] { + const asset = isRecord(value) ? value : {}; + const originalName = stringValue( + asset.originalName ?? asset.fileName ?? asset.name ?? `source-${index}` + ); + if (asset.originalName === undefined) { + warnings.push({ + code: 'renamed-field', + path: `assets[${index}].originalName`, + message: 'Migrated a legacy source filename field.', + }); + } + return { + id: stringValue(asset.id ?? `asset-${index}`), + originalName, + size: numberValue(asset.size), + lastModified: numberValue(asset.lastModified), + mimeType: stringValue(asset.mimeType ?? asset.type ?? ''), + sourceStatus: asset.sourceStatus === 'attached' ? 'attached' : 'missing', + ...(isRecord(asset.probe) + ? { + probe: + asset.probe as unknown as AvProjectV1['assets'][number]['probe'], + } + : {}), + ...(isRecord(asset.hash) + ? { + hash: asset.hash as unknown as AvProjectV1['assets'][number]['hash'], + } + : {}), + }; +} + +function migrateClip( + value: unknown, + index: number +): AvProjectV1['timeline'][number] { + const clip = isRecord(value) ? value : {}; + return { + id: stringValue(clip.id ?? `clip-${index}`), + assetId: stringValue(clip.assetId), + sourceInSeconds: numberValue( + clip.sourceInSeconds ?? clip.inSeconds ?? clip.startSeconds + ), + sourceOutSeconds: numberValue( + clip.sourceOutSeconds ?? clip.outSeconds ?? clip.endSeconds + ), + ...(isRecord(clip.crop) + ? { + crop: clip.crop as unknown as AvProjectV1['timeline'][number]['crop'], + } + : {}), + ...(isRecord(clip.resize) + ? { + resize: + clip.resize as unknown as AvProjectV1['timeline'][number]['resize'], + } + : {}), + ...(clip.rotation !== undefined + ? { + rotation: + clip.rotation as AvProjectV1['timeline'][number]['rotation'], + } + : {}), + ...(clip.frameRate !== undefined + ? { frameRate: numberValue(clip.frameRate) } + : {}), + ...(isRecord(clip.audio) + ? { + audio: + clip.audio as unknown as AvProjectV1['timeline'][number]['audio'], + } + : {}), + ...(isRecord(clip.video) + ? { + video: + clip.video as unknown as AvProjectV1['timeline'][number]['video'], + } + : {}), + }; +} + +function migrateOutput( + value: unknown, + warnings: ProjectMigrationWarning[] +): AvProjectV1['output'] { + const output = isRecord(value) ? value : {}; + const defaultsUsed = [ + 'presetId', + 'fileName', + 'container', + 'videoEnabled', + 'audioEnabled', + ].some((field) => output[field] === undefined); + if (defaultsUsed) { + warnings.push({ + code: 'defaulted-field', + path: 'output', + message: 'Filled missing legacy output settings with safe defaults.', + }); + } + return { + presetId: stringValue(output.presetId ?? 'mp4-h264-balanced'), + fileName: stringValue(output.fileName ?? 'output.mp4'), + container: stringValue(output.container ?? 'mp4'), + videoEnabled: + typeof output.videoEnabled === 'boolean' ? output.videoEnabled : true, + audioEnabled: + typeof output.audioEnabled === 'boolean' ? output.audioEnabled : true, + ...(finiteNumber(output.width) ? { width: Number(output.width) } : {}), + ...(finiteNumber(output.height) ? { height: Number(output.height) } : {}), + ...(finiteNumber(output.frameRate) + ? { frameRate: Number(output.frameRate) } + : {}), + sampleRate: finiteNumber(output.sampleRate) + ? Number(output.sampleRate) + : 48_000, + channelLayout: + output.channelLayout === 'mono' || output.channelLayout === '5.1' + ? output.channelLayout + : 'stereo', + missingAudioPolicy: + output.missingAudioPolicy === 'drop-all' || + output.missingAudioPolicy === 'reject' + ? output.missingAudioPolicy + : 'insert-silence', + }; +} + +function migrateMetadata(value: unknown): AvProjectV1['metadata'] { + const metadata = isRecord(value) ? value : {}; + return { + ...(typeof metadata.title === 'string' ? { title: metadata.title } : {}), + ...(typeof metadata.artist === 'string' ? { artist: metadata.artist } : {}), + ...(typeof metadata.album === 'string' ? { album: metadata.album } : {}), + ...(typeof metadata.comment === 'string' + ? { comment: metadata.comment } + : {}), + custom: isStringRecord(metadata.custom) ? metadata.custom : {}, + }; +} + +function migrateChapter( + value: unknown, + index: number +): AvProjectV1['chapters'][number] { + const chapter = isRecord(value) ? value : {}; + return { + id: stringValue(chapter.id ?? `chapter-${index}`), + startSeconds: numberValue(chapter.startSeconds ?? chapter.start), + endSeconds: numberValue(chapter.endSeconds ?? chapter.end), + title: stringValue(chapter.title ?? `Chapter ${index + 1}`), + timeBase: + typeof chapter.timeBase === 'string' ? chapter.timeBase : '1/1000', + ...(isStringRecord(chapter.metadata) ? { metadata: chapter.metadata } : {}), + }; +} + +function migrateSubtitle( + value: unknown, + index: number +): AvProjectV1['subtitles'][number] { + const subtitle = isRecord(value) ? value : {}; + return { + id: stringValue(subtitle.id ?? `subtitle-${index}`), + ...(typeof subtitle.assetId === 'string' + ? { assetId: subtitle.assetId } + : {}), + ...(typeof subtitle.sourceName === 'string' + ? { sourceName: subtitle.sourceName } + : {}), + ...(subtitle.sourceFormat === 'srt' || + subtitle.sourceFormat === 'vtt' || + subtitle.sourceFormat === 'ass' + ? { sourceFormat: subtitle.sourceFormat } + : {}), + ...(typeof subtitle.language === 'string' + ? { language: subtitle.language } + : {}), + ...(typeof subtitle.title === 'string' ? { title: subtitle.title } : {}), + ...(typeof subtitle.offsetSeconds === 'number' && + Number.isFinite(subtitle.offsetSeconds) + ? { offsetSeconds: subtitle.offsetSeconds } + : {}), + ...(typeof subtitle.default === 'boolean' + ? { default: subtitle.default } + : {}), + ...(typeof subtitle.forced === 'boolean' + ? { forced: subtitle.forced } + : {}), + mode: + subtitle.mode === 'mux' || subtitle.mode === 'burn-in' + ? subtitle.mode + : 'exclude', + ...(subtitle.sourceStatus === 'attached' || + subtitle.sourceStatus === 'missing' + ? { sourceStatus: subtitle.sourceStatus } + : {}), + }; +} + +function numberValue(value: unknown): number { + return typeof value === 'number' ? value : Number(value ?? 0); +} + +function finiteNumber(value: unknown): boolean { + return typeof value === 'number' && Number.isFinite(value); +} + +function stringValue(value: unknown): string { + return typeof value === 'string' ? value : String(value ?? ''); +} + +function isStringRecord(value: unknown): value is Record { + return ( + isRecord(value) && + Object.values(value).every((entry) => typeof entry === 'string') + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/src/project/project.reducer.ts b/src/project/project.reducer.ts new file mode 100644 index 0000000..d2f7c3a --- /dev/null +++ b/src/project/project.reducer.ts @@ -0,0 +1,306 @@ +import { validateSourceRange } from '../media/duration'; +import type { + AvProjectV1, + MediaAssetReference, + OutputSettings, + ProjectChapter, + ProjectMetadataSettings, + ProjectSubtitleTrack, + ProjectUiState, + TimelineClip, +} from './project.types'; +import { assertValidProject } from './project.validation'; + +export type ProjectAction = + | { type: 'project/replace'; project: AvProjectV1 } + | { type: 'project/rename'; name: string; updatedAt: string } + | { + type: 'asset/add'; + asset: MediaAssetReference; + updatedAt: string; + } + | { type: 'asset/remove'; assetId: string; updatedAt: string } + | { + type: 'asset/set-source-status'; + assetId: string; + sourceStatus: 'attached' | 'missing'; + updatedAt: string; + } + | { + type: 'asset/update'; + assetId: string; + changes: Partial< + Pick + >; + updatedAt: string; + } + | { type: 'clip/add'; clip: TimelineClip; index?: number; updatedAt: string } + | { + type: 'clip/update'; + clipId: string; + changes: Partial>; + updatedAt: string; + } + | { type: 'clip/remove'; clipId: string; updatedAt: string } + | { + type: 'clip/move'; + clipId: string; + toIndex: number; + updatedAt: string; + } + | { + type: 'output/set'; + output: OutputSettings; + updatedAt: string; + } + | { + type: 'metadata/set'; + metadata: ProjectMetadataSettings; + updatedAt: string; + } + | { + type: 'chapters/set'; + chapters: ProjectChapter[]; + updatedAt: string; + } + | { + type: 'subtitles/set'; + subtitles: ProjectSubtitleTrack[]; + updatedAt: string; + } + | { type: 'ui/set'; ui: ProjectUiState | undefined }; + +export function projectReducer( + project: AvProjectV1, + action: ProjectAction +): AvProjectV1 { + switch (action.type) { + case 'project/replace': + return assertValidProject(action.project); + case 'project/rename': { + const name = action.name.trim(); + if (!name) { + throw new ProjectReducerError('A project name cannot be empty.'); + } + return touch(project, action.updatedAt, { name }); + } + case 'asset/add': { + assertUniqueId(project.assets, action.asset.id, 'asset'); + return touch(project, action.updatedAt, { + assets: [...project.assets, action.asset], + }); + } + case 'asset/remove': { + assertExists(project.assets, action.assetId, 'asset'); + const removedClipIds = new Set( + project.timeline + .filter((clip) => clip.assetId === action.assetId) + .map((clip) => clip.id) + ); + return touch(project, action.updatedAt, { + assets: project.assets.filter((asset) => asset.id !== action.assetId), + timeline: project.timeline.filter( + (clip) => clip.assetId !== action.assetId + ), + subtitles: project.subtitles.filter( + (track) => track.assetId !== action.assetId + ), + ui: cleanUiAfterRemoval(project.ui, action.assetId, removedClipIds), + }); + } + case 'asset/set-source-status': + assertExists(project.assets, action.assetId, 'asset'); + return touch(project, action.updatedAt, { + assets: project.assets.map((asset) => + asset.id === action.assetId + ? { ...asset, sourceStatus: action.sourceStatus } + : asset + ), + }); + case 'asset/update': + assertExists(project.assets, action.assetId, 'asset'); + return touch(project, action.updatedAt, { + assets: project.assets.map((asset) => + asset.id === action.assetId ? { ...asset, ...action.changes } : asset + ), + }); + case 'clip/add': { + assertUniqueId(project.timeline, action.clip.id, 'clip'); + assertAssetReference(project, action.clip.assetId); + assertClipRange(action.clip); + const index = normalizeInsertionIndex( + action.index, + project.timeline.length + ); + const timeline = [...project.timeline]; + timeline.splice(index, 0, action.clip); + return touch(project, action.updatedAt, { timeline }); + } + case 'clip/update': { + assertExists(project.timeline, action.clipId, 'clip'); + const timeline = project.timeline.map((clip) => { + if (clip.id !== action.clipId) { + return clip; + } + const updated = { ...clip, ...action.changes, id: clip.id }; + assertAssetReference(project, updated.assetId); + assertClipRange(updated); + return updated; + }); + return touch(project, action.updatedAt, { timeline }); + } + case 'clip/remove': + assertExists(project.timeline, action.clipId, 'clip'); + return touch(project, action.updatedAt, { + timeline: project.timeline.filter((clip) => clip.id !== action.clipId), + ui: + project.ui?.selectedClipId === action.clipId + ? { ...project.ui, selectedClipId: undefined } + : project.ui, + }); + case 'clip/move': { + const fromIndex = project.timeline.findIndex( + (clip) => clip.id === action.clipId + ); + if (fromIndex < 0) { + throw new ProjectReducerError( + `Cannot move unknown clip "${action.clipId}".` + ); + } + if ( + !Number.isSafeInteger(action.toIndex) || + action.toIndex < 0 || + action.toIndex >= project.timeline.length + ) { + throw new ProjectReducerError( + 'Clip destination must be a valid timeline index.' + ); + } + if (fromIndex === action.toIndex) { + return project; + } + const timeline = [...project.timeline]; + const [clip] = timeline.splice(fromIndex, 1); + if (clip === undefined) { + return project; + } + timeline.splice(action.toIndex, 0, clip); + return touch(project, action.updatedAt, { timeline }); + } + case 'output/set': + return touch(project, action.updatedAt, { output: action.output }); + case 'metadata/set': + return touch(project, action.updatedAt, { + metadata: action.metadata, + }); + case 'chapters/set': + return touch(project, action.updatedAt, { + chapters: action.chapters, + }); + case 'subtitles/set': + return touch(project, action.updatedAt, { + subtitles: action.subtitles, + }); + case 'ui/set': + return { ...project, ui: action.ui }; + } +} + +export class ProjectReducerError extends Error { + constructor(message: string) { + super(message); + this.name = 'ProjectReducerError'; + } +} + +function touch( + project: AvProjectV1, + updatedAt: string, + changes: Partial +): AvProjectV1 { + const timestamp = Date.parse(updatedAt); + if (!/^\d{4}-\d{2}-\d{2}T/.test(updatedAt) || !Number.isFinite(timestamp)) { + throw new ProjectReducerError('updatedAt must be an ISO-8601 date.'); + } + if (timestamp < Date.parse(project.createdAt)) { + throw new ProjectReducerError( + 'updatedAt cannot be before the project creation time.' + ); + } + if (timestamp < Date.parse(project.updatedAt)) { + throw new ProjectReducerError( + 'updatedAt cannot be before the previous project update time.' + ); + } + return { ...project, ...changes, updatedAt }; +} + +function assertClipRange(clip: TimelineClip): void { + const range = validateSourceRange( + clip.sourceInSeconds, + clip.sourceOutSeconds + ); + if (!range.valid) { + throw new ProjectReducerError(range.issues[0]?.message ?? 'Invalid clip.'); + } +} + +function assertAssetReference(project: AvProjectV1, assetId: string): void { + if (!project.assets.some((asset) => asset.id === assetId)) { + throw new ProjectReducerError( + `Clip references unknown asset "${assetId}".` + ); + } +} + +function assertExists( + entries: readonly { id: string }[], + id: string, + kind: string +): void { + if (!entries.some((entry) => entry.id === id)) { + throw new ProjectReducerError(`Unknown ${kind} "${id}".`); + } +} + +function assertUniqueId( + entries: readonly { id: string }[], + id: string, + kind: string +): void { + if (entries.some((entry) => entry.id === id)) { + throw new ProjectReducerError(`Duplicate ${kind} ID "${id}".`); + } +} + +function normalizeInsertionIndex( + index: number | undefined, + length: number +): number { + if (index === undefined) { + return length; + } + if (!Number.isSafeInteger(index) || index < 0 || index > length) { + throw new ProjectReducerError( + 'Clip insertion index must be within the timeline.' + ); + } + return index; +} + +function cleanUiAfterRemoval( + ui: ProjectUiState | undefined, + assetId: string, + removedClipIds: ReadonlySet +): ProjectUiState | undefined { + if (ui === undefined) { + return undefined; + } + return { + ...ui, + ...(ui.selectedAssetId === assetId ? { selectedAssetId: undefined } : {}), + ...(ui.selectedClipId !== undefined && removedClipIds.has(ui.selectedClipId) + ? { selectedClipId: undefined } + : {}), + }; +} diff --git a/src/project/project.schema.ts b/src/project/project.schema.ts new file mode 100644 index 0000000..ff04813 --- /dev/null +++ b/src/project/project.schema.ts @@ -0,0 +1,101 @@ +import { + AV_PROJECT_SCHEMA_VERSION, + type AvProjectV1, + type BrowserFileIdentity, + type CreateMediaAssetOptions, + type CreateProjectOptions, + type MediaAssetReference, +} from './project.types'; + +export function createEmptyProject( + options: CreateProjectOptions = {} +): AvProjectV1 { + const timestamp = options.timestamp ?? new Date().toISOString(); + if (!isIsoDate(timestamp)) { + throw new TypeError('Project timestamp must be a valid ISO-8601 date.'); + } + + return { + schemaVersion: AV_PROJECT_SCHEMA_VERSION, + id: options.id ?? createProjectId(), + name: options.name?.trim() || 'Untitled project', + createdAt: timestamp, + updatedAt: timestamp, + assets: [], + timeline: [], + output: { + presetId: options.output?.presetId ?? 'mp4-h264-balanced', + fileName: options.output?.fileName ?? 'output.mp4', + container: options.output?.container ?? 'mp4', + videoEnabled: options.output?.videoEnabled ?? true, + audioEnabled: options.output?.audioEnabled ?? true, + ...(options.output?.width === undefined + ? {} + : { width: options.output.width }), + ...(options.output?.height === undefined + ? {} + : { height: options.output.height }), + ...(options.output?.frameRate === undefined + ? {} + : { frameRate: options.output.frameRate }), + sampleRate: options.output?.sampleRate ?? 48_000, + channelLayout: options.output?.channelLayout ?? 'stereo', + missingAudioPolicy: + options.output?.missingAudioPolicy ?? 'insert-silence', + }, + metadata: { + custom: {}, + }, + chapters: [], + subtitles: [], + }; +} + +export function createMediaAssetReference( + file: BrowserFileIdentity, + options: CreateMediaAssetOptions = {} +): MediaAssetReference { + if (!file.name.trim()) { + throw new TypeError('A source file must have a name.'); + } + if (!Number.isSafeInteger(file.size) || file.size < 0) { + throw new TypeError('A source file size must be a non-negative integer.'); + } + if (!Number.isSafeInteger(file.lastModified) || file.lastModified < 0) { + throw new TypeError( + 'A source last-modified time must be a non-negative integer.' + ); + } + return { + id: options.id ?? createId('asset'), + originalName: file.name, + size: file.size, + lastModified: file.lastModified, + mimeType: file.type, + sourceStatus: options.sourceStatus ?? 'attached', + ...(options.probe === undefined ? {} : { probe: options.probe }), + ...(options.hash === undefined ? {} : { hash: options.hash }), + }; +} + +function createProjectId(): string { + return createId('project'); +} + +function createId(prefix: string): string { + if ( + typeof globalThis.crypto !== 'undefined' && + typeof globalThis.crypto.randomUUID === 'function' + ) { + return globalThis.crypto.randomUUID(); + } + return `${prefix}-${Date.now().toString(36)}-${Math.random() + .toString(36) + .slice(2, 10)}`; +} + +function isIsoDate(value: string): boolean { + return ( + /^\d{4}-\d{2}-\d{2}T/.test(value) && Number.isFinite(Date.parse(value)) + ); +} diff --git a/src/project/project.selectors.ts b/src/project/project.selectors.ts new file mode 100644 index 0000000..b87a18a --- /dev/null +++ b/src/project/project.selectors.ts @@ -0,0 +1,107 @@ +import { addDurations, subtractDurations } from '../media/duration'; +import type { + AvProjectV1, + MediaAssetReference, + TimelineClip, +} from './project.types'; + +export interface TimelineEntry { + clip: TimelineClip; + asset?: MediaAssetReference; + timelineStartSeconds: number; + timelineEndSeconds: number; + durationSeconds: number; +} + +export function selectAssetById( + project: AvProjectV1, + assetId: string +): MediaAssetReference | undefined { + return project.assets.find((asset) => asset.id === assetId); +} + +export function selectClipById( + project: AvProjectV1, + clipId: string +): TimelineClip | undefined { + return project.timeline.find((clip) => clip.id === clipId); +} + +export function selectClipDuration(clip: TimelineClip): number { + return Math.max( + 0, + subtractDurations(clip.sourceOutSeconds, clip.sourceInSeconds) + ); +} + +export function selectTimelineDuration(project: AvProjectV1): number { + return addDurations(project.timeline.map(selectClipDuration)); +} + +export function selectTimelineEntries(project: AvProjectV1): TimelineEntry[] { + let cursor = 0; + return project.timeline.map((clip) => { + const durationSeconds = selectClipDuration(clip); + const timelineStartSeconds = cursor; + cursor = addDurations([cursor, durationSeconds]); + return { + clip, + asset: selectAssetById(project, clip.assetId), + timelineStartSeconds, + timelineEndSeconds: cursor, + durationSeconds, + }; + }); +} + +export function selectMissingAssets( + project: AvProjectV1 +): MediaAssetReference[] { + return project.assets.filter((asset) => asset.sourceStatus === 'missing'); +} + +export function selectAttachedAssets( + project: AvProjectV1 +): MediaAssetReference[] { + return project.assets.filter((asset) => asset.sourceStatus === 'attached'); +} + +export function selectReferencedAssetIds(project: AvProjectV1): Set { + return new Set([ + ...project.timeline.map((clip) => clip.assetId), + ...project.subtitles.flatMap((track) => + track.assetId === undefined ? [] : [track.assetId] + ), + ]); +} + +export function selectUnusedAssets( + project: AvProjectV1 +): MediaAssetReference[] { + const referenced = selectReferencedAssetIds(project); + return project.assets.filter((asset) => !referenced.has(asset.id)); +} + +export function selectCanExport(project: AvProjectV1): { + canExport: boolean; + reasons: string[]; +} { + const reasons: string[] = []; + if (project.timeline.length === 0) { + reasons.push('The timeline is empty.'); + } + const missingReferenced = selectMissingAssets(project).filter((asset) => + selectReferencedAssetIds(project).has(asset.id) + ); + if (missingReferenced.length > 0) { + reasons.push( + `${missingReferenced.length} referenced source ${ + missingReferenced.length === 1 ? 'file is' : 'files are' + } missing.` + ); + } + if (!project.output.videoEnabled && !project.output.audioEnabled) { + reasons.push('No output stream type is enabled.'); + } + return { canExport: reasons.length === 0, reasons }; +} diff --git a/src/project/project.serialization.ts b/src/project/project.serialization.ts new file mode 100644 index 0000000..e0ff0b0 --- /dev/null +++ b/src/project/project.serialization.ts @@ -0,0 +1,146 @@ +import { safeOutputFileName } from '../media/safe-file-name'; +import { + migrateProjectDocument, + type ProjectMigrationWarning, +} from './migrations'; +import type { AvProjectV1 } from './project.types'; +import { + ProjectValidationError, + assertValidProject, +} from './project.validation'; + +export interface SerializeProjectOptions { + pretty?: boolean; + includeUiState?: boolean; +} + +export interface ImportProjectOptions { + markSourcesMissing?: boolean; + maximumBytes?: number; +} + +export interface ProjectImportWarning { + code: 'source-reattachment-required' | ProjectMigrationWarning['code']; + path: string; + message: string; +} + +export interface ProjectImportResult { + project: AvProjectV1; + fromVersion: 0 | 1; + warnings: ProjectImportWarning[]; +} + +export class ProjectImportError extends Error { + readonly causeValue?: unknown; + + constructor(message: string, causeValue?: unknown) { + super(message); + this.name = 'ProjectImportError'; + this.causeValue = causeValue; + } +} + +export function serializeProject( + project: AvProjectV1, + options: SerializeProjectOptions = {} +): string { + const validProject = assertValidProject(project); + const document = + options.includeUiState === false ? omitUi(validProject) : validProject; + const indentation = options.pretty === false ? undefined : 2; + return `${JSON.stringify(document, null, indentation)}\n`; +} + +export function importProjectDocument( + input: string | unknown, + options: ImportProjectOptions = {} +): ProjectImportResult { + const maximumBytes = options.maximumBytes ?? 10 * 1024 * 1024; + if (!Number.isSafeInteger(maximumBytes) || maximumBytes <= 0) { + throw new RangeError('Maximum project size must be a positive integer.'); + } + + let parsed: unknown = input; + if (typeof input === 'string') { + if (new TextEncoder().encode(input).byteLength > maximumBytes) { + throw new ProjectImportError( + `The project document exceeds the ${maximumBytes}-byte import limit.` + ); + } + try { + parsed = JSON.parse(input) as unknown; + } catch (error) { + throw new ProjectImportError( + 'The project document is not valid JSON.', + error + ); + } + } + + const migration = migrateProjectDocument(parsed); + let project: AvProjectV1; + try { + project = assertValidProject(migration.project); + } catch (error) { + if (error instanceof ProjectValidationError) { + throw error; + } + throw new ProjectImportError( + 'The project document could not be validated.', + error + ); + } + + const warnings: ProjectImportWarning[] = [...migration.warnings]; + if (options.markSourcesMissing !== false) { + project = { + ...project, + assets: project.assets.map((asset) => ({ + ...asset, + sourceStatus: 'missing', + })), + subtitles: project.subtitles.map((track) => + track.sourceStatus === undefined + ? track + : { ...track, sourceStatus: 'missing' } + ), + }; + if ( + project.assets.length + + project.subtitles.filter((track) => track.sourceStatus !== undefined) + .length > + 0 + ) { + warnings.push({ + code: 'source-reattachment-required', + path: 'assets', + message: + 'Browser File objects are not stored in a project document. Reattach source files before export.', + }); + } + } + + return { + project, + fromVersion: migration.fromVersion, + warnings, + }; +} + +export function deserializeProject( + json: string, + options?: ImportProjectOptions +): AvProjectV1 { + return importProjectDocument(json, options).project; +} + +export function projectDocumentFileName(project: AvProjectV1): string { + return safeOutputFileName(project.name, 'avproject.json', 'av-tools-project'); +} + +function omitUi(project: AvProjectV1): AvProjectV1 { + const document = { ...project }; + delete document.ui; + return document; +} diff --git a/src/project/project.types.ts b/src/project/project.types.ts new file mode 100644 index 0000000..c3235c0 --- /dev/null +++ b/src/project/project.types.ts @@ -0,0 +1,184 @@ +import type { MediaProbe } from '../media/media.types'; + +export const AV_PROJECT_SCHEMA_VERSION = 1 as const; + +export type SourceStatus = 'attached' | 'missing'; + +export interface SourceHash { + algorithm: 'sha-256'; + value: string; + scope: 'full' | 'partial'; + bytesHashed?: number; +} + +export interface MediaAssetReference { + id: string; + originalName: string; + size: number; + lastModified: number; + mimeType: string; + probe?: MediaProbe; + sourceStatus: SourceStatus; + hash?: SourceHash; +} + +export interface CropSettings { + x: number; + y: number; + width: number; + height: number; +} + +export interface ResizeSettings { + width?: number; + height?: number; + mode: 'fit' | 'fill' | 'stretch'; + allowUpscale?: boolean; + algorithm?: 'fast_bilinear' | 'bilinear' | 'bicubic' | 'lanczos'; + paddingColor?: string; +} + +export interface NormalizationSettings { + targetLufs: number; + truePeakDb: number; + loudnessRangeLufs?: number; +} + +export type FadeCurve = 'tri' | 'qsin' | 'exp'; + +export interface TimelineClipAudioSettings { + enabled: boolean; + gainDb?: number; + normalization?: NormalizationSettings; + fadeInSeconds?: number; + fadeOutSeconds?: number; + fadeCurve?: FadeCurve; +} + +export interface TimelineClipVideoSettings { + enabled: boolean; + fadeInSeconds?: number; + fadeOutSeconds?: number; + fadeCurve?: FadeCurve; +} + +export interface TimelineClip { + id: string; + assetId: string; + sourceInSeconds: number; + sourceOutSeconds: number; + crop?: CropSettings; + resize?: ResizeSettings; + rotation?: 0 | 90 | 180 | 270; + frameRate?: number; + audio?: TimelineClipAudioSettings; + video?: TimelineClipVideoSettings; +} + +export interface OutputSettings { + presetId: string; + fileName: string; + container: string; + videoEnabled: boolean; + audioEnabled: boolean; + width?: number; + height?: number; + frameRate?: number; + sampleRate?: number; + channelLayout?: 'mono' | 'stereo' | '5.1'; + missingAudioPolicy?: 'insert-silence' | 'drop-all' | 'reject'; +} + +export interface ProjectMetadataSettings { + title?: string; + artist?: string; + album?: string; + comment?: string; + custom: Record; +} + +export interface ProjectChapter { + id: string; + startSeconds: number; + endSeconds: number; + title: string; + timeBase?: string; + metadata?: Record; +} + +export interface ProjectSubtitleTrack { + id: string; + assetId?: string; + sourceName?: string; + sourceFormat?: 'srt' | 'vtt' | 'ass'; + language?: string; + title?: string; + offsetSeconds?: number; + default?: boolean; + forced?: boolean; + mode: 'exclude' | 'mux' | 'burn-in'; + sourceStatus?: SourceStatus; +} + +export interface ProjectUiState { + selectedAssetId?: string; + selectedClipId?: string; + inspectorPanel?: 'media' | 'clip' | 'output'; + timelineZoom?: number; +} + +export interface AvProjectV1 { + schemaVersion: 1; + id: string; + name: string; + createdAt: string; + updatedAt: string; + assets: MediaAssetReference[]; + timeline: TimelineClip[]; + output: OutputSettings; + metadata: ProjectMetadataSettings; + chapters: ProjectChapter[]; + subtitles: ProjectSubtitleTrack[]; + ui?: ProjectUiState; +} + +export type AvProject = AvProjectV1; + +export interface CreateProjectOptions { + id?: string; + name?: string; + timestamp?: string; + output?: Partial; +} + +export interface BrowserFileIdentity { + name: string; + size: number; + lastModified: number; + type: string; +} + +export interface CreateMediaAssetOptions { + id?: string; + probe?: MediaProbe; + hash?: SourceHash; + sourceStatus?: SourceStatus; +} + +export interface LegacyAvProjectV0 { + schemaVersion: 0; + id: string; + title?: string; + name?: string; + createdAt: string; + updatedAt: string; + media?: unknown[]; + assets?: unknown[]; + clips?: unknown[]; + timeline?: unknown[]; + output?: unknown; + metadata?: unknown; + chapters?: unknown[]; + subtitles?: unknown[]; + ui?: unknown; +} diff --git a/src/project/project.validation.ts b/src/project/project.validation.ts new file mode 100644 index 0000000..96c6e73 --- /dev/null +++ b/src/project/project.validation.ts @@ -0,0 +1,1305 @@ +import { addDurations, validateSourceRange } from '../media/duration'; +import { + chapterSecondsToTicks, + parseChapterTimeBase, +} from '../media/chapter-time-base'; +import type { AvProjectV1 } from './project.types'; + +export type ProjectValidationIssueCode = + | 'invalid-type' + | 'missing-field' + | 'invalid-value' + | 'duplicate-id' + | 'missing-reference' + | 'invalid-range' + | 'out-of-bounds' + | 'unsupported-version'; + +export interface ProjectValidationIssue { + code: ProjectValidationIssueCode; + path: string; + message: string; +} + +export type ProjectValidationResult = + | { + valid: true; + project: AvProjectV1; + issues: []; + } + | { + valid: false; + issues: ProjectValidationIssue[]; + }; + +export class ProjectValidationError extends Error { + readonly issues: ProjectValidationIssue[]; + + constructor(issues: ProjectValidationIssue[]) { + super( + issues.length === 1 + ? `Invalid project: ${issues[0]?.message ?? 'unknown error'}` + : `Invalid project: ${issues.length} validation errors.` + ); + this.name = 'ProjectValidationError'; + this.issues = issues; + } +} + +export function validateProject(value: unknown): ProjectValidationResult { + const issues: ProjectValidationIssue[] = []; + if (!isRecord(value)) { + issue(issues, 'invalid-type', '$', 'The project must be a JSON object.'); + return { valid: false, issues }; + } + + if (value.schemaVersion !== 1) { + issue( + issues, + 'unsupported-version', + 'schemaVersion', + 'Expected project schema version 1.' + ); + } + + requireNonEmptyString(value.id, 'id', issues); + requireNonEmptyString(value.name, 'name', issues); + const createdAt = requireIsoDate(value.createdAt, 'createdAt', issues); + const updatedAt = requireIsoDate(value.updatedAt, 'updatedAt', issues); + if ( + createdAt !== undefined && + updatedAt !== undefined && + updatedAt < createdAt + ) { + issue( + issues, + 'invalid-value', + 'updatedAt', + 'The update time cannot be before the creation time.' + ); + } + + const { assetIds, sourceDurations } = validateAssets(value.assets, issues); + const { clipIds, durationSeconds } = validateTimeline( + value.timeline, + assetIds, + sourceDurations, + issues + ); + validateOutput(value.output, issues); + validateMetadata(value.metadata, issues); + validateChapters(value.chapters, durationSeconds, issues); + validateSubtitles(value.subtitles, assetIds, issues); + validateUi(value.ui, assetIds, clipIds, issues); + + if (issues.length > 0) { + return { valid: false, issues }; + } + + let project: AvProjectV1; + try { + project = structuredClone(value) as unknown as AvProjectV1; + } catch { + issue( + issues, + 'invalid-type', + '$', + 'The project contains a value that cannot be stored in a project document.' + ); + return { valid: false, issues }; + } + + return { + valid: true, + project, + issues: [], + }; +} + +export function assertValidProject(value: unknown): AvProjectV1 { + const result = validateProject(value); + if (!result.valid) { + throw new ProjectValidationError(result.issues); + } + return result.project; +} + +function validateAssets( + value: unknown, + issues: ProjectValidationIssue[] +): { + assetIds: Set; + sourceDurations: Map; +} { + const assetIds = new Set(); + const sourceDurations = new Map(); + if (!Array.isArray(value)) { + issue(issues, 'invalid-type', 'assets', 'Assets must be an array.'); + return { assetIds, sourceDurations }; + } + + value.forEach((asset, index) => { + const path = `assets[${index}]`; + if (!isRecord(asset)) { + issue(issues, 'invalid-type', path, 'Each asset must be an object.'); + return; + } + const id = requireNonEmptyString(asset.id, `${path}.id`, issues); + if (id !== undefined) { + addUniqueId(id, `${path}.id`, assetIds, issues); + } + requireNonEmptyString(asset.originalName, `${path}.originalName`, issues); + requireNumber(asset.size, `${path}.size`, issues, { + integer: true, + minimum: 0, + }); + requireNumber(asset.lastModified, `${path}.lastModified`, issues, { + integer: true, + minimum: 0, + }); + requireString(asset.mimeType, `${path}.mimeType`, issues); + requireEnum( + asset.sourceStatus, + ['attached', 'missing'], + `${path}.sourceStatus`, + issues + ); + if (asset.hash !== undefined) { + validateSourceHash(asset.hash, `${path}.hash`, issues); + } + if (asset.probe !== undefined) { + validateProbe(asset.probe, `${path}.probe`, issues); + if ( + id !== undefined && + isRecord(asset.probe) && + typeof asset.probe.durationSeconds === 'number' && + Number.isFinite(asset.probe.durationSeconds) && + asset.probe.durationSeconds >= 0 + ) { + sourceDurations.set(id, asset.probe.durationSeconds); + } + } + }); + + return { assetIds, sourceDurations }; +} + +function validateSourceHash( + value: unknown, + path: string, + issues: ProjectValidationIssue[] +): void { + if (!isRecord(value)) { + issue(issues, 'invalid-type', path, 'A source hash must be an object.'); + return; + } + requireEnum(value.algorithm, ['sha-256'], `${path}.algorithm`, issues); + const hash = requireNonEmptyString(value.value, `${path}.value`, issues); + if (hash !== undefined && !/^[a-fA-F0-9]{64}$/.test(hash)) { + issue( + issues, + 'invalid-value', + `${path}.value`, + 'A SHA-256 value must contain 64 hexadecimal characters.' + ); + } + requireEnum(value.scope, ['full', 'partial'], `${path}.scope`, issues); + if (value.bytesHashed !== undefined) { + requireNumber(value.bytesHashed, `${path}.bytesHashed`, issues, { + integer: true, + minimum: 1, + }); + } + if (value.scope === 'partial' && value.bytesHashed === undefined) { + issue( + issues, + 'missing-field', + `${path}.bytesHashed`, + 'A partial hash must state how many bytes were hashed.' + ); + } +} + +function validateProbe( + value: unknown, + path: string, + issues: ProjectValidationIssue[] +): void { + if (!isRecord(value)) { + issue( + issues, + 'invalid-type', + path, + 'A normalized probe report must be an object.' + ); + return; + } + for (const field of ['durationSeconds', 'startTimeSeconds', 'bitRate']) { + if (value[field] !== undefined) { + requireNumber(value[field], `${path}.${field}`, issues, { + minimum: field === 'startTimeSeconds' ? undefined : 0, + integer: field === 'bitRate', + }); + } + } + validateStringArray(value.formatNames, `${path}.formatNames`, issues); + if (value.formatLongName !== undefined) { + requireString(value.formatLongName, `${path}.formatLongName`, issues); + } + validateStringRecord(value.tags, `${path}.tags`, issues); + if (!Array.isArray(value.streams)) { + issue( + issues, + 'invalid-type', + `${path}.streams`, + 'Probe streams must be an array.' + ); + } else { + const streamIndexes = new Set(); + value.streams.forEach((stream, index) => { + const streamPath = `${path}.streams[${index}]`; + if (!isRecord(stream)) { + issue( + issues, + 'invalid-type', + streamPath, + 'Each probe stream must be an object.' + ); + return; + } + const streamIndex = requireNumber( + stream.index, + `${streamPath}.index`, + issues, + { integer: true, minimum: 0 } + ); + if (streamIndex !== undefined) { + if (streamIndexes.has(streamIndex)) { + issue( + issues, + 'duplicate-id', + `${streamPath}.index`, + `Probe stream index ${streamIndex} is duplicated.` + ); + } + streamIndexes.add(streamIndex); + } + requireEnum( + stream.type, + ['video', 'audio', 'subtitle', 'attachment', 'data', 'unknown'], + `${streamPath}.type`, + issues + ); + validateStringRecord(stream.tags, `${streamPath}.tags`, issues); + validateBooleanRecord( + stream.disposition, + `${streamPath}.disposition`, + issues + ); + for (const field of [ + 'codecName', + 'codecLongName', + 'profile', + 'language', + 'title', + 'pixelFormat', + 'timeBase', + 'sampleAspectRatio', + 'displayAspectRatio', + 'channelLayout', + 'sampleFormat', + ]) { + if (stream[field] !== undefined) { + requireString(stream[field], `${streamPath}.${field}`, issues); + } + } + for (const field of ['durationSeconds', 'bitRate']) { + if (stream[field] !== undefined) { + requireNumber(stream[field], `${streamPath}.${field}`, issues, { + minimum: 0, + integer: field === 'bitRate', + }); + } + } + for (const field of [ + 'width', + 'height', + 'codedWidth', + 'codedHeight', + 'sampleRate', + 'channels', + ]) { + if (stream[field] !== undefined) { + requireNumber(stream[field], `${streamPath}.${field}`, issues, { + integer: true, + minimum: 1, + }); + } + } + if (stream.frameRate !== undefined) { + requireNumber(stream.frameRate, `${streamPath}.frameRate`, issues, { + exclusiveMinimum: 0, + maximum: 1_000, + }); + } + if (stream.rotationDegrees !== undefined) { + requireNumber( + stream.rotationDegrees, + `${streamPath}.rotationDegrees`, + issues, + { + exclusiveMinimum: -180, + maximum: 180, + } + ); + } + }); + } + if (!Array.isArray(value.chapters)) { + issue( + issues, + 'invalid-type', + `${path}.chapters`, + 'Probe chapters must be an array.' + ); + } else { + value.chapters.forEach((chapter, index) => { + const chapterPath = `${path}.chapters[${index}]`; + if (!isRecord(chapter)) { + issue( + issues, + 'invalid-type', + chapterPath, + 'Each probe chapter must be an object.' + ); + return; + } + requireNumber(chapter.id, `${chapterPath}.id`, issues, { + integer: true, + minimum: 0, + }); + const start = requireNumber( + chapter.startSeconds, + `${chapterPath}.startSeconds`, + issues, + { minimum: 0 } + ); + const end = requireNumber( + chapter.endSeconds, + `${chapterPath}.endSeconds`, + issues, + { minimum: 0 } + ); + if (start !== undefined && end !== undefined && end < start) { + issue( + issues, + 'invalid-range', + chapterPath, + 'A probe chapter end cannot be before its start.' + ); + } + if (chapter.timeBase !== undefined) { + requireString(chapter.timeBase, `${chapterPath}.timeBase`, issues); + } + if (chapter.title !== undefined) { + requireString(chapter.title, `${chapterPath}.title`, issues); + } + validateStringRecord(chapter.tags, `${chapterPath}.tags`, issues); + }); + } + if (!Array.isArray(value.warnings)) { + issue( + issues, + 'invalid-type', + `${path}.warnings`, + 'Probe warnings must be an array.' + ); + } else { + value.warnings.forEach((warning, index) => { + const warningPath = `${path}.warnings[${index}]`; + if (!isRecord(warning)) { + issue( + issues, + 'invalid-type', + warningPath, + 'Each probe warning must be an object.' + ); + return; + } + requireEnum( + warning.code, + [ + 'invalid-field', + 'invalid-fraction', + 'missing-stream-index', + 'duplicate-stream-index', + 'unknown-stream-type', + 'invalid-stream', + 'invalid-chapter', + ], + `${warningPath}.code`, + issues + ); + requireNonEmptyString(warning.path, `${warningPath}.path`, issues); + requireNonEmptyString(warning.message, `${warningPath}.message`, issues); + if (warning.received !== undefined) { + requireString(warning.received, `${warningPath}.received`, issues); + } + }); + } +} + +function validateTimeline( + value: unknown, + assetIds: Set, + sourceDurations: ReadonlyMap, + issues: ProjectValidationIssue[] +): { clipIds: Set; durationSeconds: number } { + const clipIds = new Set(); + const durations: number[] = []; + if (!Array.isArray(value)) { + issue(issues, 'invalid-type', 'timeline', 'Timeline must be an array.'); + return { clipIds, durationSeconds: 0 }; + } + + value.forEach((clip, index) => { + const path = `timeline[${index}]`; + if (!isRecord(clip)) { + issue(issues, 'invalid-type', path, 'Each clip must be an object.'); + return; + } + const id = requireNonEmptyString(clip.id, `${path}.id`, issues); + if (id !== undefined) { + addUniqueId(id, `${path}.id`, clipIds, issues); + } + const assetId = requireNonEmptyString( + clip.assetId, + `${path}.assetId`, + issues + ); + if (assetId !== undefined && !assetIds.has(assetId)) { + issue( + issues, + 'missing-reference', + `${path}.assetId`, + `Clip references missing asset "${assetId}".` + ); + } + const sourceIn = requireNumber( + clip.sourceInSeconds, + `${path}.sourceInSeconds`, + issues, + { minimum: 0 } + ); + const sourceOut = requireNumber( + clip.sourceOutSeconds, + `${path}.sourceOutSeconds`, + issues, + { minimum: 0 } + ); + if (sourceIn !== undefined && sourceOut !== undefined) { + const range = validateSourceRange( + sourceIn, + sourceOut, + assetId === undefined ? undefined : sourceDurations.get(assetId) + ); + if (!range.valid) { + for (const rangeIssue of range.issues) { + issue(issues, 'invalid-range', path, rangeIssue.message); + } + } else if (range.durationSeconds !== undefined) { + durations.push(range.durationSeconds); + } + } + if (clip.crop !== undefined) { + validateCrop(clip.crop, `${path}.crop`, issues); + } + if (clip.resize !== undefined) { + validateResize(clip.resize, `${path}.resize`, issues); + } + if (clip.rotation !== undefined) { + requireEnum(clip.rotation, [0, 90, 180, 270], `${path}.rotation`, issues); + } + if (clip.frameRate !== undefined) { + requireNumber(clip.frameRate, `${path}.frameRate`, issues, { + exclusiveMinimum: 0, + maximum: 1_000, + }); + } + if (clip.audio !== undefined) { + validateAudioSettings( + clip.audio, + `${path}.audio`, + sourceIn, + sourceOut, + issues + ); + } + if (clip.video !== undefined) { + validateVideoSettings( + clip.video, + `${path}.video`, + sourceIn, + sourceOut, + issues + ); + } + }); + + return { clipIds, durationSeconds: addDurations(durations) }; +} + +function validateCrop( + value: unknown, + path: string, + issues: ProjectValidationIssue[] +): void { + if (!isRecord(value)) { + issue(issues, 'invalid-type', path, 'Crop settings must be an object.'); + return; + } + requireNumber(value.x, `${path}.x`, issues, { integer: true, minimum: 0 }); + requireNumber(value.y, `${path}.y`, issues, { integer: true, minimum: 0 }); + requireNumber(value.width, `${path}.width`, issues, { + integer: true, + minimum: 1, + }); + requireNumber(value.height, `${path}.height`, issues, { + integer: true, + minimum: 1, + }); +} + +function validateResize( + value: unknown, + path: string, + issues: ProjectValidationIssue[] +): void { + if (!isRecord(value)) { + issue(issues, 'invalid-type', path, 'Resize settings must be an object.'); + return; + } + if (value.width === undefined && value.height === undefined) { + issue( + issues, + 'missing-field', + path, + 'Resize settings require a width, a height, or both.' + ); + } + if (value.width !== undefined) { + requireNumber(value.width, `${path}.width`, issues, { + integer: true, + minimum: 1, + }); + } + if (value.height !== undefined) { + requireNumber(value.height, `${path}.height`, issues, { + integer: true, + minimum: 1, + }); + } + requireEnum(value.mode, ['fit', 'fill', 'stretch'], `${path}.mode`, issues); + if (value.allowUpscale !== undefined) { + requireBoolean(value.allowUpscale, `${path}.allowUpscale`, issues); + } + if (value.algorithm !== undefined) { + requireEnum( + value.algorithm, + ['fast_bilinear', 'bilinear', 'bicubic', 'lanczos'], + `${path}.algorithm`, + issues + ); + } + if ( + value.paddingColor !== undefined && + (typeof value.paddingColor !== 'string' || + !/^(?:#[0-9a-f]{6}|[a-z]{3,20})$/iu.test(value.paddingColor)) + ) { + issue( + issues, + 'invalid-value', + `${path}.paddingColor`, + 'Padding colour must be a named colour or #RRGGBB.' + ); + } +} + +function validateAudioSettings( + value: unknown, + path: string, + sourceIn: number | undefined, + sourceOut: number | undefined, + issues: ProjectValidationIssue[] +): void { + if (!isRecord(value)) { + issue(issues, 'invalid-type', path, 'Audio settings must be an object.'); + return; + } + requireBoolean(value.enabled, `${path}.enabled`, issues); + if (value.gainDb !== undefined) { + requireNumber(value.gainDb, `${path}.gainDb`, issues, { + minimum: -120, + maximum: 120, + }); + } + if (value.normalization !== undefined) { + if (!isRecord(value.normalization)) { + issue( + issues, + 'invalid-type', + `${path}.normalization`, + 'Normalization settings must be an object.' + ); + } else { + requireNumber( + value.normalization.targetLufs, + `${path}.normalization.targetLufs`, + issues, + { minimum: -70, maximum: 0 } + ); + requireNumber( + value.normalization.truePeakDb, + `${path}.normalization.truePeakDb`, + issues, + { minimum: -20, maximum: 0 } + ); + if (value.normalization.loudnessRangeLufs !== undefined) { + requireNumber( + value.normalization.loudnessRangeLufs, + `${path}.normalization.loudnessRangeLufs`, + issues, + { minimum: 1, maximum: 50 } + ); + } + } + } + validateFades(value, path, sourceIn, sourceOut, issues); +} + +function validateVideoSettings( + value: unknown, + path: string, + sourceIn: number | undefined, + sourceOut: number | undefined, + issues: ProjectValidationIssue[] +): void { + if (!isRecord(value)) { + issue(issues, 'invalid-type', path, 'Video settings must be an object.'); + return; + } + requireBoolean(value.enabled, `${path}.enabled`, issues); + validateFades(value, path, sourceIn, sourceOut, issues); +} + +function validateFades( + value: Record, + path: string, + sourceIn: number | undefined, + sourceOut: number | undefined, + issues: ProjectValidationIssue[] +): void { + const fadeIn = + value.fadeInSeconds === undefined + ? 0 + : requireNumber(value.fadeInSeconds, `${path}.fadeInSeconds`, issues, { + minimum: 0, + }); + const fadeOut = + value.fadeOutSeconds === undefined + ? 0 + : requireNumber(value.fadeOutSeconds, `${path}.fadeOutSeconds`, issues, { + minimum: 0, + }); + if (value.fadeCurve !== undefined) { + requireEnum( + value.fadeCurve, + ['tri', 'qsin', 'exp'], + `${path}.fadeCurve`, + issues + ); + } + if ( + fadeIn !== undefined && + fadeOut !== undefined && + sourceIn !== undefined && + sourceOut !== undefined && + fadeIn + fadeOut > sourceOut - sourceIn + 1e-6 + ) { + issue( + issues, + 'out-of-bounds', + path, + 'Combined fade duration exceeds the clip duration.' + ); + } +} + +function validateOutput( + value: unknown, + issues: ProjectValidationIssue[] +): void { + if (!isRecord(value)) { + issue( + issues, + 'invalid-type', + 'output', + 'Output settings must be an object.' + ); + return; + } + requireNonEmptyString(value.presetId, 'output.presetId', issues); + requireNonEmptyString(value.fileName, 'output.fileName', issues); + requireNonEmptyString(value.container, 'output.container', issues); + requireBoolean(value.videoEnabled, 'output.videoEnabled', issues); + requireBoolean(value.audioEnabled, 'output.audioEnabled', issues); + for (const field of ['width', 'height']) { + if (value[field] !== undefined) { + requireNumber(value[field], `output.${field}`, issues, { + integer: true, + minimum: 2, + maximum: 8192, + }); + if ( + typeof value[field] === 'number' && + Number.isInteger(value[field]) && + value[field] % 2 !== 0 + ) { + issue( + issues, + 'invalid-value', + `output.${field}`, + 'Timeline output dimensions must be even.' + ); + } + } + } + if (value.frameRate !== undefined) { + requireNumber(value.frameRate, 'output.frameRate', issues, { + exclusiveMinimum: 0, + maximum: 240, + }); + } + if (value.sampleRate !== undefined) { + requireNumber(value.sampleRate, 'output.sampleRate', issues, { + integer: true, + minimum: 8_000, + maximum: 192_000, + }); + } + if (value.channelLayout !== undefined) { + requireEnum( + value.channelLayout, + ['mono', 'stereo', '5.1'], + 'output.channelLayout', + issues + ); + } + if (value.missingAudioPolicy !== undefined) { + requireEnum( + value.missingAudioPolicy, + ['insert-silence', 'drop-all', 'reject'], + 'output.missingAudioPolicy', + issues + ); + } + if (value.videoEnabled === false && value.audioEnabled === false) { + issue( + issues, + 'invalid-value', + 'output', + 'At least one output stream type must be enabled.' + ); + } +} + +function validateMetadata( + value: unknown, + issues: ProjectValidationIssue[] +): void { + if (!isRecord(value)) { + issue( + issues, + 'invalid-type', + 'metadata', + 'Project metadata must be an object.' + ); + return; + } + for (const field of ['title', 'artist', 'album', 'comment']) { + if (value[field] !== undefined) { + requireString(value[field], `metadata.${field}`, issues); + } + } + validateStringRecord(value.custom, 'metadata.custom', issues); +} + +function validateChapters( + value: unknown, + outputDurationSeconds: number, + issues: ProjectValidationIssue[] +): void { + const ids = new Set(); + if (!Array.isArray(value)) { + issue(issues, 'invalid-type', 'chapters', 'Chapters must be an array.'); + return; + } + let previousEnd = 0; + value.forEach((chapter, index) => { + const path = `chapters[${index}]`; + if (!isRecord(chapter)) { + issue(issues, 'invalid-type', path, 'Each chapter must be an object.'); + return; + } + const id = requireNonEmptyString(chapter.id, `${path}.id`, issues); + if (id !== undefined) { + addUniqueId(id, `${path}.id`, ids, issues); + } + const start = requireNumber( + chapter.startSeconds, + `${path}.startSeconds`, + issues, + { minimum: 0 } + ); + const end = requireNumber( + chapter.endSeconds, + `${path}.endSeconds`, + issues, + { minimum: 0 } + ); + requireNonEmptyString(chapter.title, `${path}.title`, issues); + const timeBase = + chapter.timeBase === undefined + ? undefined + : parseChapterTimeBase(chapter.timeBase); + if (chapter.timeBase !== undefined && timeBase === undefined) { + issue( + issues, + 'invalid-value', + `${path}.timeBase`, + 'Chapter time base must be a positive integer fraction such as 1/1000.' + ); + } + if (start !== undefined && end !== undefined) { + if (end <= start) { + issue( + issues, + 'invalid-range', + path, + 'A chapter end must be after its start.' + ); + } + if (start < previousEnd - 1e-6) { + issue( + issues, + 'invalid-range', + `${path}.startSeconds`, + 'Chapters must be ordered and may not overlap.' + ); + } + if (end > outputDurationSeconds + 1e-6) { + issue( + issues, + 'out-of-bounds', + `${path}.endSeconds`, + 'The chapter exceeds the timeline duration.' + ); + } + if (timeBase !== undefined) { + const startTick = chapterSecondsToTicks(start, timeBase); + const endTick = chapterSecondsToTicks(end, timeBase); + if ( + startTick === undefined || + endTick === undefined || + endTick <= startTick + ) { + issue( + issues, + 'invalid-value', + `${path}.timeBase`, + 'Chapter time base is too coarse or large for this chapter range.' + ); + } + } + previousEnd = Math.max(previousEnd, end); + } + if (chapter.metadata !== undefined) { + validateStringRecord(chapter.metadata, `${path}.metadata`, issues); + } + }); +} + +function validateSubtitles( + value: unknown, + assetIds: Set, + issues: ProjectValidationIssue[] +): void { + const ids = new Set(); + if (!Array.isArray(value)) { + issue(issues, 'invalid-type', 'subtitles', 'Subtitles must be an array.'); + return; + } + let burnInCount = 0; + value.forEach((track, index) => { + const path = `subtitles[${index}]`; + if (!isRecord(track)) { + issue( + issues, + 'invalid-type', + path, + 'Each subtitle track must be an object.' + ); + return; + } + const id = requireNonEmptyString(track.id, `${path}.id`, issues); + if (id !== undefined) { + addUniqueId(id, `${path}.id`, ids, issues); + } + if (track.assetId !== undefined) { + const assetId = requireNonEmptyString( + track.assetId, + `${path}.assetId`, + issues + ); + if (assetId !== undefined && !assetIds.has(assetId)) { + issue( + issues, + 'missing-reference', + `${path}.assetId`, + `Subtitle track references missing asset "${assetId}".` + ); + } + } + for (const field of ['sourceName', 'language', 'title']) { + if (track[field] !== undefined) { + requireString(track[field], `${path}.${field}`, issues); + } + } + if (track.sourceFormat !== undefined) { + requireEnum( + track.sourceFormat, + ['srt', 'vtt', 'ass'], + `${path}.sourceFormat`, + issues + ); + } + if ( + track.offsetSeconds !== undefined && + (typeof track.offsetSeconds !== 'number' || + !Number.isFinite(track.offsetSeconds) || + Math.abs(track.offsetSeconds) > 86_400) + ) { + issue( + issues, + 'invalid-value', + `${path}.offsetSeconds`, + 'Subtitle time shift must be a finite number within 24 hours.' + ); + } + for (const field of ['default', 'forced']) { + if (track[field] !== undefined && typeof track[field] !== 'boolean') { + issue( + issues, + 'invalid-type', + `${path}.${field}`, + `${field} must be a boolean.` + ); + } + } + requireEnum( + track.mode, + ['exclude', 'mux', 'burn-in'], + `${path}.mode`, + issues + ); + if (track.mode === 'burn-in') { + burnInCount += 1; + } + if (track.sourceStatus !== undefined) { + requireEnum( + track.sourceStatus, + ['attached', 'missing'], + `${path}.sourceStatus`, + issues + ); + } + }); + if (burnInCount > 1) { + issue( + issues, + 'invalid-value', + 'subtitles', + 'Only one subtitle track can be burned in at a time.' + ); + } +} + +function validateUi( + value: unknown, + assetIds: Set, + clipIds: Set, + issues: ProjectValidationIssue[] +): void { + if (value === undefined) { + return; + } + if (!isRecord(value)) { + issue(issues, 'invalid-type', 'ui', 'UI state must be an object.'); + return; + } + if (value.selectedAssetId !== undefined) { + const id = requireNonEmptyString( + value.selectedAssetId, + 'ui.selectedAssetId', + issues + ); + if (id !== undefined && !assetIds.has(id)) { + issue( + issues, + 'missing-reference', + 'ui.selectedAssetId', + 'The selected asset does not exist.' + ); + } + } + if (value.selectedClipId !== undefined) { + const id = requireNonEmptyString( + value.selectedClipId, + 'ui.selectedClipId', + issues + ); + if (id !== undefined && !clipIds.has(id)) { + issue( + issues, + 'missing-reference', + 'ui.selectedClipId', + 'The selected clip does not exist.' + ); + } + } + if (value.inspectorPanel !== undefined) { + requireEnum( + value.inspectorPanel, + ['media', 'clip', 'output'], + 'ui.inspectorPanel', + issues + ); + } + if (value.timelineZoom !== undefined) { + requireNumber(value.timelineZoom, 'ui.timelineZoom', issues, { + exclusiveMinimum: 0, + maximum: 100, + }); + } +} + +interface NumberRequirements { + integer?: boolean; + minimum?: number; + exclusiveMinimum?: number; + maximum?: number; +} + +function requireNumber( + value: unknown, + path: string, + issues: ProjectValidationIssue[], + requirements: NumberRequirements = {} +): number | undefined { + if (typeof value !== 'number' || !Number.isFinite(value)) { + issue(issues, 'invalid-type', path, 'Expected a finite number.'); + return undefined; + } + if (requirements.integer === true && !Number.isSafeInteger(value)) { + issue(issues, 'invalid-value', path, 'Expected a safe integer.'); + } + if (requirements.minimum !== undefined && value < requirements.minimum) { + issue( + issues, + 'invalid-value', + path, + `Expected a value of at least ${requirements.minimum}.` + ); + } + if ( + requirements.exclusiveMinimum !== undefined && + value <= requirements.exclusiveMinimum + ) { + issue( + issues, + 'invalid-value', + path, + `Expected a value greater than ${requirements.exclusiveMinimum}.` + ); + } + if (requirements.maximum !== undefined && value > requirements.maximum) { + issue( + issues, + 'invalid-value', + path, + `Expected a value no greater than ${requirements.maximum}.` + ); + } + return value; +} + +function requireBoolean( + value: unknown, + path: string, + issues: ProjectValidationIssue[] +): boolean | undefined { + if (typeof value !== 'boolean') { + issue(issues, 'invalid-type', path, 'Expected a boolean.'); + return undefined; + } + return value; +} + +function requireString( + value: unknown, + path: string, + issues: ProjectValidationIssue[] +): string | undefined { + if (typeof value !== 'string') { + issue(issues, 'invalid-type', path, 'Expected a string.'); + return undefined; + } + return value; +} + +function requireNonEmptyString( + value: unknown, + path: string, + issues: ProjectValidationIssue[] +): string | undefined { + const string = requireString(value, path, issues); + if (string !== undefined && string.trim() === '') { + issue(issues, 'invalid-value', path, 'Expected a non-empty string.'); + return undefined; + } + return string; +} + +function requireIsoDate( + value: unknown, + path: string, + issues: ProjectValidationIssue[] +): number | undefined { + const string = requireNonEmptyString(value, path, issues); + if ( + string === undefined || + !/^\d{4}-\d{2}-\d{2}T/.test(string) || + !Number.isFinite(Date.parse(string)) + ) { + if (string !== undefined) { + issue( + issues, + 'invalid-value', + path, + 'Expected an ISO-8601 date and time.' + ); + } + return undefined; + } + return Date.parse(string); +} + +function requireEnum( + value: unknown, + allowed: readonly T[], + path: string, + issues: ProjectValidationIssue[] +): T | undefined { + if (!allowed.includes(value as T)) { + issue( + issues, + 'invalid-value', + path, + `Expected one of: ${allowed.map(String).join(', ')}.` + ); + return undefined; + } + return value as T; +} + +function validateStringArray( + value: unknown, + path: string, + issues: ProjectValidationIssue[] +): void { + if (!Array.isArray(value)) { + issue(issues, 'invalid-type', path, 'Expected an array of strings.'); + return; + } + value.forEach((entry, index) => + requireString(entry, `${path}[${index}]`, issues) + ); +} + +function validateStringRecord( + value: unknown, + path: string, + issues: ProjectValidationIssue[] +): void { + if (!isRecord(value)) { + issue(issues, 'invalid-type', path, 'Expected an object of string values.'); + return; + } + for (const [key, entry] of Object.entries(value)) { + if (!isSafeRecordKey(key)) { + issue(issues, 'invalid-value', `${path}.${key}`, 'Unsafe object key.'); + } + requireString(entry, `${path}.${key}`, issues); + } +} + +function validateBooleanRecord( + value: unknown, + path: string, + issues: ProjectValidationIssue[] +): void { + if (!isRecord(value)) { + issue( + issues, + 'invalid-type', + path, + 'Expected an object of boolean values.' + ); + return; + } + for (const [key, entry] of Object.entries(value)) { + if (!isSafeRecordKey(key)) { + issue(issues, 'invalid-value', `${path}.${key}`, 'Unsafe object key.'); + } + requireBoolean(entry, `${path}.${key}`, issues); + } +} + +function addUniqueId( + id: string, + path: string, + ids: Set, + issues: ProjectValidationIssue[] +): void { + if (ids.has(id)) { + issue(issues, 'duplicate-id', path, `ID "${id}" is duplicated.`); + } + ids.add(id); +} + +function issue( + issues: ProjectValidationIssue[], + code: ProjectValidationIssueCode, + path: string, + message: string +): void { + issues.push({ code, path, message }); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isSafeRecordKey(key: string): boolean { + return key !== '__proto__' && key !== 'prototype' && key !== 'constructor'; +} diff --git a/src/project/source-reattachment.ts b/src/project/source-reattachment.ts new file mode 100644 index 0000000..db82065 --- /dev/null +++ b/src/project/source-reattachment.ts @@ -0,0 +1,248 @@ +import { projectReducer } from './project.reducer'; +import type { + AvProjectV1, + MediaAssetReference, + SourceHash, +} from './project.types'; + +export interface SourceFileDescriptor { + name: string; + size: number; + lastModified: number; + mimeType?: string; + hash?: SourceHash; +} + +export type ReattachmentConfidence = 'exact' | 'likely' | 'possible' | 'none'; + +export interface ReattachmentEvidence { + field: 'size' | 'lastModified' | 'name' | 'mimeType' | 'hash'; + matches: boolean; + message: string; +} + +export interface ReattachmentMatch { + candidate: SourceFileDescriptor; + confidence: ReattachmentConfidence; + score: number; + evidence: ReattachmentEvidence[]; + requiresExplicitConfirmation: true; +} + +export interface ReattachmentMatchOptions { + lastModifiedToleranceMs?: number; +} + +export function rankReattachmentCandidates( + asset: MediaAssetReference, + candidates: readonly SourceFileDescriptor[], + options: ReattachmentMatchOptions = {} +): ReattachmentMatch[] { + const tolerance = options.lastModifiedToleranceMs ?? 2_000; + if (!Number.isFinite(tolerance) || tolerance < 0) { + throw new RangeError( + 'Last-modified tolerance must be finite and non-negative.' + ); + } + for (const candidate of candidates) { + assertValidDescriptor(candidate); + } + + return candidates + .map((candidate) => matchSource(asset, candidate, tolerance)) + .sort( + (left, right) => + right.score - left.score || + left.candidate.name.localeCompare(right.candidate.name) + ); +} + +export function bestReattachmentCandidate( + asset: MediaAssetReference, + candidates: readonly SourceFileDescriptor[], + options?: ReattachmentMatchOptions +): ReattachmentMatch | undefined { + const matches = rankReattachmentCandidates(asset, candidates, options); + const best = matches[0]; + if (best === undefined || best.confidence === 'none') { + return undefined; + } + const runnerUp = matches[1]; + if (runnerUp !== undefined && runnerUp.score === best.score) { + return undefined; + } + return best; +} + +export function confirmSourceReattachment( + project: AvProjectV1, + assetId: string, + candidate: SourceFileDescriptor, + updatedAt: string, + options?: ReattachmentMatchOptions +): AvProjectV1 { + const asset = project.assets.find((entry) => entry.id === assetId); + if (asset === undefined) { + throw new SourceReattachmentError(`Unknown asset "${assetId}".`); + } + const match = rankReattachmentCandidates(asset, [candidate], options)[0]; + if (match === undefined || match.confidence === 'none') { + throw new SourceReattachmentError( + 'The selected source does not match the stored source fingerprint.' + ); + } + return projectReducer(project, { + type: 'asset/set-source-status', + assetId, + sourceStatus: 'attached', + updatedAt, + }); +} + +export class SourceReattachmentError extends Error { + constructor(message: string) { + super(message); + this.name = 'SourceReattachmentError'; + } +} + +function matchSource( + asset: MediaAssetReference, + candidate: SourceFileDescriptor, + lastModifiedToleranceMs: number +): ReattachmentMatch { + const evidence: ReattachmentEvidence[] = []; + let score = 0; + const sizeMatches = candidate.size === asset.size; + evidence.push({ + field: 'size', + matches: sizeMatches, + message: sizeMatches ? 'File size matches.' : 'File size differs.', + }); + if (sizeMatches) { + score += 50; + } + + const modifiedMatches = + Math.abs(candidate.lastModified - asset.lastModified) <= + lastModifiedToleranceMs; + evidence.push({ + field: 'lastModified', + matches: modifiedMatches, + message: modifiedMatches + ? 'Last-modified time matches within tolerance.' + : 'Last-modified time differs.', + }); + if (modifiedMatches) { + score += 25; + } + + const nameMatches = candidate.name === asset.originalName; + evidence.push({ + field: 'name', + matches: nameMatches, + message: nameMatches + ? 'Original filename matches.' + : 'Original filename differs.', + }); + if (nameMatches) { + score += 10; + } + + const mimeMatches = + !asset.mimeType || + !candidate.mimeType || + candidate.mimeType === asset.mimeType; + evidence.push({ + field: 'mimeType', + matches: mimeMatches, + message: mimeMatches ? 'MIME type is compatible.' : 'MIME type differs.', + }); + if (mimeMatches && candidate.mimeType && asset.mimeType) { + score += 5; + } + + const hashComparison = compareHashes(asset.hash, candidate.hash); + if (hashComparison !== undefined) { + evidence.push({ + field: 'hash', + matches: hashComparison, + message: hashComparison + ? 'Stored and candidate hashes match.' + : 'Stored and candidate hashes differ.', + }); + if (hashComparison) { + score += 100; + } + } + + const incompatible = !sizeMatches || !mimeMatches || hashComparison === false; + const confidence: ReattachmentConfidence = incompatible + ? 'none' + : hashComparison === true + ? 'exact' + : modifiedMatches + ? 'likely' + : nameMatches + ? 'possible' + : 'none'; + + return { + candidate, + confidence, + score: incompatible ? 0 : score, + evidence, + requiresExplicitConfirmation: true, + }; +} + +function compareHashes( + expected: SourceHash | undefined, + actual: SourceHash | undefined +): boolean | undefined { + if (expected === undefined || actual === undefined) { + return undefined; + } + if ( + expected.algorithm !== actual.algorithm || + expected.scope !== actual.scope + ) { + return undefined; + } + if ( + expected.scope === 'partial' && + expected.bytesHashed !== actual.bytesHashed + ) { + return undefined; + } + return expected.value.toLowerCase() === actual.value.toLowerCase(); +} + +function assertValidDescriptor(candidate: SourceFileDescriptor): void { + if ( + !candidate.name || + !Number.isSafeInteger(candidate.size) || + candidate.size < 0 || + !Number.isSafeInteger(candidate.lastModified) || + candidate.lastModified < 0 + ) { + throw new SourceReattachmentError( + 'A source descriptor requires a name, non-negative size and non-negative last-modified time.' + ); + } + if (candidate.hash !== undefined) { + const hash = candidate.hash; + if ( + hash.algorithm !== 'sha-256' || + !/^[a-fA-F0-9]{64}$/.test(hash.value) || + (hash.scope !== 'full' && hash.scope !== 'partial') || + (hash.scope === 'partial' && + (!Number.isSafeInteger(hash.bytesHashed) || + (hash.bytesHashed ?? 0) <= 0)) + ) { + throw new SourceReattachmentError( + 'A source hash must be a valid full or partial SHA-256 fingerprint.' + ); + } + } +} diff --git a/src/storage/cache-key.ts b/src/storage/cache-key.ts new file mode 100644 index 0000000..0de8fba --- /dev/null +++ b/src/storage/cache-key.ts @@ -0,0 +1,86 @@ +export interface DerivedCacheIdentity { + readonly sourceFingerprint: string; + readonly operation: string; + readonly streamIndex?: number; + readonly settings?: Readonly>; + readonly version?: number; +} + +export function createSourceFingerprint( + file: Pick +): string { + return stableHash({ + name: file.name, + size: file.size, + lastModified: file.lastModified, + }); +} + +export function createCacheKey(identity: DerivedCacheIdentity): string { + if ( + !identity.sourceFingerprint.trim() || + !/^[a-z0-9._-]+$/iu.test(identity.operation) + ) { + throw new TypeError( + 'Cache identity requires a fingerprint and safe operation' + ); + } + return `${identity.operation}-${stableHash({ + sourceFingerprint: identity.sourceFingerprint, + streamIndex: identity.streamIndex ?? null, + settings: identity.settings ?? {}, + version: identity.version ?? 1, + })}`; +} + +export function stableStringify(value: unknown): string { + return JSON.stringify(normalize(value)); +} + +export function stableHash(value: unknown): string { + const text = stableStringify(value); + let high = 0xcbf29ce4; + let low = 0x84222325; + for (let index = 0; index < text.length; index += 1) { + low ^= text.charCodeAt(index); + const lowProduct = low * 0x1b3; + const carry = Math.floor(lowProduct / 0x1_0000_0000); + low = lowProduct >>> 0; + high = (high * 0x1b3 + carry) >>> 0; + } + return `${high.toString(16).padStart(8, '0')}${low + .toString(16) + .padStart(8, '0')}`; +} + +function normalize(value: unknown): unknown { + if ( + value === null || + typeof value === 'string' || + typeof value === 'boolean' + ) { + return value; + } + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + throw new TypeError('Cache settings cannot contain non-finite numbers'); + } + return Object.is(value, -0) ? 0 : value; + } + if (Array.isArray(value)) { + return value.map(normalize); + } + if (typeof value === 'object') { + const result: Record = {}; + for (const key of Object.keys(value as Record).sort()) { + const entry = (value as Record)[key]; + if (entry !== undefined) { + result[key] = normalize(entry); + } + } + return result; + } + throw new TypeError( + 'Cache settings must contain only JSON-compatible values' + ); +} diff --git a/src/storage/cache.ts b/src/storage/cache.ts new file mode 100644 index 0000000..3487097 --- /dev/null +++ b/src/storage/cache.ts @@ -0,0 +1,233 @@ +export type DerivedCacheKind = + | 'waveform-peaks' + | 'waveform-image' + | 'preview-proxy' + | 'thumbnail' + | 'contact-sheet' + | 'project' + | 'retained-output'; + +export interface DerivedCacheEntry { + readonly key: string; + readonly kind: DerivedCacheKind; + readonly projectId?: string; + readonly mediaType: string; + readonly sizeBytes: number; + readonly createdAt: string; + readonly lastAccessedAt: string; + readonly expiresAt?: string; +} + +export interface DerivedCachePutOptions { + readonly key: string; + readonly kind: DerivedCacheKind; + readonly projectId?: string; + readonly mediaType?: string; + readonly expiresAt?: string; +} + +export interface DerivedCacheValue { + readonly entry: DerivedCacheEntry; + readonly data: Blob; +} + +export interface DerivedCache { + readonly mode: 'opfs' | 'memory'; + list(): Promise; + get(key: string): Promise; + put( + options: DerivedCachePutOptions, + data: Blob | Uint8Array + ): Promise; + delete(key: string): Promise; + clear(): Promise; + clearProject(projectId: string): Promise; + pruneExpired(now?: Date): Promise; + sizeBytes(): Promise; +} + +export interface MemoryDerivedCacheOptions { + readonly maxBytes?: number; + readonly now?: () => Date; +} + +interface MemoryEntry { + entry: DerivedCacheEntry; + data: Blob; +} + +export class MemoryDerivedCache implements DerivedCache { + readonly mode = 'memory' as const; + readonly #entries = new Map(); + readonly #maxBytes: number; + readonly #now: () => Date; + + constructor(options: MemoryDerivedCacheOptions = {}) { + this.#maxBytes = options.maxBytes ?? 128 * 1024 * 1024; + this.#now = options.now ?? (() => new Date()); + if (!Number.isSafeInteger(this.#maxBytes) || this.#maxBytes <= 0) { + throw new RangeError( + 'Memory cache maxBytes must be a positive safe integer' + ); + } + } + + async list(): Promise { + return Object.freeze( + [...this.#entries.values()] + .map(({ entry }) => Object.freeze({ ...entry })) + .sort((left, right) => + right.lastAccessedAt.localeCompare(left.lastAccessedAt) + ) + ); + } + + async get(key: string): Promise { + validateCacheKey(key); + const stored = this.#entries.get(key); + if (!stored) { + return undefined; + } + if (isExpired(stored.entry, this.#now())) { + this.#entries.delete(key); + return undefined; + } + stored.entry = Object.freeze({ + ...stored.entry, + lastAccessedAt: this.#now().toISOString(), + }); + return { + entry: stored.entry, + data: stored.data, + }; + } + + async put( + options: DerivedCachePutOptions, + data: Blob | Uint8Array + ): Promise { + validatePutOptions(options); + const blob = toBlob(data, options.mediaType); + if (blob.size > this.#maxBytes) { + throw new RangeError('Cache item exceeds the in-memory cache limit'); + } + this.#entries.delete(options.key); + await this.#evictFor(blob.size); + const timestamp = this.#now().toISOString(); + const entry = createEntry(options, blob, timestamp); + this.#entries.set(options.key, { entry, data: blob }); + return entry; + } + + async delete(key: string): Promise { + validateCacheKey(key); + return this.#entries.delete(key); + } + + async clear(): Promise { + this.#entries.clear(); + } + + async clearProject(projectId: string): Promise { + let removed = 0; + for (const [key, { entry }] of this.#entries) { + if (entry.projectId === projectId) { + this.#entries.delete(key); + removed += 1; + } + } + return removed; + } + + async pruneExpired(now = this.#now()): Promise { + let removed = 0; + for (const [key, { entry }] of this.#entries) { + if (isExpired(entry, now)) { + this.#entries.delete(key); + removed += 1; + } + } + return removed; + } + + async sizeBytes(): Promise { + return [...this.#entries.values()].reduce( + (sum, stored) => sum + stored.entry.sizeBytes, + 0 + ); + } + + async #evictFor(requiredBytes: number): Promise { + let size = await this.sizeBytes(); + if (size + requiredBytes <= this.#maxBytes) { + return; + } + const oldest = [...this.#entries.values()].sort((left, right) => + left.entry.lastAccessedAt.localeCompare(right.entry.lastAccessedAt) + ); + for (const stored of oldest) { + this.#entries.delete(stored.entry.key); + size -= stored.entry.sizeBytes; + if (size + requiredBytes <= this.#maxBytes) { + break; + } + } + } +} + +export function validateCacheKey(key: string): void { + if (!/^[a-z0-9][a-z0-9._-]{0,159}$/iu.test(key)) { + throw new TypeError('Cache keys must be safe path-independent identifiers'); + } +} + +export function validatePutOptions(options: DerivedCachePutOptions): void { + validateCacheKey(options.key); + if (options.projectId !== undefined) { + validateCacheKey(options.projectId); + } + if ( + options.expiresAt !== undefined && + !Number.isFinite(Date.parse(options.expiresAt)) + ) { + throw new TypeError('Cache expiry must be an ISO-compatible timestamp'); + } + if ( + options.mediaType !== undefined && + !/^[a-z0-9.+-]+\/[a-z0-9.+-]+$/iu.test(options.mediaType) + ) { + throw new TypeError('Cache mediaType is invalid'); + } +} + +export function createEntry( + options: DerivedCachePutOptions, + blob: Blob, + timestamp: string +): DerivedCacheEntry { + return Object.freeze({ + key: options.key, + kind: options.kind, + ...(options.projectId ? { projectId: options.projectId } : {}), + mediaType: options.mediaType ?? (blob.type || 'application/octet-stream'), + sizeBytes: blob.size, + createdAt: timestamp, + lastAccessedAt: timestamp, + ...(options.expiresAt ? { expiresAt: options.expiresAt } : {}), + }); +} + +export function isExpired(entry: DerivedCacheEntry, now: Date): boolean { + return ( + entry.expiresAt !== undefined && + Date.parse(entry.expiresAt) <= now.getTime() + ); +} + +export function toBlob(data: Blob | Uint8Array, mediaType?: string): Blob { + return data instanceof Blob + ? data + : new Blob([data as BlobPart], { + type: mediaType ?? 'application/octet-stream', + }); +} diff --git a/src/storage/index.ts b/src/storage/index.ts new file mode 100644 index 0000000..121bc18 --- /dev/null +++ b/src/storage/index.ts @@ -0,0 +1,6 @@ +export * from './cache'; +export * from './cache-key'; +export * from './opfs'; +export * from './plan-resource-estimate'; +export * from './quota'; +export * from './resource-policy'; diff --git a/src/storage/opfs.ts b/src/storage/opfs.ts new file mode 100644 index 0000000..df38e5e --- /dev/null +++ b/src/storage/opfs.ts @@ -0,0 +1,391 @@ +import { + createEntry, + isExpired, + MemoryDerivedCache, + toBlob, + validateCacheKey, + validatePutOptions, + type DerivedCache, + type DerivedCacheEntry, + type DerivedCachePutOptions, + type DerivedCacheValue, +} from './cache'; + +const INDEX_FILE = '.index.json'; +const CACHE_DIRECTORY = 'av-tools-derived-cache-v1'; +const CACHE_KINDS = new Set([ + 'waveform-peaks', + 'waveform-image', + 'preview-proxy', + 'thumbnail', + 'contact-sheet', + 'project', + 'retained-output', +]); + +export interface OpfsStorageManager { + getDirectory(): Promise; +} + +export interface OpfsDerivedCacheOptions { + readonly storage: OpfsStorageManager; + readonly now?: () => Date; + readonly directoryName?: string; +} + +interface CacheIndex { + readonly schemaVersion: 1; + readonly entries: readonly DerivedCacheEntry[]; +} + +export class OpfsDerivedCache implements DerivedCache { + readonly mode = 'opfs' as const; + readonly #storage: OpfsStorageManager; + readonly #now: () => Date; + readonly #directoryName: string; + #directory?: Promise; + #entries?: Map; + + constructor(options: OpfsDerivedCacheOptions) { + this.#storage = options.storage; + this.#now = options.now ?? (() => new Date()); + this.#directoryName = options.directoryName ?? CACHE_DIRECTORY; + validateCacheKey(this.#directoryName); + } + + async initialize(): Promise { + await this.#loadEntries(); + await this.pruneExpired(); + } + + async list(): Promise { + const entries = await this.#loadEntries(); + return Object.freeze( + [...entries.values()].sort((left, right) => + right.lastAccessedAt.localeCompare(left.lastAccessedAt) + ) + ); + } + + async get(key: string): Promise { + validateCacheKey(key); + const entries = await this.#loadEntries(); + const entry = entries.get(key); + if (!entry) { + return undefined; + } + if (isExpired(entry, this.#now())) { + await this.delete(key); + return undefined; + } + try { + const fileHandle = await ( + await this.#getDirectory() + ).getFileHandle(fileName(key)); + const data = await fileHandle.getFile(); + const updated = Object.freeze({ + ...entry, + lastAccessedAt: this.#now().toISOString(), + }); + entries.set(key, updated); + await this.#writeIndex(entries); + return { entry: updated, data }; + } catch (error) { + if (isNotFound(error)) { + entries.delete(key); + await this.#writeIndex(entries); + return undefined; + } + throw error; + } + } + + async put( + options: DerivedCachePutOptions, + data: Blob | Uint8Array + ): Promise { + validatePutOptions(options); + const directory = await this.#getDirectory(); + const blob = toBlob(data, options.mediaType); + const fileHandle = await directory.getFileHandle(fileName(options.key), { + create: true, + }); + const writable = await fileHandle.createWritable(); + try { + await writable.write(blob); + await writable.close(); + } catch (error) { + await writable.abort(error).catch(() => undefined); + throw error; + } + const entries = await this.#loadEntries(); + const previous = entries.get(options.key); + const timestamp = this.#now().toISOString(); + const entry = Object.freeze({ + ...createEntry(options, blob, timestamp), + ...(previous ? { createdAt: previous.createdAt } : {}), + }); + entries.set(options.key, entry); + await this.#writeIndex(entries); + return entry; + } + + async delete(key: string): Promise { + validateCacheKey(key); + const entries = await this.#loadEntries(); + const existed = entries.delete(key); + try { + await (await this.#getDirectory()).removeEntry(fileName(key)); + } catch (error) { + if (!isNotFound(error)) { + throw error; + } + } + if (existed) { + await this.#writeIndex(entries); + } + return existed; + } + + async clear(): Promise { + const directory = await this.#getDirectory(); + for await (const name of directory.keys()) { + await directory.removeEntry(name, { recursive: true }); + } + this.#entries = new Map(); + } + + async clearProject(projectId: string): Promise { + const entries = await this.#loadEntries(); + const keys = [...entries.values()] + .filter((entry) => entry.projectId === projectId) + .map((entry) => entry.key); + for (const key of keys) { + await this.delete(key); + } + return keys.length; + } + + async pruneExpired(now = this.#now()): Promise { + const entries = await this.#loadEntries(); + const keys = [...entries.values()] + .filter((entry) => isExpired(entry, now)) + .map((entry) => entry.key); + for (const key of keys) { + await this.delete(key); + } + return keys.length; + } + + async sizeBytes(): Promise { + return (await this.list()).reduce((sum, entry) => sum + entry.sizeBytes, 0); + } + + async #getDirectory(): Promise { + return (this.#directory ??= this.#storage + .getDirectory() + .then((root) => + root.getDirectoryHandle(this.#directoryName, { create: true }) + )); + } + + async #loadEntries(): Promise> { + if (this.#entries) { + return this.#entries; + } + const entries = new Map(); + try { + const handle = await ( + await this.#getDirectory() + ).getFileHandle(INDEX_FILE); + const file = await handle.getFile(); + const parsed = JSON.parse(await file.text()) as unknown; + if (!isCacheIndex(parsed)) { + throw new TypeError('OPFS cache index is invalid'); + } + for (const entry of parsed.entries) { + entries.set(entry.key, Object.freeze({ ...entry })); + } + } catch (error) { + if (!isNotFound(error)) { + throw error; + } + } + this.#entries = entries; + return entries; + } + + async #writeIndex(entries: Map): Promise { + const handle = await ( + await this.#getDirectory() + ).getFileHandle(INDEX_FILE, { + create: true, + }); + const writable = await handle.createWritable(); + const index: CacheIndex = { + schemaVersion: 1, + entries: [...entries.values()].sort((left, right) => + left.key.localeCompare(right.key) + ), + }; + try { + await writable.write(`${JSON.stringify(index)}\n`); + await writable.close(); + } catch (error) { + await writable.abort(error).catch(() => undefined); + throw error; + } + } +} + +export class ResilientDerivedCache implements DerivedCache { + #active: DerivedCache; + readonly #fallback: DerivedCache; + #fallbackReason?: string; + + constructor( + primary: DerivedCache, + fallback: DerivedCache = new MemoryDerivedCache() + ) { + this.#active = primary; + this.#fallback = fallback; + } + + get mode(): 'opfs' | 'memory' { + return this.#active.mode; + } + + get fallbackReason(): string | undefined { + return this.#fallbackReason; + } + + list(): Promise { + return this.#run((cache) => cache.list()); + } + + get(key: string): Promise { + return this.#run((cache) => cache.get(key)); + } + + put( + options: DerivedCachePutOptions, + data: Blob | Uint8Array + ): Promise { + return this.#run((cache) => cache.put(options, data)); + } + + delete(key: string): Promise { + return this.#run((cache) => cache.delete(key)); + } + + clear(): Promise { + return this.#run((cache) => cache.clear()); + } + + clearProject(projectId: string): Promise { + return this.#run((cache) => cache.clearProject(projectId)); + } + + pruneExpired(now?: Date): Promise { + return this.#run((cache) => cache.pruneExpired(now)); + } + + sizeBytes(): Promise { + return this.#run((cache) => cache.sizeBytes()); + } + + async #run(operation: (cache: DerivedCache) => Promise): Promise { + try { + return await operation(this.#active); + } catch (error) { + if (this.#active === this.#fallback) { + throw error; + } + this.#fallbackReason = + error instanceof Error ? error.message : 'OPFS cache operation failed'; + this.#active = this.#fallback; + return operation(this.#active); + } + } +} + +export async function openDerivedCache( + options: { + readonly storage?: OpfsStorageManager; + readonly memoryMaxBytes?: number; + } = {} +): Promise { + const fallback = new MemoryDerivedCache({ + ...(options.memoryMaxBytes !== undefined + ? { maxBytes: options.memoryMaxBytes } + : {}), + }); + const storage = + options.storage ?? + (typeof navigator !== 'undefined' && + 'storage' in navigator && + 'getDirectory' in navigator.storage + ? (navigator.storage as OpfsStorageManager) + : undefined); + if (!storage) { + return new ResilientDerivedCache(fallback, fallback); + } + const primary = new OpfsDerivedCache({ storage }); + const resilient = new ResilientDerivedCache(primary, fallback); + await resilient.pruneExpired(); + return resilient; +} + +function fileName(key: string): string { + validateCacheKey(key); + return `${key}.bin`; +} + +function isNotFound(error: unknown): boolean { + return error instanceof DOMException + ? error.name === 'NotFoundError' + : error instanceof Error && error.name === 'NotFoundError'; +} + +function isCacheIndex(value: unknown): value is CacheIndex { + if ( + typeof value !== 'object' || + value === null || + (value as { schemaVersion?: unknown }).schemaVersion !== 1 || + !Array.isArray((value as { entries?: unknown }).entries) + ) { + return false; + } + return (value as { entries: unknown[] }).entries.every((entry) => { + if (typeof entry !== 'object' || entry === null) { + return false; + } + const candidate = entry as Partial; + try { + if (typeof candidate.key !== 'string') { + return false; + } + validateCacheKey(candidate.key); + if (candidate.projectId !== undefined) { + validateCacheKey(candidate.projectId); + } + } catch { + return false; + } + return ( + typeof candidate.kind === 'string' && + CACHE_KINDS.has(candidate.kind) && + typeof candidate.mediaType === 'string' && + /^[a-z0-9.+-]+\/[a-z0-9.+-]+$/iu.test(candidate.mediaType) && + Number.isSafeInteger(candidate.sizeBytes) && + (candidate.sizeBytes ?? -1) >= 0 && + typeof candidate.createdAt === 'string' && + Number.isFinite(Date.parse(candidate.createdAt)) && + typeof candidate.lastAccessedAt === 'string' && + Number.isFinite(Date.parse(candidate.lastAccessedAt)) && + (candidate.expiresAt === undefined || + (typeof candidate.expiresAt === 'string' && + Number.isFinite(Date.parse(candidate.expiresAt)))) + ); + }); +} diff --git a/src/storage/plan-resource-estimate.ts b/src/storage/plan-resource-estimate.ts new file mode 100644 index 0000000..cba72a5 --- /dev/null +++ b/src/storage/plan-resource-estimate.ts @@ -0,0 +1,327 @@ +import type { FFmpegCommandPlan } from '../commands/command-plan'; +import type { MediaProbe, MediaStreamProbe } from '../media'; +import { estimateOutputBytes, type ResourceEstimate } from './resource-policy'; + +export interface PlanResourceSource { + readonly size: number; + readonly probe?: MediaProbe; +} + +export type PlanResourceEstimate = Omit< + ResourceEstimate, + 'inputBytes' | 'streamsPerInput' | 'retainedOutputs' +>; + +/** + * Derives conservative policy inputs from a structured command plan and its + * already-probed sources. Unknown values remain absent instead of being + * represented by optimistic zeroes. + */ +export function estimatePlanResources( + plan: FFmpegCommandPlan, + sources: readonly PlanResourceSource[] +): PlanResourceEstimate { + const durationSeconds = plan.expectedDurationSeconds; + const thumbnailCount = plan.outputs.filter( + (output) => output.role === 'thumbnail' + ).length; + const contactSheetCells = readContactSheetCells(plan.args); + const geometry = readOutputGeometry(plan.args, sources); + const generatedFrameRate = + durationSeconds !== undefined && durationSeconds > 0 && thumbnailCount > 1 + ? thumbnailCount / durationSeconds + : undefined; + const frameRate = + readLastFilterNumber(plan.args, 'fps') ?? + readLastOptionNumber(plan.args, '-r') ?? + generatedFrameRate ?? + firstVideoStream(sources)?.frameRate; + const expectedOutputBytes = + estimateMediaOutputBytes(plan, sources, durationSeconds) ?? + estimateImageOutputBytes(plan, geometry); + + return { + ...(durationSeconds === undefined ? {} : { durationSeconds }), + ...(expectedOutputBytes === undefined ? {} : { expectedOutputBytes }), + ...(geometry.width === undefined ? {} : { width: geometry.width }), + ...(geometry.height === undefined ? {} : { height: geometry.height }), + ...(frameRate === undefined ? {} : { frameRate }), + passes: plan.operation.includes('loudness') ? 2 : 1, + ...(plan.outputs.length > 0 + ? { generatedOutputs: plan.outputs.length } + : {}), + ...(thumbnailCount === 0 ? {} : { thumbnailCount }), + ...(contactSheetCells === undefined ? {} : { contactSheetCells }), + ...(plan.timeoutMs === undefined + ? {} + : { expectedRuntimeMs: plan.timeoutMs }), + ...(plan.operation === 'timeline-export' + ? { timelineClips: plan.inputs.length } + : {}), + }; +} + +function estimateImageOutputBytes( + plan: FFmpegCommandPlan, + geometry: { readonly width?: number; readonly height?: number } +): number | undefined { + const imageOutputCount = plan.outputs.filter( + (output) => output.role === 'thumbnail' || output.role === 'contact-sheet' + ).length; + if ( + imageOutputCount === 0 || + geometry.width === undefined || + geometry.height === undefined + ) { + return undefined; + } + return Math.ceil( + geometry.width * geometry.height * 4 * imageOutputCount * 1.1 + ); +} + +function estimateMediaOutputBytes( + plan: FFmpegCommandPlan, + sources: readonly PlanResourceSource[], + durationSeconds?: number +): number | undefined { + const hasMediaOutput = plan.outputs.some( + (output) => output.role === 'media' || output.role === 'audio' + ); + if (!hasMediaOutput) return undefined; + + const inputBytes = sources.reduce((sum, source) => sum + source.size, 0); + if (inputBytes === 0) return undefined; + if (usesStreamCopy(plan.args)) { + return inputBytes; + } + + if (durationSeconds !== undefined) { + const declaredVideoBitrateKbps = readLastBitrateKbps(plan.args, '-b:v'); + const declaredAudioBitrateKbps = readLastBitrateKbps(plan.args, '-b:a'); + const hasVideoOutput = + !plan.args.includes('-vn') && + (hasOption(plan.args, '-c:v') || + sources.some((source) => + source.probe?.streams.some((stream) => stream.type === 'video') + )); + const hasAudioOutput = + !plan.args.includes('-an') && + (plan.outputs.some((output) => output.role === 'audio') || + hasOption(plan.args, '-c:a') || + sources.some((source) => + source.probe?.streams.some((stream) => stream.type === 'audio') + )); + const hasCompleteDeclaredBitrate = + (!hasVideoOutput || declaredVideoBitrateKbps !== undefined) && + (!hasAudioOutput || declaredAudioBitrateKbps !== undefined); + if (hasCompleteDeclaredBitrate) { + const declaredBitrateKbps = + (declaredVideoBitrateKbps ?? 0) + (declaredAudioBitrateKbps ?? 0); + if (declaredBitrateKbps > 0) { + return estimateOutputBytes(durationSeconds, declaredBitrateKbps); + } + } + const probedBitrateKbps = weightedSourceBitrateKbps(sources); + if (probedBitrateKbps !== undefined && probedBitrateKbps > 0) { + return estimateOutputBytes(durationSeconds, probedBitrateKbps); + } + + const probedDurationSeconds = sources.reduce( + (sum, source) => sum + (source.probe?.durationSeconds ?? 0), + 0 + ); + if (probedDurationSeconds > 0) { + return Math.ceil( + inputBytes * Math.min(1, durationSeconds / probedDurationSeconds) * 1.03 + ); + } + } + return inputBytes; +} + +function hasOption(args: readonly string[], option: string): boolean { + return args.some((argument) => argument === option); +} + +function usesStreamCopy(args: readonly string[]): boolean { + return args.some( + (argument, index) => + /^(?:-c|-codec)(?::[vasdt])?$/u.test(argument) && + args[index + 1] === 'copy' + ); +} + +function weightedSourceBitrateKbps( + sources: readonly PlanResourceSource[] +): number | undefined { + let totalBits = 0; + let totalDurationSeconds = 0; + for (const source of sources) { + const durationSeconds = source.probe?.durationSeconds; + const bitRate = source.probe?.bitRate; + if ( + durationSeconds !== undefined && + durationSeconds > 0 && + bitRate !== undefined && + bitRate > 0 + ) { + totalBits += bitRate * durationSeconds; + totalDurationSeconds += durationSeconds; + } + } + return totalDurationSeconds > 0 + ? totalBits / totalDurationSeconds / 1_000 + : undefined; +} + +function readLastBitrateKbps( + args: readonly string[], + option: '-b:v' | '-b:a' +): number | undefined { + for (let index = args.length - 2; index >= 0; index -= 1) { + if (args[index] !== option) continue; + const value = args[index + 1]; + if (!value) return undefined; + const match = /^(\d+(?:\.\d+)?)([kKmM])?$/u.exec(value); + if (!match?.[1]) return undefined; + const numeric = Number(match[1]); + const unit = match[2]?.toLowerCase(); + return unit === 'm' + ? numeric * 1_000 + : unit === 'k' + ? numeric + : numeric / 1_000; + } + return undefined; +} + +function readOutputGeometry( + args: readonly string[], + sources: readonly PlanResourceSource[] +): { readonly width?: number; readonly height?: number } { + const filterText = args.join(';'); + const scaleMatches = [ + ...filterText.matchAll(/(?:^|[,;])scale=(\d+):(-?\d+)(?=[:;,]|$)/gu), + ]; + const lastScale = scaleMatches.at(-1); + let width = positiveInteger(lastScale?.[1]); + let height = positiveInteger(lastScale?.[2]); + const sourceVideo = firstVideoStream(sources); + if ( + width !== undefined && + height === undefined && + sourceVideo?.width && + sourceVideo.height + ) { + height = evenInteger((width * sourceVideo.height) / sourceVideo.width); + } + + if (width === undefined || height === undefined) { + const cropMatches = [ + ...filterText.matchAll(/(?:^|[,;])crop=(\d+):(\d+)(?=[:;,]|$)/gu), + ]; + const lastCrop = cropMatches.at(-1); + width ??= positiveInteger(lastCrop?.[1]) ?? sourceVideo?.width; + height ??= positiveInteger(lastCrop?.[2]) ?? sourceVideo?.height; + } + + const tile = readTile(args); + if (tile && width !== undefined && height !== undefined) { + return { + width: + tile.columns * width + + Math.max(0, tile.columns - 1) * tile.padding + + 2 * tile.margin, + height: + tile.rows * height + + Math.max(0, tile.rows - 1) * tile.padding + + 2 * tile.margin, + }; + } + return { + ...(width === undefined ? {} : { width }), + ...(height === undefined ? {} : { height }), + }; +} + +function readContactSheetCells(args: readonly string[]): number | undefined { + const tile = readTile(args); + return tile ? tile.columns * tile.rows : undefined; +} + +function readTile(args: readonly string[]): + | { + readonly columns: number; + readonly rows: number; + readonly padding: number; + readonly margin: number; + } + | undefined { + const text = args.join(';'); + const match = + /(?:^|[,;])tile=(\d+)x(\d+)(?::padding=(\d+))?(?::margin=(\d+))?/u.exec( + text + ); + const columns = positiveInteger(match?.[1]); + const rows = positiveInteger(match?.[2]); + if (columns === undefined || rows === undefined) return undefined; + return { + columns, + rows, + padding: nonnegativeInteger(match?.[3]) ?? 0, + margin: nonnegativeInteger(match?.[4]) ?? 0, + }; +} + +function readLastFilterNumber( + args: readonly string[], + filter: string +): number | undefined { + const matches = [ + ...args + .join(';') + .matchAll(new RegExp(`(?:^|[,;])${filter}=(\\d+(?:\\.\\d+)?)`, 'gu')), + ]; + return positiveNumber(matches.at(-1)?.[1]); +} + +function readLastOptionNumber( + args: readonly string[], + option: string +): number | undefined { + for (let index = args.length - 2; index >= 0; index -= 1) { + if (args[index] === option) return positiveNumber(args[index + 1]); + } + return undefined; +} + +function firstVideoStream( + sources: readonly PlanResourceSource[] +): MediaStreamProbe | undefined { + return sources + .flatMap((source) => source.probe?.streams ?? []) + .find((stream) => stream.type === 'video'); +} + +function positiveInteger(value?: string): number | undefined { + if (!value) return undefined; + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined; +} + +function nonnegativeInteger(value?: string): number | undefined { + if (value === undefined) return undefined; + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined; +} + +function positiveNumber(value?: string): number | undefined { + if (!value) return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; +} + +function evenInteger(value: number): number { + return Math.max(2, Math.round(value / 2) * 2); +} diff --git a/src/storage/quota.ts b/src/storage/quota.ts new file mode 100644 index 0000000..0185682 --- /dev/null +++ b/src/storage/quota.ts @@ -0,0 +1,65 @@ +export interface StorageManagerLike { + estimate(): Promise; + persisted?(): Promise; + persist?(): Promise; +} + +export interface StorageQuotaSnapshot { + readonly available: boolean; + readonly usageBytes?: number; + readonly quotaBytes?: number; + readonly remainingBytes?: number; + readonly usageFraction?: number; + readonly persisted?: boolean; + readonly error?: string; +} + +export async function readStorageQuota( + storage?: StorageManagerLike +): Promise { + const manager = + storage ?? + (typeof navigator !== 'undefined' ? navigator.storage : undefined); + if (!manager?.estimate) { + return { + available: false, + error: 'Browser storage estimates are unavailable.', + }; + } + try { + const estimate = await manager.estimate(); + const usageBytes = estimate.usage; + const quotaBytes = estimate.quota; + const persisted = manager.persisted ? await manager.persisted() : undefined; + return Object.freeze({ + available: true, + ...(usageBytes !== undefined ? { usageBytes } : {}), + ...(quotaBytes !== undefined ? { quotaBytes } : {}), + ...(usageBytes !== undefined && quotaBytes !== undefined + ? { + remainingBytes: Math.max(0, quotaBytes - usageBytes), + usageFraction: quotaBytes > 0 ? usageBytes / quotaBytes : 0, + } + : {}), + ...(persisted !== undefined ? { persisted } : {}), + }); + } catch (error) { + return { + available: false, + error: + error instanceof Error ? error.message : 'Storage estimate failed.', + }; + } +} + +export async function requestPersistentStorage( + storage?: StorageManagerLike +): Promise { + const manager = + storage ?? + (typeof navigator !== 'undefined' ? navigator.storage : undefined); + if (!manager?.persist) { + return false; + } + return manager.persist(); +} diff --git a/src/storage/resource-policy.ts b/src/storage/resource-policy.ts new file mode 100644 index 0000000..de2a014 --- /dev/null +++ b/src/storage/resource-policy.ts @@ -0,0 +1,457 @@ +import { + GENERATED_OUTPUT_LIMIT_EXPLANATION, + MAX_GENERATED_OUTPUT_FILES, +} from '../limits'; + +export interface ResourcePolicy { + readonly softInputBytes: number; + readonly hardSingleInputBytes: number; + readonly hardTotalInputBytes: number; + readonly softOutputEstimateBytes: number; + readonly hardOutputEstimateBytes: number; + readonly maxConcurrentFFmpegJobs: number; + readonly maxTimelineClips: number; + readonly maxStreamsPerInput: number; + readonly maxImageDimension: number; + readonly maxPixelCount: number; + readonly maxThumbnailCount: number; + readonly maxGeneratedOutputs: number; + readonly maxContactSheetCells: number; + readonly maxJobRuntimeMs: number; +} + +export const DEFAULT_RESOURCE_POLICY: Readonly = Object.freeze({ + softInputBytes: 256 * 1024 * 1024, + hardSingleInputBytes: 1536 * 1024 * 1024, + hardTotalInputBytes: 1536 * 1024 * 1024, + softOutputEstimateBytes: 512 * 1024 * 1024, + hardOutputEstimateBytes: 1024 * 1024 * 1024, + maxConcurrentFFmpegJobs: 1, + maxTimelineClips: 100, + maxStreamsPerInput: 64, + maxImageDimension: 8192, + maxPixelCount: 8192 * 8192, + maxThumbnailCount: MAX_GENERATED_OUTPUT_FILES, + maxGeneratedOutputs: MAX_GENERATED_OUTPUT_FILES, + maxContactSheetCells: 500, + maxJobRuntimeMs: 4 * 60 * 60 * 1000, +}); + +export interface ResourceEstimate { + readonly inputBytes: readonly number[]; + readonly expectedOutputBytes?: number; + readonly durationSeconds?: number; + readonly width?: number; + readonly height?: number; + readonly frameRate?: number; + readonly passes?: number; + readonly generatedOutputs?: number; + readonly retainedOutputs?: number; + readonly timelineClips?: number; + readonly streamsPerInput?: readonly number[]; + readonly thumbnailCount?: number; + readonly contactSheetCells?: number; + readonly expectedRuntimeMs?: number; +} + +export type ResourceDecisionStatus = 'allowed' | 'warning' | 'blocked'; + +export interface ResourceIssue { + readonly code: string; + readonly severity: 'warning' | 'error'; + readonly message: string; + readonly suggestions: readonly string[]; +} + +export interface ResourceDecision { + readonly status: ResourceDecisionStatus; + readonly issues: readonly ResourceIssue[]; + readonly totals: { + readonly inputBytes: number; + readonly pixelCount?: number; + readonly pixelWorkEstimate?: number; + }; + readonly estimateDisclaimer: string; +} + +export function createResourcePolicy( + overrides: Partial = {} +): Readonly { + const policy = { ...DEFAULT_RESOURCE_POLICY, ...overrides }; + for (const [key, value] of Object.entries(policy)) { + if (!Number.isFinite(value) || value <= 0) { + throw new RangeError(`${key} must be a positive finite number`); + } + } + if (policy.softInputBytes > policy.hardSingleInputBytes) { + throw new RangeError('softInputBytes cannot exceed hardSingleInputBytes'); + } + if (policy.softOutputEstimateBytes > policy.hardOutputEstimateBytes) { + throw new RangeError( + 'softOutputEstimateBytes cannot exceed hardOutputEstimateBytes' + ); + } + for (const key of [ + 'maxConcurrentFFmpegJobs', + 'maxTimelineClips', + 'maxStreamsPerInput', + 'maxImageDimension', + 'maxPixelCount', + 'maxThumbnailCount', + 'maxGeneratedOutputs', + 'maxContactSheetCells', + ] as const) { + if (!Number.isSafeInteger(policy[key])) { + throw new RangeError(`${key} must be a positive safe integer`); + } + } + if (policy.maxThumbnailCount > policy.maxGeneratedOutputs) { + throw new RangeError('maxThumbnailCount cannot exceed maxGeneratedOutputs'); + } + return Object.freeze(policy); +} + +export function preflightResources( + estimate: ResourceEstimate, + policy: ResourcePolicy = DEFAULT_RESOURCE_POLICY +): ResourceDecision { + const issues: ResourceIssue[] = []; + validateOptionalNonnegative( + estimate.expectedOutputBytes, + 'expectedOutputBytes' + ); + validateOptionalNonnegative(estimate.durationSeconds, 'durationSeconds'); + validateOptionalNonnegative(estimate.expectedRuntimeMs, 'expectedRuntimeMs'); + validateOptionalCount(estimate.thumbnailCount, 'thumbnailCount'); + validateOptionalCount(estimate.contactSheetCells, 'contactSheetCells'); + validateOptionalCount(estimate.retainedOutputs, 'retainedOutputs'); + if ( + estimate.frameRate !== undefined && + (!Number.isFinite(estimate.frameRate) || + estimate.frameRate <= 0 || + estimate.frameRate > 1_000) + ) { + throw new RangeError( + 'frameRate must be greater than 0 and no more than 1000' + ); + } + if ( + estimate.passes !== undefined && + (!Number.isSafeInteger(estimate.passes) || + estimate.passes < 1 || + estimate.passes > 10) + ) { + throw new RangeError('passes must be an integer from 1 to 10'); + } + if ( + estimate.generatedOutputs !== undefined && + (!Number.isSafeInteger(estimate.generatedOutputs) || + estimate.generatedOutputs < 1) + ) { + throw new RangeError('generatedOutputs must be a positive integer'); + } + if ( + estimate.timelineClips !== undefined && + (!Number.isSafeInteger(estimate.timelineClips) || + estimate.timelineClips < 0) + ) { + throw new RangeError('timelineClips must be a nonnegative integer'); + } + if ( + estimate.timelineClips !== undefined && + estimate.timelineClips > policy.maxTimelineClips + ) { + issues.push( + issue( + 'timeline-clip-limit', + 'error', + 'The timeline contains more clips than the configured browser safety limit.', + ['Split the sequence into separate projects or exports'] + ) + ); + } + for (const [index, streamCount] of ( + estimate.streamsPerInput ?? [] + ).entries()) { + if (!Number.isSafeInteger(streamCount) || streamCount < 0) { + throw new RangeError( + `streamsPerInput.${index} must be a nonnegative integer` + ); + } + if (streamCount > policy.maxStreamsPerInput) { + issues.push( + issue( + 'stream-count-limit', + 'error', + `Input ${index + 1} exceeds the configured stream-count safety limit.`, + ['Remove unnecessary streams in a separate operation'] + ) + ); + } + } + const inputBytes = estimate.inputBytes.reduce((sum, value, index) => { + if (!Number.isSafeInteger(value) || value < 0) { + throw new RangeError( + `inputBytes.${index} must be a nonnegative safe integer` + ); + } + if (value > policy.hardSingleInputBytes) { + issues.push( + issue( + 'single-input-hard-limit', + 'error', + `Input ${index + 1} exceeds the configured single-file safety limit.`, + [ + 'Trim the source first', + 'Use a desktop FFmpeg workflow for this file', + ] + ) + ); + } else if (value > policy.softInputBytes) { + issues.push( + issue( + 'input-soft-limit', + 'warning', + `Input ${index + 1} exceeds the conservative browser processing threshold.`, + ['Use a lower output resolution', 'Process a shorter section'] + ) + ); + } + return sum + value; + }, 0); + if (inputBytes > policy.hardTotalInputBytes) { + issues.push( + issue( + 'total-input-hard-limit', + 'error', + 'Combined source size exceeds the configured browser safety limit.', + ['Reduce the number of clips', 'Split the work into separate exports'] + ) + ); + } + if (estimate.expectedOutputBytes !== undefined) { + if (estimate.expectedOutputBytes > policy.hardOutputEstimateBytes) { + issues.push( + issue( + 'output-hard-limit', + 'error', + 'Estimated output size exceeds the configured in-memory output safety limit.', + ['Lower the bitrate or resolution', 'Export a shorter duration'] + ) + ); + } else if (estimate.expectedOutputBytes > policy.softOutputEstimateBytes) { + issues.push( + issue( + 'output-soft-limit', + 'warning', + 'Estimated output size is above the conservative in-memory output threshold.', + ['Lower the bitrate or resolution', 'Export a shorter duration'] + ) + ); + } + } + let pixelCount: number | undefined; + let pixelWorkEstimate: number | undefined; + if (estimate.width !== undefined || estimate.height !== undefined) { + if ( + estimate.width === undefined || + estimate.height === undefined || + !Number.isFinite(estimate.width) || + !Number.isFinite(estimate.height) || + estimate.width <= 0 || + estimate.height <= 0 + ) { + throw new RangeError( + 'Both width and height must be positive when provided' + ); + } + pixelCount = estimate.width * estimate.height; + if ( + estimate.width > policy.maxImageDimension || + estimate.height > policy.maxImageDimension || + pixelCount > policy.maxPixelCount + ) { + issues.push( + issue( + 'pixel-limit', + 'error', + 'Output dimensions exceed the configured image or pixel limit.', + ['Choose a lower output resolution'] + ) + ); + } + if ( + estimate.durationSeconds !== undefined && + estimate.frameRate !== undefined + ) { + pixelWorkEstimate = + pixelCount * + estimate.durationSeconds * + estimate.frameRate * + (estimate.passes ?? 1); + } + } + if ( + estimate.thumbnailCount !== undefined && + estimate.thumbnailCount > policy.maxThumbnailCount + ) { + issues.push( + issue( + 'thumbnail-limit', + 'error', + `Requested thumbnail count exceeds the configured ${policy.maxThumbnailCount}-image limit. ${GENERATED_OUTPUT_LIMIT_EXPLANATION}`, + ['Increase the interval or reduce the count'] + ) + ); + } + if ( + estimate.contactSheetCells !== undefined && + estimate.contactSheetCells > policy.maxContactSheetCells + ) { + issues.push( + issue( + 'contact-sheet-limit', + 'error', + 'Requested contact-sheet cell count exceeds the configured limit.', + ['Use fewer rows or columns'] + ) + ); + } + if ( + estimate.generatedOutputs !== undefined && + estimate.generatedOutputs + (estimate.retainedOutputs ?? 0) > + policy.maxGeneratedOutputs + ) { + const requestedTotal = + estimate.generatedOutputs + (estimate.retainedOutputs ?? 0); + issues.push( + issue( + 'generated-output-limit', + 'error', + `This operation would retain ${requestedTotal} generated files; the managed result collection allows at most ${policy.maxGeneratedOutputs}. ${GENERATED_OUTPUT_LIMIT_EXPLANATION}`, + [ + 'Save or remove existing results before starting this operation', + 'Generate fewer outputs in this batch', + ] + ) + ); + } + if ( + estimate.expectedRuntimeMs !== undefined && + estimate.expectedRuntimeMs > policy.maxJobRuntimeMs + ) { + issues.push( + issue( + 'runtime-limit', + 'error', + 'Estimated runtime exceeds the configured maximum job duration.', + ['Use a shorter clip', 'Choose a faster preset'] + ) + ); + } + const hasErrors = issues.some((entry) => entry.severity === 'error'); + return Object.freeze({ + status: hasErrors ? 'blocked' : issues.length > 0 ? 'warning' : 'allowed', + issues: Object.freeze(issues), + totals: Object.freeze({ + inputBytes, + ...(pixelCount !== undefined ? { pixelCount } : {}), + ...(pixelWorkEstimate !== undefined ? { pixelWorkEstimate } : {}), + }), + estimateDisclaimer: + 'Resource estimates are conservative guidance, not a guarantee of browser memory use or completion time.', + }); +} + +export function estimateOutputBytes( + durationSeconds: number, + videoBitrateKbps = 0, + audioBitrateKbps = 0, + overheadFraction = 0.03 +): number { + if ( + !Number.isFinite(durationSeconds) || + durationSeconds < 0 || + !Number.isFinite(videoBitrateKbps) || + videoBitrateKbps < 0 || + !Number.isFinite(audioBitrateKbps) || + audioBitrateKbps < 0 || + !Number.isFinite(overheadFraction) || + overheadFraction < 0 || + overheadFraction > 1 + ) { + throw new RangeError( + 'Output estimate inputs are outside their supported ranges' + ); + } + return Math.ceil( + (((videoBitrateKbps + audioBitrateKbps) * 1000 * durationSeconds) / 8) * + (1 + overheadFraction) + ); +} + +export function policyForDeviceMemory( + policy: ResourcePolicy, + deviceMemoryGiB?: number +): Readonly { + if ( + deviceMemoryGiB === undefined || + !Number.isFinite(deviceMemoryGiB) || + deviceMemoryGiB >= 4 + ) { + return Object.freeze({ ...policy }); + } + const factor = deviceMemoryGiB <= 2 ? 0.35 : 0.6; + return createResourcePolicy({ + ...policy, + softInputBytes: Math.max( + 32 * 1024 * 1024, + Math.floor(policy.softInputBytes * factor) + ), + hardSingleInputBytes: Math.max( + 128 * 1024 * 1024, + Math.floor(policy.hardSingleInputBytes * factor) + ), + hardTotalInputBytes: Math.max( + 128 * 1024 * 1024, + Math.floor(policy.hardTotalInputBytes * factor) + ), + softOutputEstimateBytes: Math.max( + 64 * 1024 * 1024, + Math.floor(policy.softOutputEstimateBytes * factor) + ), + hardOutputEstimateBytes: Math.max( + 128 * 1024 * 1024, + Math.floor(policy.hardOutputEstimateBytes * factor) + ), + }); +} + +function issue( + code: string, + severity: ResourceIssue['severity'], + message: string, + suggestions: readonly string[] +): ResourceIssue { + return Object.freeze({ + code, + severity, + message, + suggestions: Object.freeze([...suggestions]), + }); +} + +function validateOptionalNonnegative( + value: number | undefined, + label: string +): void { + if (value !== undefined && (!Number.isFinite(value) || value < 0)) { + throw new RangeError(`${label} must be a finite nonnegative number`); + } +} + +function validateOptionalCount(value: number | undefined, label: string): void { + if (value !== undefined && (!Number.isSafeInteger(value) || value < 0)) { + throw new RangeError(`${label} must be a nonnegative safe integer`); + } +} diff --git a/src/test/setup.ts b/src/test/setup.ts new file mode 100644 index 0000000..bb02c60 --- /dev/null +++ b/src/test/setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom/vitest'; diff --git a/src/toolbox/manifest.source.json b/src/toolbox/manifest.source.json new file mode 100644 index 0000000..1807375 --- /dev/null +++ b/src/toolbox/manifest.source.json @@ -0,0 +1,57 @@ +{ + "$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" + ] +} diff --git a/src/toolbox/manifest.ts b/src/toolbox/manifest.ts new file mode 100644 index 0000000..0b9b363 --- /dev/null +++ b/src/toolbox/manifest.ts @@ -0,0 +1,9 @@ +import { defineToolboxApp, parseToolboxApp } from '@add-ideas/toolbox-contract'; +import manifestSource from './manifest.source.json'; + +/** + * The JSON document is the one canonical source used by both the browser and + * the build-time manifest generator. Runtime parsing also protects development + * builds from silently accepting an invalid contract. + */ +export const manifest = defineToolboxApp(parseToolboxApp(manifestSource)); diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..edc44cc --- /dev/null +++ b/src/version.ts @@ -0,0 +1,5 @@ +export const APP_VERSION = '0.1.0'; +export const FFMPEG_WRAPPER_VERSION = '0.12.15'; +export const FFMPEG_UTIL_VERSION = '0.12.2'; +export const FFMPEG_CORE_VERSION = '0.12.10'; +export const FFMPEG_ENGINE_VERSION = 'n5.1.4'; diff --git a/src/waveform/index.ts b/src/waveform/index.ts new file mode 100644 index 0000000..a081ab0 --- /dev/null +++ b/src/waveform/index.ts @@ -0,0 +1,46 @@ +export { + aggregatePcmPeaks, + decodePcmSamples, + validateWaveformPeakData, +} from './peak-aggregation'; +export { + createWaveformCacheKey, + deserializeWaveformPeaks, + serializeWaveformPeaks, + WAVEFORM_PEAKS_MEDIA_TYPE, + type WaveformCacheKeyOptions, +} from './waveform-cache'; +export { + describeWaveform, + renderWaveformToCanvas, + type WaveformCanvasRenderOptions, + type WaveformCanvasRenderResult, +} from './waveform-canvas'; +export { + WaveformCancellationError, + WaveformDataError, + WaveformRuntimeBusyError, + WaveformRuntimeTerminatedError, +} from './waveform-errors'; +export { + createDefaultWaveformWorkerFactory, + createWaveformRuntime, + WaveformRuntime, +} from './waveform-runtime'; +export { + DEFAULT_MAX_PCM_SAMPLES, + MAX_WAVEFORM_BUCKET_COUNT, + WAVEFORM_PEAKS_VERSION, + type DecodedPcm, + type PcmByteSource, + type PcmEncoding, + type PeakAggregationOptions, + type RawPcmInput, + type WaveformAggregateOptions, + type WaveformPcmInput, + type WaveformPeakData, + type WaveformRuntimeOptions, + type WaveformWorkerFactory, + type WaveformWorkerLike, + type WavePcmInput, +} from './waveform.types'; diff --git a/src/waveform/peak-aggregation.ts b/src/waveform/peak-aggregation.ts new file mode 100644 index 0000000..7403e3d --- /dev/null +++ b/src/waveform/peak-aggregation.ts @@ -0,0 +1,486 @@ +import { + DEFAULT_MAX_PCM_SAMPLES, + MAX_WAVEFORM_BUCKET_COUNT, + WAVEFORM_PEAKS_VERSION, + type DecodedPcm, + type PcmByteSource, + type PcmEncoding, + type PeakAggregationOptions, + type WaveformPcmInput, + type WaveformPeakData, +} from './waveform.types'; +import { + WaveformCancellationError, + WaveformDataError, +} from './waveform-errors'; + +const RIFF_HEADER_BYTES = 12; +const WAVE_FORMAT_PCM = 0x0001; +const WAVE_FORMAT_IEEE_FLOAT = 0x0003; +const WAVE_FORMAT_EXTENSIBLE = 0xfffe; +const PROGRESS_INTERVAL_SAMPLES = 16_384; +const MIN_SAMPLE_RATE = 1; +const MAX_SAMPLE_RATE = 384_000; + +interface InspectedPcm { + readonly view: DataView; + readonly dataOffset: number; + readonly dataByteLength: number; + readonly encoding: PcmEncoding; + readonly sampleRate: number; + readonly sampleCount: number; + readonly bytesPerSample: number; +} + +export function decodePcmSamples( + input: WaveformPcmInput, + maxSamples = DEFAULT_MAX_PCM_SAMPLES +): DecodedPcm { + const inspected = inspectPcmInput(input, maxSamples); + const samples = new Float32Array(inspected.sampleCount); + for (let index = 0; index < inspected.sampleCount; index += 1) { + samples[index] = readNormalizedSample(inspected, index); + } + return Object.freeze({ + encoding: inspected.encoding, + sampleRate: inspected.sampleRate, + samples, + }); +} + +/** + * Aggregates directly from the PCM bytes, avoiding a full-size decoded sample + * allocation. Bucket boundaries are deterministic and every input sample is + * assigned to exactly one bucket. + */ +export function aggregatePcmPeaks( + input: WaveformPcmInput, + options: PeakAggregationOptions +): WaveformPeakData { + const requestedBucketCount = validateBucketCount(options.bucketCount); + const maxSamples = validateMaxSamples(options.maxSamples); + const inspected = inspectPcmInput(input, maxSamples); + throwIfCancelled(options.isCancelled); + + if (inspected.sampleCount === 0) { + options.onProgress?.(1); + return createPeakData(inspected, 0); + } + + const bucketCount = Math.min(requestedBucketCount, inspected.sampleCount); + const result = createPeakData(inspected, bucketCount); + let lastProgress = -1; + + for (let bucketIndex = 0; bucketIndex < bucketCount; bucketIndex += 1) { + const start = Math.floor( + (bucketIndex * inspected.sampleCount) / bucketCount + ); + const end = Math.floor( + ((bucketIndex + 1) * inspected.sampleCount) / bucketCount + ); + let minimum = Number.POSITIVE_INFINITY; + let maximum = Number.NEGATIVE_INFINITY; + let squareScale = 0; + let scaledSquareSum = 1; + + for (let sampleIndex = start; sampleIndex < end; sampleIndex += 1) { + if (sampleIndex % PROGRESS_INTERVAL_SAMPLES === 0) { + throwIfCancelled(options.isCancelled); + const progress = sampleIndex / inspected.sampleCount; + if (progress !== lastProgress) { + options.onProgress?.(progress); + lastProgress = progress; + } + } + + const sample = readNormalizedSample(inspected, sampleIndex); + minimum = Math.min(minimum, sample); + maximum = Math.max(maximum, sample); + const magnitude = Math.abs(sample); + if (magnitude !== 0) { + if (squareScale < magnitude) { + const ratio = squareScale / magnitude; + scaledSquareSum = 1 + scaledSquareSum * ratio * ratio; + squareScale = magnitude; + } else { + const ratio = magnitude / squareScale; + scaledSquareSum += ratio * ratio; + } + } + } + + result.minimum[bucketIndex] = normalizeZero(minimum); + result.maximum[bucketIndex] = normalizeZero(maximum); + result.rms[bucketIndex] = + squareScale === 0 + ? 0 + : squareScale * Math.sqrt(scaledSquareSum / (end - start)); + } + + throwIfCancelled(options.isCancelled); + options.onProgress?.(1); + return result; +} + +export function validateWaveformPeakData( + value: WaveformPeakData +): WaveformPeakData { + if (value.version !== WAVEFORM_PEAKS_VERSION) { + throw new WaveformDataError( + `Unsupported waveform peak version: ${String(value.version)}` + ); + } + validateSampleRate(value.sampleRate); + if ( + !Number.isSafeInteger(value.sampleCount) || + value.sampleCount < 0 || + value.sampleCount > DEFAULT_MAX_PCM_SAMPLES + ) { + throw new WaveformDataError('Waveform sample count is invalid'); + } + if ( + !Number.isSafeInteger(value.bucketCount) || + value.bucketCount < 0 || + value.bucketCount > MAX_WAVEFORM_BUCKET_COUNT || + value.bucketCount > value.sampleCount + ) { + throw new WaveformDataError('Waveform bucket count is invalid'); + } + if ( + (value.sampleCount === 0) !== (value.bucketCount === 0) || + value.minimum.length !== value.bucketCount || + value.maximum.length !== value.bucketCount || + value.rms.length !== value.bucketCount + ) { + throw new WaveformDataError('Waveform peak array lengths are inconsistent'); + } + if (value.sourceEncoding !== 's16le' && value.sourceEncoding !== 'f32le') { + throw new WaveformDataError('Waveform source encoding is invalid'); + } + const expectedDuration = value.sampleCount / value.sampleRate; + if ( + !Number.isFinite(value.durationSeconds) || + value.durationSeconds < 0 || + Math.abs(value.durationSeconds - expectedDuration) > + Math.max(1e-9, expectedDuration * 1e-9) + ) { + throw new WaveformDataError('Waveform duration is inconsistent'); + } + for (let index = 0; index < value.bucketCount; index += 1) { + const minimum = value.minimum[index]; + const maximum = value.maximum[index]; + const rms = value.rms[index]; + if ( + minimum === undefined || + maximum === undefined || + rms === undefined || + !Number.isFinite(minimum) || + !Number.isFinite(maximum) || + !Number.isFinite(rms) || + minimum > maximum || + rms < 0 + ) { + throw new WaveformDataError( + `Waveform bucket ${String(index)} contains invalid values` + ); + } + } + return value; +} + +function inspectPcmInput( + input: WaveformPcmInput, + maxSamples: number +): InspectedPcm { + const view = toDataView(input.data); + if (input.container === 'raw') { + validateSampleRate(input.sampleRate); + const bytesPerSample = bytesPerSampleFor(input.encoding); + return inspectPayload( + view, + 0, + view.byteLength, + input.encoding, + input.sampleRate, + bytesPerSample, + maxSamples + ); + } + return inspectWaveFile(view, maxSamples); +} + +function inspectWaveFile(view: DataView, maxSamples: number): InspectedPcm { + if ( + view.byteLength < RIFF_HEADER_BYTES || + readAscii(view, 0, 4) !== 'RIFF' || + readAscii(view, 8, 4) !== 'WAVE' + ) { + throw new WaveformDataError('Expected a RIFF/WAVE PCM file'); + } + + const declaredRiffLength = view.getUint32(4, true) + 8; + if ( + declaredRiffLength < RIFF_HEADER_BYTES || + declaredRiffLength > view.byteLength + ) { + throw new WaveformDataError( + 'WAVE file is truncated or has an invalid size' + ); + } + + let encoding: PcmEncoding | undefined; + let sampleRate: number | undefined; + let bytesPerSample: number | undefined; + let blockAlign: number | undefined; + let channelCount: number | undefined; + let dataOffset: number | undefined; + let dataByteLength: number | undefined; + let offset = RIFF_HEADER_BYTES; + + while (offset + 8 <= declaredRiffLength) { + const chunkId = readAscii(view, offset, 4); + const chunkLength = view.getUint32(offset + 4, true); + const payloadOffset = offset + 8; + const payloadEnd = payloadOffset + chunkLength; + if ( + !Number.isSafeInteger(payloadEnd) || + payloadEnd > declaredRiffLength || + payloadEnd > view.byteLength + ) { + throw new WaveformDataError(`WAVE ${chunkId} chunk is truncated`); + } + + if (chunkId === 'fmt ' && encoding === undefined) { + if (chunkLength < 16) { + throw new WaveformDataError('WAVE format chunk is too short'); + } + const formatTag = view.getUint16(payloadOffset, true); + channelCount = view.getUint16(payloadOffset + 2, true); + sampleRate = view.getUint32(payloadOffset + 4, true); + blockAlign = view.getUint16(payloadOffset + 12, true); + const bitsPerSample = view.getUint16(payloadOffset + 14, true); + const resolvedFormatTag = + formatTag === WAVE_FORMAT_EXTENSIBLE + ? inspectExtensibleFormat(view, payloadOffset, chunkLength) + : formatTag; + + if (resolvedFormatTag === WAVE_FORMAT_PCM && bitsPerSample === 16) { + encoding = 's16le'; + bytesPerSample = 2; + } else if ( + resolvedFormatTag === WAVE_FORMAT_IEEE_FLOAT && + bitsPerSample === 32 + ) { + encoding = 'f32le'; + bytesPerSample = 4; + } else { + throw new WaveformDataError( + `Unsupported WAVE encoding (format ${String( + resolvedFormatTag + )}, ${String(bitsPerSample)} bits)` + ); + } + } else if (chunkId === 'data' && dataOffset === undefined) { + dataOffset = payloadOffset; + dataByteLength = chunkLength; + } + + offset = payloadEnd + (chunkLength % 2); + } + + if ( + encoding === undefined || + sampleRate === undefined || + bytesPerSample === undefined || + blockAlign === undefined || + channelCount === undefined + ) { + throw new WaveformDataError('WAVE file has no supported format chunk'); + } + if (dataOffset === undefined || dataByteLength === undefined) { + throw new WaveformDataError('WAVE file has no audio data chunk'); + } + if (channelCount !== 1) { + throw new WaveformDataError( + 'Waveform analysis requires mono PCM; downmix with FFmpeg first' + ); + } + if (blockAlign !== bytesPerSample) { + throw new WaveformDataError('WAVE block alignment is inconsistent'); + } + validateSampleRate(sampleRate); + return inspectPayload( + view, + dataOffset, + dataByteLength, + encoding, + sampleRate, + bytesPerSample, + maxSamples + ); +} + +function inspectExtensibleFormat( + view: DataView, + payloadOffset: number, + chunkLength: number +): number { + if (chunkLength < 40 || view.getUint16(payloadOffset + 16, true) < 22) { + throw new WaveformDataError('WAVE extensible format chunk is incomplete'); + } + const subFormatOffset = payloadOffset + 24; + const canonicalGuidTail = [ + 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xaa, 0x00, 0x38, 0x9b, 0x71, + ]; + for (let index = 0; index < canonicalGuidTail.length; index += 1) { + if ( + view.getUint8(subFormatOffset + 4 + index) !== canonicalGuidTail[index] + ) { + throw new WaveformDataError('Unsupported WAVE extensible sub-format'); + } + } + return view.getUint16(subFormatOffset, true); +} + +function inspectPayload( + view: DataView, + dataOffset: number, + dataByteLength: number, + encoding: PcmEncoding, + sampleRate: number, + bytesPerSample: number, + maxSamples: number +): InspectedPcm { + if (dataByteLength % bytesPerSample !== 0) { + throw new WaveformDataError( + `PCM byte length is not aligned for ${encoding}` + ); + } + const sampleCount = dataByteLength / bytesPerSample; + if (!Number.isSafeInteger(sampleCount) || sampleCount > maxSamples) { + throw new WaveformDataError( + `PCM input exceeds the ${String(maxSamples)} sample analysis limit` + ); + } + return { + view, + dataOffset, + dataByteLength, + encoding, + sampleRate, + sampleCount, + bytesPerSample, + }; +} + +function createPeakData( + inspected: Pick, + bucketCount: number +): WaveformPeakData { + return Object.freeze({ + version: WAVEFORM_PEAKS_VERSION, + sourceEncoding: inspected.encoding, + sampleRate: inspected.sampleRate, + sampleCount: inspected.sampleCount, + durationSeconds: inspected.sampleCount / inspected.sampleRate, + bucketCount, + minimum: new Float32Array(bucketCount), + maximum: new Float32Array(bucketCount), + rms: new Float32Array(bucketCount), + }); +} + +function readNormalizedSample( + inspected: InspectedPcm, + sampleIndex: number +): number { + const offset = inspected.dataOffset + sampleIndex * inspected.bytesPerSample; + if (inspected.encoding === 's16le') { + return normalizeZero(inspected.view.getInt16(offset, true) / 32_768); + } + const sample = inspected.view.getFloat32(offset, true); + if (!Number.isFinite(sample)) { + throw new WaveformDataError( + `Float PCM sample ${String(sampleIndex)} is not finite` + ); + } + return normalizeZero(sample); +} + +function validateBucketCount(bucketCount: number): number { + if ( + !Number.isSafeInteger(bucketCount) || + bucketCount <= 0 || + bucketCount > MAX_WAVEFORM_BUCKET_COUNT + ) { + throw new RangeError( + `Waveform bucketCount must be between 1 and ${String( + MAX_WAVEFORM_BUCKET_COUNT + )}` + ); + } + return bucketCount; +} + +function validateMaxSamples(maxSamples?: number): number { + const value = maxSamples ?? DEFAULT_MAX_PCM_SAMPLES; + if ( + !Number.isSafeInteger(value) || + value <= 0 || + value > DEFAULT_MAX_PCM_SAMPLES + ) { + throw new RangeError( + `maxSamples must be between 1 and ${String(DEFAULT_MAX_PCM_SAMPLES)}` + ); + } + return value; +} + +function validateSampleRate(sampleRate: number): void { + if ( + !Number.isSafeInteger(sampleRate) || + sampleRate < MIN_SAMPLE_RATE || + sampleRate > MAX_SAMPLE_RATE + ) { + throw new WaveformDataError( + `PCM sample rate must be between ${String(MIN_SAMPLE_RATE)} and ${String( + MAX_SAMPLE_RATE + )} Hz` + ); + } +} + +function bytesPerSampleFor(encoding: PcmEncoding): number { + if (encoding === 's16le') { + return 2; + } + if (encoding === 'f32le') { + return 4; + } + throw new WaveformDataError(`Unsupported PCM encoding: ${String(encoding)}`); +} + +function throwIfCancelled(isCancelled?: () => boolean): void { + if (isCancelled?.()) { + throw new WaveformCancellationError(); + } +} + +function normalizeZero(value: number): number { + return value === 0 ? 0 : value; +} + +function toDataView(source: PcmByteSource): DataView { + if (source instanceof ArrayBuffer) { + return new DataView(source); + } + return new DataView(source.buffer, source.byteOffset, source.byteLength); +} + +function readAscii(view: DataView, offset: number, length: number): string { + let result = ''; + for (let index = 0; index < length; index += 1) { + result += String.fromCharCode(view.getUint8(offset + index)); + } + return result; +} diff --git a/src/waveform/waveform-cache.ts b/src/waveform/waveform-cache.ts new file mode 100644 index 0000000..c07c63e --- /dev/null +++ b/src/waveform/waveform-cache.ts @@ -0,0 +1,213 @@ +import { createCacheKey, stableHash } from '../storage/cache-key'; +import { validateWaveformPeakData } from './peak-aggregation'; +import { WaveformDataError } from './waveform-errors'; +import { + MAX_WAVEFORM_BUCKET_COUNT, + WAVEFORM_PEAKS_VERSION, + type PcmEncoding, + type WaveformPeakData, +} from './waveform.types'; + +export const WAVEFORM_PEAKS_MEDIA_TYPE = + 'application/vnd.add-ideas.waveform-peaks'; + +const MAGIC = [0x57, 0x46, 0x50, 0x4b] as const; // WFPK +const HEADER_BYTES = 44; +const FLAG_RMS = 1; +const SUPPORTED_FLAGS = FLAG_RMS; + +export interface WaveformCacheKeyOptions { + readonly sourceSize: number; + readonly sourceLastModified: number; + readonly streamIndex: number; + readonly sampleRate: number; + readonly bucketCount: number; + readonly encoding: PcmEncoding; + readonly partialHash?: string; +} + +/** + * Produces a safe cache key containing all source and analysis identities + * required to avoid reusing peaks for a different file or stream. + */ +export function createWaveformCacheKey( + options: WaveformCacheKeyOptions +): string { + validateCacheKeyOptions(options); + const sourceFingerprint = stableHash({ + size: options.sourceSize, + lastModified: options.sourceLastModified, + partialHash: options.partialHash ?? null, + }); + return createCacheKey({ + sourceFingerprint, + operation: 'waveform-peaks', + streamIndex: options.streamIndex, + settings: { + sampleRate: options.sampleRate, + bucketCount: options.bucketCount, + encoding: options.encoding, + }, + version: WAVEFORM_PEAKS_VERSION, + }); +} + +/** + * Serializes peak arrays to a compact, explicitly little-endian, checksummed + * format suitable for the derived-data cache. + */ +export function serializeWaveformPeaks(peaks: WaveformPeakData): Uint8Array { + validateWaveformPeakData(peaks); + const payloadBytes = peaks.bucketCount * 3 * Float32Array.BYTES_PER_ELEMENT; + const result = new Uint8Array(HEADER_BYTES + payloadBytes); + const view = new DataView(result.buffer); + for (let index = 0; index < MAGIC.length; index += 1) { + view.setUint8(index, MAGIC[index] ?? 0); + } + view.setUint16(4, WAVEFORM_PEAKS_VERSION, true); + view.setUint16(6, HEADER_BYTES, true); + view.setUint8(8, encodingToCode(peaks.sourceEncoding)); + view.setUint8(9, FLAG_RMS); + view.setUint16(10, 0, true); + view.setUint32(12, peaks.sampleRate, true); + view.setUint32(16, peaks.bucketCount, true); + view.setFloat64(20, peaks.sampleCount, true); + view.setFloat64(28, peaks.durationSeconds, true); + view.setUint32(36, payloadBytes, true); + + let offset = HEADER_BYTES; + for (const values of [peaks.minimum, peaks.maximum, peaks.rms]) { + for (const value of values) { + view.setFloat32(offset, value, true); + offset += Float32Array.BYTES_PER_ELEMENT; + } + } + const checksum = checksumCacheEntry(result); + view.setUint32(40, checksum, true); + return result; +} + +export function deserializeWaveformPeaks( + source: ArrayBuffer | ArrayBufferView +): WaveformPeakData { + const bytes = toByteView(source); + if (bytes.byteLength < HEADER_BYTES) { + throw new WaveformDataError('Waveform cache entry is too short'); + } + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + for (let index = 0; index < MAGIC.length; index += 1) { + if (view.getUint8(index) !== MAGIC[index]) { + throw new WaveformDataError('Waveform cache entry has an invalid magic'); + } + } + const version = view.getUint16(4, true); + if (version !== WAVEFORM_PEAKS_VERSION) { + throw new WaveformDataError( + `Unsupported waveform cache version: ${String(version)}` + ); + } + if (view.getUint16(6, true) !== HEADER_BYTES) { + throw new WaveformDataError('Waveform cache header size is invalid'); + } + const flags = view.getUint8(9); + if ((flags & ~SUPPORTED_FLAGS) !== 0 || (flags & FLAG_RMS) === 0) { + throw new WaveformDataError('Waveform cache flags are unsupported'); + } + const bucketCount = view.getUint32(16, true); + if (bucketCount > MAX_WAVEFORM_BUCKET_COUNT) { + throw new WaveformDataError('Waveform cache has too many buckets'); + } + const expectedPayloadBytes = bucketCount * 3 * Float32Array.BYTES_PER_ELEMENT; + const payloadBytes = view.getUint32(36, true); + if ( + payloadBytes !== expectedPayloadBytes || + bytes.byteLength !== HEADER_BYTES + payloadBytes + ) { + throw new WaveformDataError('Waveform cache payload size is inconsistent'); + } + const storedChecksum = view.getUint32(40, true); + if (checksumCacheEntry(bytes) !== storedChecksum) { + throw new WaveformDataError('Waveform cache checksum does not match'); + } + + let offset = HEADER_BYTES; + const readArray = (): Float32Array => { + const result = new Float32Array(bucketCount); + for (let index = 0; index < bucketCount; index += 1) { + result[index] = view.getFloat32(offset, true); + offset += Float32Array.BYTES_PER_ELEMENT; + } + return result; + }; + const peaks: WaveformPeakData = Object.freeze({ + version: WAVEFORM_PEAKS_VERSION, + sourceEncoding: codeToEncoding(view.getUint8(8)), + sampleRate: view.getUint32(12, true), + bucketCount, + sampleCount: view.getFloat64(20, true), + durationSeconds: view.getFloat64(28, true), + minimum: readArray(), + maximum: readArray(), + rms: readArray(), + }); + return validateWaveformPeakData(peaks); +} + +function validateCacheKeyOptions(options: WaveformCacheKeyOptions): void { + if ( + !Number.isSafeInteger(options.sourceSize) || + options.sourceSize < 0 || + !Number.isSafeInteger(options.sourceLastModified) || + options.sourceLastModified < 0 || + !Number.isSafeInteger(options.streamIndex) || + options.streamIndex < 0 || + !Number.isSafeInteger(options.sampleRate) || + options.sampleRate <= 0 || + !Number.isSafeInteger(options.bucketCount) || + options.bucketCount <= 0 || + options.bucketCount > MAX_WAVEFORM_BUCKET_COUNT + ) { + throw new TypeError('Waveform cache identity contains invalid numbers'); + } + if (options.encoding !== 's16le' && options.encoding !== 'f32le') { + throw new TypeError('Waveform cache encoding is invalid'); + } + if ( + options.partialHash !== undefined && + !/^[a-z0-9._-]{4,256}$/iu.test(options.partialHash) + ) { + throw new TypeError('Waveform partial hash is invalid'); + } +} + +function encodingToCode(encoding: PcmEncoding): number { + return encoding === 's16le' ? 1 : 2; +} + +function codeToEncoding(code: number): PcmEncoding { + if (code === 1) { + return 's16le'; + } + if (code === 2) { + return 'f32le'; + } + throw new WaveformDataError('Waveform cache encoding is unsupported'); +} + +function checksumCacheEntry(bytes: Uint8Array): number { + let checksum = 0x811c9dc5; + for (let index = 0; index < bytes.byteLength; index += 1) { + if (index >= 40 && index < 44) { + continue; + } + checksum ^= bytes[index] ?? 0; + checksum = Math.imul(checksum, 0x01000193) >>> 0; + } + return checksum; +} + +function toByteView(source: ArrayBuffer | ArrayBufferView): Uint8Array { + return source instanceof ArrayBuffer + ? new Uint8Array(source) + : new Uint8Array(source.buffer, source.byteOffset, source.byteLength); +} diff --git a/src/waveform/waveform-canvas.ts b/src/waveform/waveform-canvas.ts new file mode 100644 index 0000000..ec26737 --- /dev/null +++ b/src/waveform/waveform-canvas.ts @@ -0,0 +1,299 @@ +import { validateWaveformPeakData } from './peak-aggregation'; +import type { WaveformPeakData } from './waveform.types'; + +const MAX_CANVAS_DIMENSION = 8_192; +const MAX_CANVAS_PIXELS = 16_777_216; + +export interface WaveformCanvasRenderOptions { + readonly width?: number; + readonly height?: number; + readonly devicePixelRatio?: number; + readonly startBucket?: number; + readonly endBucket?: number; + readonly waveColor?: string; + readonly backgroundColor?: string; + readonly centerLineColor?: string; + readonly selection?: { + readonly startFraction: number; + readonly endFraction: number; + readonly color?: string; + }; + /** + * Full-scale amplitude. Defaults to 1, or the largest observed magnitude + * when float PCM exceeds full scale. + */ + readonly amplitudeScale?: number; + readonly accessibleLabel?: string; +} + +export interface WaveformCanvasRenderResult { + readonly width: number; + readonly height: number; + readonly startBucket: number; + readonly endBucket: number; + readonly amplitudeScale: number; + readonly accessibleLabel: string; +} + +/** + * Canvas is efficient but has no intrinsic text alternative. This renderer + * applies an image role and a concise label while returning the same label for + * callers that prefer a separate visible or screen-reader-only description. + */ +export function renderWaveformToCanvas( + canvas: HTMLCanvasElement, + peaks: WaveformPeakData, + options: WaveformCanvasRenderOptions = {} +): WaveformCanvasRenderResult { + validateWaveformPeakData(peaks); + const width = validateCssDimension( + options.width ?? canvas.clientWidth ?? canvas.width, + 'width' + ); + const height = validateCssDimension( + options.height ?? canvas.clientHeight ?? canvas.height, + 'height' + ); + const pixelRatio = validatePixelRatio(options.devicePixelRatio ?? 1); + const physicalWidth = Math.round(width * pixelRatio); + const physicalHeight = Math.round(height * pixelRatio); + if ( + physicalWidth > MAX_CANVAS_DIMENSION || + physicalHeight > MAX_CANVAS_DIMENSION || + physicalWidth * physicalHeight > MAX_CANVAS_PIXELS + ) { + throw new RangeError('Requested waveform canvas exceeds safe size limits'); + } + + const context = canvas.getContext('2d'); + if (context === null) { + throw new Error('A 2D canvas context is required to render a waveform'); + } + canvas.width = physicalWidth; + canvas.height = physicalHeight; + context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); + context.clearRect(0, 0, width, height); + if (options.backgroundColor !== undefined) { + context.fillStyle = options.backgroundColor; + context.fillRect(0, 0, width, height); + } + + const [startBucket, endBucket] = validateBucketRange( + peaks, + options.startBucket, + options.endBucket + ); + const accessibleLabel = + options.accessibleLabel ?? describeWaveform(peaks, startBucket, endBucket); + canvas.setAttribute('role', 'img'); + canvas.setAttribute('aria-label', accessibleLabel); + + drawSelection(context, width, height, options.selection); + drawCenterLine( + context, + width, + height, + options.centerLineColor ?? 'rgba(127, 127, 127, 0.35)' + ); + + const amplitudeScale = + options.amplitudeScale === undefined + ? findAmplitudeScale(peaks, startBucket, endBucket) + : validateAmplitudeScale(options.amplitudeScale); + drawPeaks( + context, + peaks, + startBucket, + endBucket, + width, + height, + amplitudeScale, + options.waveColor ?? 'currentColor' + ); + + return Object.freeze({ + width, + height, + startBucket, + endBucket, + amplitudeScale, + accessibleLabel, + }); +} + +export function describeWaveform( + peaks: WaveformPeakData, + startBucket = 0, + endBucket = peaks.bucketCount +): string { + validateWaveformPeakData(peaks); + const range = validateBucketRange(peaks, startBucket, endBucket); + if (peaks.bucketCount === 0) { + return 'Empty audio waveform.'; + } + let peakAmplitude = 0; + for (let index = range[0]; index < range[1]; index += 1) { + peakAmplitude = Math.max( + peakAmplitude, + Math.abs(peaks.minimum[index] ?? 0), + Math.abs(peaks.maximum[index] ?? 0) + ); + } + const duration = peaks.durationSeconds; + const shownBuckets = range[1] - range[0]; + return `Audio waveform, ${formatDuration( + duration + )}, ${String(shownBuckets)} peak buckets, peak amplitude ${peakAmplitude.toFixed( + 2 + )}.`; +} + +function drawPeaks( + context: CanvasRenderingContext2D, + peaks: WaveformPeakData, + startBucket: number, + endBucket: number, + width: number, + height: number, + amplitudeScale: number, + color: string +): void { + const rangeLength = endBucket - startBucket; + if (rangeLength === 0) { + return; + } + const columnCount = Math.max(1, Math.min(Math.ceil(width), rangeLength)); + const center = height / 2; + const halfHeight = Math.max(0, height / 2 - 1); + context.strokeStyle = color; + context.lineWidth = Math.max(1, width / columnCount); + context.beginPath(); + + for (let column = 0; column < columnCount; column += 1) { + const first = + startBucket + Math.floor((column * rangeLength) / columnCount); + const last = + startBucket + Math.floor(((column + 1) * rangeLength) / columnCount); + let minimum = Number.POSITIVE_INFINITY; + let maximum = Number.NEGATIVE_INFINITY; + for (let bucket = first; bucket < last; bucket += 1) { + minimum = Math.min(minimum, peaks.minimum[bucket] ?? 0); + maximum = Math.max(maximum, peaks.maximum[bucket] ?? 0); + } + const x = ((column + 0.5) * width) / columnCount; + const yTop = center - clampAmplitude(maximum / amplitudeScale) * halfHeight; + const yBottom = + center - clampAmplitude(minimum / amplitudeScale) * halfHeight; + context.moveTo(x, yTop); + context.lineTo(x, yBottom); + } + context.stroke(); +} + +function drawCenterLine( + context: CanvasRenderingContext2D, + width: number, + height: number, + color: string +): void { + context.strokeStyle = color; + context.lineWidth = 1; + context.beginPath(); + context.moveTo(0, height / 2); + context.lineTo(width, height / 2); + context.stroke(); +} + +function drawSelection( + context: CanvasRenderingContext2D, + width: number, + height: number, + selection?: WaveformCanvasRenderOptions['selection'] +): void { + if (selection === undefined) { + return; + } + if ( + !Number.isFinite(selection.startFraction) || + !Number.isFinite(selection.endFraction) || + selection.startFraction < 0 || + selection.endFraction > 1 || + selection.startFraction > selection.endFraction + ) { + throw new RangeError('Waveform selection must be within 0 and 1'); + } + context.fillStyle = selection.color ?? 'rgba(75, 120, 255, 0.2)'; + context.fillRect( + selection.startFraction * width, + 0, + (selection.endFraction - selection.startFraction) * width, + height + ); +} + +function findAmplitudeScale( + peaks: WaveformPeakData, + startBucket: number, + endBucket: number +): number { + let maximum = 1; + for (let index = startBucket; index < endBucket; index += 1) { + maximum = Math.max( + maximum, + Math.abs(peaks.minimum[index] ?? 0), + Math.abs(peaks.maximum[index] ?? 0) + ); + } + return maximum; +} + +function validateBucketRange( + peaks: WaveformPeakData, + start = 0, + end = peaks.bucketCount +): readonly [number, number] { + if ( + !Number.isSafeInteger(start) || + !Number.isSafeInteger(end) || + start < 0 || + end < start || + end > peaks.bucketCount + ) { + throw new RangeError('Waveform bucket range is invalid'); + } + return [start, end]; +} + +function validateCssDimension(value: number, name: string): number { + if (!Number.isFinite(value) || value <= 0 || value > MAX_CANVAS_DIMENSION) { + throw new RangeError(`Waveform canvas ${name} is outside safe limits`); + } + return value; +} + +function validatePixelRatio(value: number): number { + if (!Number.isFinite(value) || value <= 0 || value > 4) { + throw new RangeError('Waveform canvas pixel ratio must be between 0 and 4'); + } + return value; +} + +function validateAmplitudeScale(value: number): number { + if (!Number.isFinite(value) || value <= 0) { + throw new RangeError('Waveform amplitude scale must be positive'); + } + return value; +} + +function clampAmplitude(value: number): number { + return Math.max(-1, Math.min(1, value)); +} + +function formatDuration(seconds: number): string { + if (seconds < 60) { + return `${seconds.toFixed(1)} seconds`; + } + const minutes = Math.floor(seconds / 60); + const remainder = Math.round(seconds % 60); + return `${String(minutes)} minutes ${String(remainder)} seconds`; +} diff --git a/src/waveform/waveform-errors.ts b/src/waveform/waveform-errors.ts new file mode 100644 index 0000000..19328dc --- /dev/null +++ b/src/waveform/waveform-errors.ts @@ -0,0 +1,27 @@ +export class WaveformDataError extends Error { + constructor(message: string) { + super(message); + this.name = 'WaveformDataError'; + } +} + +export class WaveformCancellationError extends Error { + constructor(message = 'Waveform aggregation was cancelled') { + super(message); + this.name = 'AbortError'; + } +} + +export class WaveformRuntimeBusyError extends Error { + constructor() { + super('A waveform aggregation is already active'); + this.name = 'WaveformRuntimeBusyError'; + } +} + +export class WaveformRuntimeTerminatedError extends Error { + constructor() { + super('The waveform runtime has been terminated'); + this.name = 'WaveformRuntimeTerminatedError'; + } +} diff --git a/src/waveform/waveform-runtime.ts b/src/waveform/waveform-runtime.ts new file mode 100644 index 0000000..9148f70 --- /dev/null +++ b/src/waveform/waveform-runtime.ts @@ -0,0 +1,372 @@ +import { + aggregatePcmPeaks, + validateWaveformPeakData, +} from './peak-aggregation'; +import { + WaveformCancellationError, + WaveformDataError, + WaveformRuntimeBusyError, + WaveformRuntimeTerminatedError, +} from './waveform-errors'; +import type { + WaveformAggregateOptions, + WaveformPcmInput, + WaveformPeakData, + WaveformRuntimeOptions, + WaveformWorkerAggregateRequest, + WaveformWorkerFactory, + WaveformWorkerLike, + WaveformWorkerResponse, +} from './waveform.types'; + +interface ActiveWorkerTask { + readonly requestId: number; + readonly worker: WaveformWorkerLike; + readonly reject: (reason: unknown) => void; + readonly removeListeners: () => void; + readonly removeAbortListener: () => void; +} + +interface ActiveSynchronousTask { + readonly requestId: number; + cancelled: boolean; + readonly reject: (reason: unknown) => void; + readonly removeAbortListener: () => void; +} + +/** + * Runs at most one aggregation at a time. Worker failure during construction + * falls back to the synchronous implementation; a worker-reported data error + * is surfaced rather than repeated on the main thread. + */ +export class WaveformRuntime { + readonly #workerFactory: WaveformWorkerFactory | null; + #nextRequestId = 1; + #active: ActiveWorkerTask | undefined; + #activeSynchronous: ActiveSynchronousTask | undefined; + #terminated = false; + #workerUnavailable = false; + + constructor(options: WaveformRuntimeOptions = {}) { + this.#workerFactory = + options.workerFactory === undefined + ? createDefaultWaveformWorkerFactory() + : options.workerFactory; + } + + get mode(): 'worker' | 'synchronous' { + return this.#workerFactory === null || this.#workerUnavailable + ? 'synchronous' + : 'worker'; + } + + get isActive(): boolean { + return this.#active !== undefined || this.#activeSynchronous !== undefined; + } + + async aggregate( + input: WaveformPcmInput, + options: WaveformAggregateOptions + ): Promise { + if (this.#terminated) { + throw new WaveformRuntimeTerminatedError(); + } + if (this.isActive) { + throw new WaveformRuntimeBusyError(); + } + throwIfAborted(options.signal); + + if (this.#workerFactory === null || this.#workerUnavailable) { + return this.#aggregateSynchronously(input, options); + } + + let worker: WaveformWorkerLike; + try { + worker = this.#workerFactory(); + } catch { + this.#workerUnavailable = true; + return this.#aggregateSynchronously(input, options); + } + return this.#aggregateInWorker(worker, input, options); + } + + cancelActive(): boolean { + const active = this.#active; + if (active !== undefined) { + active.removeListeners(); + active.removeAbortListener(); + active.worker.terminate(); + this.#active = undefined; + active.reject(new WaveformCancellationError()); + return true; + } + const synchronous = this.#activeSynchronous; + if (synchronous !== undefined) { + synchronous.cancelled = true; + synchronous.removeAbortListener(); + this.#activeSynchronous = undefined; + synchronous.reject(new WaveformCancellationError()); + return true; + } + return false; + } + + terminate(): void { + this.cancelActive(); + this.#terminated = true; + } + + #aggregateSynchronously( + input: WaveformPcmInput, + options: WaveformAggregateOptions + ): Promise { + const requestId = this.#nextRequestId; + this.#nextRequestId += 1; + return new Promise((resolve, reject) => { + const onAbort = (): void => { + const active = this.#activeSynchronous; + if (active?.requestId !== requestId) { + return; + } + active.cancelled = true; + active.removeAbortListener(); + this.#activeSynchronous = undefined; + reject(new WaveformCancellationError()); + }; + options.signal?.addEventListener('abort', onAbort, { once: true }); + const active: ActiveSynchronousTask = { + requestId, + cancelled: false, + reject, + removeAbortListener: () => { + options.signal?.removeEventListener('abort', onAbort); + }, + }; + this.#activeSynchronous = active; + + queueMicrotask(() => { + if (active.cancelled) { + return; + } + try { + throwIfAborted(options.signal); + const peaks = aggregatePcmPeaks(input, { + bucketCount: options.bucketCount, + ...(options.maxSamples === undefined + ? {} + : { maxSamples: options.maxSamples }), + isCancelled: () => + active.cancelled || options.signal?.aborted === true, + ...(options.onProgress === undefined + ? {} + : { onProgress: options.onProgress }), + }); + resolve(peaks); + } catch (error) { + reject(error); + } finally { + active.removeAbortListener(); + if (this.#activeSynchronous?.requestId === requestId) { + this.#activeSynchronous = undefined; + } + } + }); + }); + } + + #aggregateInWorker( + worker: WaveformWorkerLike, + input: WaveformPcmInput, + options: WaveformAggregateOptions + ): Promise { + const requestId = this.#nextRequestId; + this.#nextRequestId += 1; + const ownedData = copyByteSource(input.data); + + return new Promise((resolve, reject) => { + const cleanup = (): void => { + worker.removeEventListener('message', onMessage); + worker.removeEventListener('error', onWorkerFailure); + worker.removeEventListener('messageerror', onWorkerFailure); + options.signal?.removeEventListener('abort', onAbort); + if (this.#active?.requestId === requestId) { + this.#active = undefined; + } + }; + + const finish = (callback: () => void, shouldTerminate = true): void => { + cleanup(); + if (shouldTerminate) { + worker.terminate(); + } + callback(); + }; + + const onAbort = (): void => { + finish(() => reject(new WaveformCancellationError())); + }; + + const onWorkerFailure = (): void => { + finish(() => + reject(new WaveformDataError('Waveform worker failed unexpectedly')) + ); + }; + + const onMessage = (event: MessageEvent): void => { + const response = parseWorkerResponse(event.data, requestId); + if (response === undefined) { + return; + } + try { + if (response.type === 'progress') { + options.onProgress?.(clampProgress(response.progress)); + return; + } + if (response.type === 'error') { + const error = isSerializedError(response.error) + ? createWorkerError(response.error) + : new WaveformDataError( + 'Waveform worker returned a malformed error' + ); + finish(() => reject(error)); + return; + } + const peaks = validateWaveformPeakData(response.peaks); + options.onProgress?.(1); + finish(() => resolve(peaks)); + } catch (error) { + finish(() => reject(error)); + } + }; + + worker.addEventListener('message', onMessage); + worker.addEventListener('error', onWorkerFailure); + worker.addEventListener('messageerror', onWorkerFailure); + options.signal?.addEventListener('abort', onAbort, { once: true }); + this.#active = { + requestId, + worker, + reject, + removeListeners: () => { + worker.removeEventListener('message', onMessage); + worker.removeEventListener('error', onWorkerFailure); + worker.removeEventListener('messageerror', onWorkerFailure); + }, + removeAbortListener: () => { + options.signal?.removeEventListener('abort', onAbort); + }, + }; + + const request: WaveformWorkerAggregateRequest = { + type: 'aggregate', + requestId, + input: { + container: input.container, + data: ownedData, + ...(input.container === 'raw' + ? { + encoding: input.encoding, + sampleRate: input.sampleRate, + } + : {}), + }, + bucketCount: options.bucketCount, + ...(options.maxSamples === undefined + ? {} + : { maxSamples: options.maxSamples }), + }; + try { + worker.postMessage(request, [ownedData]); + } catch { + this.#workerUnavailable = true; + cleanup(); + worker.terminate(); + void this.#aggregateSynchronously(input, options).then(resolve, reject); + } + }); + } +} + +export function createWaveformRuntime( + options: WaveformRuntimeOptions = {} +): WaveformRuntime { + return new WaveformRuntime(options); +} + +export function createDefaultWaveformWorkerFactory(): WaveformWorkerFactory | null { + if (typeof Worker === 'undefined') { + return null; + } + return () => + new Worker(new URL('./waveform.worker.ts', import.meta.url), { + type: 'module', + name: 'waveform-peak-aggregation', + }); +} + +function parseWorkerResponse( + value: unknown, + requestId: number +): WaveformWorkerResponse | undefined { + if ( + typeof value !== 'object' || + value === null || + !('type' in value) || + !('requestId' in value) || + value.requestId !== requestId + ) { + return undefined; + } + const type = value.type; + if (type !== 'progress' && type !== 'result' && type !== 'error') { + return undefined; + } + return value as WaveformWorkerResponse; +} + +function createWorkerError(error: { + readonly name: string; + readonly message: string; +}): Error { + const result = + error.name === 'AbortError' + ? new WaveformCancellationError(error.message) + : new WaveformDataError(error.message); + if (error.name && error.name !== 'AbortError') { + result.name = error.name; + } + return result; +} + +function isSerializedError( + value: unknown +): value is { readonly name: string; readonly message: string } { + return ( + typeof value === 'object' && + value !== null && + 'name' in value && + typeof value.name === 'string' && + 'message' in value && + typeof value.message === 'string' + ); +} + +function copyByteSource(source: ArrayBuffer | ArrayBufferView): ArrayBuffer { + const bytes = + source instanceof ArrayBuffer + ? new Uint8Array(source) + : new Uint8Array(source.buffer, source.byteOffset, source.byteLength); + const copy = new Uint8Array(bytes.byteLength); + copy.set(bytes); + return copy.buffer; +} + +function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted === true) { + throw new WaveformCancellationError(); + } +} + +function clampProgress(progress: number): number { + return Number.isFinite(progress) ? Math.max(0, Math.min(1, progress)) : 0; +} diff --git a/src/waveform/waveform.types.ts b/src/waveform/waveform.types.ts new file mode 100644 index 0000000..b26e76a --- /dev/null +++ b/src/waveform/waveform.types.ts @@ -0,0 +1,133 @@ +export const WAVEFORM_PEAKS_VERSION = 1 as const; +export const MAX_WAVEFORM_BUCKET_COUNT = 65_536; +export const DEFAULT_MAX_PCM_SAMPLES = 100_000_000; + +export type PcmEncoding = 's16le' | 'f32le'; + +export type PcmByteSource = ArrayBuffer | ArrayBufferView; + +export interface RawPcmInput { + readonly container: 'raw'; + readonly data: PcmByteSource; + readonly encoding: PcmEncoding; + readonly sampleRate: number; +} + +export interface WavePcmInput { + readonly container: 'wav'; + readonly data: PcmByteSource; +} + +export type WaveformPcmInput = RawPcmInput | WavePcmInput; + +export interface DecodedPcm { + readonly encoding: PcmEncoding; + readonly sampleRate: number; + readonly samples: Float32Array; +} + +export interface WaveformPeakData { + readonly version: typeof WAVEFORM_PEAKS_VERSION; + readonly sourceEncoding: PcmEncoding; + readonly sampleRate: number; + readonly sampleCount: number; + readonly durationSeconds: number; + readonly bucketCount: number; + readonly minimum: Float32Array; + readonly maximum: Float32Array; + readonly rms: Float32Array; +} + +export interface PeakAggregationOptions { + /** + * Requested maximum number of output buckets. The actual count is smaller + * when the input contains fewer samples. + */ + readonly bucketCount: number; + /** + * A lower per-call input limit can be used by resource policy. It cannot + * exceed DEFAULT_MAX_PCM_SAMPLES. + */ + readonly maxSamples?: number; + readonly isCancelled?: () => boolean; + readonly onProgress?: (progress: number) => void; +} + +export interface WaveformRuntimeOptions { + /** + * Override worker construction for tests or constrained hosts. Set to null + * to force synchronous aggregation. + */ + readonly workerFactory?: WaveformWorkerFactory | null; +} + +export interface WaveformAggregateOptions extends Omit< + PeakAggregationOptions, + 'isCancelled' +> { + readonly signal?: AbortSignal; +} + +export interface WaveformWorkerLike { + postMessage(message: unknown, transfer?: readonly Transferable[]): void; + addEventListener( + type: 'message', + listener: (event: MessageEvent) => void + ): void; + addEventListener( + type: 'error' | 'messageerror', + listener: (event: Event) => void + ): void; + removeEventListener( + type: 'message', + listener: (event: MessageEvent) => void + ): void; + removeEventListener( + type: 'error' | 'messageerror', + listener: (event: Event) => void + ): void; + terminate(): void; +} + +export type WaveformWorkerFactory = () => WaveformWorkerLike; + +export interface WaveformWorkerInput { + readonly container: WaveformPcmInput['container']; + readonly data: ArrayBuffer; + readonly encoding?: PcmEncoding; + readonly sampleRate?: number; +} + +export interface WaveformWorkerAggregateRequest { + readonly type: 'aggregate'; + readonly requestId: number; + readonly input: WaveformWorkerInput; + readonly bucketCount: number; + readonly maxSamples?: number; +} + +export interface WaveformWorkerProgressResponse { + readonly type: 'progress'; + readonly requestId: number; + readonly progress: number; +} + +export interface WaveformWorkerResultResponse { + readonly type: 'result'; + readonly requestId: number; + readonly peaks: WaveformPeakData; +} + +export interface WaveformWorkerErrorResponse { + readonly type: 'error'; + readonly requestId: number; + readonly error: { + readonly name: string; + readonly message: string; + }; +} + +export type WaveformWorkerResponse = + | WaveformWorkerProgressResponse + | WaveformWorkerResultResponse + | WaveformWorkerErrorResponse; diff --git a/src/waveform/waveform.worker.ts b/src/waveform/waveform.worker.ts new file mode 100644 index 0000000..3eb32d7 --- /dev/null +++ b/src/waveform/waveform.worker.ts @@ -0,0 +1,88 @@ +/// + +import { aggregatePcmPeaks } from './peak-aggregation'; +import type { + WaveformPcmInput, + WaveformWorkerAggregateRequest, + WaveformWorkerErrorResponse, + WaveformWorkerProgressResponse, + WaveformWorkerResultResponse, +} from './waveform.types'; + +const workerScope = self as DedicatedWorkerGlobalScope; + +workerScope.addEventListener( + 'message', + (event: MessageEvent) => { + const request = event.data; + if ( + request?.type !== 'aggregate' || + !Number.isSafeInteger(request.requestId) + ) { + return; + } + + try { + const input = parseInput(request); + const peaks = aggregatePcmPeaks(input, { + bucketCount: request.bucketCount, + ...(request.maxSamples === undefined + ? {} + : { maxSamples: request.maxSamples }), + onProgress: (progress) => { + const response: WaveformWorkerProgressResponse = { + type: 'progress', + requestId: request.requestId, + progress, + }; + workerScope.postMessage(response); + }, + }); + const response: WaveformWorkerResultResponse = { + type: 'result', + requestId: request.requestId, + peaks, + }; + workerScope.postMessage(response, [ + peaks.minimum.buffer, + peaks.maximum.buffer, + peaks.rms.buffer, + ]); + } catch (error) { + const response: WaveformWorkerErrorResponse = { + type: 'error', + requestId: request.requestId, + error: { + name: error instanceof Error ? error.name : 'Error', + message: + error instanceof Error + ? error.message + : 'Unknown waveform aggregation error', + }, + }; + workerScope.postMessage(response); + } + } +); + +function parseInput(request: WaveformWorkerAggregateRequest): WaveformPcmInput { + if (request.input.container === 'wav') { + return { + container: 'wav', + data: request.input.data, + }; + } + if ( + request.input.container !== 'raw' || + request.input.encoding === undefined || + request.input.sampleRate === undefined + ) { + throw new TypeError('Waveform worker received invalid PCM settings'); + } + return { + container: 'raw', + data: request.input.data, + encoding: request.input.encoding, + sampleRate: request.input.sampleRate, + }; +} diff --git a/tests/browser/application.spec.ts b/tests/browser/application.spec.ts new file mode 100644 index 0000000..e8c1512 --- /dev/null +++ b/tests/browser/application.spec.ts @@ -0,0 +1,256 @@ +import { expect, test } from '@playwright/test'; +import { + observeRuntime, + startNestedStaticServer, + type NestedStaticServer, +} from './browser-helpers'; + +test.describe('static application and Toolbox shell', () => { + test('runs standalone, stays lazy and supports keyboard-operated shell controls', async ({ + page, + }) => { + const runtime = observeRuntime(page); + + await page.goto('/'); + + await expect(page).toHaveTitle('Audio & Video Tools'); + await expect( + page.getByRole('heading', { + level: 1, + name: 'Audio & Video Tools', + }) + ).toBeVisible(); + await expect(page.locator('[data-toolbox-context]')).toHaveAttribute( + 'data-toolbox-context', + 'standalone' + ); + await expect(page.getByRole('main')).toBeVisible(); + await expect( + page.getByRole('tab', { name: 'Quick Convert' }) + ).toHaveAttribute('aria-selected', 'true'); + await expect( + page.getByRole('button', { name: 'Choose files' }) + ).toBeEnabled(); + + expect( + runtime.requests.some((url) => url.includes('/vendor/ffmpeg/')) + ).toBe(false); + expect( + runtime.requests.some((url) => /\/assets\/worker-[^/]+\.js$/u.test(url)) + ).toBe(false); + + const helpButton = page.getByRole('button', { name: 'Help' }); + await helpButton.focus(); + await page.keyboard.press('Enter'); + const helpDialog = page.getByRole('dialog', { + name: 'Audio & Video Tools', + }); + await expect(helpDialog).toBeVisible(); + await expect( + helpDialog.getByRole('heading', { + level: 2, + name: 'Audio & Video Tools', + }) + ).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(helpDialog).toBeHidden(); + + const personalizeButton = page.getByRole('button', { + name: 'Personalize', + }); + await personalizeButton.focus(); + await page.keyboard.press('Enter'); + await expect( + page.getByRole('heading', { + level: 2, + name: 'Personalize your toolbox', + }) + ).toBeVisible(); + await page.getByRole('button', { name: 'Dark' }).click(); + await expect(page.locator('.toolbox-shell').first()).toHaveAttribute( + 'data-toolbox-theme', + 'dark' + ); + await page.keyboard.press('Escape'); + await expect(personalizeButton).toBeFocused(); + + const networkUrls = runtime.requests.filter((url) => /^https?:/u.test(url)); + expect( + networkUrls.every( + (url) => new URL(url).origin === 'http://127.0.0.1:4173' + ) + ).toBe(true); + expect(runtime.failedRequests).toEqual([]); + expect(runtime.pageErrors).toEqual([]); + expect(runtime.consoleErrors).toEqual([]); + }); + + test('keeps preview primary and exposes accessible Edit drawers on narrow screens', async ({ + page, + }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto('/'); + await page.getByRole('tab', { name: 'Edit' }).click(); + + await expect(page.getByRole('heading', { name: 'Preview' })).toBeVisible(); + const mediaDrawer = page.locator('#edit-media-drawer'); + const inspectorDrawer = page.locator('#edit-inspector-drawer'); + await expect(mediaDrawer).toBeHidden(); + await expect(inspectorDrawer).toBeHidden(); + + const sources = page.getByRole('button', { name: 'Sources' }); + await sources.click(); + await expect(mediaDrawer).toBeVisible(); + await expect( + mediaDrawer.getByRole('button', { name: 'Close media bin' }) + ).toBeFocused(); + await page.keyboard.press('Escape'); + await expect(mediaDrawer).toBeHidden(); + await expect(sources).toBeFocused(); + + const inspector = page.getByRole('button', { name: 'Inspector' }); + await inspector.click(); + await expect(inspectorDrawer).toBeVisible(); + const closeInspector = inspectorDrawer.getByRole('button', { + name: 'Close inspector', + }); + await expect(closeInspector).toBeFocused(); + await closeInspector.click(); + await expect(inspectorDrawer).toBeHidden(); + await expect(inspector).toBeFocused(); + }); +}); + +test.describe('nested static deployment and Toolbox context', () => { + let nestedServer: NestedStaticServer; + + test.beforeAll(async () => { + nestedServer = await startNestedStaticServer(); + }); + + test.afterAll(async () => { + await nestedServer.close(); + }); + + test('loads a valid same-origin catalog and all runtime assets below the nested path', async ({ + page, + }) => { + const runtime = observeRuntime(page); + await page.goto(`${nestedServer.appUrl}?toolbox=../toolbox.catalog.json`); + + await expect(page.locator('[data-toolbox-context]')).toHaveAttribute( + 'data-toolbox-context', + 'connected' + ); + await expect( + page.getByRole('link', { name: 'add·ideas Toolbox' }) + ).toHaveAttribute('href', `${nestedServer.origin}/deep/nested/`); + + await page.getByRole('button', { name: 'Apps' }).click(); + const appSwitcher = page.getByRole('navigation', { + name: 'Toolbox applications', + }); + await expect( + appSwitcher.getByRole('link', { name: 'Browser Test Tool' }) + ).toBeVisible(); + await expect( + appSwitcher.getByRole('link', { name: 'Audio & Video Tools' }) + ).toBeVisible(); + await page.keyboard.press('Escape'); + + expect( + runtime.requests.some((url) => + /\/deep\/nested\/av\/assets\/index-[^/]+\.js$/u.test(url) + ) + ).toBe(true); + expect( + runtime.requests.some((url) => + /\/deep\/nested\/av\/assets\/index-[^/]+\.css$/u.test(url) + ) + ).toBe(true); + expect( + runtime.requests.some((url) => url.includes('/vendor/ffmpeg/')) + ).toBe(false); + + await page.getByText('Engine & browser').click(); + await page.getByLabel('Engine mode').selectOption('force-single-thread'); + await page.getByRole('button', { name: 'Initialize engine' }).click(); + + await expect( + page + .locator('.engine-pill') + .filter({ hasText: 'Single-threaded compatibility mode' }) + ).toBeVisible({ timeout: 120_000 }); + await expect(page.getByText('0.12.15 / 0.12.10')).toBeVisible(); + await expect( + page.getByText('Encoders').locator('..').locator('dd') + ).not.toHaveText('0'); + await expect( + page.getByText('Filters').locator('..').locator('dd') + ).not.toHaveText('0'); + + const coreScript = runtime.responses.find((response) => + response.url.endsWith( + '/deep/nested/av/vendor/ffmpeg/0.12.10/st/ffmpeg-core.js' + ) + ); + const coreWasm = runtime.responses.find((response) => + response.url.endsWith( + '/deep/nested/av/vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm' + ) + ); + expect(coreScript).toMatchObject({ + status: 200, + contentType: 'text/javascript; charset=utf-8', + }); + expect(coreWasm).toMatchObject({ + status: 200, + contentType: 'application/wasm', + }); + expect( + runtime.responses.some((response) => + /\/deep\/nested\/av\/assets\/worker-[^/]+\.js$/u.test(response.url) + ) + ).toBe(true); + + const networkUrls = runtime.requests.filter((url) => /^https?:/u.test(url)); + expect( + networkUrls.every((url) => new URL(url).origin === nestedServer.origin) + ).toBe(true); + expect(runtime.failedRequests).toEqual([]); + expect(runtime.pageErrors).toEqual([]); + expect(runtime.consoleErrors).toEqual([]); + }); + + test('rejects an invalid same-origin catalog and remains usable standalone', async ({ + page, + }) => { + const runtime = observeRuntime(page); + await page.goto(`${nestedServer.appUrl}?toolbox=../invalid-catalog.json`); + + await expect + .poll(() => runtime.consoleWarnings.length, { timeout: 10_000 }) + .toBeGreaterThan(0); + await expect(page.locator('[data-toolbox-context]')).toHaveAttribute( + 'data-toolbox-context', + 'standalone' + ); + await expect( + page.getByRole('button', { name: 'Choose files' }) + ).toBeEnabled(); + expect( + runtime.consoleWarnings.some((warning) => + warning.includes('Toolbox context unavailable; continuing standalone.') + ) + ).toBe(true); + expect( + runtime.requests.some( + (url) => + url === `${nestedServer.origin}/deep/nested/invalid-catalog.json` + ) + ).toBe(true); + expect(runtime.failedRequests).toEqual([]); + expect(runtime.pageErrors).toEqual([]); + expect(runtime.consoleErrors).toEqual([]); + }); +}); diff --git a/tests/browser/browser-helpers.ts b/tests/browser/browser-helpers.ts new file mode 100644 index 0000000..cc3debc --- /dev/null +++ b/tests/browser/browser-helpers.ts @@ -0,0 +1,298 @@ +/// + +import { createServer, type Server } from 'node:http'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, extname, join, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { ConsoleMessage, Page, Request, Response } from '@playwright/test'; + +export const REPOSITORY_ROOT = resolve( + dirname(fileURLToPath(import.meta.url)), + '../..' +); +export const DIST_ROOT = join(REPOSITORY_ROOT, 'dist'); +export const GENERATED_FIXTURES = join( + REPOSITORY_ROOT, + 'tests/fixtures/generated' +); +export const PRIMARY_FIXTURE = join(GENERATED_FIXTURES, 'pattern-av.mp4'); + +const NESTED_PREFIX = '/deep/nested/av/'; +const SECURITY_HEADERS = { + 'Cross-Origin-Embedder-Policy': 'require-corp', + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Cross-Origin-Resource-Policy': 'same-origin', +}; + +function contentType(filePath: string): string { + const types: Readonly> = { + '.css': 'text/css; charset=utf-8', + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', + '.wasm': 'application/wasm', + }; + return types[extname(filePath).toLowerCase()] ?? 'application/octet-stream'; +} + +function readApplicationManifest(): Record { + return JSON.parse( + readFileSync(join(DIST_ROOT, 'toolbox-app.json'), 'utf8') + ) as Record; +} + +function writeJson( + response: import('node:http').ServerResponse, + status: number, + value: unknown +): void { + response.writeHead(status, { + ...SECURITY_HEADERS, + 'Cache-Control': 'no-store', + 'Content-Type': 'application/json; charset=utf-8', + }); + response.end(JSON.stringify(value)); +} + +function serveFile( + response: import('node:http').ServerResponse, + filePath: string +): void { + try { + const stats = statSync(filePath); + if (!stats.isFile()) { + response.writeHead(404, SECURITY_HEADERS); + response.end('Not found'); + return; + } + response.writeHead(200, { + ...SECURITY_HEADERS, + 'Cache-Control': 'no-store', + 'Content-Length': String(stats.size), + 'Content-Type': contentType(filePath), + }); + response.end(readFileSync(filePath)); + } catch { + response.writeHead(404, SECURITY_HEADERS); + response.end('Not found'); + } +} + +function closeServer(server: Server): Promise { + return new Promise((resolveClose, rejectClose) => { + server.close((error) => { + if (error) rejectClose(error); + else resolveClose(); + }); + }); +} + +export interface NestedStaticServer { + origin: string; + appUrl: string; + close: () => Promise; +} + +/** + * Serves the built artifact exactly as it will be assembled below a portal + * path. This catches root-absolute URLs that a root-only preview cannot. + */ +export async function startNestedStaticServer(): Promise { + const applicationManifest = readApplicationManifest(); + const otherManifest = { + ...applicationManifest, + id: 'de.add-ideas.browser-test-tool', + name: 'Browser Test Tool', + description: 'A same-origin catalog peer used by browser validation.', + entry: './', + actions: [], + }; + const catalog = { + $schema: + 'https://git.add-ideas.de/zemion/toolbox-sdk/raw/branch/main/schemas/toolbox-catalog.v1.schema.json', + schemaVersion: 1, + id: 'de.add-ideas.browser-test-toolbox', + name: 'Browser Test Toolbox', + home: './', + theme: { mode: 'system', brand: 'Browser test' }, + apps: [ + { manifest: './av/toolbox-app.json', enabled: true }, + { manifest: './other/toolbox-app.json', enabled: true }, + ], + }; + const distWithSeparator = `${resolve(DIST_ROOT)}${sep}`; + + const server = createServer((request, response) => { + const requestUrl = new URL(request.url ?? '/', 'http://127.0.0.1'); + const { pathname } = requestUrl; + + if (pathname === '/deep/nested/toolbox.catalog.json') { + writeJson(response, 200, catalog); + return; + } + if (pathname === '/deep/nested/invalid-catalog.json') { + writeJson(response, 200, { + schemaVersion: 999, + id: 'invalid', + }); + return; + } + if (pathname === '/deep/nested/other/toolbox-app.json') { + writeJson(response, 200, otherManifest); + return; + } + if (pathname === '/deep/nested/favicon.svg') { + serveFile(response, join(DIST_ROOT, 'favicon.svg')); + return; + } + if (!pathname.startsWith(NESTED_PREFIX)) { + response.writeHead(404, SECURITY_HEADERS); + response.end('Not found'); + return; + } + + const relativePath = decodeURIComponent( + pathname.slice(NESTED_PREFIX.length) + ); + const requestedPath = resolve( + DIST_ROOT, + relativePath === '' ? 'index.html' : relativePath + ); + if ( + requestedPath !== resolve(DIST_ROOT) && + !requestedPath.startsWith(distWithSeparator) + ) { + response.writeHead(400, SECURITY_HEADERS); + response.end('Invalid path'); + return; + } + serveFile(response, requestedPath); + }); + + await new Promise((resolveListen, rejectListen) => { + server.once('error', rejectListen); + server.listen(0, '127.0.0.1', () => { + server.off('error', rejectListen); + resolveListen(); + }); + }); + const address = server.address(); + if (!address || typeof address === 'string') { + await closeServer(server); + throw new Error('Nested test server did not expose a TCP port'); + } + const origin = `http://127.0.0.1:${address.port}`; + return { + origin, + appUrl: `${origin}${NESTED_PREFIX}`, + close: () => closeServer(server), + }; +} + +export interface RuntimeObservation { + requests: string[]; + responses: Array<{ + url: string; + status: number; + contentType: string | undefined; + }>; + failedRequests: string[]; + pageErrors: string[]; + consoleErrors: string[]; + consoleWarnings: string[]; +} + +export function observeRuntime(page: Page): RuntimeObservation { + const observation: RuntimeObservation = { + requests: [], + responses: [], + failedRequests: [], + pageErrors: [], + consoleErrors: [], + consoleWarnings: [], + }; + + page.on('request', (request: Request) => { + observation.requests.push(request.url()); + }); + page.on('response', (response: Response) => { + observation.responses.push({ + url: response.url(), + status: response.status(), + contentType: response.headers()['content-type'], + }); + }); + page.on('requestfailed', (request: Request) => { + observation.failedRequests.push( + `${request.url()}: ${request.failure()?.errorText ?? 'unknown failure'}` + ); + }); + page.on('pageerror', (error: Error) => { + observation.pageErrors.push(error.stack ?? error.message); + }); + page.on('console', (message: ConsoleMessage) => { + if (message.type() === 'error') { + observation.consoleErrors.push(message.text()); + } + if (message.type() === 'warning') { + observation.consoleWarnings.push(message.text()); + } + }); + + return observation; +} + +export interface TemporaryMediaFixture { + filePath: string; + dispose: () => void; +} + +export function createCancellationFixture(): TemporaryMediaFixture { + const directory = mkdtempSync(join(tmpdir(), 'av-tools-browser-')); + const filePath = join(directory, 'long-cancellation-source.mp4'); + + try { + execFileSync( + 'ffmpeg', + [ + '-hide_banner', + '-loglevel', + 'error', + '-y', + '-stream_loop', + '14', + '-i', + PRIMARY_FIXTURE, + '-t', + '30', + '-vf', + 'scale=1280:720', + '-r', + '30', + '-c:v', + 'libx264', + '-preset', + 'ultrafast', + '-crf', + '32', + '-c:a', + 'aac', + '-b:a', + '64k', + filePath, + ], + { stdio: 'pipe' } + ); + } catch (error) { + rmSync(directory, { recursive: true, force: true }); + throw error; + } + + return { + filePath, + dispose: () => rmSync(directory, { recursive: true, force: true }), + }; +} diff --git a/tests/browser/ffmpeg-runtime.spec.ts b/tests/browser/ffmpeg-runtime.spec.ts new file mode 100644 index 0000000..9739ee5 --- /dev/null +++ b/tests/browser/ffmpeg-runtime.spec.ts @@ -0,0 +1,359 @@ +/// + +import { execFileSync } from 'node:child_process'; +import { basename, join } from 'node:path'; +import { expect, test } from '@playwright/test'; +import { + createCancellationFixture, + GENERATED_FIXTURES, + observeRuntime, + PRIMARY_FIXTURE, + type TemporaryMediaFixture, +} from './browser-helpers'; + +function mediaInput(page: import('@playwright/test').Page) { + return page.locator('input[type="file"][accept*="audio"]'); +} + +async function selectSingleThread( + page: import('@playwright/test').Page +): Promise { + await page.getByText('Engine & browser').click(); + await page.getByLabel('Engine mode').selectOption('force-single-thread'); +} + +function assertLocalOnly(runtime: ReturnType): void { + const networkUrls = runtime.requests.filter((url) => /^https?:/u.test(url)); + expect( + networkUrls.every((url) => new URL(url).origin === 'http://127.0.0.1:4173') + ).toBe(true); + const materialFailures = runtime.failedRequests.filter( + (failure) => + !(failure.startsWith('blob:') && failure.endsWith('net::ERR_ABORTED')) + ); + // Chromium aborts the superseded local preview blob when React switches from + // the source object URL to the generated result object URL. + expect(materialFailures).toEqual([]); + expect(runtime.pageErrors).toEqual([]); + expect(runtime.consoleErrors).toEqual([]); +} + +test.describe.serial('real pinned FFmpeg browser runtime', () => { + let cancellationFixture: TemporaryMediaFixture; + + test.beforeAll(() => { + cancellationFixture = createCancellationFixture(); + }); + + test.afterAll(() => { + cancellationFixture.dispose(); + }); + + test('loads and queries the multithread core under cross-origin isolation', async ({ + page, + }) => { + const runtime = observeRuntime(page); + await page.goto('/'); + const environment = await page.evaluate(() => ({ + isolated: globalThis.crossOriginIsolated, + sharedArrayBuffer: typeof globalThis.SharedArrayBuffer, + })); + test.skip( + !environment.isolated || environment.sharedArrayBuffer !== 'function', + 'Chromium did not expose SharedArrayBuffer in this server context' + ); + + await page.getByText('Engine & browser').click(); + await page.getByRole('button', { name: 'Initialize engine' }).click(); + + await expect( + page.locator('.engine-pill').filter({ hasText: 'Multithreaded' }) + ).toBeVisible({ timeout: 120_000 }); + await expect(page.getByText('0.12.15 / 0.12.10')).toBeVisible(); + await expect(page.getByText('Isolation').locator('..')).toContainText( + 'Enabled' + ); + + const expectedAssets = [ + '/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', + ]; + for (const suffix of expectedAssets) { + expect( + runtime.responses.some( + (response) => response.status === 200 && response.url.endsWith(suffix) + ) + ).toBe(true); + } + const wasm = runtime.responses.find((response) => + response.url.endsWith('/vendor/ffmpeg/0.12.10/mt/ffmpeg-core.wasm') + ); + expect(wasm?.contentType).toContain('application/wasm'); + expect( + runtime.requests.some((url) => url.includes('/vendor/ffmpeg/0.12.10/st/')) + ).toBe(false); + + await mediaInput(page).setInputFiles(PRIMARY_FIXTURE); + await expect(page.locator('.source-summary .phase--ready')).toHaveText( + 'Ready', + { timeout: 120_000 } + ); + await page.getByRole('button', { name: 'Convert', exact: true }).click(); + const result = page.locator('.result-card'); + await expect(result).toContainText('pattern-av-convert.mp4', { + timeout: 120_000, + }); + await expect(result).toContainText(/created · verified|completed locally/u); + const outputBytes = await page + .locator('.quick-preview video') + .evaluate(async (element) => { + const response = await fetch((element as HTMLVideoElement).src); + return (await response.arrayBuffer()).byteLength; + }); + expect(outputBytes).toBeGreaterThan(1_000); + assertLocalOnly(runtime); + }); + + test('falls back to the single-thread core when the preferred multithread core cannot load', async ({ + page, + }) => { + const runtime = observeRuntime(page); + await page.route('**/vendor/ffmpeg/0.12.10/mt/**', (route) => + route.abort('failed') + ); + await page.goto('/'); + const environment = await page.evaluate(() => ({ + isolated: globalThis.crossOriginIsolated, + sharedArrayBuffer: typeof globalThis.SharedArrayBuffer, + })); + test.skip( + !environment.isolated || environment.sharedArrayBuffer !== 'function', + 'Chromium did not expose SharedArrayBuffer in this server context' + ); + + await page.getByText('Engine & browser').click(); + await page.getByLabel('Engine mode').selectOption('prefer-multithread'); + await page.getByRole('button', { name: 'Initialize engine' }).click(); + + await expect( + page + .locator('.engine-pill') + .filter({ hasText: 'Single-threaded compatibility mode' }) + ).toBeVisible({ timeout: 120_000 }); + await expect( + page.getByText( + 'The multithread core failed to load. The app recovered with the single-thread compatibility core.' + ) + ).toBeVisible(); + expect( + runtime.requests.some((url) => url.includes('/vendor/ffmpeg/0.12.10/mt/')) + ).toBe(true); + expect( + runtime.responses.some( + (response) => + response.status === 200 && + response.url.endsWith('/vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm') + ) + ).toBe(true); + expect(runtime.pageErrors).toEqual([]); + expect(runtime.consoleErrors).toEqual([]); + }); + + test('probes H.264/AAC media and produces a verified H.264/AAC result with the single-thread core', async ({ + page, + }) => { + await page.addInitScript(() => { + Reflect.deleteProperty(globalThis, 'showSaveFilePicker'); + }); + const runtime = observeRuntime(page); + await page.goto('/'); + await selectSingleThread(page); + + expect( + runtime.requests.some((url) => url.includes('/vendor/ffmpeg/')) + ).toBe(false); + await mediaInput(page).setInputFiles(PRIMARY_FIXTURE); + + await expect(page.locator('.source-summary .phase--ready')).toHaveText( + 'Ready', + { timeout: 120_000 } + ); + await expect(page.locator('.source-summary')).toContainText( + 'pattern-av.mp4' + ); + await expect(page.locator('.source-summary')).toContainText('0:02.00'); + await expect( + page.getByRole('checkbox', { name: /#0 · h264/u }) + ).toBeChecked(); + await expect( + page.getByRole('checkbox', { name: /#1 · aac/u }) + ).toBeChecked(); + await expect( + page + .locator('.engine-pill') + .filter({ hasText: 'Single-threaded compatibility mode' }) + ).toBeVisible(); + + await page.getByRole('button', { name: 'Convert', exact: true }).click(); + const result = page.locator('.result-card'); + await expect(result).toBeVisible({ timeout: 120_000 }); + await expect(result).toContainText('pattern-av-convert.mp4'); + await expect(result).toContainText(/created · verified|completed locally/u); + + const resultVideo = page.locator('.quick-preview video'); + await expect(resultVideo).toHaveAttribute('src', /^blob:/u); + const outputBytes = await resultVideo.evaluate(async (element) => { + const video = element as HTMLVideoElement; + const response = await fetch(video.src); + return new Uint8Array(await response.arrayBuffer()).length; + }); + expect(outputBytes).toBeGreaterThan(1_000); + + const downloadPromise = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Save result' }).click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toBe('pattern-av-convert.mp4'); + const downloadedPath = await download.path(); + expect(downloadedPath).not.toBeNull(); + const probeJson = execFileSync( + 'ffprobe', + [ + '-v', + 'error', + '-show_entries', + 'stream=codec_name,codec_type', + '-of', + 'json', + downloadedPath as string, + ], + { encoding: 'utf8' } + ); + const nativeProbe = JSON.parse(probeJson) as { + streams?: Array<{ codec_name?: string; codec_type?: string }>; + }; + expect(nativeProbe.streams).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + codec_name: 'h264', + codec_type: 'video', + }), + expect.objectContaining({ + codec_name: 'aac', + codec_type: 'audio', + }), + ]) + ); + + expect( + runtime.requests.some((url) => + url.endsWith('/vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm') + ) + ).toBe(true); + assertLocalOnly(runtime); + }); + + test('refreshes retained tile-filter state before a fourth contact sheet', async ({ + page, + }) => { + test.setTimeout(120_000); + const runtime = observeRuntime(page); + await page.goto('/'); + await selectSingleThread(page); + await page.getByRole('tab', { name: 'Edit' }).click(); + await mediaInput(page) + .first() + .setInputFiles(join(GENERATED_FIXTURES, 'compatible-a.mp4')); + await expect( + page + .locator('.media-card') + .filter({ hasText: 'compatible-a.mp4' }) + .locator('.phase--ready') + ).toBeVisible({ timeout: 120_000 }); + + const advanced = page.locator('.advanced-operations'); + await advanced + .getByRole('tab', { name: 'Thumbnails and contact sheets' }) + .click(); + await advanced + .getByRole('spinbutton', { name: 'Frames', exact: true }) + .fill('4'); + await advanced + .getByRole('spinbutton', { name: 'Rows', exact: true }) + .fill('2'); + await advanced + .getByRole('spinbutton', { name: 'Columns', exact: true }) + .fill('2'); + await advanced + .getByRole('spinbutton', { name: /^Cell width/u }) + .fill('128'); + + const results = page.locator('.result-card'); + const create = advanced.getByRole('button', { + name: 'Generate contact sheet', + }); + for (let index = 1; index <= 4; index += 1) { + await create.click(); + await expect(results).toHaveCount(index, { timeout: 120_000 }); + await expect(page.getByRole('alert')).toHaveCount(0); + } + + await expect( + results.filter({ hasText: 'compatible-a-contact-sheet' }) + ).toHaveCount(4); + expect( + runtime.requests.filter((url) => + url.endsWith('/vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm') + ) + ).toHaveLength(2); + assertLocalOnly(runtime); + }); + + test('cancels an active transcode, recreates the engine and completes another job', async ({ + page, + }) => { + const runtime = observeRuntime(page); + await page.goto('/'); + await selectSingleThread(page); + await mediaInput(page).setInputFiles(cancellationFixture.filePath); + await expect(page.locator('.source-summary .phase--ready')).toHaveText( + 'Ready', + { timeout: 120_000 } + ); + + await page.getByRole('button', { name: 'Convert', exact: true }).click(); + const cancelButton = page.getByRole('button', { + name: 'Cancel & recover', + }); + await expect(cancelButton).toBeVisible({ timeout: 30_000 }); + await cancelButton.click(); + + await expect(page.getByRole('alert')).toContainText( + 'The operation was cancelled and the engine was recreated.', + { timeout: 120_000 } + ); + await expect( + page + .locator('.engine-pill') + .filter({ hasText: 'Single-threaded compatibility mode' }) + ).toBeVisible({ timeout: 120_000 }); + + await page + .getByRole('button', { + name: `Remove ${basename(cancellationFixture.filePath)}`, + }) + .click(); + await mediaInput(page).setInputFiles(PRIMARY_FIXTURE); + await expect(page.locator('.source-summary .phase--ready')).toHaveText( + 'Ready', + { timeout: 120_000 } + ); + await page.getByRole('button', { name: 'Convert', exact: true }).click(); + await expect(page.locator('.result-card')).toContainText( + 'pattern-av-convert.mp4', + { timeout: 120_000 } + ); + await expect(page.getByRole('alert')).toHaveCount(0); + assertLocalOnly(runtime); + }); +}); diff --git a/tests/browser/fixture-recovery.spec.ts b/tests/browser/fixture-recovery.spec.ts new file mode 100644 index 0000000..fea691c --- /dev/null +++ b/tests/browser/fixture-recovery.spec.ts @@ -0,0 +1,143 @@ +/// + +import { join } from 'node:path'; +import { expect, test, type Page } from '@playwright/test'; +import { + GENERATED_FIXTURES, + observeRuntime, + PRIMARY_FIXTURE, + type RuntimeObservation, +} from './browser-helpers'; + +function mediaInput(page: Page) { + return page.locator('input[type="file"][accept*="audio"]').first(); +} + +async function initializeSingleThread(page: Page): Promise { + await page.getByText('Engine & browser').click(); + await page.getByLabel('Engine mode').selectOption('force-single-thread'); + await page.getByRole('button', { name: 'Initialize engine' }).click(); + await expect( + page + .locator('.engine-pill') + .filter({ hasText: 'Single-threaded compatibility mode' }) + ).toBeVisible({ timeout: 120_000 }); +} + +function assertLocalOnly(runtime: RuntimeObservation): void { + const networkUrls = runtime.requests.filter((url) => /^https?:/u.test(url)); + expect( + networkUrls.every((url) => new URL(url).origin === 'http://127.0.0.1:4173') + ).toBe(true); + const materialFailures = runtime.failedRequests.filter( + (failure) => + !(failure.startsWith('blob:') && failure.endsWith('net::ERR_ABORTED')) + ); + expect(materialFailures).toEqual([]); + expect(runtime.pageErrors).toEqual([]); + expect(runtime.consoleErrors).toEqual([]); +} + +test.describe + .serial('generated edge-case fixtures in pinned ffmpeg.wasm', () => { + test('probes attached cover art without treating it as the playback video stream', async ({ + page, + }) => { + test.setTimeout(180_000); + const runtime = observeRuntime(page); + await page.goto('/'); + await initializeSingleThread(page); + + await mediaInput(page).setInputFiles( + join(GENERATED_FIXTURES, 'attached-cover.mp3') + ); + await expect(page.locator('.source-summary .phase--ready')).toHaveText( + 'Ready', + { timeout: 60_000 } + ); + await expect(page.locator('.source-summary')).toContainText( + 'attached-cover.mp3' + ); + await expect(page.locator('.source-summary')).toContainText('0:01.08'); + await expect(page.locator('.quick-preview audio')).toHaveAttribute( + 'src', + /^blob:/u + ); + await expect(page.locator('.quick-preview video')).toHaveCount(0); + await expect( + page.getByRole('checkbox', { name: /#0 · mp3/u }) + ).toBeChecked(); + await expect( + page.getByRole('checkbox', { name: /#1 · png/u }) + ).toBeChecked(); + + await page.getByRole('tab', { name: 'Edit' }).click(); + const inspectorStreams = page.locator('.inspector-streams'); + await expect( + inspectorStreams.locator('li').filter({ hasText: '#0 · mp3' }) + ).toContainText('audio'); + await expect( + inspectorStreams.locator('li').filter({ hasText: '#1 · png' }) + ).toContainText('video'); + + const advanced = page.locator('.advanced-operations'); + await advanced.getByRole('tab', { name: 'Metadata policies' }).click(); + await expect(advanced.getByLabel('Cover art')).toHaveValue('keep'); + await advanced + .getByText('Other stream dispositions', { exact: true }) + .nth(1) + .click(); + await expect( + advanced.getByRole('checkbox', { + name: 'Attached pic stream #1', + }) + ).toBeChecked(); + + assertLocalOnly(runtime); + }); + + test('bounds a truncated-file probe failure, cleans up, and probes a valid file next', async ({ + page, + }) => { + test.setTimeout(180_000); + const runtime = observeRuntime(page); + await page.goto('/'); + await initializeSingleThread(page); + + const probeStartedAt = Date.now(); + await mediaInput(page).setInputFiles( + join(GENERATED_FIXTURES, 'truncated.mp4') + ); + const malformedCard = page + .locator('.media-card') + .filter({ hasText: 'truncated.mp4' }); + await expect(malformedCard.locator('.phase--error')).toContainText( + 'ffprobe did not find any media streams.', + { timeout: 30_000 } + ); + expect(Date.now() - probeStartedAt).toBeLessThan(30_000); + await expect( + page + .locator('.engine-pill') + .filter({ hasText: 'Single-threaded compatibility mode' }) + ).toBeVisible(); + + await mediaInput(page).setInputFiles(PRIMARY_FIXTURE); + const validCard = page + .locator('.media-card') + .filter({ hasText: 'pattern-av.mp4' }); + await expect(validCard.locator('.phase--ready')).toContainText('1 audio', { + timeout: 60_000, + }); + await validCard.click(); + await expect( + page.getByRole('checkbox', { name: /#0 · h264/u }) + ).toBeChecked(); + await expect( + page.getByRole('checkbox', { name: /#1 · aac/u }) + ).toBeChecked(); + await expect(page.getByRole('alert')).toHaveCount(0); + + assertLocalOnly(runtime); + }); +}); diff --git a/tests/browser/operation-matrix.spec.ts b/tests/browser/operation-matrix.spec.ts new file mode 100644 index 0000000..af4d14f --- /dev/null +++ b/tests/browser/operation-matrix.spec.ts @@ -0,0 +1,996 @@ +/// + +import { execFileSync } from 'node:child_process'; +import { readFileSync, statSync } from 'node:fs'; +import { basename, join } from 'node:path'; +import { + expect, + test, + type Download, + type Locator, + type Page, +} from '@playwright/test'; +import { + createCancellationFixture, + GENERATED_FIXTURES, + observeRuntime, + type RuntimeObservation, + type TemporaryMediaFixture, +} from './browser-helpers'; + +const fixture = (name: string): string => join(GENERATED_FIXTURES, name); + +function mediaInput(page: Page): Locator { + return page.locator('input[type="file"][accept*="audio"]').first(); +} + +async function importMedia( + page: Page, + names: readonly string[] +): Promise { + await mediaInput(page).setInputFiles(names.map((name) => fixture(name))); + for (const name of names) { + const card = page.locator('.media-card').filter({ hasText: name }); + await expect(card.locator('.phase--ready')).toBeVisible({ + timeout: 120_000, + }); + } +} + +async function initializeSingleThread(page: Page): Promise { + await page.getByText('Engine & browser').click(); + await page.getByLabel('Engine mode').selectOption('force-single-thread'); + await page.getByRole('button', { name: 'Initialize engine' }).click(); + await expect( + page + .locator('.engine-pill') + .filter({ hasText: 'Single-threaded compatibility mode' }) + ).toBeVisible({ timeout: 120_000 }); +} + +const RESULT_SNAPSHOT_ATTRIBUTE = 'data-playwright-existing-result'; + +async function markExistingResults(page: Page): Promise { + await page.locator('.result-card').evaluateAll((cards) => { + for (const card of cards) { + card.setAttribute('data-playwright-existing-result', ''); + } + }); +} + +function newResultCards(page: Page): Locator { + return page.locator(`.result-card:not([${RESULT_SNAPSHOT_ATTRIBUTE}])`); +} + +function accumulatedOutputName(fileName: string): RegExp { + const dot = fileName.lastIndexOf('.'); + const stem = dot > 0 ? fileName.slice(0, dot) : fileName; + const extension = dot > 0 ? fileName.slice(dot) : ''; + const escape = (value: string): string => + value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'); + return new RegExp(`^${escape(stem)}(?:-\\d+)?${escape(extension)}$`, 'u'); +} + +async function runForResults( + page: Page, + action: () => Promise, + expectedFileNames: readonly string[], + options: { verifyMedia?: boolean } = {} +): Promise { + await markExistingResults(page); + await action(); + + const cards = expectedFileNames.map((_, index) => + newResultCards(page).nth(index) + ); + await expect + .poll( + async () => { + if ((await cards[0]?.locator('strong').count()) === 1) { + return 'result'; + } + if ((await page.getByRole('alert').count()) > 0) { + const alert = (await page.getByRole('alert').allTextContents()).join( + ' | ' + ); + const diagnostics = + (await page.locator('.diagnostics pre').textContent()) ?? ''; + throw new Error( + `${alert}\n\nFFmpeg diagnostics:\n${diagnostics + .split('\n') + .slice(-160) + .join('\n')}` + ); + } + return 'waiting'; + }, + { timeout: 120_000 } + ) + .toBe('result'); + for (const [index, card] of cards.entries()) { + await expect(card.locator('strong')).toHaveText( + accumulatedOutputName(expectedFileNames[index] as string), + { timeout: 120_000 } + ); + await expect(card).toContainText( + /created · (?:verified|verification warning)|completed locally/u, + { timeout: 120_000 } + ); + if (options.verifyMedia) { + await expect(card).toContainText(/verified stream/u, { + timeout: 120_000, + }); + } + } + await expect(page.getByRole('alert')).toHaveCount(0); + return cards; +} + +async function downloadResult( + page: Page, + card: Locator +): Promise<{ download: Download; filePath: string }> { + const downloadPromise = page.waitForEvent('download'); + await card.locator('button.button--primary').click(); + const download = await downloadPromise; + const filePath = await download.path(); + expect(filePath).not.toBeNull(); + expect(statSync(filePath as string).size).toBeGreaterThan(0); + return { download, filePath: filePath as string }; +} + +function probe(filePath: string): { + streams?: Array<{ + codec_name?: string; + codec_type?: string; + width?: number; + height?: number; + avg_frame_rate?: string; + tags?: Record; + disposition?: Record; + }>; + format?: { tags?: Record }; + chapters?: Array<{ tags?: Record }>; +} { + return JSON.parse( + execFileSync( + 'ffprobe', + [ + '-v', + 'error', + '-show_streams', + '-show_format', + '-show_chapters', + '-of', + 'json', + filePath, + ], + { encoding: 'utf8' } + ) + ) as ReturnType; +} + +function assertLocalOnly(runtime: RuntimeObservation): void { + const networkUrls = runtime.requests.filter((url) => /^https?:/u.test(url)); + expect( + networkUrls.every((url) => new URL(url).origin === 'http://127.0.0.1:4173') + ).toBe(true); + const materialFailures = runtime.failedRequests.filter( + (failure) => + !(failure.startsWith('blob:') && failure.endsWith('net::ERR_ABORTED')) + ); + expect(materialFailures).toEqual([]); + expect(runtime.pageErrors).toEqual([]); + expect(runtime.consoleErrors).toEqual([]); +} + +test.describe.serial('real ffmpeg.wasm operation matrix', () => { + let cancellationFixture: TemporaryMediaFixture; + + test.beforeAll(() => { + cancellationFixture = createCancellationFixture(); + }); + + test.afterAll(() => { + cancellationFixture.dispose(); + }); + + test('executes conversion, structural, analysis, image, metadata, chapter and subtitle workflows in one local engine session', async ({ + page, + }) => { + test.setTimeout(300_000); + page.setDefaultTimeout(15_000); + await page.addInitScript(() => { + Reflect.deleteProperty(globalThis, 'showSaveFilePicker'); + }); + const runtime = observeRuntime(page); + await page.goto('/'); + await initializeSingleThread(page); + + await test.step('queues a second browser job, cancels the active job and runs the waiting job after recovery', async () => { + const sourceName = basename(cancellationFixture.filePath); + await mediaInput(page).setInputFiles(cancellationFixture.filePath); + await expect( + page + .locator('.media-card') + .filter({ hasText: sourceName }) + .locator('.phase--ready') + ).toBeVisible({ timeout: 120_000 }); + + await page.getByRole('button', { name: 'Convert', exact: true }).click(); + const enqueueButton = page.getByRole('button', { + name: 'Add to queue', + }); + await expect(enqueueButton).toBeVisible(); + await enqueueButton.click(); + await expect(page.getByText('1 waiting', { exact: true })).toBeVisible(); + + const firstCancelButton = page.getByRole('button', { + name: 'Cancel & recover', + }); + const firstCancelElement = await firstCancelButton.elementHandle(); + await firstCancelButton.click(); + await expect + .poll( + () => + firstCancelElement?.evaluate((element) => element.isConnected) ?? + false, + { timeout: 120_000 } + ) + .toBe(false); + await expect(page.getByText('1 waiting', { exact: true })).toBeHidden({ + timeout: 120_000, + }); + const secondCancelButton = page.getByRole('button', { + name: 'Cancel & recover', + }); + await expect(secondCancelButton).toBeVisible({ + timeout: 120_000, + }); + await secondCancelButton.click(); + await expect(secondCancelButton).toBeHidden({ timeout: 120_000 }); + await expect(page.getByRole('alert')).toContainText( + 'The operation was cancelled and the engine was recreated.', + { timeout: 120_000 } + ); + await expect( + page + .locator('.engine-pill') + .filter({ hasText: 'Single-threaded compatibility mode' }) + ).toBeVisible({ timeout: 120_000 }); + await page.getByRole('button', { name: 'New', exact: true }).click(); + }); + + await test.step('audio conversion and WebM remux/re-encode', async () => { + await importMedia(page, ['tone.flac']); + await page.getByLabel('Export preset').selectOption('audio-mp3'); + const [mp3Card] = await runForResults( + page, + () => + page.getByRole('button', { name: 'Convert', exact: true }).click(), + ['tone-convert.mp3'], + { verifyMedia: true } + ); + const mp3 = await downloadResult(page, mp3Card as Locator); + expect(probe(mp3.filePath).streams).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + codec_name: 'mp3', + codec_type: 'audio', + }), + ]) + ); + + await page.getByLabel('Export preset').selectOption('audio-ogg-vorbis'); + const [oggCard] = await runForResults( + page, + () => + page.getByRole('button', { name: 'Convert', exact: true }).click(), + ['tone-convert.ogg'], + { verifyMedia: true } + ); + expect( + probe((await downloadResult(page, oggCard as Locator)).filePath).streams + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + codec_name: 'vorbis', + codec_type: 'audio', + }), + ]) + ); + + await page.getByLabel('Export preset').selectOption('audio-flac'); + const [flacCard] = await runForResults( + page, + () => + page.getByRole('button', { name: 'Convert', exact: true }).click(), + ['tone-convert.flac'], + { verifyMedia: true } + ); + expect( + probe((await downloadResult(page, flacCard as Locator)).filePath) + .streams + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + codec_name: 'flac', + codec_type: 'audio', + }), + ]) + ); + + await page.getByLabel('Export preset').selectOption('audio-wav-pcm'); + const [wavCard] = await runForResults( + page, + () => + page.getByRole('button', { name: 'Convert', exact: true }).click(), + ['tone-convert.wav'], + { verifyMedia: true } + ); + expect( + probe((await downloadResult(page, wavCard as Locator)).filePath).streams + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + codec_name: 'pcm_s16le', + codec_type: 'audio', + }), + ]) + ); + + await importMedia(page, ['pattern-av.mp4']); + await page + .locator('.media-card') + .filter({ hasText: 'pattern-av.mp4' }) + .click(); + await page.getByLabel('Export preset').selectOption('audio-mp3'); + await page.getByRole('checkbox', { name: /#0 · h264/u }).uncheck(); + const [extractedAudioCard] = await runForResults( + page, + () => + page.getByRole('button', { name: 'Convert', exact: true }).click(), + ['pattern-av-convert.mp3'], + { verifyMedia: true } + ); + const extractedAudio = probe( + (await downloadResult(page, extractedAudioCard as Locator)).filePath + ); + expect( + extractedAudio.streams?.map((stream) => stream.codec_type) + ).toEqual(['audio']); + expect(extractedAudio.streams?.[0]?.codec_name).toBe('mp3'); + + await page + .getByLabel('Export preset') + .selectOption('mp4-h264-compatibility'); + await page.getByRole('checkbox', { name: /#0 · h264/u }).check(); + await page.getByRole('checkbox', { name: /#1 · aac/u }).uncheck(); + const [videoOnlyCard] = await runForResults( + page, + () => + page.getByRole('button', { name: 'Convert', exact: true }).click(), + ['pattern-av-convert.mp4'], + { verifyMedia: true } + ); + const videoOnly = probe( + (await downloadResult(page, videoOnlyCard as Locator)).filePath + ); + expect(videoOnly.streams?.map((stream) => stream.codec_type)).toEqual([ + 'video', + ]); + expect(videoOnly.streams?.[0]?.codec_name).toBe('h264'); + + await importMedia(page, ['pattern.webm']); + await page + .locator('.media-card') + .filter({ hasText: 'pattern.webm' }) + .click(); + await page.getByRole('button', { name: 'Fast remux' }).click(); + await page.getByLabel('Target container').selectOption('matroska'); + const [remuxCard] = await runForResults( + page, + () => page.getByRole('button', { name: 'Remux', exact: true }).click(), + ['pattern-remux.mkv'], + { verifyMedia: true } + ); + const remux = await downloadResult(page, remuxCard as Locator); + expect(probe(remux.filePath).streams).toEqual( + expect.arrayContaining([ + expect.objectContaining({ codec_name: 'vp8', codec_type: 'video' }), + expect.objectContaining({ + codec_name: 'vorbis', + codec_type: 'audio', + }), + ]) + ); + + await page.getByRole('button', { name: 'Re-encode' }).click(); + await page.getByLabel('Export preset').selectOption('webm-vp8-vorbis'); + const [webmCard] = await runForResults( + page, + () => + page.getByRole('button', { name: 'Convert', exact: true }).click(), + ['pattern-convert.webm'], + { verifyMedia: true } + ); + const webm = await downloadResult(page, webmCard as Locator); + expect(probe(webm.filePath).streams).toEqual( + expect.arrayContaining([ + expect.objectContaining({ codec_name: 'vp8', codec_type: 'video' }), + expect.objectContaining({ + codec_name: 'vorbis', + codec_type: 'audio', + }), + ]) + ); + + await importMedia(page, ['metadata.mp4']); + await page + .locator('.media-card') + .filter({ hasText: 'metadata.mp4' }) + .click(); + await page.getByRole('button', { name: 'Fast remux' }).click(); + await page.getByLabel('Target container').selectOption('mp4'); + await page.getByRole('checkbox', { name: 'Remove metadata' }).check(); + const [metadataRemovalCard] = await runForResults( + page, + () => page.getByRole('button', { name: 'Remux', exact: true }).click(), + ['metadata-remux.mp4'], + { verifyMedia: true } + ); + const metadataRemovalProbe = probe( + (await downloadResult(page, metadataRemovalCard as Locator)).filePath + ); + expect(metadataRemovalProbe.format?.tags?.title).toBeUndefined(); + expect(metadataRemovalProbe.format?.tags?.artist).toBeUndefined(); + expect(metadataRemovalProbe.format?.tags?.comment).toBeUndefined(); + }); + + await test.step('fast and accurate trim plus fast and normalized concat', async () => { + await page.getByRole('button', { name: 'New', exact: true }).click(); + await page.getByRole('tab', { name: 'Edit' }).click(); + await importMedia(page, ['compatible-a.mp4', 'compatible-b.mp4']); + + const selectedTimelineClip = page + .locator('.timeline-clip') + .filter({ hasText: 'compatible-a.mp4' }); + await selectedTimelineClip.locator('.timeline-clip__main').click(); + await selectedTimelineClip.getByLabel('In').fill('0.1'); + await selectedTimelineClip.getByLabel('Out').fill('0.8'); + + const structural = page.locator('.structural-operations'); + const [fastTrimCard] = await runForResults( + page, + () => + structural.getByRole('button', { name: 'Export fast trim' }).click(), + ['compatible-a-trim.mp4'], + { verifyMedia: true } + ); + expect( + probe((await downloadResult(page, fastTrimCard as Locator)).filePath) + .streams + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ codec_name: 'h264', codec_type: 'video' }), + ]) + ); + + await structural.getByLabel('Accurate re-encode').check(); + await structural + .getByLabel('Accurate trim preset') + .selectOption('mp4-h264-compatibility'); + const [accurateTrimCard] = await runForResults( + page, + () => + structural.getByRole('button', { name: 'Export exact trim' }).click(), + ['compatible-a-trim.mp4'], + { verifyMedia: true } + ); + expect( + probe( + (await downloadResult(page, accurateTrimCard as Locator)).filePath + ).streams + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ codec_name: 'h264', codec_type: 'video' }), + expect.objectContaining({ codec_name: 'aac', codec_type: 'audio' }), + ]) + ); + + await expect( + structural.getByRole('heading', { + name: 'Fast compatibility passed', + }) + ).toBeVisible(); + const [fastConcatCard] = await runForResults( + page, + () => + structural + .getByRole('button', { name: 'Export fast concat' }) + .click(), + ['compatible-a-concat.mp4'], + { verifyMedia: true } + ); + expect( + probe((await downloadResult(page, fastConcatCard as Locator)).filePath) + .streams + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ codec_name: 'h264', codec_type: 'video' }), + expect.objectContaining({ codec_name: 'aac', codec_type: 'audio' }), + ]) + ); + + await structural.getByLabel('Normalized re-encode').check(); + await structural + .getByLabel('Normalized concat preset') + .selectOption('mp4-h264-compatibility'); + await structural.getByLabel('Insert silence').check(); + await structural.getByLabel('Reject subtitle input').check(); + const [normalizedConcatCard] = await runForResults( + page, + () => + structural + .getByRole('button', { name: 'Export normalized concat' }) + .click(), + ['compatible-a-concat.mp4'], + { verifyMedia: true } + ); + expect( + probe( + (await downloadResult(page, normalizedConcatCard as Locator)).filePath + ).streams + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ codec_name: 'h264', codec_type: 'video' }), + expect.objectContaining({ codec_name: 'aac', codec_type: 'audio' }), + ]) + ); + }); + + const advanced = page.locator('.advanced-operations'); + await test.step('fast/accurate split and waveform/loudness analysis', async () => { + await advanced.getByLabel('Marker time (seconds)').fill('0.5'); + await advanced.getByRole('button', { name: 'Add marker' }).click(); + await runForResults( + page, + () => + advanced.getByRole('button', { name: 'Queue 2 segments' }).click(), + ['compatible-a-split-001.mp4', 'compatible-a-split-002.mp4'], + { verifyMedia: true } + ); + + await advanced.getByLabel('Accurate re-encode').check(); + await advanced + .getByLabel('Accurate split preset') + .selectOption('mp4-h264-compatibility'); + await runForResults( + page, + () => + advanced.getByRole('button', { name: 'Queue 2 segments' }).click(), + ['compatible-a-split-001.mp4', 'compatible-a-split-002.mp4'], + { verifyMedia: true } + ); + + await advanced + .getByRole('tab', { name: 'Waveform and loudness' }) + .click(); + const [waveformCard] = await runForResults( + page, + () => + advanced + .getByRole('button', { name: 'Generate static waveform' }) + .click(), + ['compatible-a-waveform-static.png'] + ); + expect( + statSync((await downloadResult(page, waveformCard as Locator)).filePath) + .size + ).toBeGreaterThan(1_000); + + await advanced + .getByRole('button', { name: 'Analyze peak waveform' }) + .click(); + await expect( + page.getByRole('region', { + name: 'Waveform editor for compatible-a.mp4', + }) + ).toBeVisible({ timeout: 120_000 }); + + await advanced.getByRole('button', { name: 'Measure EBU R128' }).click(); + await expect(page.locator('.project-notice')).toContainText('Measured', { + timeout: 120_000, + }); + await expect(advanced.getByText('Measured loudness')).toBeVisible(); + await advanced + .getByLabel('Normalization preset') + .selectOption('audio-mp3'); + + const [normalizedAudioCard] = await runForResults( + page, + () => + advanced + .getByRole('button', { name: 'Apply measured normalization' }) + .click(), + ['compatible-a-normalize.mp3'], + { verifyMedia: true } + ); + expect( + probe( + (await downloadResult(page, normalizedAudioCard as Locator)).filePath + ).streams + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ codec_name: 'mp3', codec_type: 'audio' }), + ]) + ); + + const [peakAudioCard] = await runForResults( + page, + () => + advanced + .getByRole('button', { name: 'Apply peak normalization' }) + .click(), + ['compatible-a-peak-normalize.mp3'], + { verifyMedia: true } + ); + expect( + probe((await downloadResult(page, peakAudioCard as Locator)).filePath) + .streams + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ codec_name: 'mp3', codec_type: 'audio' }), + ]) + ); + }); + + await test.step('thumbnail series and contact sheet', async () => { + await advanced + .getByRole('tab', { name: 'Thumbnails and contact sheets' }) + .click(); + await advanced.getByLabel('Number of frames').fill('3'); + await runForResults( + page, + () => + advanced + .getByRole('button', { name: 'Generate thumbnail series' }) + .click(), + [ + 'compatible-a-thumbnail-001-250ms.jpg', + 'compatible-a-thumbnail-002-500ms.jpg', + 'compatible-a-thumbnail-003-750ms.jpg', + ] + ); + + await advanced + .getByRole('spinbutton', { name: 'Frames', exact: true }) + .fill('4'); + await advanced + .getByRole('spinbutton', { name: 'Rows', exact: true }) + .fill('2'); + await advanced + .getByRole('spinbutton', { name: 'Columns', exact: true }) + .fill('2'); + await advanced + .getByRole('spinbutton', { name: /^Cell width/u }) + .fill('128'); + const [sheetCard] = await runForResults( + page, + () => + advanced + .getByRole('button', { name: 'Generate contact sheet' }) + .click(), + ['compatible-a-contact-sheet.jpg'] + ); + expect( + statSync((await downloadResult(page, sheetCard as Locator)).filePath) + .size + ).toBeGreaterThan(500); + }); + + await test.step('single frame and a transformed timeline with audio/video fades', async () => { + const inspector = page.locator('.inspector'); + await inspector.getByRole('tab', { name: 'Frames' }).click(); + const inspectorBody = inspector.locator('.inspector__body'); + await inspectorBody + .getByRole('spinbutton', { name: 'Time (seconds)', exact: true }) + .fill('0.2'); + await inspectorBody + .getByRole('spinbutton', { name: 'Width', exact: true }) + .fill('96'); + await inspectorBody + .getByRole('combobox', { name: 'Format', exact: true }) + .selectOption('png'); + const [singleFrameCard] = await runForResults( + page, + () => + inspectorBody.getByRole('button', { name: 'Extract frame' }).click(), + ['compatible-a-thumbnail-200ms.png'] + ); + expect( + statSync( + (await downloadResult(page, singleFrameCard as Locator)).filePath + ).size + ).toBeGreaterThan(100); + + await inspector.getByRole('tab', { name: 'Transform' }).click(); + const cropFields = inspector.locator('.crop-editor__numeric'); + await cropFields + .getByRole('spinbutton', { name: 'X', exact: true }) + .fill('4'); + await cropFields + .getByRole('spinbutton', { name: 'Y', exact: true }) + .fill('2'); + await cropFields + .getByRole('spinbutton', { name: 'Width', exact: true }) + .fill('120'); + await cropFields + .getByRole('spinbutton', { name: 'Height', exact: true }) + .fill('68'); + + const resizeFields = inspector.locator('.inspector-section > .mini-grid'); + await resizeFields + .getByRole('spinbutton', { name: 'Width', exact: true }) + .fill('96'); + await resizeFields + .getByRole('spinbutton', { name: 'Height', exact: true }) + .fill('54'); + await resizeFields + .getByRole('combobox', { name: 'Rotate', exact: true }) + .selectOption('90'); + await resizeFields + .getByRole('combobox', { name: 'Scale algorithm', exact: true }) + .selectOption('bicubic'); + await resizeFields + .getByRole('textbox', { name: 'Padding colour', exact: true }) + .fill('#112233'); + await page + .getByRole('spinbutton', { name: 'Output frame rate', exact: true }) + .fill('12'); + + const videoFades = inspector + .locator('fieldset.subpanel') + .filter({ hasText: 'Video fades' }); + await videoFades + .getByRole('spinbutton', { name: 'Fade in from black (s)' }) + .fill('0.1'); + await videoFades + .getByRole('spinbutton', { name: 'Fade out to black (s)' }) + .fill('0.1'); + + await inspector.getByRole('tab', { name: 'Audio' }).click(); + await expect( + inspectorBody.getByRole('checkbox', { name: 'Include audio' }) + ).toBeChecked(); + await inspectorBody + .getByRole('spinbutton', { name: 'Fade in (s)', exact: true }) + .fill('0.1'); + await inspectorBody + .getByRole('spinbutton', { name: 'Fade out (s)', exact: true }) + .fill('0.1'); + + const [timelineCard] = await runForResults( + page, + () => page.getByRole('button', { name: 'Render timeline' }).click(), + ['output.mp4'], + { verifyMedia: true } + ); + const timelineProbe = probe( + (await downloadResult(page, timelineCard as Locator)).filePath + ); + const timelineVideo = timelineProbe.streams?.find( + (stream) => stream.codec_type === 'video' + ); + expect(timelineVideo).toMatchObject({ + codec_name: 'h264', + width: 96, + height: 54, + avg_frame_rate: '12/1', + }); + expect( + timelineProbe.streams?.some( + (stream) => + stream.codec_type === 'audio' && stream.codec_name === 'aac' + ) + ).toBe(true); + }); + + await test.step('metadata and chapters survive round-trip probing', async () => { + await advanced.getByRole('tab', { name: 'Metadata policies' }).click(); + const metadataPanel = advanced.getByRole('tabpanel'); + await metadataPanel + .getByRole('textbox', { name: 'Title', exact: true }) + .fill('Browser Matrix'); + await metadataPanel + .getByRole('textbox', { name: 'Artist', exact: true }) + .fill('av-tools Playwright'); + const [metadataCard] = await runForResults( + page, + () => + advanced + .getByRole('button', { name: 'Apply metadata policies' }) + .click(), + ['compatible-a-metadata.mp4'], + { verifyMedia: true } + ); + const metadataProbe = probe( + (await downloadResult(page, metadataCard as Locator)).filePath + ); + expect(metadataProbe.format?.tags).toMatchObject({ + title: 'Browser Matrix', + artist: 'av-tools Playwright', + }); + + await advanced.getByRole('tab', { name: 'Chapter execution' }).click(); + await advanced + .getByLabel('Create output chapters from') + .selectOption('fixed'); + await advanced.getByLabel('Chapter interval').fill('0.5'); + const [chapterCard] = await runForResults( + page, + () => + advanced + .getByRole('button', { name: 'Replace output chapters' }) + .click(), + ['compatible-a-chapters.mp4'], + { verifyMedia: true } + ); + const chapterProbe = probe( + (await downloadResult(page, chapterCard as Locator)).filePath + ); + expect(chapterProbe.chapters).toHaveLength(2); + expect( + chapterProbe.chapters?.map((chapter) => chapter.tags?.title) + ).toEqual(['Chapter 1', 'Chapter 2']); + }); + + await test.step('subtitle convert, mux, burn-in and extraction', async () => { + await advanced.getByRole('tab', { name: 'Subtitle operations' }).click(); + const subtitlePanel = advanced.getByRole('tabpanel'); + const subtitleOperation = subtitlePanel + .locator('label.advanced-field') + .filter({ hasText: /^Operation/u }) + .locator('select'); + const outputTextFormat = subtitlePanel + .locator('label.advanced-field') + .filter({ hasText: /^Output text format/u }) + .locator('select'); + await subtitleOperation.selectOption('convert'); + const localSubtitleInput = subtitlePanel.locator( + 'label.advanced-file-field input[type="file"]' + ); + await markExistingResults(page); + await localSubtitleInput.setInputFiles({ + name: 'invalid-subtitle.srt', + mimeType: 'application/x-subrip', + buffer: Buffer.from('this is not a timed subtitle\n'), + }); + await subtitlePanel + .getByRole('button', { name: 'Convert subtitle' }) + .click(); + await expect( + advanced.locator('.advanced-operations__status') + ).toContainText(/FFmpeg (?:exited|produced)/u, { + timeout: 120_000, + }); + await expect(newResultCards(page)).toHaveCount(0); + await expect( + page + .locator('.engine-pill') + .filter({ hasText: 'Single-threaded compatibility mode' }) + ).toBeVisible({ timeout: 120_000 }); + + await localSubtitleInput.setInputFiles(fixture('subtitle.srt')); + await outputTextFormat.selectOption('vtt'); + await subtitlePanel.getByLabel('Time offset').fill('0.1'); + const [convertedCard] = await runForResults( + page, + () => + subtitlePanel + .getByRole('button', { name: 'Convert subtitle' }) + .click(), + ['subtitle-subtitle.vtt'] + ); + const converted = await downloadResult(page, convertedCard as Locator); + expect(readFileSync(converted.filePath, 'utf8')).toContain('WEBVTT'); + + await subtitleOperation.selectOption('soft-mux'); + await subtitlePanel + .locator('label.advanced-field') + .filter({ hasText: /^Target container/u }) + .locator('select') + .selectOption('matroska'); + await subtitlePanel + .getByRole('textbox', { name: 'Language', exact: true }) + .fill('eng'); + await subtitlePanel + .getByRole('textbox', { name: 'Track title', exact: true }) + .fill('Browser subtitle'); + await subtitlePanel + .getByRole('checkbox', { name: 'Default track' }) + .check(); + const [muxedCard] = await runForResults( + page, + () => + subtitlePanel + .getByRole('button', { name: 'Mux soft subtitle' }) + .click(), + ['compatible-a-subtitled.mkv'], + { verifyMedia: true } + ); + const muxed = await downloadResult(page, muxedCard as Locator); + expect(probe(muxed.filePath).streams).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + codec_name: 'subrip', + codec_type: 'subtitle', + tags: expect.objectContaining({ + language: 'eng', + title: 'Browser subtitle', + }), + disposition: expect.objectContaining({ default: 1 }), + }), + ]) + ); + + await subtitleOperation.selectOption('burn-in'); + await subtitlePanel + .locator('label.advanced-field') + .filter({ hasText: /^Video export preset/u }) + .locator('select') + .selectOption('mp4-h264-compatibility'); + const [burnedCard] = await runForResults( + page, + () => + subtitlePanel + .getByRole('button', { name: 'Burn subtitle into video' }) + .click(), + ['compatible-a-subtitle-burn.mp4'], + { verifyMedia: true } + ); + expect( + probe((await downloadResult(page, burnedCard as Locator)).filePath) + .streams + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ codec_name: 'h264', codec_type: 'video' }), + ]) + ); + + await page.getByRole('button', { name: 'New', exact: true }).click(); + await mediaInput(page).setInputFiles({ + name: 'subtitled-source.mkv', + mimeType: 'video/x-matroska', + buffer: readFileSync(muxed.filePath), + }); + await expect( + page + .locator('.media-card') + .filter({ hasText: 'subtitled-source.mkv' }) + .locator('.phase--ready') + ).toBeVisible({ timeout: 120_000 }); + await subtitleOperation.selectOption('extract'); + await outputTextFormat.selectOption('srt'); + const [extractedCard] = await runForResults( + page, + () => + subtitlePanel + .getByRole('button', { name: 'Extract subtitle' }) + .click(), + ['subtitled-source-subtitle-eng.srt'] + ); + const extracted = await downloadResult(page, extractedCard as Locator); + expect(readFileSync(extracted.filePath, 'utf8')).toContain( + 'Generated subtitle' + ); + }); + + expect( + runtime.requests.some((url) => + url.endsWith('/vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm') + ) + ).toBe(true); + assertLocalOnly(runtime); + }); +}); diff --git a/tests/browser/performance.spec.ts b/tests/browser/performance.spec.ts new file mode 100644 index 0000000..d61423f --- /dev/null +++ b/tests/browser/performance.spec.ts @@ -0,0 +1,242 @@ +/// + +import { expect, test, type Browser, type Page } from '@playwright/test'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { PRIMARY_FIXTURE } from './browser-helpers'; + +interface BenchmarkResult { + readonly mode: 'single-thread' | 'multithread'; + readonly source: 'small' | 'medium'; + readonly sourceDescription: string; + readonly cacheCondition: string; + readonly initMs: number; + readonly probeMs: number; + readonly conversionMs: number; + readonly outputReadMs: number; + readonly cleanupMs: number; + readonly exportEndToEndMs: number; + readonly outputBytes: number; + readonly hardwareConcurrency: number; + readonly crossOriginIsolated: boolean; + readonly inputBytes: number; + readonly usedJsHeapBytes?: number; +} + +test.describe('opt-in pinned-core performance record', () => { + test.skip( + process.env.AV_BENCHMARK !== '1', + 'Run with AV_BENCHMARK=1 through npm run benchmark:browser.' + ); + + test('records comparable fresh-context ST and MT end-to-end timings', async ({ + browser, + }, testInfo) => { + test.setTimeout(600_000); + const medium = createMediumFixture(); + const results: BenchmarkResult[] = []; + try { + results.push( + await runMode(browser, 'single-thread', PRIMARY_FIXTURE, 'small') + ); + results.push( + await runMode(browser, 'multithread', PRIMARY_FIXTURE, 'small') + ); + results.push( + await runMode(browser, 'single-thread', medium.filePath, 'medium') + ); + results.push( + await runMode(browser, 'multithread', medium.filePath, 'medium') + ); + await testInfo.attach('av-tools-browser-benchmark.json', { + body: Buffer.from(`${JSON.stringify(results, null, 2)}\n`), + contentType: 'application/json', + }); + process.stdout.write(`AV_BENCHMARK_RESULT ${JSON.stringify(results)}\n`); + } finally { + medium.dispose(); + } + }); +}); + +async function runMode( + browser: Browser, + mode: BenchmarkResult['mode'], + fixturePath: string, + source: BenchmarkResult['source'] +): Promise { + const context = await browser.newContext(); + const page = await context.newPage(); + try { + page.on('dialog', (dialog) => void dialog.accept()); + await page.addInitScript(() => { + Reflect.deleteProperty(globalThis, 'showSaveFilePicker'); + }); + await page.goto('http://127.0.0.1:4173/'); + const environment = await page.evaluate(() => ({ + hardwareConcurrency: navigator.hardwareConcurrency, + crossOriginIsolated: globalThis.crossOriginIsolated, + })); + if (mode === 'multithread') { + expect(environment.crossOriginIsolated).toBe(true); + } + + await page.getByText('Engine & browser').click(); + await page + .getByLabel('Engine mode') + .selectOption( + mode === 'single-thread' ? 'force-single-thread' : 'prefer-multithread' + ); + const initStarted = await monotonicNow(page); + await page.getByRole('button', { name: 'Initialize engine' }).click(); + await expect( + page.locator('.engine-pill').filter({ + hasText: + mode === 'single-thread' + ? 'Single-threaded compatibility mode' + : 'Multithreaded', + }) + ).toBeVisible({ timeout: 120_000 }); + const initMs = (await monotonicNow(page)) - initStarted; + + const probeStarted = await monotonicNow(page); + await mediaInput(page).setInputFiles(fixturePath); + await expect(page.locator('.source-summary .phase--ready')).toHaveText( + 'Ready', + { timeout: 120_000 } + ); + const probeMs = (await monotonicNow(page)) - probeStarted; + + const exportStarted = await monotonicNow(page); + await page.getByRole('button', { name: 'Convert', exact: true }).click(); + const result = page.locator('.result-card').last(); + await expect(result).toContainText( + /created · verified|completed locally/u, + { + timeout: 180_000, + } + ); + const exportEndToEndMs = (await monotonicNow(page)) - exportStarted; + const reportDownload = page.waitForEvent('download'); + await page + .getByRole('button', { name: 'Export validation report' }) + .click(); + const reportPath = await (await reportDownload).path(); + expect(reportPath).not.toBeNull(); + const report = JSON.parse( + readFileSync(reportPath as string, 'utf8') + ) as PerformanceReport; + const outputBytes = await page + .locator('.quick-preview video') + .evaluate(async (element) => { + const response = await fetch((element as HTMLVideoElement).src); + return (await response.arrayBuffer()).byteLength; + }); + const usedJsHeapBytes = await page.evaluate(() => { + const memory = ( + performance as Performance & { + memory?: { usedJSHeapSize?: number }; + } + ).memory; + return memory?.usedJSHeapSize; + }); + + const benchmarkSample: BenchmarkResult = { + mode, + source, + sourceDescription: + source === 'small' + ? '2 s, 160×90 H.264/AAC synthetic pattern' + : '6 s, 640×360 H.264/AAC synthetic loop', + cacheCondition: + 'fresh browser context; browser HTTP cache may be warm after the first run', + initMs: rounded(initMs), + probeMs: rounded(probeMs), + conversionMs: rounded(report.execution.conversionMilliseconds), + outputReadMs: rounded(report.execution.outputReadMilliseconds), + cleanupMs: rounded(report.execution.cleanupMilliseconds), + exportEndToEndMs: rounded(exportEndToEndMs), + outputBytes, + hardwareConcurrency: environment.hardwareConcurrency, + crossOriginIsolated: environment.crossOriginIsolated, + inputBytes: statSync(fixturePath).size, + ...(usedJsHeapBytes === undefined ? {} : { usedJsHeapBytes }), + }; + process.stdout.write( + `AV_BENCHMARK_SAMPLE ${JSON.stringify(benchmarkSample)}\n` + ); + return benchmarkSample; + } finally { + await context.close(); + } +} + +interface PerformanceReport { + readonly execution: { + readonly conversionMilliseconds: number; + readonly outputReadMilliseconds: number; + readonly cleanupMilliseconds: number; + }; +} + +function createMediumFixture(): { + readonly filePath: string; + readonly dispose: () => void; +} { + const directory = mkdtempSync(join(tmpdir(), 'av-tools-benchmark-')); + const filePath = join(directory, 'medium-pattern.mp4'); + try { + execFileSync( + 'ffmpeg', + [ + '-hide_banner', + '-loglevel', + 'error', + '-y', + '-stream_loop', + '2', + '-i', + PRIMARY_FIXTURE, + '-t', + '6', + '-vf', + 'scale=640:360', + '-r', + '24', + '-c:v', + 'libx264', + '-preset', + 'ultrafast', + '-crf', + '30', + '-c:a', + 'aac', + '-b:a', + '64k', + filePath, + ], + { stdio: 'pipe' } + ); + } catch (error) { + rmSync(directory, { recursive: true, force: true }); + throw error; + } + return { + filePath, + dispose: () => rmSync(directory, { recursive: true, force: true }), + }; +} + +function mediaInput(page: Page) { + return page.locator('input[type="file"][accept*="audio"]').first(); +} + +async function monotonicNow(page: Page): Promise { + return page.evaluate(() => performance.now()); +} + +function rounded(value: number): number { + return Math.round(value * 10) / 10; +} diff --git a/tests/browser/ui-workflows.spec.ts b/tests/browser/ui-workflows.spec.ts new file mode 100644 index 0000000..850a647 --- /dev/null +++ b/tests/browser/ui-workflows.spec.ts @@ -0,0 +1,185 @@ +/// + +import { readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { expect, test } from '@playwright/test'; +import { GENERATED_FIXTURES, PRIMARY_FIXTURE } from './browser-helpers'; + +test('covers drop, editor keyboard/drag controls, cache, and project reattachment', async ({ + page, +}) => { + test.setTimeout(240_000); + await page.addInitScript(() => { + Object.defineProperty(HTMLMediaElement.prototype, 'canPlayType', { + configurable: true, + value: () => '', + }); + Reflect.deleteProperty(globalThis, 'showSaveFilePicker'); + }); + await page.goto('/'); + await page.getByText('Engine & browser').click(); + await page.getByLabel('Engine mode').selectOption('force-single-thread'); + + await dropFixture(page, PRIMARY_FIXTURE, 'video/mp4'); + await expect(page.locator('.source-summary .phase--ready')).toHaveText( + 'Ready', + { timeout: 120_000 } + ); + await expect(page.locator('.quick-preview video')).toHaveAttribute( + 'src', + /^blob:/u + ); + + await page.getByRole('button', { name: 'Create proxy' }).click(); + await expect( + page.locator('.result-card').filter({ hasText: 'pattern-av-preview.mp4' }) + ).toContainText(/created · verified|completed locally/u, { + timeout: 120_000, + }); + await page.getByText('Derived cache').click(); + const clearCache = page.getByRole('button', { name: 'Clear cache' }); + await expect(clearCache).toBeEnabled({ timeout: 10_000 }); + await clearCache.click(); + await expect(page.getByRole('status')).toContainText( + 'Derived cache cleared.' + ); + + await page.getByRole('tab', { name: 'Edit' }).click(); + const compatibleB = join(GENERATED_FIXTURES, 'compatible-b.mp4'); + await mediaInput(page).setInputFiles(compatibleB); + await expect(page.locator('.media-card .phase--ready')).toHaveCount(2, { + timeout: 120_000, + }); + + const clips = page.locator('.timeline-clip'); + await expect(clips).toHaveCount(2); + const secondName = await clips.nth(1).locator('strong').textContent(); + await clips.nth(1).dragTo(clips.nth(0)); + await expect(clips.nth(0).locator('strong')).toHaveText( + secondName ?? 'compatible-b.mp4' + ); + + await clips + .filter({ hasText: 'compatible-b.mp4' }) + .locator('.timeline-clip__main') + .click(); + const inspector = page.locator('.inspector'); + await inspector.getByRole('tab', { name: 'Transform' }).click(); + const crop = inspector.locator('.crop-editor__numeric'); + await crop.getByRole('spinbutton', { name: 'Width' }).fill('100'); + await crop.getByRole('spinbutton', { name: 'Height' }).fill('60'); + const cropMove = inspector.getByRole('button', { + name: 'Move crop rectangle', + }); + await cropMove.focus(); + await page.keyboard.press('Shift+ArrowRight'); + await expect(crop.getByRole('spinbutton', { name: 'X' })).toHaveValue('10'); + + const resizeRight = inspector.getByRole('button', { + name: 'Resize crop from right', + }); + const handleBox = await resizeRight.boundingBox(); + expect(handleBox).not.toBeNull(); + const widthBefore = Number( + await crop.getByRole('spinbutton', { name: 'Width' }).inputValue() + ); + const handleCenterX = (handleBox?.x ?? 0) + (handleBox?.width ?? 0) / 2; + const handleCenterY = (handleBox?.y ?? 0) + (handleBox?.height ?? 0) / 2; + await page.mouse.move(handleCenterX, handleCenterY); + await page.mouse.down(); + await page.mouse.move(handleCenterX + 16, handleCenterY); + await page.mouse.up(); + await expect + .poll(async () => + Number(await crop.getByRole('spinbutton', { name: 'Width' }).inputValue()) + ) + .not.toBe(widthBefore); + + await crop.getByRole('spinbutton', { name: 'X' }).fill('999'); + await expect(inspector.locator('.crop-editor__errors')).toHaveAttribute( + 'role', + 'alert' + ); + await inspector.getByRole('button', { name: 'Reset crop' }).click(); + + const advanced = page.locator('.advanced-operations'); + await advanced.getByRole('tab', { name: 'Waveform and loudness' }).click(); + await advanced.getByRole('button', { name: 'Analyze peak waveform' }).click(); + const inHandle = page.getByRole('slider', { name: 'In trim handle' }); + await expect(inHandle).toBeVisible({ timeout: 120_000 }); + const inBefore = Number(await inHandle.getAttribute('aria-valuenow')); + await inHandle.focus(); + await page.keyboard.press('ArrowRight'); + await expect + .poll(async () => Number(await inHandle.getAttribute('aria-valuenow'))) + .toBeGreaterThan(inBefore); + + const projectDownload = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Save project' }).click(); + const projectPath = await (await projectDownload).path(); + expect(projectPath).not.toBeNull(); + await page.getByRole('button', { name: 'New', exact: true }).click(); + await page + .locator('.project-actions input[type="file"]') + .setInputFiles(projectPath as string); + await expect(page.locator('.timeline-clip')).toHaveCount(2); + await expect(page.locator('.media-card')).toHaveCount(0); + + page.on('dialog', (dialog) => void dialog.accept()); + await mediaInput(page).setInputFiles([PRIMARY_FIXTURE, compatibleB]); + await expect(page.locator('.media-card .phase--ready')).toHaveCount(2, { + timeout: 120_000, + }); + await expect(page.locator('.timeline-clip')).toHaveCount(2); +}); + +function mediaInput(page: import('@playwright/test').Page) { + return page.locator('input[type="file"][accept*="audio"]').first(); +} + +async function dropFixture( + page: import('@playwright/test').Page, + filePath: string, + mimeType: string +): Promise { + const bytes = readFileSync(filePath).toString('base64'); + const stats = statSync(filePath); + await page + .locator('.drop-zone') + .first() + .evaluate( + (element, fixture) => { + const binary = atob(fixture.base64); + const data = Uint8Array.from(binary, (character) => + character.charCodeAt(0) + ); + const transfer = new DataTransfer(); + transfer.items.add( + new File([data], fixture.name, { + type: fixture.mimeType, + lastModified: fixture.lastModified, + }) + ); + element.dispatchEvent( + new DragEvent('dragenter', { + bubbles: true, + cancelable: true, + dataTransfer: transfer, + }) + ); + element.dispatchEvent( + new DragEvent('drop', { + bubbles: true, + cancelable: true, + dataTransfer: transfer, + }) + ); + }, + { + base64: bytes, + name: filePath.split('/').at(-1) ?? 'fixture.mp4', + mimeType, + lastModified: Math.trunc(stats.mtimeMs), + } + ); +} diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md new file mode 100644 index 0000000..89eca2c --- /dev/null +++ b/tests/fixtures/README.md @@ -0,0 +1,60 @@ +# Generated test fixtures + +The binary fixtures in `generated/` contain synthetic test patterns, solid +colours, sine waves, short subtitle strings and deliberately malformed bytes. +They contain no downloaded or recorded media. Native FFmpeg is a +development/test-generation tool only; the browser application never invokes +it. + +Regenerate the exact fixture set with: + +```sh +npm run fixtures:generate +# or, to replace the known generated set: +scripts/generate-fixtures.sh --force +``` + +The generator records the native FFmpeg first-version line when it runs. +The checked-in bytes were generated on 2026-07-24 with FFmpeg 7.1.3 on +Freedesktop SDK 25.08. Output may differ with another FFmpeg build; review and +update this inventory and `generated/SHA256SUMS` when intentionally +regenerating. Regeneration requires native H.264/x264, VP8/libvpx, +Vorbis/libvorbis, MP3/LAME, AAC, FLAC and PNG encoders. The fixture data is +dedicated to the public domain under +CC0-1.0; this statement applies only to `tests/fixtures/generated/`, not to the +application, which is licensed separately under GPL-3.0-or-later. + +## Inventory + +The exact native argument vectors are in +[`scripts/generate-fixtures.sh`](../../scripts/generate-fixtures.sh). “Generated +by” below identifies the relevant source and encoding operation. Every fixture +is intentionally tiny and exists to exercise a bounded behavior, not to +establish broad codec compatibility. + +All rows have licence **CC0-1.0 (fixture data only)**. + +| Fixture | SHA-256 | Expected duration and streams | Expected metadata | Generated by / purpose | +| -------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| `pattern-av.mp4` | `3bd192d1b6cc9f1adad0cdbadbe82c61a5ae1789ce99102fcfb2011f1e1e9927` | 2.000 s; H.264 160×90 at 12 fps; mono AAC 16 kHz | title `Generated AV fixture` | `testsrc2` + 440 Hz `sine`, x264/AAC; primary import, probe, preview and conversion smoke | +| `compatible-a.mp4` | `93f5c81e90e592217b7d98204fbc5631602055f782659a44390db64c6f125fd2` | 1.000 s; H.264 128×72 at 10 fps; mono AAC 16 kHz | no semantic tags | red `color` + 330 Hz `sine`, x264/AAC; first stream-copy concat clip | +| `compatible-b.mp4` | `34a49f3f0dbfa3c90562524c1762e360fddce55de8e1fa2ead3a405927c2eff2` | 1.000 s; H.264 128×72 at 10 fps; mono AAC 16 kHz | no semantic tags | blue `color` + 550 Hz `sine`, same settings as A; second stream-copy concat clip | +| `different-dimensions.mp4` | `018be12c1c13ca2b196b3e30996d712cc3174ddb5a2225e65c0acf25b94ca20d` | 1.000 s; H.264 96×96 at 10 fps; mono AAC 16 kHz | no semantic tags | `testsrc2` + 660 Hz `sine`; incompatible concat, crop and resize | +| `tone.wav` | `0fd1f54d6be2f1cc7fd778775dd29cf184770d3716cf3394bf03ed54ac264c3c` | 1.000 s; mono PCM signed 16-bit LE, 16 kHz | title `Generated tone` | 440 Hz `sine`, PCM; waveform and lossless audio output | +| `tone.mp3` | `68e45aefbe931a1ec3a9566713c80da64e58e379efe8ef86e8a7b846391334ce` | 1.080 s; mono MP3, 16 kHz | title `Generated tone` | 440 Hz `sine`, LAME 32 kbit/s; MP3 input/output | +| `tone.flac` | `3f27e37b4d4c17c7ac1c3131c21fbe561c0d4637610e615fc96ff12b2870a5f8` | 1.000 s; mono FLAC, 16 kHz | title `Generated tone` | 440 Hz `sine`, FLAC level 5; FLAC input/output | +| `pattern.webm` | `0613c85e11dffd72f166a7cdba03b57e9b293acd9edb8b308d4ec0feaf19ff8e` | 1.016 s; VP8 128×72 at 10 fps; mono Vorbis 16 kHz | title `Generated WebM fixture` | `testsrc2` + 440 Hz `sine`, libvpx/Vorbis; WebM and alternate-codec path | +| `subtitle.srt` | `f36e5e9dd09da79774e7d4288bc177a273e63bc678132a23ee0b756184dd0f91` | cue 0.100–0.700 s; UTF-8 text | cue text `Generated subtitle` | literal generated text; SRT import and shift | +| `subtitle.vtt` | `6e19fc5920479512739e126433c7d80fbd168fc8a34786a30d2e270f48da8bff` | cue 0.100–0.700 s; UTF-8 text | cue text `Generated subtitle` | literal generated text; WebVTT import and shift | +| `subtitle.ass` | `cb1c44b5808a5aaa173d756d0b819b282138f73ce50ca9c883ee377eb646ddd8` | dialogue 0.100–0.700 s; UTF-8 text | 128×72 script, local generic `sans-serif` style | literal generated text; ASS parsing and local-font burn-in path | +| `metadata.mp4` | `372d81c7bcaae67c09042fda95860902eb728374301ae97848e536a27b065dcd` | 1.000 s; same H.264/AAC streams as `compatible-a.mp4` | title `Metadata fixture`; artist `add ideas test generator`; comment `Local generated media` | stream-copy remux of A with tags; metadata round trip/removal | +| `chapters.mp4` | `4f1b2bd90d1a041c90d325805e42a76518b6569f93715dbd711773cdedfb1222` | 1.000 s; H.264, AAC and MP4 chapter-data stream; chapters 0–0.5 s and 0.5–1.0 s | title `Chapter fixture`; chapter titles `First`, `Second` | stream-copy remux of B with generated FFMETADATA; chapter inspection/export | +| `attached-cover.mp3` | `62fe9916406bbd9b25213f1aede0791ad382b235e425a42ea463f58c403588c3` | 1.080 s; mono MP3 plus 32×32 PNG with `attached_pic=1` | title `Attached cover fixture`; cover title/comment | 440 Hz `sine` + generated yellow `color` PNG; attachment/cover-art stream handling | +| `truncated.mp4` | `c7e91f4a3d1e67bc1bcba2e7511299de5bda2867ba886d1bec786d1f60cfe93a` | invalid: first 128 bytes of `compatible-a.mp4`; no usable streams | none expected | bounded `head -c 128`; safe malformed-input diagnostics and cleanup | + +`generated/SHA256SUMS` is the canonical machine-checkable copy of these +digests. Verify it from its directory: + +```sh +(cd tests/fixtures/generated && sha256sum -c SHA256SUMS) +``` diff --git a/tests/fixtures/generated/SHA256SUMS b/tests/fixtures/generated/SHA256SUMS new file mode 100644 index 0000000..e336316 --- /dev/null +++ b/tests/fixtures/generated/SHA256SUMS @@ -0,0 +1,15 @@ +62fe9916406bbd9b25213f1aede0791ad382b235e425a42ea463f58c403588c3 attached-cover.mp3 +4f1b2bd90d1a041c90d325805e42a76518b6569f93715dbd711773cdedfb1222 chapters.mp4 +93f5c81e90e592217b7d98204fbc5631602055f782659a44390db64c6f125fd2 compatible-a.mp4 +34a49f3f0dbfa3c90562524c1762e360fddce55de8e1fa2ead3a405927c2eff2 compatible-b.mp4 +018be12c1c13ca2b196b3e30996d712cc3174ddb5a2225e65c0acf25b94ca20d different-dimensions.mp4 +372d81c7bcaae67c09042fda95860902eb728374301ae97848e536a27b065dcd metadata.mp4 +3bd192d1b6cc9f1adad0cdbadbe82c61a5ae1789ce99102fcfb2011f1e1e9927 pattern-av.mp4 +0613c85e11dffd72f166a7cdba03b57e9b293acd9edb8b308d4ec0feaf19ff8e pattern.webm +cb1c44b5808a5aaa173d756d0b819b282138f73ce50ca9c883ee377eb646ddd8 subtitle.ass +f36e5e9dd09da79774e7d4288bc177a273e63bc678132a23ee0b756184dd0f91 subtitle.srt +6e19fc5920479512739e126433c7d80fbd168fc8a34786a30d2e270f48da8bff subtitle.vtt +3f27e37b4d4c17c7ac1c3131c21fbe561c0d4637610e615fc96ff12b2870a5f8 tone.flac +68e45aefbe931a1ec3a9566713c80da64e58e379efe8ef86e8a7b846391334ce tone.mp3 +0fd1f54d6be2f1cc7fd778775dd29cf184770d3716cf3394bf03ed54ac264c3c tone.wav +c7e91f4a3d1e67bc1bcba2e7511299de5bda2867ba886d1bec786d1f60cfe93a truncated.mp4 diff --git a/tests/fixtures/generated/attached-cover.mp3 b/tests/fixtures/generated/attached-cover.mp3 new file mode 100644 index 0000000..dc5bcaf Binary files /dev/null and b/tests/fixtures/generated/attached-cover.mp3 differ diff --git a/tests/fixtures/generated/chapters.mp4 b/tests/fixtures/generated/chapters.mp4 new file mode 100644 index 0000000..640edc5 Binary files /dev/null and b/tests/fixtures/generated/chapters.mp4 differ diff --git a/tests/fixtures/generated/compatible-a.mp4 b/tests/fixtures/generated/compatible-a.mp4 new file mode 100644 index 0000000..cc7f65a Binary files /dev/null and b/tests/fixtures/generated/compatible-a.mp4 differ diff --git a/tests/fixtures/generated/compatible-b.mp4 b/tests/fixtures/generated/compatible-b.mp4 new file mode 100644 index 0000000..8fff346 Binary files /dev/null and b/tests/fixtures/generated/compatible-b.mp4 differ diff --git a/tests/fixtures/generated/different-dimensions.mp4 b/tests/fixtures/generated/different-dimensions.mp4 new file mode 100644 index 0000000..92b8f56 Binary files /dev/null and b/tests/fixtures/generated/different-dimensions.mp4 differ diff --git a/tests/fixtures/generated/metadata.mp4 b/tests/fixtures/generated/metadata.mp4 new file mode 100644 index 0000000..4e75b9d Binary files /dev/null and b/tests/fixtures/generated/metadata.mp4 differ diff --git a/tests/fixtures/generated/pattern-av.mp4 b/tests/fixtures/generated/pattern-av.mp4 new file mode 100644 index 0000000..3517888 Binary files /dev/null and b/tests/fixtures/generated/pattern-av.mp4 differ diff --git a/tests/fixtures/generated/pattern.webm b/tests/fixtures/generated/pattern.webm new file mode 100644 index 0000000..33b2179 Binary files /dev/null and b/tests/fixtures/generated/pattern.webm differ diff --git a/tests/fixtures/generated/subtitle.ass b/tests/fixtures/generated/subtitle.ass new file mode 100644 index 0000000..6a4ea0f --- /dev/null +++ b/tests/fixtures/generated/subtitle.ass @@ -0,0 +1,12 @@ +[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 diff --git a/tests/fixtures/generated/subtitle.srt b/tests/fixtures/generated/subtitle.srt new file mode 100644 index 0000000..a57a87d --- /dev/null +++ b/tests/fixtures/generated/subtitle.srt @@ -0,0 +1,3 @@ +1 +00:00:00,100 --> 00:00:00,700 +Generated subtitle diff --git a/tests/fixtures/generated/subtitle.vtt b/tests/fixtures/generated/subtitle.vtt new file mode 100644 index 0000000..2f2b969 --- /dev/null +++ b/tests/fixtures/generated/subtitle.vtt @@ -0,0 +1,4 @@ +WEBVTT + +00:00:00.100 --> 00:00:00.700 +Generated subtitle diff --git a/tests/fixtures/generated/tone.flac b/tests/fixtures/generated/tone.flac new file mode 100644 index 0000000..dd79a76 Binary files /dev/null and b/tests/fixtures/generated/tone.flac differ diff --git a/tests/fixtures/generated/tone.mp3 b/tests/fixtures/generated/tone.mp3 new file mode 100644 index 0000000..ddec42c Binary files /dev/null and b/tests/fixtures/generated/tone.mp3 differ diff --git a/tests/fixtures/generated/tone.wav b/tests/fixtures/generated/tone.wav new file mode 100644 index 0000000..bc63151 Binary files /dev/null and b/tests/fixtures/generated/tone.wav differ diff --git a/tests/fixtures/generated/truncated.mp4 b/tests/fixtures/generated/truncated.mp4 new file mode 100644 index 0000000..83e51d1 Binary files /dev/null and b/tests/fixtures/generated/truncated.mp4 differ diff --git a/tests/integration/command-runtime-native.test.ts b/tests/integration/command-runtime-native.test.ts new file mode 100644 index 0000000..9434e1a --- /dev/null +++ b/tests/integration/command-runtime-native.test.ts @@ -0,0 +1,1273 @@ +import { spawnSync } from 'node:child_process'; +import { + copyFileSync, + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, extname, join, resolve } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { + buildAudioExtractionPlan, + buildChapterMuxPlan, + buildContactSheetPlan, + buildConvertPlan, + buildFadePlan, + buildFastConcatPlan, + buildLoudnessAnalysisPlan, + buildLoudnessApplyPlan, + buildMetadataEditPlan, + buildNormalizedConcatPlan, + buildPeakAnalysisPlan, + buildPeakNormalizationFilter, + buildPeakNormalizationPlan, + buildPreviewProxyPlan, + buildRemuxPlan, + buildSingleThumbnailPlan, + buildSplitPlan, + buildStaticWaveformPlan, + buildStreamRemovalPlan, + buildSubtitleBurnPlan, + buildSubtitleConversionPlan, + buildSubtitleExtractionPlan, + buildSubtitleMuxPlan, + buildThumbnailSeriesPlan, + buildTimelineExportPlan, + buildTransformPlan, + buildTrimPlan, + buildWaveformAnalysisPlan, + createConcatListEntries, + parseLoudnessMeasurement, + parsePeakMeasurement, + resolveThumbnailTimes, + streamMapArguments, + type ConcatSource, + type FFmpegCommandPlan, + type SourceReference, +} from '../../src/commands'; +import { findBuiltInPreset } from '../../src/presets'; + +const FIXTURES = resolve(process.cwd(), 'tests/fixtures/generated'); +const NATIVE_FFMPEG_AVAILABLE = + spawnSync('ffmpeg', ['-version'], { stdio: 'ignore' }).status === 0 && + spawnSync('ffprobe', ['-version'], { stdio: 'ignore' }).status === 0; +const NATIVE_SUBTITLES_FILTER_AVAILABLE = + NATIVE_FFMPEG_AVAILABLE && + /\bsubtitles\b/u.test( + spawnSync('ffmpeg', ['-hide_banner', '-filters'], { + encoding: 'utf8', + }).stdout ?? '' + ); +const describeNative = NATIVE_FFMPEG_AVAILABLE ? describe : describe.skip; +const itNativeSubtitles = NATIVE_SUBTITLES_FILTER_AVAILABLE ? it : it.skip; + +interface NativeExecution { + readonly outputPaths: readonly string[]; + readonly stdout: string; + readonly stderr: string; +} + +let suiteDirectory = ''; +let executionSequence = 0; + +beforeAll(() => { + suiteDirectory = mkdtempSync(join(tmpdir(), 'av-tools-command-runtime-')); +}); + +afterAll(() => { + if (suiteDirectory) { + rmSync(suiteDirectory, { recursive: true, force: true }); + } +}); + +function fixture(name: string): string { + const path = join(FIXTURES, name); + if (!existsSync(path)) { + throw new Error(`Missing generated fixture: ${name}`); + } + return path; +} + +function source( + id: string, + sourceIndex: number, + fileName: string +): SourceReference { + return { id, sourceIndex, fileName }; +} + +function runNative( + plan: FFmpegCommandPlan, + sourceFiles: Readonly>, + logLevel: 'warning' | 'info' = 'warning' +): NativeExecution { + executionSequence += 1; + const directory = join( + suiteDirectory, + `${String(executionSequence).padStart(3, '0')}-${plan.operation}` + ); + mkdirSync(directory, { recursive: true }); + + const replacements = new Map(); + plan.inputs.forEach((input, index) => { + const sourcePath = sourceFiles[input.id]; + if (!sourcePath) { + throw new Error(`No fixture was supplied for planned input ${input.id}`); + } + const extension = extname(sourcePath) || '.bin'; + const inputPath = join( + directory, + `input-${String(index).padStart(3, '0')}${extension}` + ); + copyFileSync(sourcePath, inputPath); + replacements.set(input.path, inputPath); + }); + const outputPaths = plan.outputs.map((output, index) => { + const outputPath = join( + directory, + `${String(index).padStart(3, '0')}-${output.fileName}` + ); + replacements.set(output.path, outputPath); + return outputPath; + }); + const temporaryPaths = plan.temporaryFiles.map((temporary, index) => { + const extension = extname(temporary.path) || '.tmp'; + const temporaryPath = join( + directory, + `temporary-${String(index).padStart(3, '0')}${extension}` + ); + replacements.set(temporary.path, temporaryPath); + return temporaryPath; + }); + const substitute = (value: string): string => { + let result = value; + for (const [virtualPath, nativePath] of [...replacements.entries()].sort( + ([left], [right]) => right.length - left.length + )) { + result = result.replaceAll(virtualPath, nativePath); + } + return result; + }; + plan.temporaryFiles.forEach((temporary, index) => { + const path = temporaryPaths[index]; + if (!path) { + throw new Error(`Missing native temporary path ${index}`); + } + if (typeof temporary.content === 'string') { + writeFileSync(path, substitute(temporary.content), 'utf8'); + } else { + writeFileSync(path, temporary.content); + } + }); + + const execution = spawnSync( + 'ffmpeg', + [ + '-hide_banner', + '-nostdin', + '-y', + '-loglevel', + logLevel, + ...plan.args.map(substitute), + ], + { + encoding: 'utf8', + maxBuffer: 20 * 1024 * 1024, + timeout: 60_000, + } + ); + const stdout = execution.stdout ?? ''; + const stderr = execution.stderr ?? ''; + if (execution.status !== 0) { + throw new Error( + [ + `Native FFmpeg rejected ${plan.operation} (status ${String(execution.status)}, signal ${String(execution.signal)}).`, + `argv: ${plan.args.map(substitute).join(' ')}`, + stderr, + stdout, + ].join('\n') + ); + } + for (const path of outputPaths) { + expect(statSync(path).size, basename(path)).toBeGreaterThan(0); + } + return { outputPaths, stdout, stderr }; +} + +function nativeCommand(args: readonly string[], outputPath: string): void { + const execution = spawnSync( + 'ffmpeg', + ['-hide_banner', '-nostdin', '-y', '-loglevel', 'error', ...args], + { + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, + timeout: 60_000, + } + ); + if (execution.status !== 0) { + throw new Error(execution.stderr || `Could not create ${outputPath}`); + } + expect(statSync(outputPath).size).toBeGreaterThan(0); +} + +function probe(path: string): { + readonly streams?: readonly { + readonly index?: number; + readonly codec_name?: string; + readonly codec_type?: string; + readonly width?: number; + readonly height?: number; + readonly tags?: Readonly>; + readonly disposition?: Readonly>; + }[]; + readonly chapters?: readonly { + readonly start_time?: string; + readonly end_time?: string; + readonly tags?: Readonly>; + }[]; + readonly format?: { + readonly duration?: string; + readonly tags?: Readonly>; + }; +} { + const result = spawnSync( + 'ffprobe', + [ + '-v', + 'error', + '-show_streams', + '-show_chapters', + '-show_format', + '-of', + 'json', + path, + ], + { encoding: 'utf8', timeout: 20_000 } + ); + if (result.status !== 0) { + throw new Error(result.stderr || `Could not probe ${path}`); + } + return JSON.parse(result.stdout) as ReturnType; +} + +function preset(id: string) { + const result = findBuiltInPreset(id); + if (!result) { + throw new Error(`Missing built-in preset ${id}`); + } + return result; +} + +function concatSource( + id: string, + sourceIndex: number, + fileName: string, + width: number, + height: number, + hasAudio = true +): ConcatSource { + return { + id, + sourceIndex, + fileName, + durationSeconds: 1, + streams: [ + { + kind: 'video', + codec: 'h264', + width, + height, + pixelFormat: 'yuv420p', + timeBase: '1/10240', + }, + ...(hasAudio + ? [ + { + kind: 'audio' as const, + codec: 'aac', + sampleRate: 16_000, + channelLayout: 'mono', + timeBase: '1/16000', + }, + ] + : []), + ], + }; +} + +describe('command-plan runtime hardening', () => { + it('serializes validated ffconcat in/out directives without source names', () => { + expect( + createConcatListEntries([ + { + path: '/input/job-safe/source-0.mp4', + inPointSeconds: 0.25, + outPointSeconds: 0.75, + }, + ]) + ).toBe( + "file '/input/job-safe/source-0.mp4'\ninpoint 0.25\noutpoint 0.75\n" + ); + expect(() => + createConcatListEntries([ + { + path: '/input/job-safe/source-0.mp4', + inPointSeconds: 1, + outPointSeconds: 0.5, + }, + ]) + ).toThrow(/outpoint/iu); + }); + + it('bounds and uniquely names split outputs and gives fast segments independent inputs', () => { + const media = source('media', 0, 'pattern-av.mp4'); + const plan = buildSplitPlan({ + jobId: 'split-hardening', + source: media, + durationSeconds: 2, + definition: { + type: 'clips', + clips: [ + { startSeconds: 0, endSeconds: 0.8, label: 'same' }, + { startSeconds: 1, endSeconds: 1.8, label: 'same' }, + ], + }, + mode: 'fast', + targetExtension: 'mp4', + }); + expect(plan.outputs.map((output) => output.fileName)).toEqual([ + 'pattern-av-split-001-same.mp4', + 'pattern-av-split-002-same.mp4', + ]); + expect(plan.args.filter((argument) => argument === '-i')).toHaveLength(2); + expect(() => + buildSplitPlan({ + jobId: 'too-many-splits', + source: media, + durationSeconds: 100, + definition: { + type: 'equal-duration', + segmentDurationSeconds: 0.1, + }, + mode: 'fast', + targetExtension: 'mp4', + }) + ).toThrow(/at most 255/iu); + }); + + it('rejects container mismatches, ambiguous stream groups, and unsafe transform values', () => { + expect(() => + buildMetadataEditPlan({ + jobId: 'muxer-mismatch', + source: source('media', 0, 'pattern-av.mp4'), + edits: {}, + targetExtension: 'mkv', + targetMuxer: 'mp4', + }) + ).toThrow(/does not match/iu); + expect(() => streamMapArguments({ video: [0], audio: [0] })).toThrow( + /more than one stream group/iu + ); + expect(() => + buildTransformPlan({ + jobId: 'fractional-resize', + source: source('media', 0, 'pattern-av.mp4'), + preset: preset('mp4-h264-balanced'), + settings: { + resize: { + sourceWidth: 160, + sourceHeight: 90, + width: 80.5, + mode: 'fit', + }, + }, + }) + ).toThrow(/integers/iu); + expect(() => + buildTransformPlan({ + jobId: 'filter-injection', + source: source('media', 0, 'pattern-av.mp4'), + preset: preset('mp4-h264-balanced'), + settings: { + resize: { + sourceWidth: 160, + sourceHeight: 90, + width: 80, + height: 80, + mode: 'contain', + paddingColor: 'black,scale=1:1', + }, + }, + }) + ).toThrow(/padding colour/iu); + }); + + it('requires explicit subtitle loss and bounds generated image counts', () => { + const sources = [ + { + ...concatSource('first', 0, 'compatible-a.mp4', 128, 72), + streams: [ + ...concatSource('first', 0, 'compatible-a.mp4', 128, 72).streams, + { kind: 'subtitle' as const, codec: 'mov_text', timeBase: '1/1000' }, + ], + }, + concatSource('second', 1, 'compatible-b.mp4', 128, 72), + ]; + const options = { + jobId: 'subtitle-concat-policy', + sources, + targetExtension: 'mp4', + targetMuxer: 'mp4', + preset: preset('mp4-h264-balanced'), + width: 128, + height: 72, + frameRate: 10, + missingAudioPolicy: 'reject' as const, + }; + expect(() => buildNormalizedConcatPlan(options)).toThrow( + /cannot retain subtitle/iu + ); + expect( + buildNormalizedConcatPlan({ + ...options, + subtitlePolicy: 'drop-all', + }).diagnostics + ).toContainEqual(expect.objectContaining({ code: 'dropped-subtitles' })); + expect(() => + resolveThumbnailTimes({ + durationSeconds: 1000, + intervalSeconds: 1, + }) + ).toThrow(/at most 255/iu); + }); + + it('requires finite measured peaks and validates subtitle stream indexes', () => { + expect(() => parsePeakMeasurement('max_volume: -inf dB')).toThrow( + /silent audio/iu + ); + expect(() => + buildPeakNormalizationFilter(-1, { inputPeakDbfs: -80 }) + ).toThrow(/more than 60 dB/iu); + expect(() => + buildSubtitleExtractionPlan({ + jobId: 'invalid-subtitle-index', + source: source('media', 0, 'pattern-av.mp4'), + stream: { index: -1, codec: 'subrip' }, + outputFormat: 'srt', + }) + ).toThrow(/stream index/iu); + }); + + it('makes the contact-sheet aspect toggle affect the typed filter graph', () => { + const plan = buildContactSheetPlan({ + jobId: 'square-contact-cells', + source: source('media', 0, 'pattern-av.mp4'), + durationSeconds: 2, + frameCount: 4, + rows: 2, + columns: 2, + cellWidth: 80, + maintainAspect: false, + format: 'png', + }); + expect(plan.args.find((argument) => argument.includes('tile='))).toContain( + 'scale=80:80' + ); + }); +}); + +describeNative('native execution of structured command plans', () => { + it('executes conversion, remux, fast/accurate trim, transform, fades, and preview plans', () => { + const media = source('media', 0, 'pattern-av.mp4'); + const mediaFile = fixture('pattern-av.mp4'); + const h264 = preset('mp4-h264-balanced'); + + const converted = runNative( + buildConvertPlan({ + jobId: 'convert-native', + source: media, + preset: h264, + streamSelection: { video: [0], audio: [1] }, + expectedDurationSeconds: 2, + }), + { media: mediaFile } + ); + expect(probe(converted.outputPaths[0]!).streams?.map(streamType)).toEqual([ + 'video:h264', + 'audio:aac', + ]); + + const gif = runNative( + buildConvertPlan({ + jobId: 'gif-native', + source: media, + preset: preset('animated-gif'), + streamSelection: { video: [0] }, + }), + { media: mediaFile } + ); + expect(probe(gif.outputPaths[0]!).streams?.[0]?.codec_name).toBe('gif'); + + const attachedPictureGif = runNative( + buildConvertPlan({ + jobId: 'gif-absolute-stream-native', + source: source('cover-audio', 0, 'attached-cover.mp3'), + preset: preset('animated-gif'), + streamSelection: { video: [1] }, + }), + { 'cover-audio': fixture('attached-cover.mp3') } + ); + expect( + probe(attachedPictureGif.outputPaths[0]!).streams?.[0]?.codec_name + ).toBe('gif'); + + const remuxed = runNative( + buildRemuxPlan({ + jobId: 'remux-native', + source: media, + targetContainer: 'matroska', + fileExtension: 'mkv', + description: { + streams: [ + { index: 0, kind: 'video', codec: 'h264' }, + { index: 1, kind: 'audio', codec: 'aac' }, + ], + }, + expectedDurationSeconds: 2, + }), + { media: mediaFile } + ); + expect(probe(remuxed.outputPaths[0]!).streams).toHaveLength(2); + + const fastTrim = runNative( + buildTrimPlan({ + jobId: 'trim-fast-native', + source: media, + mode: 'fast', + startSeconds: 0.4, + endSeconds: 1.6, + sourceDurationSeconds: 2, + targetExtension: 'mp4', + }), + { media: mediaFile } + ); + expect( + Number(probe(fastTrim.outputPaths[0]!).format?.duration) + ).toBeGreaterThan(0); + + const accurateTrim = runNative( + buildTrimPlan({ + jobId: 'trim-accurate-native', + source: media, + mode: 'accurate', + startSeconds: 0.4, + endSeconds: 1.6, + sourceDurationSeconds: 2, + preset: h264, + }), + { media: mediaFile } + ); + expect( + Number(probe(accurateTrim.outputPaths[0]!).format?.duration) + ).toBeGreaterThanOrEqual(1.15); + expect( + Number(probe(accurateTrim.outputPaths[0]!).format?.duration) + ).toBeLessThanOrEqual(1.3); + + const transformed = runNative( + buildTransformPlan({ + jobId: 'transform-native', + source: media, + preset: h264, + expectedDurationSeconds: 2, + settings: { + crop: { + x: 0, + y: 0, + width: 120, + height: 80, + sourceWidth: 160, + sourceHeight: 90, + requireEvenDimensions: true, + }, + resize: { + sourceWidth: 120, + sourceHeight: 80, + width: 96, + height: 72, + mode: 'contain', + }, + rotation: 180, + frameRate: 8, + }, + }), + { media: mediaFile } + ); + expect(probe(transformed.outputPaths[0]!).streams?.[0]).toMatchObject({ + codec_type: 'video', + width: 96, + height: 72, + }); + + runNative( + buildFadePlan({ + jobId: 'fades-native', + source: media, + preset: h264, + durationSeconds: 2, + audioFadeInSeconds: 0.2, + audioFadeOutSeconds: 0.2, + videoFadeInSeconds: 0.2, + videoFadeOutSeconds: 0.2, + }), + { media: mediaFile } + ); + + const preview = runNative( + buildPreviewProxyPlan({ + jobId: 'preview-native', + source: media, + sourceDurationSeconds: 2, + startSeconds: 0.25, + maxDurationSeconds: 1, + maxWidth: 160, + }), + { media: mediaFile } + ); + expect( + Number(probe(preview.outputPaths[0]!).format?.duration) + ).toBeLessThanOrEqual(1.1); + + const audioPreview = runNative( + buildPreviewProxyPlan({ + jobId: 'audio-preview-native', + source: source('tone', 0, 'tone.wav'), + sourceDurationSeconds: 1, + maxDurationSeconds: 0.5, + hasVideo: false, + }), + { tone: fixture('tone.wav') } + ); + expect(audioPreview.outputPaths[0]).toMatch(/\.m4a$/u); + expect(probe(audioPreview.outputPaths[0]!).streams?.[0]).toMatchObject({ + codec_type: 'audio', + codec_name: 'aac', + }); + }, 120_000); + + it('executes fast and accurate multi-output split plans', () => { + const media = source('media', 0, 'pattern-av.mp4'); + const mediaFile = fixture('pattern-av.mp4'); + for (const mode of ['fast', 'accurate'] as const) { + const plan = buildSplitPlan({ + jobId: `split-${mode}-native`, + source: media, + durationSeconds: 2, + definition: { type: 'markers', markers: [0.7, 1.3] }, + mode, + targetExtension: 'mp4', + ...(mode === 'accurate' ? { preset: preset('mp4-h264-balanced') } : {}), + }); + const execution = runNative(plan, { media: mediaFile }); + expect(execution.outputPaths).toHaveLength(3); + for (const path of execution.outputPaths) { + expect( + probe(path).streams?.some((stream) => stream.codec_type === 'video'), + `${mode} split output ${basename(path)} must retain video` + ).toBe(true); + } + } + }, 120_000); + + it('executes fast and normalized concat, including generated silence', () => { + const first = concatSource('first', 8, 'compatible-a.mp4', 128, 72); + const second = concatSource('second', 3, 'compatible-b.mp4', 128, 72); + const firstRange = { + ...first, + sourceInSeconds: 0.2, + sourceOutSeconds: 0.8, + }; + const secondRange = { + ...second, + sourceInSeconds: 0.1, + sourceOutSeconds: 0.9, + }; + const fastPlan = buildFastConcatPlan({ + jobId: 'fast-concat-native', + sources: [firstRange, secondRange], + targetExtension: 'mp4', + targetMuxer: 'mp4', + }); + expect(String(fastPlan.temporaryFiles[0]?.content)).toContain( + 'inpoint 0.2' + ); + expect(fastPlan.expectedDurationSeconds).toBeCloseTo(1.4, 5); + const fast = runNative(fastPlan, { + first: fixture('compatible-a.mp4'), + second: fixture('compatible-b.mp4'), + }); + expect( + Number(probe(fast.outputPaths[0]!).format?.duration) + ).toBeGreaterThan(1.2); + expect(Number(probe(fast.outputPaths[0]!).format?.duration)).toBeLessThan( + 2.1 + ); + + const normalized = runNative( + buildNormalizedConcatPlan({ + jobId: 'normalized-concat-native', + sources: [ + concatSource('first', 0, 'compatible-a.mp4', 128, 72), + { + ...concatSource('different', 1, 'different-dimensions.mp4', 96, 96), + sourceInSeconds: 0.2, + sourceOutSeconds: 0.8, + }, + ], + targetExtension: 'mp4', + targetMuxer: 'mp4', + preset: preset('mp4-h264-balanced'), + width: 128, + height: 72, + frameRate: 10, + sampleRate: 16_000, + channelLayout: 'mono', + missingAudioPolicy: 'reject', + }), + { + first: fixture('compatible-a.mp4'), + different: fixture('different-dimensions.mp4'), + } + ); + expect(probe(normalized.outputPaths[0]!).streams?.[0]).toMatchObject({ + width: 128, + height: 72, + }); + expect( + Number(probe(normalized.outputPaths[0]!).format?.duration) + ).toBeCloseTo(1.6, 1); + + const videoOnly = join(suiteDirectory, 'compatible-video-only.mp4'); + nativeCommand( + [ + '-i', + fixture('compatible-b.mp4'), + '-map', + '0:v:0', + '-c', + 'copy', + videoOnly, + ], + videoOnly + ); + const silence = runNative( + buildNormalizedConcatPlan({ + jobId: 'silence-concat-native', + sources: [ + concatSource('first', 0, 'compatible-a.mp4', 128, 72), + concatSource( + 'video-only', + 1, + 'compatible-video-only.mp4', + 128, + 72, + false + ), + ], + targetExtension: 'mp4', + targetMuxer: 'mp4', + preset: preset('mp4-h264-balanced'), + width: 128, + height: 72, + frameRate: 10, + sampleRate: 16_000, + channelLayout: 'mono', + missingAudioPolicy: 'insert-silence', + }), + { first: fixture('compatible-a.mp4'), 'video-only': videoOnly } + ); + expect( + probe(silence.outputPaths[0]!).streams?.some( + (stream) => stream.codec_type === 'audio' + ) + ).toBe(true); + }, 120_000); + + it('executes audio extraction, waveform, loudness, and normalization plans', () => { + const media = source('media', 0, 'pattern-av.mp4'); + const mediaFile = fixture('pattern-av.mp4'); + + const copiedAudio = runNative( + buildAudioExtractionPlan({ + jobId: 'audio-copy-native', + source: media, + audioStreamIndex: 1, + mode: 'copy', + inputCodec: 'aac', + targetMuxer: 'ipod', + targetExtension: 'm4a', + }), + { media: mediaFile } + ); + expect(probe(copiedAudio.outputPaths[0]!).streams?.[0]?.codec_name).toBe( + 'aac' + ); + + const encodedAudio = runNative( + buildAudioExtractionPlan({ + jobId: 'audio-encode-native', + source: media, + audioStreamIndex: 1, + mode: 'encode', + preset: preset('audio-wav-pcm'), + }), + { media: mediaFile } + ); + expect(probe(encodedAudio.outputPaths[0]!).streams?.[0]?.codec_name).toBe( + 'pcm_s16le' + ); + + runNative( + buildWaveformAnalysisPlan({ + jobId: 'waveform-data-native', + source: media, + audioStreamIndex: 1, + sampleRate: 8_000, + }), + { media: mediaFile } + ); + const staticWaveform = runNative( + buildStaticWaveformPlan({ + jobId: 'waveform-static-native', + source: media, + audioStreamIndex: 1, + width: 320, + height: 80, + color: '#ffffff', + background: '#000000', + }), + { media: mediaFile } + ); + expect(probe(staticWaveform.outputPaths[0]!).streams?.[0]).toMatchObject({ + codec_name: 'png', + width: 320, + height: 80, + }); + + const targets = { + integratedLufs: -16, + truePeakDbtp: -1, + loudnessRangeLu: 11, + }; + const analysis = runNative( + buildLoudnessAnalysisPlan({ + jobId: 'loudness-analysis-native', + source: media, + targets, + audioStreamIndex: 1, + }), + { media: mediaFile }, + 'info' + ); + const measurement = parseLoudnessMeasurement( + `${analysis.stderr}\n${analysis.stdout}` + ); + expect(measurement.inputIntegratedLufs).toBeTypeOf('number'); + + runNative( + buildLoudnessApplyPlan({ + jobId: 'loudness-apply-native', + source: media, + targets, + audioStreamIndex: 1, + measurement, + preset: preset('audio-m4a-aac'), + }), + { media: mediaFile } + ); + const peakAnalysis = runNative( + buildPeakAnalysisPlan({ + jobId: 'peak-analysis-native', + source: media, + audioStreamIndex: 1, + preset: preset('audio-m4a-aac'), + }), + { media: mediaFile }, + 'info' + ); + const peakMeasurement = parsePeakMeasurement( + `${peakAnalysis.stderr}\n${peakAnalysis.stdout}` + ); + expect(peakMeasurement.inputPeakDbfs).toBeLessThanOrEqual(0); + const peakApplied = runNative( + buildPeakNormalizationPlan({ + jobId: 'peak-native', + source: media, + audioStreamIndex: 1, + targetPeakDb: -1, + measurement: peakMeasurement, + preset: preset('audio-m4a-aac'), + }), + { media: mediaFile } + ); + const verifiedPeak = runNative( + buildPeakAnalysisPlan({ + jobId: 'peak-verify-native', + source: source('normalized', 0, 'peak-normalized.m4a'), + audioStreamIndex: 0, + preset: preset('audio-m4a-aac'), + }), + { normalized: peakApplied.outputPaths[0]! }, + 'info' + ); + const outputPeak = parsePeakMeasurement( + `${verifiedPeak.stderr}\n${verifiedPeak.stdout}` + ).inputPeakDbfs; + expect(outputPeak).toBeGreaterThanOrEqual(-2); + expect(outputPeak).toBeLessThanOrEqual(0); + }, 120_000); + + it('executes stream removal, metadata editing, and chapter mux plans', () => { + const media = source('media', 0, 'metadata.mp4'); + const mediaFile = fixture('metadata.mp4'); + const removed = runNative( + buildStreamRemovalPlan({ + jobId: 'streams-native', + source: media, + selection: { audio: [1] }, + targetExtension: 'm4a', + targetMuxer: 'ipod', + }), + { media: mediaFile } + ); + expect(probe(removed.outputPaths[0]!).streams?.map(streamType)).toEqual([ + 'audio:aac', + ]); + + const metadataPlan = buildMetadataEditPlan({ + jobId: 'metadata-native', + source: media, + edits: { + format: { title: 'Runtime title', artist: 'Local artist' }, + streams: { 1: { language: 'deu' } }, + dispositions: { 0: [], 1: ['forced'] }, + }, + targetExtension: 'mp4', + targetMuxer: 'mp4', + sourceMetadataPolicy: 'remove', + chapterPolicy: 'remove', + }); + expect(metadataPlan.args).toEqual( + expect.arrayContaining(['-disposition:0', '0']) + ); + const metadata = runNative(metadataPlan, { media: mediaFile }); + expect(probe(metadata.outputPaths[0]!).format?.tags).toMatchObject({ + title: 'Runtime title', + artist: 'Local artist', + }); + expect(probe(metadata.outputPaths[0]!).streams).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + index: 1, + disposition: expect.objectContaining({ forced: 1 }), + }), + ]) + ); + + const chaptered = runNative( + buildChapterMuxPlan({ + jobId: 'chapters-native', + source: source('media', 0, 'pattern-av.mp4'), + chapters: [ + { + id: 'intro', + title: 'Intro', + startSeconds: 0, + endSeconds: 0.75, + timeBase: '1/48000', + }, + { + id: 'main', + title: 'Main', + startSeconds: 0.75, + endSeconds: 2, + timeBase: '1/1000', + }, + ], + targetExtension: 'mp4', + targetMuxer: 'mp4', + durationSeconds: 2, + }), + { media: fixture('pattern-av.mp4') } + ); + const chapterProbe = probe(chaptered.outputPaths[0]!).chapters; + expect(chapterProbe?.map(chapterTitle)).toEqual(['Intro', 'Main']); + expect(Number(chapterProbe?.[0]?.end_time)).toBeCloseTo(0.75, 3); + expect(Number(chapterProbe?.[1]?.start_time)).toBeCloseTo(0.75, 3); + }, 120_000); + + it('executes subtitle extraction, conversion, mux, and burn-in plans', () => { + const subtitledSourcePath = join(suiteDirectory, 'subtitled-source.mp4'); + nativeCommand( + [ + '-i', + fixture('pattern-av.mp4'), + '-i', + fixture('subtitle.srt'), + '-map', + '0', + '-map', + '1:s:0', + '-c', + 'copy', + '-c:s', + 'mov_text', + subtitledSourcePath, + ], + subtitledSourcePath + ); + + const subtitledSource = source('media', 0, 'subtitled-source.mp4'); + const convertedMedia = runNative( + buildConvertPlan({ + jobId: 'convert-selected-subtitle-native', + source: subtitledSource, + preset: preset('mp4-h264-balanced'), + streamSelection: { video: [0], audio: [1], subtitles: [2] }, + }), + { media: subtitledSourcePath } + ); + expect( + probe(convertedMedia.outputPaths[0]!).streams?.map(streamType) + ).toEqual(['video:h264', 'audio:aac', 'subtitle:mov_text']); + + const extracted = runNative( + buildSubtitleExtractionPlan({ + jobId: 'subtitle-extract-native', + source: subtitledSource, + stream: { index: 2, codec: 'mov_text', language: 'eng' }, + outputFormat: 'srt', + }), + { media: subtitledSourcePath } + ); + expect(readFileSync(extracted.outputPaths[0]!, 'utf8')).toContain( + 'Generated subtitle' + ); + + const externalSubtitle = source('subtitle', 1, 'subtitle.srt'); + const converted = runNative( + buildSubtitleConversionPlan({ + jobId: 'subtitle-convert-native', + subtitle: externalSubtitle, + outputFormat: 'vtt', + offsetSeconds: 0.1, + }), + { subtitle: fixture('subtitle.srt') } + ); + expect(readFileSync(converted.outputPaths[0]!, 'utf8')).toContain('WEBVTT'); + + const muxed = runNative( + buildSubtitleMuxPlan({ + jobId: 'subtitle-mux-native', + source: subtitledSource, + subtitle: externalSubtitle, + subtitleCodec: 'subrip', + sourceStreams: [ + { index: 0, kind: 'video', codec: 'h264' }, + { index: 1, kind: 'audio', codec: 'aac' }, + { index: 2, kind: 'subtitle', codec: 'mov_text' }, + ], + targetContainer: 'mp4', + targetExtension: 'mp4', + language: 'eng', + title: 'English', + default: true, + }), + { + media: subtitledSourcePath, + subtitle: fixture('subtitle.srt'), + } + ); + expect( + probe(muxed.outputPaths[0]!).streams?.filter( + (stream) => stream.codec_name === 'mov_text' + ) + ).toHaveLength(2); + expect( + probe(muxed.outputPaths[0]!).streams?.filter( + (stream) => stream.codec_type === 'subtitle' + )[1]?.tags + ).toMatchObject({ language: 'eng', handler_name: 'English' }); + }, 120_000); + + itNativeSubtitles( + 'executes subtitle burn-in when the native test binary has libass', + () => { + const burned = runNative( + buildSubtitleBurnPlan({ + jobId: 'subtitle-burn-native', + source: source('media', 0, 'pattern-av.mp4'), + subtitle: source('subtitle', 1, 'subtitle.srt'), + preset: preset('mp4-h264-balanced'), + expectedDurationSeconds: 2, + }), + { + media: fixture('pattern-av.mp4'), + subtitle: fixture('subtitle.srt'), + } + ); + expect( + probe(burned.outputPaths[0]!).streams?.some( + (stream) => stream.codec_type === 'subtitle' + ) + ).toBe(false); + }, + 120_000 + ); + + it('executes single/series thumbnail and contact-sheet plans', () => { + const media = source('media', 0, 'pattern-av.mp4'); + const mediaFile = fixture('pattern-av.mp4'); + const single = runNative( + buildSingleThumbnailPlan({ + jobId: 'thumbnail-native', + source: media, + timeSeconds: 0.5, + size: { width: 80 }, + format: 'png', + }), + { media: mediaFile } + ); + expect(probe(single.outputPaths[0]!).streams?.[0]).toMatchObject({ + codec_name: 'png', + width: 80, + }); + + const series = runNative( + buildThumbnailSeriesPlan({ + jobId: 'thumbnail-series-native', + source: media, + durationSeconds: 2, + timesSeconds: [0.2, 0.8, 1.4], + size: { width: 80 }, + format: 'jpeg', + quality: 75, + }), + { media: mediaFile } + ); + expect(series.outputPaths).toHaveLength(3); + expect( + series.outputPaths.map((path) => probe(path).streams?.[0]?.codec_name) + ).toEqual(['mjpeg', 'mjpeg', 'mjpeg']); + + const sheet = runNative( + buildContactSheetPlan({ + jobId: 'contact-sheet-native', + source: media, + durationSeconds: 2, + frameCount: 4, + rows: 2, + columns: 2, + cellWidth: 80, + spacing: 2, + timestampLabels: false, + sourceLabel: false, + format: 'png', + }), + { media: mediaFile } + ); + expect(probe(sheet.outputPaths[0]!).streams?.[0]).toMatchObject({ + codec_name: 'png', + width: 166, + }); + }, 120_000); + + it('executes a transformed, faded, normalized sequential timeline', () => { + const execution = runNative( + buildTimelineExportPlan({ + jobId: 'timeline-native', + clips: [ + { + id: 'clip-a', + source: source('first', 0, 'compatible-a.mp4'), + sourceInSeconds: 0.1, + sourceOutSeconds: 0.9, + sourceDurationSeconds: 1, + hasVideo: true, + hasAudio: true, + transform: { rotation: 180 }, + audioGainDb: -1, + audioNormalization: { + mode: 'peak', + targetPeakDb: -1, + measurement: { inputPeakDbfs: -18 }, + }, + audioFadeInSeconds: 0.1, + audioFadeCurve: 'qsin', + videoFadeOutSeconds: 0.1, + videoFadeCurve: 'exp', + }, + { + id: 'clip-b', + source: source('second', 1, 'different-dimensions.mp4'), + sourceInSeconds: 0, + sourceOutSeconds: 0.8, + sourceDurationSeconds: 1, + hasVideo: true, + hasAudio: true, + transform: { + crop: { + x: 0, + y: 0, + width: 80, + height: 80, + sourceWidth: 96, + sourceHeight: 96, + requireEvenDimensions: true, + }, + }, + audioFadeOutSeconds: 0.1, + audioFadeCurve: 'exp', + videoFadeInSeconds: 0.1, + videoFadeCurve: 'qsin', + }, + ], + preset: preset('mp4-h264-balanced'), + outputWidth: 128, + outputHeight: 72, + frameRate: 10, + sampleRate: 16_000, + channelLayout: 'mono', + missingAudioPolicy: 'reject', + }), + { + first: fixture('compatible-a.mp4'), + second: fixture('different-dimensions.mp4'), + } + ); + const result = probe(execution.outputPaths[0]!); + expect(result.streams?.[0]).toMatchObject({ width: 128, height: 72 }); + expect( + result.streams?.some((stream) => stream.codec_type === 'audio') + ).toBe(true); + expect(Number(result.format?.duration)).toBeCloseTo(1.6, 1); + }, 120_000); +}); + +function streamType(stream: { + readonly codec_type?: string; + readonly codec_name?: string; +}): string { + return `${stream.codec_type ?? 'unknown'}:${stream.codec_name ?? 'unknown'}`; +} + +function chapterTitle(chapter: { + readonly tags?: Readonly>; +}): string | undefined { + return chapter.tags?.title; +} diff --git a/tests/unit/advanced-operations.test.tsx b/tests/unit/advanced-operations.test.tsx new file mode 100644 index 0000000..3b3ed5a --- /dev/null +++ b/tests/unit/advanced-operations.test.tsx @@ -0,0 +1,555 @@ +import { + cleanup, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + AdvancedOperations, + type AdvancedCapabilityKey, + type AdvancedCapabilityStatus, + type AdvancedOperationsProps, +} from '../../src/components/AdvancedOperations'; + +const AVAILABLE: AdvancedCapabilityStatus = { available: true }; + +function capabilities( + ...keys: readonly AdvancedCapabilityKey[] +): AdvancedOperationsProps['availability'] { + return Object.fromEntries(keys.map((key) => [key, AVAILABLE])); +} + +const SOURCE: NonNullable = { + durationSeconds: 90, + sourceExtension: 'mp4', + hasAudio: true, + hasVideo: true, + selectedAudioStreamIndex: 1, +}; + +const EXPORT_PRESETS: NonNullable = [ + { + id: 'mp4-h264-balanced', + name: 'MP4 · H.264 + AAC', + fileExtension: 'mp4', + supportsAudio: true, + supportsVideo: true, + }, + { + id: 'audio-mp3', + name: 'MP3', + fileExtension: 'mp3', + supportsAudio: true, + supportsVideo: false, + }, +]; + +afterEach(cleanup); + +describe('AdvancedOperations', () => { + it('fails closed when a runtime capability was not explicitly verified', () => { + render( + + ); + + const action = screen.getByRole('button', { name: 'Queue 2 segments' }); + expect(action).toBeDisabled(); + expect( + screen.getByText( + 'The required browser and FFmpeg capabilities have not been verified.' + ) + ).toBeInTheDocument(); + }); + + it('builds a validated marker request and explains fast versus accurate mode', async () => { + const user = userEvent.setup(); + const onSplit = vi.fn(); + render( + + ); + + expect( + screen.getByText(/boundaries may move to nearby keyframes/iu) + ).toBeInTheDocument(); + expect(screen.getByText(/practical frame accuracy/iu)).toBeInTheDocument(); + const ranges = screen.getByLabelText('Exact planned segment ranges'); + expect(within(ranges).getByText('0 s–30 s')).toBeVisible(); + expect(within(ranges).getByText('30 s–60 s')).toBeVisible(); + expect(within(ranges).getByText('60 s–90 s')).toBeVisible(); + + await user.click(screen.getByRole('button', { name: 'Queue 3 segments' })); + await waitFor(() => + expect(onSplit).toHaveBeenCalledWith({ + mode: 'fast', + definition: { type: 'markers', markers: [30, 60] }, + targetExtension: 'mp4', + }) + ); + + await user.click( + screen.getByRole('radio', { name: /Accurate re-encode/iu }) + ); + await user.click(screen.getByRole('button', { name: 'Queue 3 segments' })); + await waitFor(() => + expect(onSplit).toHaveBeenLastCalledWith({ + mode: 'accurate', + definition: { type: 'markers', markers: [30, 60] }, + targetExtension: 'mp4', + presetId: 'mp4-h264-balanced', + }) + ); + }); + + it('shows exact latest-batch status and individual segment failure details', () => { + render( + + ); + + const status = screen.getByLabelText('Split segment status'); + expect(within(status).getByText('source-split-001.mp4')).toBeVisible(); + expect(within(status).getByText('0 s–30 s')).toBeVisible(); + expect(within(status).getByText('verified')).toBeVisible(); + expect( + within(status).getByText('FFmpeg did not return this expected output.') + ).toBeVisible(); + }); + + it('supports keyboard tab navigation and emits typed loudness targets', async () => { + const user = userEvent.setup(); + const onAudioAction = vi.fn(); + render( + + ); + + const splitTab = screen.getByRole('tab', { name: 'Split media' }); + splitTab.focus(); + await user.keyboard('{ArrowRight}'); + expect( + screen.getByRole('tab', { name: 'Waveform and loudness' }) + ).toHaveAttribute('aria-selected', 'true'); + + await user.selectOptions( + screen.getByLabelText(/^Target profile/iu), + 'podcast' + ); + await user.click(screen.getByRole('button', { name: 'Measure EBU R128' })); + await waitFor(() => + expect(onAudioAction).toHaveBeenCalledWith({ + type: 'loudness-measurement', + audioStreamIndex: 1, + targets: { + integratedLufs: -16, + truePeakDbtp: -1, + loudnessRangeLu: 11, + }, + }) + ); + + const normalize = screen.getByRole('button', { + name: 'Apply measured normalization', + }); + expect(normalize).toBeDisabled(); + expect( + screen.getByText( + 'Run the first-pass loudness measurement before normalization.' + ) + ).toBeInTheDocument(); + }); + + it('uses an audio-only preset for measured normalization', async () => { + const user = userEvent.setup(); + const onAudioAction = vi.fn(); + const measurement = { + inputIntegratedLufs: -19.7, + inputTruePeakDbtp: -2.1, + inputLoudnessRangeLu: 4.2, + inputThresholdLufs: -30, + targetOffsetLu: -0.3, + }; + render( + + ); + + await user.click( + screen.getByRole('tab', { name: 'Waveform and loudness' }) + ); + await user.click( + screen.getByRole('button', { name: 'Apply measured normalization' }) + ); + await waitFor(() => + expect(onAudioAction).toHaveBeenCalledWith({ + type: 'loudness-normalization', + audioStreamIndex: 1, + targets: { + integratedLufs: -14, + truePeakDbtp: -1, + loudnessRangeLu: 11, + }, + measurement, + presetId: 'audio-mp3', + }) + ); + }); + + it('keeps unverified contact-sheet labels off and emits a bounded series request', async () => { + const user = userEvent.setup(); + const onThumbnailSeries = vi.fn(); + render( + + ); + + await user.click( + screen.getByRole('tab', { + name: 'Thumbnails and contact sheets', + }) + ); + expect(screen.getByLabelText('Timestamp labels')).toBeDisabled(); + expect(screen.getByLabelText('Source filename label')).toBeDisabled(); + expect( + screen.getByText( + 'Labels stay off until drawtext and a safe local font are verified.' + ) + ).toBeInTheDocument(); + + await user.click( + screen.getByRole('button', { name: 'Generate thumbnail series' }) + ); + await waitFor(() => + expect(onThumbnailSeries).toHaveBeenCalledWith({ + distribution: { type: 'evenly-spaced', count: 12 }, + width: 480, + format: 'jpeg', + quality: 85, + }) + ); + }); + + it('classifies bitmap subtitles conservatively before enabling extraction', async () => { + const user = userEvent.setup(); + const onSubtitleAction = vi.fn(); + render( + + ); + + await user.click(screen.getByRole('tab', { name: 'Subtitle operations' })); + expect( + screen.getByRole('button', { name: 'Extract subtitle' }) + ).toBeDisabled(); + expect( + screen.getByText(/bitmap subtitle extraction is not text conversion/iu) + ).toBeInTheDocument(); + + await user.selectOptions(screen.getByLabelText('Subtitle stream'), '3'); + await user.click(screen.getByRole('button', { name: 'Extract subtitle' })); + await waitFor(() => + expect(onSubtitleAction).toHaveBeenCalledWith({ + type: 'extract', + stream: { index: 3, codec: 'subrip', language: 'eng' }, + outputFormat: 'srt', + }) + ); + }); + + it('retains validated stream metadata JSON for apply and export', async () => { + const user = userEvent.setup(); + const onMetadataAction = vi.fn(); + render( + + ); + + await user.click(screen.getByRole('tab', { name: 'Metadata policies' })); + await user.upload( + screen.getByLabelText('Import metadata JSON'), + new File( + [ + JSON.stringify({ + format: { title: 'Imported title', genre: 'Synthetic' }, + streams: { + 1: { language: 'deu', title: 'German dialogue' }, + }, + }), + ], + 'metadata.json', + { type: 'application/json' } + ) + ); + await waitFor(() => + expect(onMetadataAction).toHaveBeenCalledWith({ + type: 'import', + edits: { + format: { title: 'Imported title', genre: 'Synthetic' }, + streams: { + 1: { language: 'deu', title: 'German dialogue' }, + }, + }, + }) + ); + + expect(screen.getByText(/1 stream metadata edit loaded/iu)).toBeVisible(); + await user.click( + screen.getByRole('button', { name: 'Apply metadata policies' }) + ); + await waitFor(() => + expect(onMetadataAction).toHaveBeenLastCalledWith({ + type: 'apply', + edits: { + format: { genre: 'Synthetic', title: 'Imported title' }, + streams: { + 1: { language: 'deu', title: 'German dialogue' }, + }, + }, + sourceMetadataPolicy: 'copy', + chapterPolicy: 'keep', + coverArtPolicy: 'keep', + }) + ); + }); + + it('edits probed stream language, title, and typed dispositions directly', async () => { + const user = userEvent.setup(); + const onMetadataAction = vi.fn(); + render( + + ); + + await user.click(screen.getByRole('tab', { name: 'Metadata policies' })); + const language = screen.getByLabelText('Stream #1 language'); + await user.clear(language); + await user.type(language, 'deu'); + const title = screen.getByLabelText('Stream #1 title'); + await user.clear(title); + await user.type(title, 'German dialogue'); + await user.click(screen.getByLabelText('Default stream #1')); + await user.click( + screen.getByRole('button', { name: 'Apply metadata policies' }) + ); + + await waitFor(() => + expect(onMetadataAction).toHaveBeenCalledWith({ + type: 'apply', + edits: { + format: {}, + streams: { + 1: { language: 'deu', title: 'German dialogue' }, + }, + dispositions: { 1: ['default'] }, + }, + sourceMetadataPolicy: 'copy', + chapterPolicy: 'keep', + coverArtPolicy: 'keep', + }) + ); + }); + + it('does not clear untouched source container tags', async () => { + const user = userEvent.setup(); + const onMetadataAction = vi.fn(); + render( + + ); + + await user.click(screen.getByRole('tab', { name: 'Metadata policies' })); + expect(screen.getByLabelText('Artist')).toHaveValue('Original artist'); + const title = screen.getByLabelText('Title'); + await user.clear(title); + await user.type(title, 'Replacement title'); + await user.click( + screen.getByRole('button', { name: 'Apply metadata policies' }) + ); + + await waitFor(() => + expect(onMetadataAction).toHaveBeenCalledWith({ + type: 'apply', + edits: { format: { title: 'Replacement title' } }, + sourceMetadataPolicy: 'copy', + chapterPolicy: 'keep', + coverArtPolicy: 'keep', + }) + ); + }); + + it('creates only an allowlisted typed user preset', async () => { + const user = userEvent.setup(); + const onUserPresetAction = vi.fn(); + render( + + ); + + await user.click(screen.getByRole('tab', { name: 'User export presets' })); + const editor = screen + .getByRole('heading', { name: 'Create typed preset' }) + .closest('section'); + expect(editor).not.toBeNull(); + const editorQueries = within(editor as HTMLElement); + await user.type(editorQueries.getByLabelText('Safe ID'), 'my-web-export'); + await user.type(editorQueries.getByLabelText('Name'), 'My web export'); + await user.click( + editorQueries.getByRole('button', { name: 'Create preset' }) + ); + + await waitFor(() => + expect(onUserPresetAction).toHaveBeenCalledWith({ + type: 'create', + preset: { + schemaVersion: 1, + id: 'my-web-export', + name: 'My web export', + kind: 'video', + container: 'mp4', + fileExtension: 'mp4', + video: { + codec: 'libx264', + qualityMode: 'balanced', + crf: 23, + }, + audio: { + codec: 'aac', + bitrateKbps: 160, + sampleRate: 48000, + channels: 2, + }, + metadataPolicy: 'copy', + chapterPolicy: 'keep', + subtitlePolicy: 'none', + fastStart: true, + }, + }) + ); + expect(JSON.stringify(onUserPresetAction.mock.calls)).not.toMatch( + /(?:args|filter|protocol|inputPath)/u + ); + }); + + it('does not offer preset policies that require explicit advanced inputs', async () => { + const user = userEvent.setup(); + render( + + ); + + await user.click(screen.getByRole('tab', { name: 'User export presets' })); + const editor = screen + .getByRole('heading', { name: 'Create typed preset' }) + .closest('section'); + expect(editor).not.toBeNull(); + const editorQueries = within(editor as HTMLElement); + + expect( + within(editorQueries.getByLabelText('Metadata')).queryByRole('option', { + name: 'Apply edits', + }) + ).not.toBeInTheDocument(); + expect( + within(editorQueries.getByLabelText('Chapters')).queryByRole('option', { + name: 'Replace', + }) + ).not.toBeInTheDocument(); + expect( + within(editorQueries.getByLabelText('Subtitles')).queryByRole('option', { + name: 'Burn in', + }) + ).not.toBeInTheDocument(); + expect( + editorQueries.getByText(/remain explicit operations/iu) + ).toBeVisible(); + }); +}); diff --git a/tests/unit/commands-advanced.test.ts b/tests/unit/commands-advanced.test.ts new file mode 100644 index 0000000..665e6fa --- /dev/null +++ b/tests/unit/commands-advanced.test.ts @@ -0,0 +1,458 @@ +import { describe, expect, it } from 'vitest'; +import { + buildContactSheetPlan, + buildConvertPlan, + buildFadePlan, + buildLoudnessAnalysisPlan, + buildLoudnessApplyPlan, + buildMetadataEditPlan, + buildPeakAnalysisPlan, + buildPeakNormalizationFilter, + buildPeakNormalizationPlan, + buildSubtitleBurnPlan, + buildSubtitleExtractionPlan, + buildSubtitleMuxPlan, + buildStaticWaveformPlan, + buildSingleThumbnailPlan, + buildThumbnailSeriesPlan, + buildTransformPlan, + classifySubtitleCodec, + createChaptersAtIntervals, + createChaptersFromMarkers, + escapeFFMetadata, + escapeFilterValue, + metadataArguments, + parseChapterJson, + parseLoudnessMeasurement, + parsePeakMeasurement, + parseMetadataJson, + resolveThumbnailTimes, + serializeChapterJson, + serializeFFMetadata, + validateChapters, +} from '../../src/commands'; +import { findBuiltInPreset } from '../../src/presets'; + +const SOURCE = { + id: 'media', + sourceIndex: 0, + fileName: 'source.mp4', + extension: 'mp4', +} as const; + +describe('metadata and chapter commands', () => { + it('serializes deterministic metadata args without a command string', () => { + expect( + metadataArguments({ + format: { title: 'A title', artist: 'An artist' }, + streams: { 1: { language: 'deu', title: null } }, + }) + ).toEqual([ + '-metadata', + 'artist=An artist', + '-metadata', + 'title=A title', + '-metadata:s:1', + 'language=deu', + '-metadata:s:1', + 'title=', + ]); + expect( + parseMetadataJson( + '{"format":{"title":"Demo"},"streams":{"0":{"language":"eng"}},"dispositions":{"0":["forced","default"]}}' + ) + ).toEqual({ + format: { title: 'Demo' }, + streams: { 0: { language: 'eng' } }, + dispositions: { 0: ['default', 'forced'] }, + }); + expect( + metadataArguments({ + dispositions: { 0: [], 1: ['forced', 'default'] }, + }) + ).toEqual(['-disposition:0', '0', '-disposition:1', 'default+forced']); + expect(() => + parseMetadataJson('{"dispositions":{"0":["default","default"]}}') + ).toThrow(/duplicates/iu); + expect(() => parseMetadataJson('{"args":["-i","remote"]}')).toThrow( + /unsupported/iu + ); + }); + + it('escapes every FFMETADATA delimiter and round-trips chapter JSON', () => { + expect(escapeFFMetadata('a=b;c#d\\e\nf')).toBe('a\\=b\\;c\\#d\\\\e\\\nf'); + const chapters = [ + { id: 'intro', title: 'Intro #1', startSeconds: 0, endSeconds: 2 }, + { id: 'main', title: 'Main', startSeconds: 2, endSeconds: 5 }, + ]; + expect(serializeFFMetadata(chapters)).toContain('title=Intro \\#1'); + const json = serializeChapterJson(chapters); + expect(parseChapterJson(json).chapters).toEqual(chapters); + expect( + validateChapters([chapters[0]!, { ...chapters[1]!, startSeconds: 1 }])[0] + ).toMatchObject({ severity: 'error' }); + expect(createChaptersFromMarkers([2, 5], 8)).toHaveLength(3); + expect( + createChaptersAtIntervals(10, 4).map((chapter) => chapter.startSeconds) + ).toEqual([0, 4, 8]); + }); + + it('preserves validated per-chapter time bases and converts seconds to ticks', () => { + const metadata = serializeFFMetadata([ + { + id: 'milliseconds', + title: 'Milliseconds', + startSeconds: 0, + endSeconds: 0.5, + timeBase: '1/1000', + }, + { + id: 'samples', + title: 'Samples', + startSeconds: 0.5, + endSeconds: 1, + timeBase: '1/48000', + }, + ]); + expect(metadata).toContain('TIMEBASE=1/1000\nSTART=0\nEND=500'); + expect(metadata).toContain('TIMEBASE=1/48000\nSTART=24000\nEND=48000'); + expect(() => + serializeFFMetadata([ + { + id: 'invalid', + title: 'Invalid', + startSeconds: 0, + endSeconds: 1, + timeBase: '0/1000', + }, + ]) + ).toThrow(/time base/iu); + expect(() => + serializeFFMetadata([ + { + id: 'coarse', + title: 'Coarse', + startSeconds: 0, + endSeconds: 0.1, + timeBase: '1/1', + }, + ]) + ).toThrow(/too coarse/iu); + }); + + it('builds a stream-copy metadata edit with explicit removal diagnostics', () => { + const plan = buildMetadataEditPlan({ + jobId: 'meta', + source: SOURCE, + edits: { format: { title: 'Edited' } }, + targetExtension: 'mp4', + targetMuxer: 'mp4', + sourceMetadataPolicy: 'remove', + chapterPolicy: 'remove', + }); + expect(plan.args).toContain('title=Edited'); + expect(plan.args).toContain('copy'); + expect(plan.diagnostics).toContainEqual( + expect.objectContaining({ code: 'metadata-removal-scope' }) + ); + }); +}); + +describe('loudness and subtitle commands', () => { + const LOG = ` +[Parsed_loudnorm_0] { + "input_i" : "-19.70", + "input_tp" : "-2.10", + "input_lra" : "4.20", + "input_thresh" : "-30.00", + "target_offset" : "-0.30" +}`; + + it('parses complete loudnorm JSON and rejects invalid measurements', () => { + expect(parseLoudnessMeasurement(LOG)).toEqual({ + inputIntegratedLufs: -19.7, + inputTruePeakDbtp: -2.1, + inputLoudnessRangeLu: 4.2, + inputThresholdLufs: -30, + targetOffsetLu: -0.3, + }); + expect(() => + parseLoudnessMeasurement( + '{"input_i":"-inf","input_tp":"0","input_lra":"0","input_thresh":"0"}' + ) + ).toThrow(/finite/u); + expect(buildPeakNormalizationFilter(-1, { inputPeakDbfs: -6 })).toMatch( + /^volume=1\.778/u + ); + }); + + it('builds distinct first and second loudness passes', () => { + const targets = { + integratedLufs: -16, + truePeakDbtp: -1, + loudnessRangeLu: 11, + }; + const measurement = parseLoudnessMeasurement(LOG); + const analysis = buildLoudnessAnalysisPlan({ + jobId: 'loud', + source: SOURCE, + targets, + audioStreamIndex: 1, + }); + expect(analysis.outputs).toEqual([]); + expect(analysis.args).toContain('-f'); + expect(analysis.args).toContain('null'); + expect(analysis.args).toContain('0:1'); + expect( + analysis.args.find((value) => value.startsWith('loudnorm=')) + ).toContain('print_format=json'); + const apply = buildLoudnessApplyPlan({ + jobId: 'loud', + source: SOURCE, + targets, + measurement, + audioStreamIndex: 1, + preset: findBuiltInPreset('audio-m4a-aac')!, + }); + expect(apply.args.find((value) => value.startsWith('loudnorm='))).toContain( + 'measured_I=-19.7' + ); + const peakAnalysis = buildPeakAnalysisPlan({ + jobId: 'peak', + source: SOURCE, + audioStreamIndex: 1, + preset: findBuiltInPreset('audio-m4a-aac')!, + }); + expect( + peakAnalysis.args.find((value) => value.includes('volumedetect')) + ).toContain('aformat=channel_layouts=stereo'); + const peakMeasurement = parsePeakMeasurement('max_volume: -6.0 dB'); + const peak = buildPeakNormalizationPlan({ + jobId: 'peak', + source: SOURCE, + audioStreamIndex: 1, + targetPeakDb: -1, + measurement: peakMeasurement, + preset: findBuiltInPreset('audio-m4a-aac')!, + }); + expect(peak.args.find((value) => value.includes('volume='))).toContain( + 'volume=1.778' + ); + }); + + it('classifies text and bitmap subtitles and blocks bitmap-to-text extraction', () => { + expect(classifySubtitleCodec('subrip')).toBe('text'); + expect(classifySubtitleCodec('hdmv_pgs_subtitle')).toBe('bitmap'); + expect(() => + buildSubtitleExtractionPlan({ + jobId: 'sub', + source: SOURCE, + stream: { index: 2, codec: 'hdmv_pgs_subtitle' }, + outputFormat: 'srt', + }) + ).toThrow(/bitmap/iu); + }); + + it('converts text subtitles for target containers and safely builds burn-in', () => { + const subtitle = { + id: 'subtitle', + sourceIndex: 1, + fileName: "my:sub's.srt", + extension: 'srt', + } as const; + const mux = buildSubtitleMuxPlan({ + jobId: 'mux', + source: SOURCE, + subtitle, + subtitleCodec: 'subrip', + sourceStreams: [ + { index: 0, kind: 'video', codec: 'h264' }, + { index: 1, kind: 'audio', codec: 'aac' }, + ], + targetContainer: 'mp4', + targetExtension: 'mp4', + language: 'eng', + default: true, + }); + expect(mux.args).toContain('mov_text'); + const burn = buildSubtitleBurnPlan({ + jobId: 'burn', + source: SOURCE, + subtitle, + preset: findBuiltInPreset('mp4-h264-balanced')!, + }); + expect(burn.inputs).toHaveLength(2); + expect(burn.args.find((value) => value.startsWith('subtitles='))).toContain( + '/input/job-burn/source-1.srt' + ); + expect(escapeFilterValue("/path/a:b,c's.ass")).toBe( + "/path/a\\:b\\,c\\'s.ass" + ); + }); +}); + +describe('waveform plans', () => { + it('builds a bounded static derivative for an absolute audio stream', () => { + const plan = buildStaticWaveformPlan({ + jobId: 'wave', + source: SOURCE, + audioStreamIndex: 1, + width: 640, + height: 160, + }); + expect(plan.args.join(' ')).toContain( + '[0:1]aformat=channel_layouts=mono,showwavespic=s=640x160' + ); + expect(plan.outputs[0]).toMatchObject({ + role: 'analysis', + mediaType: 'image/png', + }); + }); +}); + +describe('thumbnail and contact sheet plans', () => { + it('supports explicit stretched single-frame dimensions and quality', () => { + const plan = buildSingleThumbnailPlan({ + jobId: 'single-thumb', + source: SOURCE, + timeSeconds: 1.25, + size: { width: 640, height: 360, maintainAspect: false }, + format: 'jpeg', + quality: 85, + }); + expect(plan.args).toContain('scale=640:360'); + expect(plan.args).not.toContain( + 'scale=640:360:force_original_aspect_ratio=decrease' + ); + expect(plan.args).toEqual( + expect.arrayContaining(['-q:v', '6', '-frames:v', '1']) + ); + }); + + it('generates ordered unique times from interval, count, and explicit selections', () => { + expect( + resolveThumbnailTimes({ durationSeconds: 10, intervalSeconds: 4 }) + ).toEqual([0, 4, 8]); + expect(resolveThumbnailTimes({ durationSeconds: 10, count: 3 })).toEqual([ + 2.5, 5, 7.5, + ]); + expect( + resolveThumbnailTimes({ durationSeconds: 10, timesSeconds: [5, 1, 5] }) + ).toEqual([1, 5]); + }); + + it('builds one invocation for a series and declares every output', () => { + const plan = buildThumbnailSeriesPlan({ + jobId: 'thumbs', + source: SOURCE, + durationSeconds: 10, + count: 3, + format: 'png', + size: { width: 320 }, + }); + expect(plan.outputs).toHaveLength(3); + expect(plan.args.filter((value) => value === '-i')).toHaveLength(1); + expect(plan.args).toContain('-filter_complex'); + expect(plan.args.filter((value) => value === '-frames:v')).toHaveLength(3); + for (const output of plan.outputs) { + expect(plan.args).toContain(output.path); + } + expect(plan.args.some((value) => value.includes('%03d'))).toBe(false); + }); + + it('degrades contact-sheet labels when drawtext is unavailable', () => { + const plan = buildContactSheetPlan({ + jobId: 'sheet', + source: SOURCE, + durationSeconds: 10, + frameCount: 6, + rows: 2, + columns: 3, + cellWidth: 240, + timestampLabels: true, + format: 'jpeg', + drawtextAvailable: false, + }); + expect(plan.diagnostics).toContainEqual( + expect.objectContaining({ + code: 'labels-unavailable', + severity: 'warning', + }) + ); + expect(plan.requiredCapabilities.filters).not.toContain('drawtext'); + }); +}); + +describe('composed visual operations', () => { + it('creates palette-based GIF conversion instead of a raw encoder pass', () => { + const plan = buildConvertPlan({ + jobId: 'gif', + source: SOURCE, + preset: findBuiltInPreset('animated-gif')!, + }); + expect(plan.args).toContain('-filter_complex'); + expect(plan.args.find((value) => value.includes('palettegen'))).toContain( + 'paletteuse' + ); + expect(plan.args).not.toContain('-vf'); + }); + + it('wraps transforms and A/V fades into executable conversion plans', () => { + const preset = findBuiltInPreset('mp4-h264-balanced')!; + const transformed = buildTransformPlan({ + jobId: 'transform', + source: SOURCE, + preset, + settings: { + crop: { + x: 0, + y: 0, + width: 640, + height: 360, + sourceWidth: 1280, + sourceHeight: 720, + requireEvenDimensions: true, + }, + frameRate: 25, + }, + }); + expect(transformed.args).toContain('crop=640:360:0:0,fps=25'); + const faded = buildFadePlan({ + jobId: 'fade', + source: SOURCE, + preset, + durationSeconds: 10, + audioFadeInSeconds: 1, + videoFadeOutSeconds: 2, + audioCurve: 'qsin', + videoCurve: 'exp', + }); + expect(faded.args.find((value) => value.startsWith('afade='))).toContain( + 'curve=qsin' + ); + expect(faded.args.find((value) => value.startsWith('geq='))).toContain( + 'exp(5*' + ); + expect(faded.requiredCapabilities.filters).toContain('geq'); + expect(() => + buildFadePlan({ + jobId: 'overlap', + source: SOURCE, + preset, + durationSeconds: 2, + audioFadeInSeconds: 1.5, + audioFadeOutSeconds: 1, + }) + ).toThrow(/overlap/u); + expect(() => + buildFadePlan({ + jobId: 'unsupported-curve', + source: SOURCE, + preset, + durationSeconds: 2, + audioFadeInSeconds: 1, + audioCurve: 'log' as 'tri', + }) + ).toThrow(/tri, qsin, or exp/u); + }); +}); diff --git a/tests/unit/commands.test.ts b/tests/unit/commands.test.ts new file mode 100644 index 0000000..0ee45e2 --- /dev/null +++ b/tests/unit/commands.test.ts @@ -0,0 +1,392 @@ +import { describe, expect, it } from 'vitest'; +import { + buildConvertPlan, + buildFastConcatPlan, + buildPreviewProxyPlan, + buildRemuxPlan, + buildSplitPlan, + buildStreamRemovalPlan, + buildTimelineExportPlan, + buildTrimPlan, + calculateResize, + createConcatList, + createPlannedInput, + createVideoFiltergraph, + displayArguments, + sourceVirtualPath, + validateConcatCompatibility, + validateCrop, + validateSplitMarkers, +} from '../../src/commands'; +import { findBuiltInPreset } from '../../src/presets'; + +const SOURCE = { + id: 'source', + sourceIndex: 0, + fileName: "../../My holiday's clip.mp4", + extension: 'mp4', +} as const; + +describe('structured command plans', () => { + it('generates safe deterministic virtual paths without source path disclosure', () => { + expect(sourceVirtualPath('../job id', SOURCE)).toBe( + '/input/job-job-id/source-0.mp4' + ); + expect(createPlannedInput('abc', SOURCE)).toEqual({ + id: 'source', + sourceIndex: 0, + kind: 'media', + path: '/input/job-abc/source-0.mp4', + }); + }); + + it('renders shell-like diagnostics without changing structured arguments', () => { + const args = [ + '-i', + "/input/a file's.mp4", + '-metadata', + 'title=hello world', + ]; + expect(displayArguments(args)).toBe( + "-i '/input/a file'\\''s.mp4' -metadata 'title=hello world'" + ); + expect(args).toEqual([ + '-i', + "/input/a file's.mp4", + '-metadata', + 'title=hello world', + ]); + }); + + it('escapes generated concat paths and rejects line injection', () => { + expect(createConcatList(['/input/source-0.mp4', "/input/it's.mp4"])).toBe( + "file '/input/source-0.mp4'\nfile '/input/it'\\''s.mp4'\n" + ); + expect(() => + createConcatList(["/input/a.mp4\nfile '/etc/passwd'"]) + ).toThrow(/line breaks/u); + }); + + it('composes conversion filters into one -vf argument', () => { + const preset = findBuiltInPreset('mp4-h264-balanced'); + expect(preset).toBeDefined(); + const plan = buildConvertPlan({ + jobId: 'convert-1', + source: SOURCE, + preset: preset!, + videoFiltergraph: 'crop=640:360:0:0,fade=t=in:st=0:d=1', + audioFiltergraph: 'afade=t=in:st=0:d=1', + expectedDurationSeconds: 10, + }); + expect(plan.args.filter((argument) => argument === '-vf')).toHaveLength(1); + expect(plan.args).toContain('crop=640:360:0:0,fade=t=in:st=0:d=1'); + expect(plan.args).toContain('afade=t=in:st=0:d=1'); + expect(plan.args.at(-1)).toMatch(/\.mp4$/u); + expect(Object.isFrozen(plan.args)).toBe(true); + }); + + it('distinguishes fast and accurate trim argument placement', () => { + const fast = buildTrimPlan({ + jobId: 'trim-fast', + source: SOURCE, + mode: 'fast', + startSeconds: 1.25, + endSeconds: 3.5, + sourceDurationSeconds: 10, + targetExtension: 'mp4', + }); + expect(fast.args.slice(0, 7)).toEqual([ + '-ss', + '1.25', + '-to', + '3.5', + '-i', + '/input/job-trim-fast/source-0.mp4', + '-map', + ]); + expect(fast.args).toContain('copy'); + expect(fast.diagnostics[0]?.severity).toBe('warning'); + + const preset = findBuiltInPreset('mp4-h264-balanced'); + const accurate = buildTrimPlan({ + jobId: 'trim-accurate', + source: SOURCE, + mode: 'accurate', + startSeconds: 1.25, + endSeconds: 3.5, + preset: preset!, + }); + expect(accurate.args.slice(0, 6)).toEqual([ + '-ss', + '1.25', + '-to', + '3.5', + '-i', + '/input/job-trim-accurate/source-0.mp4', + ]); + expect(accurate.args).toContain('libx264'); + }); + + it('validates and expands every supported split definition', () => { + expect(validateSplitMarkers([8, 2, 5], 10)).toEqual([2, 5, 8]); + expect(() => validateSplitMarkers([2, 2.01], 10)).toThrow(/apart/u); + const plan = buildSplitPlan({ + jobId: 'split', + source: SOURCE, + durationSeconds: 10, + definition: { type: 'part-count', parts: 3 }, + mode: 'fast', + targetExtension: 'mp4', + }); + expect(plan.outputs.map((output) => output.fileName)).toEqual([ + 'My-holiday-s-clip-split-001.mp4', + 'My-holiday-s-clip-split-002.mp4', + 'My-holiday-s-clip-split-003.mp4', + ]); + expect(plan.outputs.map((output) => output.timeRange)).toEqual([ + { startSeconds: 0, endSeconds: 10 / 3 }, + { startSeconds: 10 / 3, endSeconds: 20 / 3 }, + { startSeconds: 20 / 3, endSeconds: 10 }, + ]); + expect( + plan.args.filter((argument) => argument === '-progress') + ).toHaveLength(1); + expect(plan.args.filter((argument) => argument === 'copy')).toHaveLength(3); + }); + + it('blocks incompatible remuxes before returning a plan', () => { + expect(() => + buildRemuxPlan({ + jobId: 'remux', + source: SOURCE, + targetContainer: 'mp4', + fileExtension: 'mp4', + description: { + streams: [{ index: 0, kind: 'video', codec: 'vp9' }], + }, + }) + ).toThrow(/cannot be remuxed/u); + }); + + it('maps selected attachment and data streams during compatible remux', () => { + const plan = buildRemuxPlan({ + jobId: 'remux-auxiliary', + source: SOURCE, + targetContainer: 'matroska', + fileExtension: 'mkv', + description: { + streams: [ + { index: 2, kind: 'attachment', codec: 'ttf' }, + { index: 3, kind: 'data', codec: 'bin_data' }, + ], + }, + streamSelection: { + attachments: [2], + data: [3], + }, + }); + + expect(plan.args).toEqual( + expect.arrayContaining(['-map', '0:2', '-map', '0:3', '-c', 'copy']) + ); + }); + + it('maps only explicitly retained streams for stream removal', () => { + const plan = buildStreamRemovalPlan({ + jobId: 'streams', + source: SOURCE, + selection: { video: [0], audio: [1], subtitles: [] }, + targetExtension: 'mkv', + targetMuxer: 'matroska', + }); + expect(plan.args).toEqual([ + '-i', + '/input/job-streams/source-0.mp4', + '-map', + '0:0', + '-map', + '0:1', + '-c', + 'copy', + '-map_metadata', + '0', + '-map_chapters', + '0', + '/work/job-streams/My-holiday-s-clip-streams.mkv', + ]); + }); +}); + +describe('concat and transform validation', () => { + const STREAMS = [ + { + kind: 'video' as const, + codec: 'h264', + width: 640, + height: 360, + pixelFormat: 'yuv420p', + timeBase: '1/12800', + }, + { + kind: 'audio' as const, + codec: 'aac', + sampleRate: 48_000, + channelLayout: 'stereo', + timeBase: '1/48000', + }, + ]; + const SOURCES = [ + { + ...SOURCE, + id: 'one', + sourceIndex: 0, + durationSeconds: 2, + streams: STREAMS, + }, + { + ...SOURCE, + id: 'two', + sourceIndex: 1, + fileName: 'two.mp4', + durationSeconds: 3, + streams: STREAMS, + }, + ]; + + it('checks exact fast-concat stream arrangement and properties', () => { + expect(validateConcatCompatibility(SOURCES)).toEqual([]); + expect( + validateConcatCompatibility([ + SOURCES[0]!, + { + ...SOURCES[1]!, + streams: [{ ...STREAMS[0]!, width: 1280 }, STREAMS[1]!], + }, + ])[0] + ).toMatchObject({ code: 'stream-property', severity: 'error' }); + const plan = buildFastConcatPlan({ + jobId: 'concat', + sources: SOURCES, + targetExtension: 'mp4', + targetMuxer: 'mp4', + }); + expect(plan.temporaryFiles[0]?.content).toBe( + "file '/input/job-concat/source-0.mp4'\nfile '/input/job-concat/source-1.mp4'\n" + ); + expect(plan.expectedDurationSeconds).toBe(5); + }); + + it('corrects crop to even bounds and calculates aspect-preserving resize', () => { + expect( + validateCrop({ + x: 3, + y: 5, + width: 101, + height: 51, + sourceWidth: 200, + sourceHeight: 100, + requireEvenDimensions: true, + }) + ).toMatchObject({ x: 2, y: 4, width: 100, height: 50 }); + expect( + calculateResize({ + sourceWidth: 1920, + sourceHeight: 1080, + width: 640, + height: 640, + mode: 'contain', + }) + ).toEqual({ width: 640, height: 360 }); + }); + + it('orders typed filter stages independently of insertion order', () => { + const builder = createVideoFiltergraph() + .add({ id: 'fade', stage: 'fade', expression: 'fade=t=in:d=1' }) + .add({ id: 'crop', stage: 'crop', expression: 'crop=100:100:0:0' }) + .add({ id: 'fps', stage: 'frame-rate', expression: 'fps=25' }); + expect(builder.build().graph).toBe('crop=100:100:0:0,fps=25,fade=t=in:d=1'); + }); + + it('builds a bounded preview proxy with optional audio mapping', () => { + const plan = buildPreviewProxyPlan({ + jobId: 'preview', + source: SOURCE, + sourceDurationSeconds: 120, + startSeconds: 10, + }); + expect(plan.expectedDurationSeconds).toBe(30); + expect(plan.args).toContain('0:a:0?'); + expect(plan.args).toContain("scale=w='max(2,trunc(min(960,iw)/2)*2)':h=-2"); + }); + + it('builds a bounded AAC/M4A proxy for an audio-only source', () => { + const plan = buildPreviewProxyPlan({ + jobId: 'audio-preview', + source: SOURCE, + sourceDurationSeconds: 90, + maxDurationSeconds: 20, + hasVideo: false, + }); + + expect(plan.expectedDurationSeconds).toBe(20); + expect(plan.outputs[0]?.fileName).toMatch(/-preview\.m4a$/u); + expect(plan.outputs[0]?.mediaType).toBe('audio/mp4'); + expect(plan.args).toContain('0:a:0'); + expect(plan.args).toContain('aac'); + expect(plan.args).not.toContain('0:v:0'); + expect(plan.args).not.toContain('libx264'); + expect(plan.args).not.toContain('-vf'); + expect(plan.requiredCapabilities).toMatchObject({ + muxers: ['ipod'], + encoders: ['aac'], + filters: [], + }); + }); + + it('builds one normalized timeline filter graph', () => { + const preset = findBuiltInPreset('mp4-h264-balanced'); + const plan = buildTimelineExportPlan({ + jobId: 'timeline', + preset: preset!, + outputWidth: 640, + outputHeight: 360, + frameRate: 25, + missingAudioPolicy: 'insert-silence', + outputFileName: '../../My final cut.mov', + clips: [ + { + id: 'one', + source: SOURCES[0]!, + sourceInSeconds: 0.25, + sourceOutSeconds: 1.75, + hasVideo: true, + hasAudio: true, + audioFadeInSeconds: 0.25, + audioFadeCurve: 'qsin', + videoFadeOutSeconds: 0.25, + videoFadeCurve: 'exp', + }, + { + id: 'two', + source: SOURCES[1]!, + sourceInSeconds: 0, + sourceOutSeconds: 2, + hasVideo: true, + hasAudio: false, + }, + ], + }); + expect( + plan.args.filter((argument) => argument === '-filter_complex') + ).toHaveLength(1); + const graph = plan.args[plan.args.indexOf('-filter_complex') + 1]; + expect(graph).toContain('anullsrc='); + expect(graph).toContain('curve=qsin'); + expect(graph).toContain("geq=lum_expr='lum(X,Y)*((exp(5*"); + expect(graph).toContain('concat=n=2:v=1:a=1[vout][aout]'); + expect(plan.requiredCapabilities.filters).toContain('geq'); + expect(plan.expectedDurationSeconds).toBe(3.5); + expect(plan.outputs[0]?.fileName).toBe('My-final-cut.mp4'); + expect(plan.outputs[0]?.path).not.toContain('..'); + }); +}); diff --git a/tests/unit/components/capability-panel.test.tsx b/tests/unit/components/capability-panel.test.tsx new file mode 100644 index 0000000..1f17ef5 --- /dev/null +++ b/tests/unit/components/capability-panel.test.tsx @@ -0,0 +1,139 @@ +import { cleanup, render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { CapabilityPanel } from '../../../src/components/CapabilityPanel'; +import type { + EngineState, + FFmpegCapabilities, +} from '../../../src/ffmpeg/ffmpeg.types'; +import { createResourcePolicy } from '../../../src/storage'; + +afterEach(cleanup); + +describe('CapabilityPanel', () => { + it('shows the active device-adjusted resource policy', async () => { + const user = userEvent.setup(); + const policy = createResourcePolicy({ + softInputBytes: 128 * 1024 * 1024, + hardSingleInputBytes: 512 * 1024 * 1024, + hardTotalInputBytes: 768 * 1024 * 1024, + softOutputEstimateBytes: 256 * 1024 * 1024, + hardOutputEstimateBytes: 512 * 1024 * 1024, + }); + + render( + + ); + + await user.click(screen.getByText('Engine & browser')); + expect(screen.getByText('Active resource limits')).toBeVisible(); + expect(screen.getByText('Adjusted for 2 GiB device memory')).toBeVisible(); + expect(screen.getByText('128 MiB / 512 MiB')).toBeVisible(); + expect( + screen.getByText(/conservative estimates, not media format limits/i) + ).toBeVisible(); + }); + + it('reports the exact FFmpeg version, build flags, and capability counts', async () => { + const user = userEvent.setup(); + render( + + ); + + await user.click(screen.getByText('Engine & browser')); + expect(screen.getByTitle(/ffmpeg version n5\.1\.4/iu)).toHaveTextContent( + 'ffmpeg version n5.1.4' + ); + expect(valueFor('Demuxers')).toHaveTextContent('3'); + expect(valueFor('Muxers')).toHaveTextContent('2'); + expect(valueFor('Decoders')).toHaveTextContent('4'); + expect(valueFor('Encoders')).toHaveTextContent('5'); + expect(valueFor('Filters')).toHaveTextContent('6'); + expect( + screen.getByText('Detected capability inventory (20 entries)') + ).toBeInTheDocument(); + expect( + screen.getByText( + /Demuxers \(3\)\s+matroska, mov, wav[\s\S]+Encoders \(5\)\s+aac, libvorbis, libvpx, libx264, png/u + ) + ).toBeInTheDocument(); + expect( + screen.getByText('FFmpeg build configuration (2 flags)') + ).toBeInTheDocument(); + expect( + screen.getByText(/--enable-gpl\s+--enable-libx264/u) + ).toBeInTheDocument(); + }); + + it('exposes controlled initialization failure details and retries', async () => { + const user = userEvent.setup(); + const onInitialize = vi.fn(); + const state: EngineState = { + status: 'error', + error: { + code: 'load-failed', + message: 'The compatibility core could not be loaded.', + details: 'worker bootstrap failed', + exitCode: 1, + }, + }; + + render( + + ); + + await user.click(screen.getByText('Engine & browser')); + expect(screen.getByRole('alert')).toHaveTextContent( + 'The compatibility core could not be loaded.' + ); + expect(screen.getByText('load-failed')).toBeVisible(); + expect(screen.getByText('worker bootstrap failed')).toBeInTheDocument(); + await user.click( + screen.getByRole('button', { name: 'Retry initialization' }) + ); + expect(onInitialize).toHaveBeenCalledOnce(); + }); +}); + +function valueFor(label: string): HTMLElement { + const container = screen.getByText(label).closest('div'); + if (!container) throw new Error(`Missing capability cell for ${label}`); + return within(container).getByRole('definition'); +} + +function readyEngine(): EngineState { + const capabilities: FFmpegCapabilities = { + versionText: + 'ffmpeg version n5.1.4 Copyright the FFmpeg developers\nconfiguration: test', + buildConfiguration: ['--enable-gpl', '--enable-libx264'], + demuxers: new Set(['mov', 'matroska', 'wav']), + muxers: new Set(['mp4', 'webm']), + decoders: new Set(['h264', 'aac', 'vp8', 'vorbis']), + encoders: new Set(['libx264', 'aac', 'libvpx', 'libvorbis', 'png']), + filters: new Set(['scale', 'crop', 'fps', 'format', 'aresample', 'volume']), + }; + return { + status: 'ready', + mode: 'single-thread', + capabilities, + }; +} diff --git a/tests/unit/components/crop-overlay.test.tsx b/tests/unit/components/crop-overlay.test.tsx new file mode 100644 index 0000000..02c9127 --- /dev/null +++ b/tests/unit/components/crop-overlay.test.tsx @@ -0,0 +1,149 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { CropOverlay } from '../../../src/components/CropOverlay'; + +describe('CropOverlay', () => { + afterEach(cleanup); + + it('maps a clockwise-oriented preview back to encoded source pixels', () => { + const onChange = vi.fn(); + const { container } = render( + + Local preview + + ); + + const rectangle = container.querySelector('.crop-editor__rectangle'); + expect(rectangle).toHaveStyle({ + left: '25%', + top: '10%', + width: '50%', + height: '30%', + }); + + fireEvent.keyDown( + screen.getByRole('button', { name: 'Move crop rectangle' }), + { key: 'ArrowRight' } + ); + + expect(onChange).toHaveBeenLastCalledWith({ + x: 10, + y: 19, + width: 30, + height: 40, + }); + }); + + it('supports pointer dragging with bounded percentage-to-pixel math', () => { + const onChange = vi.fn(); + const { container } = render( + +
Preview
+
+ ); + const stage = container.querySelector('.crop-editor__stage'); + if (!(stage instanceof HTMLElement)) throw new Error('Missing crop stage'); + vi.spyOn(stage, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + left: 0, + top: 0, + right: 200, + bottom: 100, + width: 200, + height: 100, + toJSON: () => ({}), + }); + const move = screen.getByRole('button', { name: 'Move crop rectangle' }); + + fireEvent.pointerDown(move, { + pointerId: 7, + clientX: 10, + clientY: 10, + }); + fireEvent.pointerMove(move, { + pointerId: 7, + clientX: 60, + clientY: 30, + }); + fireEvent.pointerUp(move, { pointerId: 7 }); + + expect(onChange).toHaveBeenLastCalledWith({ + x: 70, + y: 30, + width: 100, + height: 60, + }); + }); + + it('locks common display ratios without confusing rotated dimensions', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render( + +
Preview
+
+ ); + + await user.selectOptions( + screen.getByRole('combobox', { name: 'Common ratio' }), + '16:9' + ); + + const crop = onChange.mock.lastCall?.[0] as + { width: number; height: number } | undefined; + expect(crop).toBeDefined(); + expect((crop?.width ?? 0) / (crop?.height ?? 1)).toBeCloseTo(9 / 16, 2); + expect( + screen.getByRole('checkbox', { name: 'Lock aspect ratio' }) + ).toBeChecked(); + }); + + it('rejects invalid numeric edits and emits even reset bounds when required', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render( + +
Preview
+
+ ); + + await user.clear(screen.getByRole('spinbutton', { name: 'Width' })); + expect(screen.getByRole('alert')).toHaveTextContent( + 'All crop fields are required.' + ); + expect(onChange).not.toHaveBeenCalled(); + + await user.click(screen.getByRole('button', { name: 'Reset crop' })); + expect(onChange).toHaveBeenLastCalledWith({ + x: 0, + y: 0, + width: 100, + height: 98, + }); + }); +}); diff --git a/tests/unit/components/editor-integration.test.tsx b/tests/unit/components/editor-integration.test.tsx new file mode 100644 index 0000000..14c3e2f --- /dev/null +++ b/tests/unit/components/editor-integration.test.tsx @@ -0,0 +1,363 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ImportedMediaAsset } from '../../../src/app/application-state'; +import { Inspector } from '../../../src/components/Inspector'; +import { Timeline } from '../../../src/components/Timeline'; +import type { AvProjectV1, TimelineClip } from '../../../src/project'; +import { aggregatePcmPeaks } from '../../../src/waveform'; + +describe('visual editor integration', () => { + beforeEach(() => { + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue( + createCanvasContext() + ); + }); + + afterEach(cleanup); + + it('shows encoded dimensions separately from normalized display metadata', () => { + const project = createProject(); + + render( + + ); + + expect( + screen.getByText('Encoded pixels 101×99 · coded 102×100 · 25.000 fps') + ).toBeVisible(); + expect( + screen.getByText('SAR 4:3 · DAR 16:9 · rotation -90°') + ).toBeVisible(); + }); + + it('uses the local video object URL in the rotation-aware crop editor', async () => { + const user = userEvent.setup(); + const onClipUpdate = vi.fn(); + const project = createProject(); + const clip = { + ...project.timeline[0]!, + crop: { x: 0, y: 0, width: 100, height: 98 }, + rotation: 90 as const, + }; + const asset = createImportedAsset(); + + render( + + ); + + await user.click(screen.getByRole('tab', { name: 'Transform' })); + + const cropEditor = screen.getByRole('region', { + name: 'Crop local-video.mp4', + }); + expect(cropEditor).toHaveAttribute('data-orientation', '90'); + expect( + screen.getByLabelText('Local preview of local-video.mp4') + ).toHaveAttribute('src', 'blob:local-video'); + expect( + screen.getByRole('button', { name: 'Move crop rectangle' }) + ).toBeDisabled(); + expect( + screen.getByRole('group', { name: 'Encoded source pixels' }) + ).toBeVisible(); + }); + + it('keeps numeric crop inputs when a visual video source is unavailable', async () => { + const user = userEvent.setup(); + const onClipUpdate = vi.fn(); + const project = createProject(); + + render( + + ); + + await user.click(screen.getByRole('tab', { name: 'Transform' })); + expect( + screen.getByText(/visual crop preview is unavailable/i) + ).toBeVisible(); + + fireEvent.change(screen.getByRole('spinbutton', { name: 'WIDTH' }), { + target: { value: '101' }, + }); + expect(onClipUpdate).toHaveBeenLastCalledWith({ + crop: { x: 0, y: 0, width: 100, height: 2 }, + }); + }); + + it('edits allowlisted fade curves and persists chapter time bases', async () => { + const user = userEvent.setup(); + const onClipUpdate = vi.fn(); + const onChaptersChange = vi.fn(); + const baseProject = createProject(); + const project: AvProjectV1 = { + ...baseProject, + chapters: [ + { + id: 'chapter-1', + title: 'Intro', + startSeconds: 0, + endSeconds: 1, + timeBase: '1/1000', + }, + ], + }; + + render( + + ); + + await user.click(screen.getByRole('tab', { name: 'Transform' })); + await user.selectOptions( + screen.getByRole('combobox', { name: 'Video fade curve' }), + 'exp' + ); + expect(onClipUpdate).toHaveBeenLastCalledWith({ + video: { enabled: true, fadeCurve: 'exp' }, + }); + + await user.click(screen.getByRole('tab', { name: 'Audio' })); + await user.selectOptions( + screen.getByRole('combobox', { name: 'Audio fade curve' }), + 'qsin' + ); + expect(onClipUpdate).toHaveBeenLastCalledWith({ + audio: { enabled: true, fadeCurve: 'qsin' }, + }); + + await user.click(screen.getByRole('tab', { name: 'Chapters' })); + fireEvent.change(screen.getByLabelText('Chapter 1 time base'), { + target: { value: '1/48000' }, + }); + expect(onChaptersChange).toHaveBeenLastCalledWith([ + expect.objectContaining({ id: 'chapter-1', timeBase: '1/48000' }), + ]); + }); + + it('updates the selected clip and forwards validated waveform selections', () => { + const project = createProject(); + const onUpdate = vi.fn(); + const onSelectionChange = vi.fn(); + + render( + + ); + + expect(screen.getByText('local-video.mp4')).toBeVisible(); + expect(screen.getByText('Single sequential audio track')).toBeVisible(); + + fireEvent.keyDown(screen.getByRole('slider', { name: 'In trim handle' }), { + key: 'ArrowRight', + }); + + expect(onUpdate).toHaveBeenLastCalledWith('clip-1', { + sourceInSeconds: 1.1, + sourceOutSeconds: 9, + }); + expect(onSelectionChange).toHaveBeenLastCalledWith({ + inSeconds: 1.1, + outSeconds: 9, + markers: [3], + }); + }); + + it('preserves the original clip-card timeline when waveform data is omitted', () => { + const project = createProject(); + + render( + + ); + + expect(screen.getByText('local-video.mp4')).toBeVisible(); + expect( + screen.queryByText('Single sequential audio track') + ).not.toBeInTheDocument(); + expect(screen.getByRole('spinbutton', { name: 'In' })).toHaveValue(1); + expect(screen.getByRole('spinbutton', { name: 'Out' })).toHaveValue(9); + }); + + it('resets the selected clip trim to its full probed source duration', async () => { + const user = userEvent.setup(); + const project = createProject(); + const onUpdate = vi.fn(); + + render( + + ); + + await user.click( + screen.getByRole('button', { name: 'Reset trim to full source' }) + ); + + expect(onUpdate).toHaveBeenCalledWith('clip-1', { + sourceInSeconds: 0, + sourceOutSeconds: 10, + }); + }); +}); + +function createProject(): AvProjectV1 { + const clip: TimelineClip = { + id: 'clip-1', + assetId: 'asset-1', + sourceInSeconds: 1, + sourceOutSeconds: 9, + }; + return { + schemaVersion: 1, + id: 'project-1', + name: 'Test project', + createdAt: '2026-07-24T12:00:00.000Z', + updatedAt: '2026-07-24T12:00:00.000Z', + assets: [ + { + id: 'asset-1', + originalName: 'local-video.mp4', + size: 4, + lastModified: 0, + mimeType: 'video/mp4', + sourceStatus: 'attached', + probe: createProbe(), + }, + ], + timeline: [clip], + output: { + presetId: 'video-h264', + fileName: 'output.mp4', + container: 'mp4', + videoEnabled: true, + audioEnabled: true, + }, + metadata: { custom: {} }, + chapters: [], + subtitles: [], + }; +} + +function createImportedAsset(): ImportedMediaAsset { + return { + id: 'asset-1', + file: new File(['test'], 'local-video.mp4', { type: 'video/mp4' }), + objectUrl: 'blob:local-video', + phase: 'ready', + probe: createProbe(), + }; +} + +function createProbe() { + return { + durationSeconds: 10, + formatNames: ['mov', 'mp4'], + tags: {}, + chapters: [], + warnings: [], + streams: [ + { + index: 0, + type: 'video' as const, + codecName: 'h264', + width: 101, + height: 99, + codedWidth: 102, + codedHeight: 100, + sampleAspectRatio: '4:3', + displayAspectRatio: '16:9', + rotationDegrees: -90, + frameRate: 25, + disposition: {}, + tags: {}, + }, + ], + }; +} + +function createPeaks() { + return aggregatePcmPeaks( + { + container: 'raw', + data: new Int16Array( + Array.from({ length: 100 }, (_, index) => + Math.round(Math.sin(index / 5) * 20_000) + ) + ), + encoding: 's16le', + sampleRate: 10, + }, + { bucketCount: 100 } + ); +} + +function createCanvasContext(): CanvasRenderingContext2D { + return { + setTransform: vi.fn(), + clearRect: vi.fn(), + fillRect: vi.fn(), + beginPath: vi.fn(), + moveTo: vi.fn(), + lineTo: vi.fn(), + stroke: vi.fn(), + fillStyle: '', + strokeStyle: '', + lineWidth: 1, + } as unknown as CanvasRenderingContext2D; +} diff --git a/tests/unit/components/help-dialog.test.tsx b/tests/unit/components/help-dialog.test.tsx new file mode 100644 index 0000000..e6e5f43 --- /dev/null +++ b/tests/unit/components/help-dialog.test.tsx @@ -0,0 +1,48 @@ +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { HelpDialog } from '../../../src/components/HelpDialog'; + +afterEach(cleanup); + +describe('HelpDialog', () => { + it('exposes bundled engine, licence, source and privacy context', () => { + render(); + + expect( + screen.getByRole('heading', { + name: 'About & licences', + hidden: true, + }) + ).toBeInTheDocument(); + expect(screen.getByText('n5.1.4')).toBeInTheDocument(); + expect(screen.getByText('0.12.15')).toBeInTheDocument(); + expect(screen.getByText('0.12.10 · ST & MT')).toBeInTheDocument(); + expect( + screen.getByText(/copyright © 2026 Albrecht Degering/i) + ).toBeInTheDocument(); + expect( + screen.getByRole('link', { name: 'Application GPL', hidden: true }) + ).toHaveAttribute('href', expect.stringContaining('LICENSE')); + expect( + screen.getByRole('link', { name: 'MIT text', hidden: true }) + ).toHaveAttribute('href', expect.stringContaining('ffmpeg.wasm-MIT')); + expect( + screen.getByRole('link', { name: 'GPL text', hidden: true }) + ).toHaveAttribute('href', expect.stringContaining('GPL-2.0-or-later')); + expect( + screen.getByRole('link', { + name: 'Complete inventory', + hidden: true, + }) + ).toHaveAttribute('href', expect.stringContaining('THIRD_PARTY_NOTICES')); + expect( + screen.getByRole('link', { + name: 'Source & reproducibility record', + hidden: true, + }) + ).toHaveAttribute('href', expect.stringContaining('SOURCE')); + expect( + screen.getByText(/source files stay inside the browser session/i) + ).toBeInTheDocument(); + }); +}); diff --git a/tests/unit/components/job-panel.test.tsx b/tests/unit/components/job-panel.test.tsx new file mode 100644 index 0000000..ac9ea1d --- /dev/null +++ b/tests/unit/components/job-panel.test.tsx @@ -0,0 +1,196 @@ +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { JobPanel } from '../../../src/components/JobPanel'; +import type { ExportResult } from '../../../src/export'; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe('JobPanel result selection', () => { + it('downloads only the selected generated results as ZIP', async () => { + const user = userEvent.setup(); + const onSaveZip = vi.fn(); + const first = result('one', 'one.png'); + const second = result('two', 'two.png'); + + render( + + ); + + await waitFor(() => + expect( + screen.getByRole('button', { + name: 'Download selected as ZIP (2)', + }) + ).toBeEnabled() + ); + await user.click( + screen.getByRole('checkbox', { name: 'Select two.png for ZIP' }) + ); + await user.click( + screen.getByRole('button', { name: 'Download selected as ZIP (1)' }) + ); + expect(onSaveZip).toHaveBeenCalledWith([first]); + }); + + it('marks browser-rejected media as download-only and does not offer Preview', () => { + vi.spyOn(HTMLMediaElement.prototype, 'canPlayType').mockReturnValue(''); + const output = mediaResult('audio', 'archive.flac', 'audio/flac'); + + render( + + ); + + expect(screen.queryByRole('button', { name: 'Preview' })).toBeNull(); + expect( + screen.getByText(/Download only: The browser reports no native/iu) + ).toBeVisible(); + expect(screen.getByRole('button', { name: 'Save result' })).toBeEnabled(); + }); + + it('shows the exact split interval and per-result verification state', () => { + const output = { + ...mediaResult('segment', 'clip-001.mp4', 'video/mp4'), + timeRange: { startSeconds: 1.25, endSeconds: 3.5 }, + }; + + render( + + ); + + expect( + screen.getByText( + 'Segment range 00:00:01.250–00:00:03.500 · 00:00:02.250 duration' + ) + ).toBeVisible(); + expect(screen.getByText(/created · verification warning/iu)).toBeVisible(); + expect( + screen.getByText('Output was created, but its verification probe failed.') + ).toBeVisible(); + }); +}); + +function result(id: string, fileName: string): ExportResult { + const blob = new Blob([id], { type: 'image/png' }); + return { + id, + planId: `plan-${id}`, + outputId: `output-${id}`, + fileName, + blob, + mimeType: blob.type, + role: 'thumbnail', + size: blob.size, + createdAt: '2026-01-01T00:00:00.000Z', + }; +} + +function mediaResult( + id: string, + fileName: string, + mimeType: string +): ExportResult { + const blob = new Blob([id], { type: mimeType }); + return { + id, + planId: `plan-${id}`, + outputId: `output-${id}`, + fileName, + blob, + mimeType, + role: 'media', + size: blob.size, + createdAt: '2026-01-01T00:00:00.000Z', + }; +} diff --git a/tests/unit/components/media-preview.test.tsx b/tests/unit/components/media-preview.test.tsx new file mode 100644 index 0000000..72dab7a --- /dev/null +++ b/tests/unit/components/media-preview.test.tsx @@ -0,0 +1,259 @@ +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ImportedMediaAsset } from '../../../src/app/application-state'; +import { + MediaPreview, + type PreviewExportResult, +} from '../../../src/components/MediaPreview'; +import { aggregatePcmPeaks } from '../../../src/waveform'; + +beforeEach(() => { + vi.spyOn(HTMLMediaElement.prototype, 'canPlayType').mockReturnValue( + 'probably' + ); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe('MediaPreview', () => { + it('offers bounded configurable proxy settings after playback fails', async () => { + const user = userEvent.setup(); + const onCreateProxy = vi.fn(); + const onProxySettingsChange = vi.fn(); + const { container } = render( + + ); + + fireEvent.error(requiredElement(container.querySelector('video'))); + + expect(screen.getByText('Create a disposable preview proxy')).toBeVisible(); + fireEvent.change( + screen.getByRole('spinbutton', { name: 'Maximum seconds' }), + { target: { value: '60' } } + ); + expect(onProxySettingsChange).toHaveBeenLastCalledWith({ + maxDurationSeconds: 60, + maxWidth: 960, + }); + await user.selectOptions( + screen.getByRole('combobox', { name: 'Maximum width' }), + '1280' + ); + expect(onProxySettingsChange).toHaveBeenLastCalledWith({ + maxDurationSeconds: 30, + maxWidth: 1280, + }); + await user.click(screen.getByRole('button', { name: 'Create proxy' })); + expect(onCreateProxy).toHaveBeenCalledOnce(); + }); + + it('seeks a source preview externally and reports its actual playback time', () => { + const onCurrentTimeChange = vi.fn(); + const { container, rerender } = render( + + ); + + const video = requiredElement(container.querySelector('video')); + expect(video.currentTime).toBeCloseTo(3.25); + + video.currentTime = 7.75; + fireEvent.timeUpdate(video); + expect(onCurrentTimeChange).toHaveBeenLastCalledWith(7.75); + + rerender( + + ); + expect(video.currentTime).toBeCloseTo(1.5); + }); + + it('uses the result media kind, honors result seeks, and reports result time', () => { + const onCurrentTimeChange = vi.fn(); + const result: PreviewExportResult = { + name: 'audio-result.mp3', + mimeType: 'audio/mpeg', + objectUrl: 'blob:audio-result', + }; + const { container, rerender } = render( + + ); + + expect(container.querySelector('video')).toBeNull(); + const audio = requiredElement(container.querySelector('audio')); + expect(audio).toHaveAttribute('src', result.objectUrl); + expect(audio.currentTime).toBeCloseTo(2); + + rerender( + + ); + expect(audio.currentTime).toBeCloseTo(5.5); + + audio.currentTime = 8.125; + fireEvent.timeUpdate(audio); + expect(onCurrentTimeChange).toHaveBeenLastCalledWith(8.125); + }); + + it('uses bounded peaks for an audio-only source and offers an AAC/M4A proxy', async () => { + vi.mocked(HTMLMediaElement.prototype.canPlayType).mockReturnValue(''); + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue( + canvasContext() + ); + const peaks = aggregatePcmPeaks( + { + container: 'raw', + data: new Int16Array([-32_768, 0, 16_384, 32_767]), + encoding: 's16le', + sampleRate: 2, + }, + { bucketCount: 4 } + ); + const { container } = render( + + ); + + await waitFor(() => + expect(container.querySelector('canvas')).toHaveAttribute( + 'aria-label', + 'Bounded waveform preview for source.flac.' + ) + ); + expect(container.querySelector('.audio-preview__disc')).toBeNull(); + expect(container.querySelector('audio')).toBeInTheDocument(); + expect(screen.getByText(/AAC\/M4A/iu)).toBeVisible(); + expect( + screen.queryByRole('combobox', { name: 'Maximum width' }) + ).toBeNull(); + }); + + it('shows an explicit download-only result instead of mounting unsupported playback', () => { + vi.mocked(HTMLMediaElement.prototype.canPlayType).mockReturnValue(''); + const result: PreviewExportResult = { + name: 'archive.flac', + mimeType: 'audio/flac', + objectUrl: 'blob:unsupported-result', + }; + const { container } = render(); + + expect(container.querySelector('audio')).toBeNull(); + expect( + screen.getByText( + 'Download only: this browser cannot preview this result.' + ) + ).toBeVisible(); + expect(screen.getByText('Download only · archive.flac')).toBeVisible(); + }); +}); + +function videoAsset(): ImportedMediaAsset { + return { + id: 'asset-video', + file: new File(['video'], 'source.mp4', { type: 'video/mp4' }), + objectUrl: 'blob:source-video', + phase: 'ready', + probe: { + durationSeconds: 10, + formatNames: ['mov', 'mp4'], + tags: {}, + chapters: [], + warnings: [], + streams: [ + { + index: 0, + type: 'video', + codecName: 'h264', + width: 640, + height: 360, + disposition: {}, + tags: {}, + }, + ], + }, + }; +} + +function audioAsset(): ImportedMediaAsset { + return { + id: 'asset-audio', + file: new File(['audio'], 'source.flac', { type: 'audio/flac' }), + objectUrl: 'blob:source-audio', + phase: 'ready', + probe: { + durationSeconds: 2, + formatNames: ['flac'], + tags: {}, + chapters: [], + warnings: [], + streams: [ + { + index: 0, + type: 'audio', + codecName: 'flac', + sampleRate: 2, + channels: 1, + disposition: {}, + tags: {}, + }, + ], + }, + }; +} + +function canvasContext(): CanvasRenderingContext2D { + return { + setTransform: vi.fn(), + clearRect: vi.fn(), + fillRect: vi.fn(), + beginPath: vi.fn(), + moveTo: vi.fn(), + lineTo: vi.fn(), + stroke: vi.fn(), + fillStyle: '', + strokeStyle: '', + lineWidth: 1, + } as unknown as CanvasRenderingContext2D; +} + +function requiredElement(element: T | null): T { + if (!element) { + throw new Error('Expected media element to be rendered'); + } + return element; +} diff --git a/tests/unit/components/quick-convert.test.tsx b/tests/unit/components/quick-convert.test.tsx new file mode 100644 index 0000000..d67da0b --- /dev/null +++ b/tests/unit/components/quick-convert.test.tsx @@ -0,0 +1,335 @@ +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { ImportedMediaAsset } from '../../../src/app/application-state'; +import { + QuickConvert, + type QuickExportConfiguration, +} from '../../../src/components/QuickConvert'; +import type { + EngineState, + FFmpegCapabilities, +} from '../../../src/ffmpeg/ffmpeg.types'; +import { BUILT_IN_PRESETS, validateUserPreset } from '../../../src/presets'; +import type { UserExportPreset } from '../../../src/presets'; + +afterEach(cleanup); + +describe('QuickConvert', () => { + it('labels a submission honestly when another operation is active', () => { + render( + + ); + + expect(screen.getByRole('button', { name: 'Add to queue' })).toBeEnabled(); + }); + + it('preserves an explicitly empty stream selection and blocks export', async () => { + const user = userEvent.setup(); + const onExport = vi.fn(); + const asset = createAsset('asset-a', 'source-a.mp4'); + const { rerender } = render( + + ); + + const videoStream = screen.getByRole('checkbox', { + name: /video.*#0/iu, + }); + const audioStream = screen.getByRole('checkbox', { + name: /audio.*#1/iu, + }); + await user.click(videoStream); + await user.click(audioStream); + + expect(videoStream).not.toBeChecked(); + expect(audioStream).not.toBeChecked(); + expect( + screen.getByText('Select at least one output stream.') + ).toBeVisible(); + expect(screen.getByRole('button', { name: 'Convert' })).toBeDisabled(); + + rerender( + + ); + + expect( + screen.getByRole('checkbox', { name: /video.*#0/iu }) + ).not.toBeChecked(); + expect( + screen.getByRole('checkbox', { name: /audio.*#1/iu }) + ).not.toBeChecked(); + expect(onExport).not.toHaveBeenCalled(); + }); + + it('starts a newly selected asset with all of its streams selected', async () => { + const user = userEvent.setup(); + const firstAsset = createAsset('asset-a', 'source-a.mp4'); + const secondAsset = createAsset('asset-b', 'source-b.mp4'); + const { rerender } = render( + + ); + + await user.click(screen.getByRole('checkbox', { name: /video.*#0/iu })); + expect( + screen.getByRole('checkbox', { name: /video.*#0/iu }) + ).not.toBeChecked(); + + rerender( + + ); + + expect(screen.getByText('source-b.mp4')).toBeVisible(); + expect(screen.getByRole('checkbox', { name: /video.*#0/iu })).toBeChecked(); + expect(screen.getByRole('checkbox', { name: /audio.*#1/iu })).toBeChecked(); + }); + + it('disables unavailable presets and incompatible remux targets with reasons', async () => { + const user = userEvent.setup(); + render( + + ); + + expect( + screen.getByRole('option', { + name: /WebM · VP8 \+ Vorbis — unavailable: Missing muxers: webm/iu, + }) + ).toBeDisabled(); + + await user.click(screen.getByRole('button', { name: 'Fast remux' })); + expect( + screen.getByText('The loaded core is missing muxer matroska.') + ).toBeVisible(); + expect(screen.getByRole('button', { name: 'Remux' })).toBeDisabled(); + expect( + screen.getByRole('option', { + name: /WebM.*unavailable: missing muxer webm.*video codec h264.*audio codec aac/iu, + }) + ).toBeDisabled(); + }); + + it('exposes a validated user preset and returns it unchanged on export', async () => { + const user = userEvent.setup(); + const onExport = vi.fn<(configuration: QuickExportConfiguration) => void>(); + const userPreset = validatedUserPreset(); + + render( + + ); + + const presetSelect = screen.getByRole('combobox', { + name: /^Export preset/iu, + }); + expect( + screen.getByRole('option', { name: 'My local H.264 export' }) + ).toBeInTheDocument(); + + await user.selectOptions(presetSelect, userPreset.id); + expect( + screen.getByText('A validated user-created local export preset.') + ).toBeVisible(); + await user.click(screen.getByRole('button', { name: 'Convert' })); + + await waitFor(() => expect(onExport).toHaveBeenCalledOnce()); + expect(onExport).toHaveBeenCalledWith({ + operation: 'convert', + preset: userPreset, + remuxContainer: 'matroska', + streamSelection: { + video: [0], + audio: [1], + subtitles: [], + attachments: [], + data: [], + }, + removeMetadata: false, + removeChapters: false, + }); + }); + + it('disables unsupported auxiliary streams for conversion and maps them for Matroska remux', async () => { + const user = userEvent.setup(); + const onExport = vi.fn<(configuration: QuickExportConfiguration) => void>(); + const asset = createAsset('asset-a', 'source-a.mkv'); + asset.probe?.streams.push( + { + index: 2, + type: 'attachment', + codecName: 'ttf', + disposition: {}, + tags: {}, + }, + { + index: 3, + type: 'data', + codecName: 'bin_data', + disposition: {}, + tags: {}, + } + ); + + render( + + ); + + const attachment = screen.getByRole('checkbox', { + name: /attachment.*#2/iu, + }); + const data = screen.getByRole('checkbox', { name: /data.*#3/iu }); + expect(attachment).toBeDisabled(); + expect(attachment).not.toBeChecked(); + expect(data).toBeDisabled(); + expect(data).not.toBeChecked(); + expect( + screen.getByText(/attachment streams are only preserved.*Matroska/iu) + ).toBeVisible(); + expect( + screen.getByText(/data streams are only preserved.*Matroska/iu) + ).toBeVisible(); + + await user.click(screen.getByRole('button', { name: 'Fast remux' })); + expect(attachment).toBeEnabled(); + expect(attachment).toBeChecked(); + expect(data).toBeEnabled(); + expect(data).toBeChecked(); + await user.click(screen.getByRole('button', { name: 'Remux' })); + + await waitFor(() => expect(onExport).toHaveBeenCalledOnce()); + expect(onExport.mock.calls[0]?.[0].streamSelection).toEqual({ + video: [0], + audio: [1], + subtitles: [], + attachments: [2], + data: [3], + }); + }); +}); + +function createAsset(id: string, name: string): ImportedMediaAsset { + return { + id, + file: new File(['media'], name, { type: 'video/mp4' }), + objectUrl: `blob:${id}`, + phase: 'ready', + probe: { + durationSeconds: 12, + formatNames: ['mov', 'mp4'], + tags: {}, + chapters: [], + warnings: [], + streams: [ + { + index: 0, + type: 'video', + codecName: 'h264', + width: 640, + height: 360, + disposition: {}, + tags: {}, + }, + { + index: 1, + type: 'audio', + codecName: 'aac', + sampleRate: 48_000, + channels: 2, + disposition: {}, + tags: {}, + }, + ], + }, + }; +} + +function readyEngine(muxers: readonly string[] = ['mp4']): EngineState { + return { + status: 'ready', + mode: 'single-thread', + capabilities: capabilities(muxers), + }; +} + +function capabilities(muxers: readonly string[] = ['mp4']): FFmpegCapabilities { + return { + versionText: 'test', + buildConfiguration: [], + demuxers: new Set(['mov']), + muxers: new Set(muxers), + decoders: new Set(['h264', 'aac']), + encoders: new Set(['libx264', 'aac']), + filters: new Set(), + }; +} + +function validatedUserPreset(): UserExportPreset { + const candidate: UserExportPreset = { + schemaVersion: 1, + id: 'my-local-h264', + name: 'My local H.264 export', + kind: 'video', + container: 'mp4', + fileExtension: 'mp4', + video: { + codec: 'libx264', + qualityMode: 'balanced', + crf: 22, + pixelFormat: 'yuv420p', + }, + audio: { + codec: 'aac', + bitrateKbps: 160, + sampleRate: 48_000, + channels: 2, + }, + metadataPolicy: 'copy', + chapterPolicy: 'keep', + subtitlePolicy: 'none', + fastStart: true, + }; + const result = validateUserPreset(candidate); + if (!result.valid || !result.preset) { + throw new Error(`Test preset is invalid: ${result.errors.join('; ')}`); + } + return result.preset; +} diff --git a/tests/unit/components/structural-operations.test.tsx b/tests/unit/components/structural-operations.test.tsx new file mode 100644 index 0000000..51fa3e5 --- /dev/null +++ b/tests/unit/components/structural-operations.test.tsx @@ -0,0 +1,376 @@ +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + StructuralOperations, + type StructuralCapabilityKey, + type StructuralCapabilityStatus, + type StructuralOperationsProps, +} from '../../../src/components/StructuralOperations'; + +const AVAILABLE: StructuralCapabilityStatus = { available: true }; + +const SELECTED_CLIP: NonNullable = { + id: 'clip-a', + label: 'Opening shot', + sourceInSeconds: 5.25, + sourceOutSeconds: 18.75, + sourceDurationSeconds: 60, + sourceExtension: '.MP4', + hasAudio: true, + hasVideo: true, +}; + +const TIMELINE: NonNullable = { + clips: [ + { + id: 'clip-a', + label: 'Opening shot', + hasAudio: true, + hasVideo: true, + hasSubtitles: false, + }, + { + id: 'clip-b', + label: 'Silent cutaway', + hasAudio: false, + hasVideo: true, + hasSubtitles: false, + }, + ], + fastTargetExtension: 'mp4', + fastTargetMuxer: 'mp4', + fastCompatibilityDiagnostics: [], +}; + +const PRESETS: NonNullable = [ + { + id: 'video-ready', + name: 'MP4 · H.264 + AAC', + fileExtension: 'mp4', + supportsAudio: true, + supportsVideo: true, + }, + { + id: 'video-disabled', + name: 'WebM · VP9 + Opus', + fileExtension: 'webm', + supportsAudio: true, + supportsVideo: true, + disabledReason: 'The Opus encoder is not available.', + }, + { + id: 'audio-only', + name: 'MP3 audio', + fileExtension: 'mp3', + supportsAudio: true, + supportsVideo: false, + }, +]; + +function capabilities( + ...keys: readonly StructuralCapabilityKey[] +): StructuralOperationsProps['availability'] { + return Object.fromEntries(keys.map((key) => [key, AVAILABLE])); +} + +afterEach(cleanup); + +describe('StructuralOperations', () => { + it('uses the selected clip range for a fast stream-copy trim', async () => { + const user = userEvent.setup(); + const onTrim = vi.fn(); + render( + + ); + + expect(screen.getByText('00:00:05.250–00:00:18.750')).toBeInTheDocument(); + expect( + screen.getByText(/first frame may move to a nearby keyframe/iu) + ).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Export fast trim' })); + + await waitFor(() => + expect(onTrim).toHaveBeenCalledWith({ + operation: 'trim', + mode: 'fast', + clipId: 'clip-a', + startSeconds: 5.25, + endSeconds: 18.75, + targetExtension: 'mp4', + }) + ); + }); + + it('requires and emits an available typed preset for an accurate trim', async () => { + const user = userEvent.setup(); + const onTrim = vi.fn(); + render( + + ); + + await user.click( + screen.getByRole('radio', { name: /Accurate re-encode/iu }) + ); + const action = screen.getByRole('button', { name: 'Export exact trim' }); + expect(action).toBeDisabled(); + expect( + screen.getByText( + 'Choose an available typed preset for the accurate trim.' + ) + ).toBeInTheDocument(); + + const preset = screen.getByLabelText('Accurate trim preset'); + expect( + screen.getByRole('option', { name: /WebM · VP9 \+ Opus/iu }) + ).toBeDisabled(); + expect(screen.getByRole('option', { name: /MP3 audio/iu })).toBeDisabled(); + await user.selectOptions(preset, 'video-ready'); + await user.click(action); + + await waitFor(() => + expect(onTrim).toHaveBeenCalledWith({ + operation: 'trim', + mode: 'accurate', + clipId: 'clip-a', + startSeconds: 5.25, + endSeconds: 18.75, + presetId: 'video-ready', + }) + ); + }); + + it('fails closed when a capability was not explicitly verified', () => { + render( + + ); + + expect( + screen.getByRole('button', { name: 'Export fast trim' }) + ).toBeDisabled(); + expect( + screen.getByText( + 'The required browser and FFmpeg capabilities have not been verified.' + ) + ).toBeInTheDocument(); + }); + + it('shows concrete incompatibilities and blocks fast concat', () => { + render( + + ); + + expect( + screen.getByRole('heading', { name: 'Fast compatibility failed' }) + ).toBeInTheDocument(); + expect( + screen.getByText('Clip 2 has incompatible width: 1280 instead of 1920.') + ).toBeInTheDocument(); + expect( + screen.getByText('Checked field: sources.1.streams.0.width') + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Export fast concat' }) + ).toBeDisabled(); + }); + + it('emits the verified sequential clip order for fast concat', async () => { + const user = userEvent.setup(); + const onConcat = vi.fn(); + render( + + ); + + expect( + screen.getByRole('heading', { name: 'Fast compatibility passed' }) + ).toBeInTheDocument(); + await user.click( + screen.getByRole('button', { name: 'Export fast concat' }) + ); + + await waitFor(() => + expect(onConcat).toHaveBeenCalledWith({ + operation: 'concat', + mode: 'fast', + clipIds: ['clip-a', 'clip-b'], + targetExtension: 'mp4', + targetMuxer: 'mp4', + }) + ); + }); + + it('requires an explicit missing-audio policy for normalized concat', async () => { + const user = userEvent.setup(); + const onConcat = vi.fn(); + render( + + ); + + expect(screen.getByText('No audio: Silent cutaway')).toBeInTheDocument(); + await user.click( + screen.getByRole('radio', { name: /Normalized re-encode/iu }) + ); + await user.selectOptions( + screen.getByLabelText('Normalized concat preset'), + 'video-ready' + ); + + const action = screen.getByRole('button', { + name: 'Export normalized concat', + }); + expect(action).toBeDisabled(); + expect( + screen.getByText('Choose an explicit missing-audio policy.') + ).toBeInTheDocument(); + + await user.click(screen.getByRole('radio', { name: /Insert silence/iu })); + expect(action).toBeDisabled(); + expect( + screen.getByText('Choose an explicit subtitle policy.') + ).toBeInTheDocument(); + await user.click( + screen.getByRole('radio', { name: /Reject subtitle input/iu }) + ); + await user.click(action); + + await waitFor(() => + expect(onConcat).toHaveBeenCalledWith({ + operation: 'concat', + mode: 'normalized', + clipIds: ['clip-a', 'clip-b'], + presetId: 'video-ready', + missingAudioPolicy: 'insert-silence', + subtitlePolicy: 'reject', + }) + ); + }); + + it('requires destructive subtitle loss to be explicit for normalized concat', async () => { + const user = userEvent.setup(); + const onConcat = vi.fn(); + render( + + index === 0 ? { ...clip, hasSubtitles: true } : clip + ), + }} + exportPresets={PRESETS} + availability={capabilities('concat-normalized')} + onConcat={onConcat} + /> + ); + + await user.click( + screen.getByRole('radio', { name: /Normalized re-encode/iu }) + ); + await user.selectOptions( + screen.getByLabelText('Normalized concat preset'), + 'video-ready' + ); + await user.click(screen.getByRole('radio', { name: /Insert silence/iu })); + await user.click( + screen.getByRole('radio', { name: /Reject subtitle input/iu }) + ); + + const action = screen.getByRole('button', { + name: 'Export normalized concat', + }); + expect(action).toBeDisabled(); + expect( + screen.getByText(/sequence contains subtitles.*explicitly drop all/iu) + ).toBeVisible(); + + await user.click( + screen.getByRole('radio', { name: /Drop all subtitles/iu }) + ); + await user.click(action); + + await waitFor(() => + expect(onConcat).toHaveBeenCalledWith({ + operation: 'concat', + mode: 'normalized', + clipIds: ['clip-a', 'clip-b'], + presetId: 'video-ready', + missingAudioPolicy: 'insert-silence', + subtitlePolicy: 'drop-all', + }) + ); + }); + + it('treats an absent fast compatibility result as unchecked', () => { + render( + + ); + + expect( + screen.getByRole('heading', { name: 'Fast compatibility not checked' }) + ).toBeInTheDocument(); + expect( + screen.getByText( + 'Fast concat remains disabled until every input stream is checked.' + ) + ).toBeInTheDocument(); + }); + + it('surfaces a synchronous runner rejection and releases pending state', async () => { + const user = userEvent.setup(); + render( + { + throw new Error('Queue capacity was reached.'); + }} + /> + ); + + const action = screen.getByRole('button', { name: 'Export fast trim' }); + await user.click(action); + + expect( + await screen.findByText( + 'The operation could not be queued: Queue capacity was reached.' + ) + ).toBeInTheDocument(); + await waitFor(() => expect(action).toBeEnabled()); + }); +}); diff --git a/tests/unit/components/waveform-editor.test.tsx b/tests/unit/components/waveform-editor.test.tsx new file mode 100644 index 0000000..eb0ce22 --- /dev/null +++ b/tests/unit/components/waveform-editor.test.tsx @@ -0,0 +1,216 @@ +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { WaveformEditor } from '../../../src/components/WaveformEditor'; +import { aggregatePcmPeaks } from '../../../src/waveform'; + +describe('WaveformEditor', () => { + afterEach(cleanup); + + beforeEach(() => { + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue( + createCanvasContext() + ); + }); + + it('renders a bounded canvas with accessible trim and marker controls', async () => { + render( + + ); + + const canvas = document.querySelector('canvas'); + if (!(canvas instanceof HTMLCanvasElement)) { + throw new Error('Missing waveform canvas'); + } + await waitFor(() => + expect(canvas).toHaveAttribute( + 'aria-label', + expect.stringContaining('Selected 00:00:01.000–00:00:09.000') + ) + ); + expect( + screen.getByRole('slider', { name: 'In trim handle' }) + ).toHaveAttribute('aria-valuenow', '1'); + expect( + screen.getByRole('slider', { name: 'Out trim handle' }) + ).toHaveAttribute('aria-valuenow', '9'); + expect( + screen.getByRole('slider', { name: 'Split marker 1' }) + ).toHaveAttribute('aria-valuenow', '3'); + expect(screen.getByText('Single sequential audio track')).toBeVisible(); + }); + + it('emits validated keyboard edits and current-time actions', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render( + + ); + + fireEvent.keyDown(screen.getByRole('slider', { name: 'In trim handle' }), { + key: 'ArrowRight', + }); + expect(onChange).toHaveBeenLastCalledWith({ + inSeconds: 1.1, + outSeconds: 9, + markers: [3], + }); + + await user.click( + screen.getByRole('button', { name: 'Add split at current time' }) + ); + expect(onChange).toHaveBeenLastCalledWith({ + inSeconds: 1, + outSeconds: 9, + markers: [3, 4], + }); + + fireEvent.keyDown(screen.getByRole('slider', { name: 'Split marker 1' }), { + key: 'Delete', + }); + expect(onChange).toHaveBeenLastCalledWith({ + inSeconds: 1, + outSeconds: 9, + markers: [], + }); + }); + + it('drags split markers and seeks using the visible zoomed range', async () => { + const onChange = vi.fn(); + const onSeek = vi.fn(); + const { container } = render( + + ); + const stage = container.querySelector('.waveform-editor__stage'); + if (!(stage instanceof HTMLElement)) { + throw new Error('Missing waveform stage'); + } + vi.spyOn(stage, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + left: 0, + top: 0, + right: 100, + bottom: 168, + width: 100, + height: 168, + toJSON: () => ({}), + }); + const marker = screen.getByRole('slider', { name: 'Split marker 1' }); + + fireEvent.pointerDown(marker, { + pointerId: 11, + clientX: 20, + clientY: 20, + }); + fireEvent.pointerMove(marker, { + pointerId: 11, + clientX: 50, + clientY: 20, + }); + fireEvent.pointerUp(marker, { pointerId: 11 }); + expect(onChange).toHaveBeenLastCalledWith({ + inSeconds: 0, + outSeconds: 10, + markers: [5], + }); + + fireEvent.pointerDown(stage, { + button: 0, + clientX: 75, + clientY: 30, + }); + expect(onSeek).toHaveBeenLastCalledWith(7.5); + + fireEvent.change(screen.getByRole('slider', { name: 'Zoom waveform' }), { + target: { value: '2' }, + }); + expect( + screen.getByRole('slider', { name: 'Scroll waveform' }) + ).toBeEnabled(); + }); + + it('normalizes untrusted selection values and caps marker count', () => { + render( + 2.01 + index * 0.01), + ]} + onChange={vi.fn()} + /> + ); + + expect( + screen.getByText(/Selected 00:00:00.000–00:00:10.000/u) + ).toBeVisible(); + expect(screen.getByText('254/254')).toBeVisible(); + expect( + screen.getAllByRole('slider', { name: /Split marker/u }) + ).toHaveLength(254); + }); +}); + +function createPeaks() { + return aggregatePcmPeaks( + { + container: 'raw', + data: new Int16Array( + Array.from({ length: 100 }, (_, index) => + Math.round(Math.sin(index / 5) * 20_000) + ) + ), + encoding: 's16le', + sampleRate: 10, + }, + { bucketCount: 100 } + ); +} + +function createCanvasContext(): CanvasRenderingContext2D { + return { + setTransform: vi.fn(), + clearRect: vi.fn(), + fillRect: vi.fn(), + beginPath: vi.fn(), + moveTo: vi.fn(), + lineTo: vi.fn(), + stroke: vi.fn(), + fillStyle: '', + strokeStyle: '', + lineWidth: 1, + } as unknown as CanvasRenderingContext2D; +} diff --git a/tests/unit/derived-cache.test.ts b/tests/unit/derived-cache.test.ts new file mode 100644 index 0000000..f4ec097 --- /dev/null +++ b/tests/unit/derived-cache.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { + cacheDerivative, + readCachedDerivative, +} from '../../src/app/derived-cache'; + +describe('application derivative cache', () => { + it('reuses only an exact source and settings identity', async () => { + const file = new File(['source'], 'preview-source.mp4', { + type: 'video/mp4', + lastModified: 123, + }); + const settings = { + maxDurationSeconds: 30, + maxWidth: 960, + includeAudio: true, + }; + const derivative = new Blob(['proxy'], { type: 'video/mp4' }); + + await cacheDerivative( + file, + 'preview-proxy-test', + 'preview-proxy', + 'project-preview-cache-test', + derivative, + settings + ); + + expect( + await readCachedDerivative(file, 'preview-proxy-test', settings) + ).toEqual(derivative); + expect( + await readCachedDerivative(file, 'preview-proxy-test', { + ...settings, + maxWidth: 1280, + }) + ).toBeUndefined(); + expect( + await readCachedDerivative( + new File(['other'], file.name, { + type: file.type, + lastModified: file.lastModified, + }), + 'preview-proxy-test', + settings + ) + ).toBeUndefined(); + }); +}); diff --git a/tests/unit/engine-lifecycle.test.ts b/tests/unit/engine-lifecycle.test.ts new file mode 100644 index 0000000..3327bf9 --- /dev/null +++ b/tests/unit/engine-lifecycle.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from 'vitest'; +import { DeferredResourceLifecycle } from '../../src/ffmpeg/engine-lifecycle'; + +describe('singleton engine lifecycle', () => { + it('cancels deferred disposal when StrictMode immediately reacquires', () => { + const dispose = vi.fn(); + const deferred: Array<() => void> = []; + const lifecycle = new DeferredResourceLifecycle({ dispose }, (callback) => + deferred.push(callback) + ); + + const releaseFirstMount = lifecycle.acquire(); + releaseFirstMount(); + const releaseStrictModeRemount = lifecycle.acquire(); + deferred.shift()?.(); + + expect(dispose).not.toHaveBeenCalled(); + expect(lifecycle.leaseCount).toBe(1); + + releaseStrictModeRemount(); + deferred.shift()?.(); + expect(dispose).toHaveBeenCalledOnce(); + expect(lifecycle.leaseCount).toBe(0); + }); + + it('disposes only after the last lease and ignores duplicate releases', () => { + const dispose = vi.fn(); + const deferred: Array<() => void> = []; + const lifecycle = new DeferredResourceLifecycle({ dispose }, (callback) => + deferred.push(callback) + ); + const releaseOne = lifecycle.acquire(); + const releaseTwo = lifecycle.acquire(); + + releaseOne(); + releaseOne(); + expect(deferred).toHaveLength(0); + expect(lifecycle.leaseCount).toBe(1); + + releaseTwo(); + expect(deferred).toHaveLength(1); + deferred[0]?.(); + expect(dispose).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/unit/export-output.test.ts b/tests/unit/export-output.test.ts new file mode 100644 index 0000000..7f6e657 --- /dev/null +++ b/tests/unit/export-output.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from 'vitest'; +import { + createExportReport, + deriveOutputFileName, + exportReportBlob, + isBrowserPreviewMimeType, + isSafeArchiveLeafName, + mimeTypeFromOutputName, + normalizeOutputMimeType, + preferredExtensionForMimeType, + redactPrivatePaths, + safeExportFileName, + serializeExportReport, + uniqueExportFileNames, + type ExportResult, +} from '../../src/export'; + +describe('safe export output names', () => { + it('removes directory traversal, device names, and unsafe punctuation', () => { + expect(safeExportFileName('../../CON.mp4')).toBe('_CON.mp4'); + expect(safeExportFileName('..\\..\\holiday 🎬.MP4')).toBe('holiday.mp4'); + expect( + safeExportFileName('/private/video.mov', { extension: '.webm' }) + ).toBe('video.webm'); + expect(isSafeArchiveLeafName('../unsafe.mp4')).toBe(false); + expect(isSafeArchiveLeafName('safe-output.mp4')).toBe(true); + }); + + it('derives numbered names and rejects invalid part positions', () => { + expect( + deriveOutputFileName({ + sourceName: 'My recording.mov', + operation: 'split', + extension: 'mp4', + partIndex: 1, + partCount: 12, + }) + ).toBe('My-recording-split-002.mp4'); + expect(() => + deriveOutputFileName({ + sourceName: 'source.mp4', + operation: 'split', + partIndex: 2, + partCount: 2, + }) + ).toThrow(/part index/iu); + }); + + it('deduplicates names for case-insensitive filesystems', () => { + expect( + uniqueExportFileNames([ + 'Clip.mp4', + 'clip.mp4', + '../../CLIP.mp4', + 'other.mp4', + ]) + ).toEqual(['Clip.mp4', 'clip-2.mp4', 'CLIP-3.mp4', 'other.mp4']); + }); +}); + +describe('output MIME helpers', () => { + it('normalizes base types and falls back to known extensions', () => { + expect(normalizeOutputMimeType('Video/MP4; codecs=avc1')).toBe('video/mp4'); + expect(normalizeOutputMimeType('not a mime', 'frame.png')).toBe( + 'image/png' + ); + expect(normalizeOutputMimeType(undefined, 'unknown.xyz')).toBe( + 'application/octet-stream' + ); + expect(mimeTypeFromOutputName('captions.vtt')).toBe('text/vtt'); + expect(preferredExtensionForMimeType('image/jpeg')).toBe('.jpg'); + expect(isBrowserPreviewMimeType('audio/mpeg')).toBe(true); + expect(isBrowserPreviewMimeType('application/json')).toBe(false); + }); +}); + +describe('privacy-safe JSON export reports', () => { + const result = { + id: 'result', + planId: 'plan', + outputId: 'media', + fileName: '../../converted.mp4', + blob: new Blob(['private bytes']), + mimeType: 'video/mp4', + role: 'media', + size: 13, + createdAt: '2026-01-01T00:00:00.000Z', + } satisfies ExportResult; + + it('includes plan diagnostics and output metadata without source data', async () => { + const privateRuntimeResult = { + ...result, + // Extra runtime fields must never be copied into the report. + sourceBlob: new Blob(['source secret']), + hostPath: '/home/zemion/private/source.mp4', + }; + const report = createExportReport({ + plan: { + id: 'plan-/home/zemion/private/source.mp4', + operation: 'convert', + diagnostics: [ + { + code: 'codec-warning', + severity: 'warning', + message: + 'Read /mnt/DATA/Videos/secret.mov via blob:https://local/id', + field: 'C:\\Users\\Person\\Videos\\secret.mov', + }, + ], + }, + results: [privateRuntimeResult], + execution: { + status: 'completed', + engineMode: 'single-thread', + ffmpegVersion: '6.0', + elapsedSeconds: 1.25, + conversionMilliseconds: 1_000, + outputReadMilliseconds: 25, + cleanupMilliseconds: 5, + exitCode: 0, + }, + generatedAt: '2026-07-24T12:00:00+02:00', + }); + + expect(report.generatedAt).toBe('2026-07-24T10:00:00.000Z'); + expect(report.plan.id).toContain('[local path]'); + expect(report.outputs[0]).toEqual({ + id: 'result', + outputId: 'media', + fileName: 'converted.mp4', + mimeType: 'video/mp4', + role: 'media', + sizeBytes: 13, + }); + expect(report.diagnostics[0]?.message).toBe( + 'Read [local path] via [object URL]' + ); + expect(report.diagnostics[0]?.field).toBe('[local path]'); + expect(report.privacy).toEqual({ + includesSourceMedia: false, + includesCommandArguments: false, + privatePathsRedacted: true, + }); + expect(report.execution).toMatchObject({ + conversionMilliseconds: 1_000, + outputReadMilliseconds: 25, + cleanupMilliseconds: 5, + }); + + const serialized = serializeExportReport(report); + expect(serialized.endsWith('\n')).toBe(true); + expect(serialized).not.toContain('sourceBlob'); + expect(serialized).not.toContain('hostPath'); + expect(serialized).not.toContain('/home/zemion'); + const blob = exportReportBlob(report); + expect(blob.type).toBe('application/json'); + expect(await blob.text()).toBe(serialized); + }); + + it('redacts common local path and URL forms', () => { + expect( + redactPrivatePaths( + 'file:///home/me/a.mp4 C:\\fakepath\\b.mp4 /Users/me/c.mp4' + ) + ).toBe('[local path] [local path] [local path]'); + }); + + it('rejects invalid numeric report metadata', () => { + expect(() => + createExportReport({ + plan: { id: 'plan', operation: 'convert', diagnostics: [] }, + results: [{ ...result, size: Number.NaN }], + }) + ).toThrow(/output size/iu); + }); +}); diff --git a/tests/unit/export-results.test.ts b/tests/unit/export-results.test.ts new file mode 100644 index 0000000..9b5026a --- /dev/null +++ b/tests/unit/export-results.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + collectCommandOutputs, + DEFAULT_RESULT_COLLECTION_LIMITS, + DEFAULT_RESULT_ZIP_LIMITS, + ExportResultCollection, + ExportResultLimitError, + MissingCommandOutputError, + saveCollectionResult, + type BlobSaver, + type ExportResultInput, +} from '../../src/export'; +import type { FFmpegCommandPlan } from '../../src/commands'; + +function input( + id: string, + fileName = 'result.mp4', + contents = 'result' +): ExportResultInput { + return { + id, + planId: 'plan', + outputId: id, + fileName, + blob: new Blob([contents]), + mimeType: 'video/mp4', + role: 'media', + }; +} + +describe('export result collection', () => { + it('reserves one ZIP entry for the optional report', () => { + expect(DEFAULT_RESULT_COLLECTION_LIMITS.maxResults).toBe(255); + expect(DEFAULT_RESULT_ZIP_LIMITS.maxEntries).toBe( + DEFAULT_RESULT_COLLECTION_LIMITS.maxResults + 1 + ); + }); + + it('owns result Blobs and revokes every replaced or released object URL', () => { + let urlIndex = 0; + const revokeObjectURL = vi.fn(); + const collection = new ExportResultCollection({ + objectUrlApi: { + createObjectURL: () => `blob:result-${urlIndex++}`, + revokeObjectURL, + }, + now: () => new Date('2026-07-24T10:00:00.000Z'), + }); + const first = collection.add(input('one')); + expect(first).toMatchObject({ + fileName: 'result.mp4', + mimeType: 'video/mp4', + size: 6, + createdAt: '2026-07-24T10:00:00.000Z', + }); + expect(collection.objectUrl('one')).toBe('blob:result-0'); + expect(collection.objectUrl('one')).toBe('blob:result-0'); + + collection.add(input('two', 'RESULT.mp4')); + expect(collection.get('two')?.fileName).toBe('RESULT-2.mp4'); + collection.replace(input('one', 'replacement.mp4', 'new')); + expect(revokeObjectURL).toHaveBeenCalledWith('blob:result-0'); + expect(collection.totalBytes).toBe(9); + + collection.objectUrl('two'); + expect(collection.remove('two')).toBe(true); + expect(revokeObjectURL).toHaveBeenCalledWith('blob:result-1'); + collection.dispose(); + expect(collection.totalBytes).toBe(0); + expect(() => collection.list()).toThrow(/disposed/iu); + expect(() => collection.dispose()).not.toThrow(); + }); + + it('adds a batch atomically and enforces count and byte limits', () => { + const collection = new ExportResultCollection({ + limits: { + maxResults: 2, + maxSingleResultBytes: 5, + maxTotalBytes: 8, + }, + }); + expect(() => + collection.addMany([ + input('one', 'one.bin', '1234'), + input('bad', 'bad.bin', '123456'), + ]) + ).toThrowError(ExportResultLimitError); + expect(collection.size).toBe(0); + + collection.add(input('one', 'one.bin', '1234')); + expect(() => collection.add(input('two', 'two.bin', '12345'))).toThrowError( + expect.objectContaining({ limit: 'total-result-bytes' }) + ); + expect(collection.size).toBe(1); + }); + + it('collects all planned outputs atomically from IDs or virtual paths', () => { + const collection = new ExportResultCollection(); + const plan: Pick = { + id: 'split-plan', + outputs: [ + { + id: 'one', + path: '/work/job/one.mp4', + fileName: 'clip-001.mp4', + mediaType: 'video/mp4', + role: 'media', + timeRange: { startSeconds: 0, endSeconds: 5 }, + }, + { + id: 'two', + path: '/work/job/two.mp4', + fileName: 'clip-002.mp4', + mediaType: 'video/mp4', + role: 'media', + }, + ], + }; + const results = collectCommandOutputs( + plan, + new Map([ + ['one', Uint8Array.of(1, 2)], + ['/work/job/two.mp4', new Blob(['two'])], + ]), + collection + ); + expect(results.map((result) => result.outputId)).toEqual(['one', 'two']); + expect(results[0]?.blob.type).toBe('video/mp4'); + expect(results[0]?.timeRange).toEqual({ + startSeconds: 0, + endSeconds: 5, + }); + + const empty = new ExportResultCollection(); + expect(() => + collectCommandOutputs(plan, new Map([['one', new Blob(['one'])]]), empty) + ).toThrowError(MissingCommandOutputError); + expect(empty.size).toBe(0); + }); +}); + +describe('browser result saving', () => { + it('sanitizes the download name and releases a successfully saved result', async () => { + const collection = new ExportResultCollection(); + collection.add(input('one', 'result.mp4')); + const saver = vi.fn(async () => 'downloaded'); + + await expect( + saveCollectionResult(collection, 'one', { + fileName: '../../My result.mp4', + saver, + }) + ).resolves.toBe('downloaded'); + expect(saver).toHaveBeenCalledWith( + expect.any(Blob), + 'My-result.mp4', + 'video/mp4' + ); + expect(collection.size).toBe(0); + }); + + it('retains the result when the save picker is cancelled', async () => { + const collection = new ExportResultCollection(); + collection.add(input('one')); + const saver: BlobSaver = async () => 'cancelled'; + await saveCollectionResult(collection, 'one', { saver }); + expect(collection.size).toBe(1); + }); +}); diff --git a/tests/unit/export-zip.test.ts b/tests/unit/export-zip.test.ts new file mode 100644 index 0000000..9e70adf --- /dev/null +++ b/tests/unit/export-zip.test.ts @@ -0,0 +1,109 @@ +import { unzipSync } from 'fflate'; +import { describe, expect, it } from 'vitest'; +import { + createResultZip, + saveResultsZip, + type BlobSaver, + type ExportResult, +} from '../../src/export'; + +function result(id: string, fileName: string, contents: string): ExportResult { + const blob = new Blob([contents], { type: 'text/plain' }); + return { + id, + planId: 'plan', + outputId: id, + fileName, + blob, + mimeType: 'text/plain', + role: 'analysis', + size: blob.size, + createdAt: '2026-07-24T10:00:00.000Z', + }; +} + +describe('deterministic result ZIP creation', () => { + it('sorts and deduplicates safe paths with fixed archive metadata', async () => { + const entries = [ + { fileName: '../../Z.txt', blob: new Blob(['z']) }, + { fileName: 'a.txt', blob: new Blob(['first']) }, + { fileName: 'A.TXT', blob: new Blob(['second']) }, + ]; + const first = await createResultZip(entries, { + fileName: '../My outputs.any', + }); + const second = await createResultZip(entries, { + fileName: '../My outputs.any', + }); + + expect(first.fileName).toBe('My-outputs.zip'); + expect(first.entryNames).toEqual(['A-2.txt', 'Z.txt', 'a.txt']); + expect(first.totalInputBytes).toBe(12); + expect(new Uint8Array(await first.blob.arrayBuffer())).toEqual( + new Uint8Array(await second.blob.arrayBuffer()) + ); + + const extracted = unzipSync(new Uint8Array(await first.blob.arrayBuffer())); + expect(Object.keys(extracted)).toEqual(['A-2.txt', 'Z.txt', 'a.txt']); + expect(new TextDecoder().decode(extracted['a.txt'])).toBe('first'); + expect(new TextDecoder().decode(extracted['A-2.txt'])).toBe('second'); + }); + + it('rejects empty, oversized, and pre-cancelled batches', async () => { + await expect(createResultZip([])).rejects.toThrow(/at least one/iu); + await expect( + createResultZip([{ fileName: 'large.bin', blob: new Blob(['12345']) }], { + limits: { + maxEntries: 2, + maxEntryBytes: 4, + maxTotalInputBytes: 4, + maxArchiveBytes: 100, + }, + }) + ).rejects.toThrowError( + expect.objectContaining({ + limit: 'entry-bytes', + }) + ); + + const controller = new AbortController(); + controller.abort(); + await expect( + createResultZip([{ fileName: 'one.txt', blob: new Blob(['one']) }], { + signal: controller.signal, + }) + ).rejects.toMatchObject({ name: 'AbortError' }); + }); + + it('packages an optional report and passes the ZIP to the save adapter', async () => { + const saverCalls: { fileName: string; type: string }[] = []; + const saver: BlobSaver = async (blob, fileName) => { + saverCalls.push({ fileName, type: blob.type }); + return 'saved'; + }; + const saved = await saveResultsZip([result('one', 'one.txt', 'contents')], { + fileName: 'batch.zip', + saver, + report: { + schemaVersion: 1, + generatedAt: '2026-07-24T10:00:00.000Z', + plan: { id: 'plan', operation: 'convert' }, + outputs: [], + diagnostics: [], + privacy: { + includesSourceMedia: false, + includesCommandArguments: false, + privatePathsRedacted: true, + }, + }, + }); + expect(saved.outcome).toBe('saved'); + expect(saved.archive.entryNames).toEqual([ + 'convert-export-report.json', + 'one.txt', + ]); + expect(saverCalls).toEqual([ + { fileName: 'batch.zip', type: 'application/zip' }, + ]); + }); +}); diff --git a/tests/unit/ffmpeg-capabilities.test.ts b/tests/unit/ffmpeg-capabilities.test.ts new file mode 100644 index 0000000..9e2e9f0 --- /dev/null +++ b/tests/unit/ffmpeg-capabilities.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; +import { + deserializeCapabilities, + parseBuildConfiguration, + parseCodecs, + parseFilters, + parseFormats, + parseNamedComponents, + serializeCapabilities, +} from '../../src/ffmpeg/capability-parser'; + +describe('FFmpeg capability parsing', () => { + it('parses demuxer and muxer flags including aliases', () => { + const parsed = parseFormats(` + File formats: + D. = Demuxing supported + .E = Muxing supported + DE matroska,webm Matroska / WebM + D mov,mp4,m4a QuickTime / MOV + E mp3 MP3 +`); + expect(parsed.demuxers).toEqual( + new Set(['matroska', 'webm', 'mov', 'mp4', 'm4a']) + ); + expect(parsed.muxers).toEqual(new Set(['matroska', 'webm', 'mp3'])); + }); + + it('parses codecs, encoders, filters and configuration flags', () => { + const codecs = parseCodecs(` + Codecs: + D..... = Decoding supported + .E.... = Encoding supported + DEV.LS h264 H.264 + D.A.L. aac AAC + .EV.L. vp9 VP9 +`); + expect(codecs.decoders).toEqual(new Set(['h264', 'aac'])); + expect(codecs.encoders).toEqual(new Set(['h264', 'vp9'])); + + expect( + parseNamedComponents(` + Encoders: + V..... libx264 H.264 + A..... aac AAC +`) + ).toEqual(new Set(['libx264', 'aac'])); + + expect( + parseFilters(` + Filters: + ..C scale V->V Scale the input video size. + T.. loudnorm A->A EBU R128 loudness normalization + ... concat N->N Concatenate audio and video streams. +`) + ).toEqual(new Set(['scale', 'loudnorm', 'concat'])); + + expect( + parseBuildConfiguration( + 'ffmpeg version x\nconfiguration: --enable-gpl --disable-doc' + ) + ).toEqual(['--enable-gpl', '--disable-doc']); + }); + + it('serializes Set values for durable caches without losing data', () => { + const original = { + versionText: 'ffmpeg version n6.1', + buildConfiguration: ['--enable-gpl'], + demuxers: new Set(['mov']), + muxers: new Set(['mp4']), + decoders: new Set(['h264']), + encoders: new Set(['libx264']), + filters: new Set(['scale']), + }; + expect(deserializeCapabilities(serializeCapabilities(original))).toEqual( + original + ); + }); +}); diff --git a/tests/unit/ffmpeg-engine-arguments.test.ts b/tests/unit/ffmpeg-engine-arguments.test.ts new file mode 100644 index 0000000..0a9d77d --- /dev/null +++ b/tests/unit/ffmpeg-engine-arguments.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_JOB_TIMEOUT_MILLISECONDS, + MAX_CONTACT_SHEETS_PER_ENGINE, + MAX_EXECUTIONS_PER_ENGINE, + MULTITHREAD_CODEC_THREAD_LIMIT, + engineJobTimeoutMilliseconds, + prepareEngineArguments, + shouldRecycleExecutionCore, +} from '../../src/ffmpeg/EngineManager'; + +describe('prepareEngineArguments', () => { + it('leaves single-thread commands unchanged', () => { + const args = ['-i', '/input/source.mp4', '/work/output.mp4']; + expect( + prepareEngineArguments( + args, + 'single-thread', + ['/input/source.mp4'], + ['/work/output.mp4'] + ) + ).toEqual(args); + }); + + it('bounds multithread input and output codec threads', () => { + expect( + prepareEngineArguments( + ['-i', '/input/source.mp4', '-c:v', 'libx264', '/work/output.mp4'], + 'multithread', + ['/input/source.mp4'], + ['/work/output.mp4'] + ) + ).toEqual([ + '-threads', + String(MULTITHREAD_CODEC_THREAD_LIMIT), + '-i', + '/input/source.mp4', + '-c:v', + 'libx264', + '-threads', + String(MULTITHREAD_CODEC_THREAD_LIMIT), + '/work/output.mp4', + ]); + }); + + it('bounds a generated output pattern in the final output position', () => { + expect( + prepareEngineArguments( + ['-i', '/input/source.mp4', '-f', 'segment', '/work/part-%03d.mp4'], + 'multithread', + ['/input/source.mp4'], + ['/work/part-001.mp4', '/work/part-002.mp4'] + ) + ).toEqual([ + '-threads', + '2', + '-i', + '/input/source.mp4', + '-f', + 'segment', + '-threads', + '2', + '/work/part-%03d.mp4', + ]); + }); +}); + +describe('engineJobTimeoutMilliseconds', () => { + it('uses a bounded duration-aware watchdog', () => { + expect(engineJobTimeoutMilliseconds({ expectedDurationSeconds: 2 })).toBe( + 120_000 + ); + expect( + engineJobTimeoutMilliseconds({ expectedDurationSeconds: 10_000 }) + ).toBe(DEFAULT_JOB_TIMEOUT_MILLISECONDS); + }); + + it('honors a shorter explicit timeout and caps a longer one', () => { + expect( + engineJobTimeoutMilliseconds({ + expectedDurationSeconds: 30, + timeoutMilliseconds: 5_000, + }) + ).toBe(5_000); + expect( + engineJobTimeoutMilliseconds({ + timeoutMilliseconds: DEFAULT_JOB_TIMEOUT_MILLISECONDS * 2, + }) + ).toBe(DEFAULT_JOB_TIMEOUT_MILLISECONDS); + }); +}); + +describe('execution core recycling policy', () => { + it('refreshes only after the bounded idle-core job budget is exhausted', () => { + expect(shouldRecycleExecutionCore(MAX_EXECUTIONS_PER_ENGINE - 1)).toBe( + false + ); + expect(shouldRecycleExecutionCore(MAX_EXECUTIONS_PER_ENGINE)).toBe(true); + }); + + it('refreshes before a fourth contact sheet hits retained tile-filter state', () => { + expect( + shouldRecycleExecutionCore( + 3, + 'contact-sheet', + MAX_CONTACT_SHEETS_PER_ENGINE - 1 + ) + ).toBe(false); + expect( + shouldRecycleExecutionCore( + 3, + 'contact-sheet', + MAX_CONTACT_SHEETS_PER_ENGINE + ) + ).toBe(true); + expect( + shouldRecycleExecutionCore( + 3, + 'thumbnail-series', + MAX_CONTACT_SHEETS_PER_ENGINE + ) + ).toBe(false); + }); +}); diff --git a/tests/unit/ffmpeg-mode.test.ts b/tests/unit/ffmpeg-mode.test.ts new file mode 100644 index 0000000..b3b8a5a --- /dev/null +++ b/tests/unit/ffmpeg-mode.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { + describeEngineMode, + selectEngineMode, + supportsMultithread, +} from '../../src/ffmpeg/engine-mode'; +import type { EngineEnvironment } from '../../src/ffmpeg/ffmpeg.types'; + +const isolated: EngineEnvironment = { + secureContext: true, + crossOriginIsolated: true, + sharedArrayBuffer: true, + hardwareConcurrency: 8, +}; + +describe('engine mode selection', () => { + it('selects multithread only when every required capability is present', () => { + expect(supportsMultithread(isolated)).toBe(true); + expect(selectEngineMode(isolated, 'automatic')).toBe('multithread'); + + for (const missing of [ + 'secureContext', + 'crossOriginIsolated', + 'sharedArrayBuffer', + ] as const) { + expect( + selectEngineMode({ ...isolated, [missing]: false }, 'automatic') + ).toBe('single-thread'); + } + }); + + it('honours the safe single-thread preference', () => { + expect(selectEngineMode(isolated, 'force-single-thread')).toBe( + 'single-thread' + ); + expect(describeEngineMode('single-thread')).toMatch(/compatibility/); + }); +}); diff --git a/tests/unit/ffmpeg-utils.test.ts b/tests/unit/ffmpeg-utils.test.ts new file mode 100644 index 0000000..6b7cb0f --- /dev/null +++ b/tests/unit/ffmpeg-utils.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { + getCoreAssetUrls, + resolveFromAppBase, +} from '../../src/ffmpeg/asset-urls'; +import { LogBuffer } from '../../src/ffmpeg/log-buffer'; +import { normalizeProgress } from '../../src/ffmpeg/progress-parser'; + +describe('FFmpeg runtime utilities', () => { + it('resolves assets under a nested application base', () => { + expect( + resolveFromAppBase( + 'vendor/core.wasm', + 'https://tools.example/apps/av/index.html', + './' + ) + ).toBe('https://tools.example/apps/av/vendor/core.wasm'); + + const mt = getCoreAssetUrls( + 'multithread', + (path) => `https://tools.example/deep/${path}` + ); + expect(mt.coreURL).toContain('/0.12.10/mt/ffmpeg-core.js'); + expect(mt.workerURL).toContain('/0.12.10/mt/ffmpeg-core.worker.js'); + }); + + it('clamps experimental progress and removes invalid numbers', () => { + expect(normalizeProgress({ progress: 3, time: -10 })).toEqual({ + progress: 1, + time: 0, + }); + expect( + normalizeProgress({ progress: Number.NaN, time: Number.NaN }) + ).toEqual({ progress: 0, time: 0 }); + }); + + it('bounds diagnostic log memory', () => { + const logs = new LogBuffer(2, 100); + logs.append({ type: 'stdout', message: 'one' }); + logs.append({ type: 'stderr', message: 'two\u0000' }); + logs.append({ type: 'stderr', message: 'three' }); + expect(logs.snapshot()).toEqual([ + { type: 'stderr', message: 'two' }, + { type: 'stderr', message: 'three' }, + ]); + }); +}); diff --git a/tests/unit/ffmpeg-virtual-fs.test.ts b/tests/unit/ffmpeg-virtual-fs.test.ts new file mode 100644 index 0000000..5fe7d47 --- /dev/null +++ b/tests/unit/ffmpeg-virtual-fs.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { FFmpegAdapter } from '../../src/ffmpeg/ffmpeg.types'; +import { + describeMountedInputAccess, + mountJobFiles, +} from '../../src/ffmpeg/virtual-fs'; + +function createFilesystemAdapter( + options: { keepWorkDirectory?: boolean; rejectWorkerFs?: boolean } = {} +) { + const directories = new Set(['/input', '/work']); + const files = new Set(); + const adapter = { + createDir: vi.fn(async (path: string) => { + directories.add(path); + }), + mount: vi.fn(async () => { + if (options.rejectWorkerFs) { + throw new Error('WORKERFS unavailable'); + } + }), + unmount: vi.fn(async () => undefined), + writeFile: vi.fn(async (path: string) => { + files.add(path); + }), + deleteFile: vi.fn(async (path: string) => { + files.delete(path); + }), + deleteDir: vi.fn(async (path: string) => { + if (options.keepWorkDirectory && path.startsWith('/work/job-')) return; + directories.delete(path); + }), + listDir: vi.fn(async (path: string) => + [...directories] + .filter( + (entry) => + entry !== path && entry.slice(0, entry.lastIndexOf('/')) === path + ) + .map((entry) => ({ + name: entry.split('/').at(-1) ?? '', + isDir: true, + isLink: false, + })) + ), + }; + return adapter as unknown as FFmpegAdapter; +} + +describe('per-job FFmpeg filesystem', () => { + it('uses generated paths and verifies complete cleanup', async () => { + const adapter = createFilesystemAdapter(); + const mounted = await mountJobFiles( + adapter, + '../../job with separators', + [new File(['source'], '../../private name.MP4')], + ['../unsafe result.mp4'] + ); + + expect(mounted.inputPaths).toEqual([ + '/input/job-job-with-separators/source-0.mp4', + ]); + expect(mounted.outputPaths).toEqual([ + '/work/job-job-with-separators/output-0.mp4', + ]); + expect(mounted).toMatchObject({ + inputMode: 'workerfs', + inputBytes: 6, + copiedInputBytes: 0, + }); + expect(describeMountedInputAccess(mounted)).toContain( + '0 bytes copied into engine memory' + ); + expect(mounted.inputPaths[0]).not.toContain('private'); + expect(await mounted.cleanup()).toBe(true); + }); + + it('accounts for and explains the actual memory-copy fallback', async () => { + const adapter = createFilesystemAdapter({ rejectWorkerFs: true }); + const mounted = await mountJobFiles( + adapter, + 'copy-fallback', + [new File(['1234'], 'one.mp4'), new File(['567'], 'two.mp4')], + ['result.mp4'] + ); + + expect(mounted).toMatchObject({ + inputMode: 'memory-copy', + inputBytes: 7, + copiedInputBytes: 7, + }); + expect(adapter.writeFile).toHaveBeenCalledTimes(2); + expect(describeMountedInputAccess(mounted)).toBe( + 'Input compatibility fallback: WORKERFS mounting was unavailable, so 7 bytes were copied into engine memory.' + ); + expect(await mounted.cleanup()).toBe(true); + }); + + it('reports a directory that survives cleanup', async () => { + const adapter = createFilesystemAdapter({ keepWorkDirectory: true }); + const mounted = await mountJobFiles( + adapter, + 'job', + [new File(['source'], 'source.wav')], + ['result.wav'] + ); + + expect(await mounted.cleanup()).toBe(false); + }); +}); diff --git a/tests/unit/jobs.test.ts b/tests/unit/jobs.test.ts new file mode 100644 index 0000000..528630c --- /dev/null +++ b/tests/unit/jobs.test.ts @@ -0,0 +1,269 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + MediaJobQueue, + MemoryJobMetadataStore, + ObjectUrlRegistry, + parseFFmpegProgress, + progressUpdateFromFFmpeg, + weightedJobProgress, + type JobExecutionContext, + type JobExecutionResult, + type MediaJob, + type MediaJobDefinition, + type MediaJobExecutor, +} from '../../src/jobs'; +import { freezeCommandPlan } from '../../src/commands'; + +function definition(id: string, stepIds = ['encode']): MediaJobDefinition { + return { + id, + operation: 'test', + steps: stepIds.map((stepId) => ({ + id: stepId, + name: stepId, + weight: stepId === 'read' ? 1 : 9, + plan: freezeCommandPlan({ + id: `${id}:${stepId}`, + operation: stepId, + inputs: [], + temporaryFiles: [], + args: ['-version'], + outputs: [], + requiredCapabilities: {}, + diagnostics: [], + }), + })), + }; +} + +class ControlledExecutor implements MediaJobExecutor { + readonly calls: string[] = []; + readonly cancelCurrentJob = vi.fn(async () => undefined); + readonly recoverAfterCancellation = vi.fn(async () => undefined); + readonly cleanup = vi.fn(async () => undefined); + #gates: (() => void)[] = []; + failNext?: Error; + + execute( + plan: { readonly id: string }, + context: JobExecutionContext + ): Promise { + this.calls.push(plan.id); + context.onProgress({ progress: 0.5 }); + if (this.failNext) { + const error = this.failNext; + this.failNext = undefined; + return Promise.reject(error); + } + return new Promise((resolve, reject) => { + const release = () => { + context.signal.removeEventListener('abort', abort); + resolve({ outputs: new Map() }); + }; + const abort = () => { + const index = this.#gates.indexOf(release); + if (index >= 0) { + this.#gates.splice(index, 1); + } + reject(new DOMException('cancelled', 'AbortError')); + }; + context.signal.addEventListener('abort', abort, { once: true }); + this.#gates.push(release); + }); + } + + release(): void { + this.#gates.shift()?.(); + } +} + +describe('progress parsing', () => { + it('parses FFmpeg machine progress and caps completion until output read', () => { + const parsed = parseFFmpegProgress( + 'frame=25\nfps=12.5\nout_time_us=5000000\nspeed=1.5x\nprogress=continue\n' + ); + expect(parsed).toEqual({ + frame: 25, + fps: 12.5, + outTimeSeconds: 5, + speed: 1.5, + terminal: false, + }); + expect(progressUpdateFromFFmpeg(parsed, 10)).toMatchObject({ + progress: 0.5, + }); + expect( + progressUpdateFromFFmpeg( + parseFFmpegProgress('progress=end\nout_time_us=10000000'), + 10 + ).progress + ).toBe(0.99); + }); + + it('combines weighted multi-step progress below 100 percent', () => { + expect( + weightedJobProgress([ + { + id: 'analysis', + name: 'Analysis', + weight: 1, + status: 'completed', + progress: 1, + planId: 'one', + }, + { + id: 'encode', + name: 'Encoding', + weight: 9, + status: 'running', + progress: 0.5, + planId: 'two', + }, + ]) + ).toBe(0.55); + }); +}); + +describe('sequential durable job queue', () => { + it('runs only one command at a time and reaches 100% after cleanup', async () => { + const executor = new ControlledExecutor(); + const store = new MemoryJobMetadataStore(); + const queue = new MediaJobQueue({ executor, store }); + await queue.initialize(); + await queue.enqueue(definition('one')); + await queue.enqueue(definition('two')); + await waitUntil(() => executor.calls.length === 1); + expect(executor.calls).toEqual(['one:encode']); + expect(queue.get('one')?.progress).toBe(0.5); + executor.release(); + await waitUntil(() => executor.calls.length === 2); + expect(queue.get('one')).toMatchObject({ + status: 'completed', + progress: 1, + }); + executor.release(); + await queue.waitForIdle(); + expect(queue.get('two')).toMatchObject({ + status: 'completed', + progress: 1, + }); + expect(queue.getResults('two')).toHaveLength(1); + expect(queue.releaseResults('two')).toBe(true); + expect(queue.getResults('two')).toBeUndefined(); + expect((await store.list()).map((job) => job.status)).toEqual([ + 'completed', + 'completed', + ]); + }); + + it('terminates processing, recovers the engine, and runs the next job', async () => { + const executor = new ControlledExecutor(); + const queue = new MediaJobQueue({ + executor, + store: new MemoryJobMetadataStore(), + }); + await queue.initialize(); + await queue.enqueue(definition('cancel-me')); + await queue.enqueue(definition('after')); + await waitUntil(() => queue.get('cancel-me')?.status === 'running'); + await queue.cancel('cancel-me'); + expect(executor.cancelCurrentJob).toHaveBeenCalledOnce(); + expect(executor.recoverAfterCancellation).toHaveBeenCalledOnce(); + expect(queue.get('cancel-me')?.status).toBe('cancelled'); + await waitUntil(() => executor.calls.includes('after:encode')); + executor.release(); + await queue.waitForIdle(); + expect(queue.get('after')?.status).toBe('completed'); + }); + + it('marks failures, cleans up, and retries with the retained definition', async () => { + const executor = new ControlledExecutor(); + executor.failNext = new Error('encoder failed'); + const queue = new MediaJobQueue({ + executor, + store: new MemoryJobMetadataStore(), + }); + await queue.initialize(); + await queue.enqueue(definition('retry')); + await queue.waitForIdle(); + expect(queue.get('retry')).toMatchObject({ + status: 'failed', + error: { code: 'execution-failed', recoverable: true }, + }); + await queue.retry('retry'); + await waitUntil(() => executor.calls.length === 2); + executor.release(); + await queue.waitForIdle(); + expect(queue.get('retry')).toMatchObject({ + status: 'completed', + retryCount: 1, + }); + }); + + it('recovers interrupted durable metadata as an explicit failed job', async () => { + const store = new MemoryJobMetadataStore(); + const interrupted: MediaJob = { + id: 'old', + operation: 'convert', + status: 'running', + steps: [ + { + id: 'encode', + name: 'Encode', + weight: 1, + status: 'running', + progress: 0.4, + planId: 'old:encode', + }, + ], + progress: 0.4, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:01:00.000Z', + startedAt: '2026-01-01T00:00:01.000Z', + retryCount: 0, + }; + await store.put(interrupted); + const queue = new MediaJobQueue({ + executor: new ControlledExecutor(), + store, + }); + await queue.initialize(); + expect(queue.get('old')).toMatchObject({ + status: 'failed', + recoveryNote: expect.stringContaining('previous browser session'), + error: { code: 'session-interrupted', recoverable: true }, + }); + }); +}); + +describe('object URL cleanup', () => { + it('revokes replaced and cleared result URLs', () => { + let index = 0; + const revoke = vi.fn(); + const registry = new ObjectUrlRegistry({ + createObjectURL: () => `blob:${index++}`, + revokeObjectURL: revoke, + }); + expect(registry.replace('output', new Blob(['one']))).toBe('blob:0'); + expect(registry.replace('output', new Blob(['two']))).toBe('blob:1'); + registry.replace('preview', new Blob(['three'])); + expect(revoke).toHaveBeenCalledWith('blob:0'); + registry.clear(); + expect(revoke).toHaveBeenCalledWith('blob:1'); + expect(revoke).toHaveBeenCalledWith('blob:2'); + expect(registry.size).toBe(0); + }); +}); + +async function waitUntil( + predicate: () => boolean, + timeoutMs = 2_000 +): Promise { + const started = Date.now(); + while (!predicate()) { + if (Date.now() - started > timeoutMs) { + throw new Error('Timed out waiting for queue state'); + } + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} diff --git a/tests/unit/media/duration-timecode.test.ts b/tests/unit/media/duration-timecode.test.ts new file mode 100644 index 0000000..a175e08 --- /dev/null +++ b/tests/unit/media/duration-timecode.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; + +import { + addDurations, + clampTime, + roundSeconds, + validateSourceRange, +} from '../../../src/media/duration'; +import { + formatFrameTimecode, + formatTimecode, + parseTimecode, + parseTimecodeDetailed, +} from '../../../src/media/timecode'; + +describe('duration arithmetic', () => { + it('adds durations without exposing common binary drift', () => { + expect(addDurations([0.1, 0.2, 1.0000004])).toBe(1.3); + expect(roundSeconds(-0.0000001, 3)).toBe(0); + }); + + it('rejects invalid durations and clamps preview times', () => { + expect(() => addDurations([1, Number.NaN])).toThrow(); + expect(() => addDurations([-1])).toThrow(); + expect(clampTime(-2, 10)).toBe(0); + expect(clampTime(12, 10)).toBe(10); + }); + + it('validates source in/out ranges and source bounds', () => { + expect(validateSourceRange(1, 3, 5)).toEqual({ + valid: true, + durationSeconds: 2, + issues: [], + }); + expect(validateSourceRange(3, 1, 5).issues[0]?.code).toBe( + 'source-out-before-in' + ); + expect(validateSourceRange(1, 1, 5).issues[0]?.code).toBe('clip-too-short'); + expect( + validateSourceRange(1, 6, 5).issues.some( + (issue) => issue.code === 'source-out-after-duration' + ) + ).toBe(true); + }); +}); + +describe('timecode parsing and formatting', () => { + it.each([ + ['12.5', 12.5, undefined], + ['01:02.500', 62.5, undefined], + ['02:03:04.125', 7_384.125, undefined], + ['00:00:10:12', 10.5, { frameRate: 24 }], + ])('parses %s', (text, expected, options) => { + expect(parseTimecode(text, options)).toBe(expected); + }); + + it('reports malformed components', () => { + expect(parseTimecodeDetailed('01:60')).toEqual({ + ok: false, + reason: 'seconds-out-of-range', + }); + expect(parseTimecodeDetailed('00:00:01:10')).toEqual({ + ok: false, + reason: 'frame-rate-required', + }); + expect(parseTimecodeDetailed('-00:01', { allowNegative: false })).toEqual({ + ok: false, + reason: 'negative-not-allowed', + }); + expect(parseTimecode('anything')).toBeUndefined(); + expect(parseTimecode('1.5:02:03')).toBeUndefined(); + }); + + it('formats carry, precision, negative values and nominal frames', () => { + expect(formatTimecode(59.9996)).toBe('00:01:00.000'); + expect( + formatTimecode(62.5, { + decimalPlaces: 2, + alwaysShowHours: false, + }) + ).toBe('01:02.50'); + expect(formatTimecode(-1.25, { decimalPlaces: 2 })).toBe('-00:00:01.25'); + expect(formatFrameTimecode(10.5, 24)).toBe('00:00:10:12'); + expect(() => formatFrameTimecode(5, 0.5)).toThrow(); + }); +}); diff --git a/tests/unit/media/probe-parser.test.ts b/tests/unit/media/probe-parser.test.ts new file mode 100644 index 0000000..53bda99 --- /dev/null +++ b/tests/unit/media/probe-parser.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, it } from 'vitest'; + +import { + ProbeParseError, + normalizeFfprobeReport, + parseFfprobeJson, + parseFraction, + parseFractionDetailed, + parseFrameRate, +} from '../../../src/media/probe-parser'; + +describe('fraction parsing', () => { + it.each([ + ['30000/1001', 30000 / 1001], + ['25/1', 25], + ['-1/2', -0.5], + [' 1 / 4 ', 0.25], + ['23.976', 23.976], + [5, 5], + ])('parses %j safely', (input, expected) => { + expect(parseFraction(input)).toBeCloseTo(expected); + }); + + it.each([ + ['0/0', 'division-by-zero'], + ['1/0', 'division-by-zero'], + ['NaN', 'invalid-syntax'], + ['Infinity', 'invalid-syntax'], + ['1e3', 'invalid-syntax'], + ['1/2/3', 'invalid-syntax'], + ['999999999999999999/1', 'component-too-large'], + ])('rejects %j with a useful reason', (input, reason) => { + expect(parseFractionDetailed(input)).toEqual({ ok: false, reason }); + }); + + it('rejects implausible, zero and negative frame rates', () => { + expect(parseFrameRate('0/0')).toBeUndefined(); + expect(parseFrameRate('0/1')).toBeUndefined(); + expect(parseFrameRate('-25/1')).toBeUndefined(); + expect(parseFrameRate('1001/1')).toBeUndefined(); + }); +}); + +describe('ffprobe normalization', () => { + it('normalizes format, streams, dispositions, tags and chapters', () => { + const report = { + format: { + duration: '12.500000', + start_time: '-0.021', + bit_rate: '192000', + format_name: 'mov,mp4,m4a,3gp,3g2,mj2', + format_long_name: 'QuickTime / MOV', + tags: { title: 'Demo' }, + }, + streams: [ + { + index: 2, + codec_type: 'audio', + codec_name: 'aac', + sample_rate: '48000', + channels: 2, + channel_layout: 'stereo', + disposition: { default: 1, forced: 0 }, + tags: { language: 'deu', title: 'German' }, + }, + { + index: 0, + codec_type: 'video', + codec_name: 'h264', + width: 1920, + height: 1080, + coded_width: 1920, + coded_height: 1088, + pix_fmt: 'yuv420p', + sample_aspect_ratio: '8:6', + display_aspect_ratio: '16/9', + time_base: '1/30000', + avg_frame_rate: '30000/1001', + r_frame_rate: '30/1', + side_data_list: [ + { + side_data_type: 'Display Matrix', + rotation: -90, + }, + ], + disposition: { default: true }, + tags: {}, + }, + ], + chapters: [ + { + id: 4, + time_base: '1/1000', + start: 1000, + end: 2500, + tags: { title: 'Opening' }, + }, + ], + }; + + const probe = normalizeFfprobeReport(report); + + expect(probe).toMatchObject({ + durationSeconds: 12.5, + startTimeSeconds: -0.021, + bitRate: 192000, + formatNames: ['mov', 'mp4', 'm4a', '3gp', '3g2', 'mj2'], + formatLongName: 'QuickTime / MOV', + tags: { title: 'Demo' }, + warnings: [], + }); + expect(probe.streams.map((stream) => stream.index)).toEqual([0, 2]); + expect(probe.streams[0]).toMatchObject({ + type: 'video', + codecName: 'h264', + width: 1920, + height: 1080, + sampleAspectRatio: '4:3', + displayAspectRatio: '16:9', + rotationDegrees: -90, + frameRate: 30000 / 1001, + timeBase: '1/30000', + disposition: { default: true }, + }); + expect(probe.streams[1]).toMatchObject({ + type: 'audio', + sampleRate: 48000, + channels: 2, + language: 'deu', + title: 'German', + disposition: { default: true, forced: false }, + }); + expect(probe.chapters).toEqual([ + { + id: 4, + startSeconds: 1, + endSeconds: 2.5, + timeBase: '1/1000', + title: 'Opening', + tags: { title: 'Opening' }, + }, + ]); + }); + + it('falls back to r_frame_rate and records malformed data as warnings', () => { + const probe = normalizeFfprobeReport({ + format: { duration: 'not-a-number', tags: { title: 42 } }, + streams: [ + { + index: 0, + codec_type: 'video', + avg_frame_rate: '0/0', + r_frame_rate: '25/1', + disposition: { default: 'sometimes' }, + tags: [], + }, + { index: 1, codec_type: 'telepathy', tags: {}, disposition: {} }, + { index: 1, codec_type: 'audio', tags: {}, disposition: {} }, + { codec_type: 'audio' }, + 'not a stream', + ], + chapters: [{ id: 0, start_time: '2', end_time: '1', tags: {} }], + }); + + expect(probe.durationSeconds).toBeUndefined(); + expect(probe.streams).toHaveLength(2); + expect(probe.streams[0]?.frameRate).toBe(25); + expect(probe.streams[1]?.type).toBe('unknown'); + expect(probe.chapters).toEqual([]); + expect(probe.warnings.map((warning) => warning.code)).toEqual( + expect.arrayContaining([ + 'invalid-field', + 'invalid-fraction', + 'unknown-stream-type', + 'duplicate-stream-index', + 'missing-stream-index', + 'invalid-stream', + 'invalid-chapter', + ]) + ); + }); + + it('parses legacy rotation and rejects misleading aspect ratios', () => { + const probe = normalizeFfprobeReport({ + streams: [ + { + index: 0, + codec_type: 'video', + sample_aspect_ratio: '0:0', + display_aspect_ratio: 'not-available', + side_data_list: [{ rotation: 'not-a-number' }], + disposition: {}, + tags: { rotate: '270' }, + }, + { + index: 1, + codec_type: 'video', + sample_aspect_ratio: ' 32 / 18 ', + display_aspect_ratio: '64:36', + disposition: {}, + tags: { rotate: '540' }, + }, + ], + }); + + expect(probe.streams[0]).toMatchObject({ + rotationDegrees: -90, + }); + expect(probe.streams[0]).not.toHaveProperty('sampleAspectRatio'); + expect(probe.streams[0]).not.toHaveProperty('displayAspectRatio'); + expect(probe.streams[1]).toMatchObject({ + sampleAspectRatio: '16:9', + displayAspectRatio: '16:9', + rotationDegrees: 180, + }); + expect(probe.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'invalid-fraction', + path: 'streams[0].sample_aspect_ratio', + }), + expect.objectContaining({ + code: 'invalid-fraction', + path: 'streams[0].display_aspect_ratio', + }), + expect.objectContaining({ + code: 'invalid-field', + path: 'streams[0].side_data_list[0].rotation', + }), + ]) + ); + }); + + it('keeps raw diagnostics separate from the normalized model', () => { + const rawReport = '{"format":{"format_name":"wav"},"streams":[]}'; + const result = parseFfprobeJson(rawReport); + + expect(result.rawReport).toBe(rawReport); + expect(result.probe.formatNames).toEqual(['wav']); + expect(result.probe).not.toHaveProperty('rawReport'); + }); + + it('throws a typed error for malformed JSON or a non-object root', () => { + expect(() => parseFfprobeJson('{')).toThrow(ProbeParseError); + expect(() => normalizeFfprobeReport([])).toThrow( + 'The ffprobe report must be a JSON object.' + ); + }); +}); diff --git a/tests/unit/media/safe-file-name.test.ts b/tests/unit/media/safe-file-name.test.ts new file mode 100644 index 0000000..0c8139a --- /dev/null +++ b/tests/unit/media/safe-file-name.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; + +import { + createSafeVirtualFileName, + safeFileName, + safeOutputFileName, + splitFileName, +} from '../../../src/media/safe-file-name'; + +describe('safe filenames', () => { + it('removes path traversal, control characters and shell-like punctuation', () => { + expect(safeFileName('../../My $ video\u0000 (final).MP4')).toBe( + 'My-video-final.mp4' + ); + expect(safeFileName('..\\..\\evil.webm')).toBe('evil.webm'); + expect(safeFileName('../')).toBe('file'); + }); + + it('handles Unicode, hidden names and reserved device names', () => { + expect(safeFileName('Crème brûlée.mov')).toBe('Creme-brulee.mov'); + expect(safeFileName('.env')).toBe('env'); + expect(safeFileName('CON.mp4')).toBe('_CON.mp4'); + }); + + it('preserves a short extension while enforcing the requested maximum', () => { + const result = safeFileName(`${'a'.repeat(200)}.mp4`, { + maxLength: 32, + }); + expect(result).toHaveLength(32); + expect(result.endsWith('.mp4')).toBe(true); + }); + + it('creates generated virtual names without leaking the original stem', () => { + expect(createSafeVirtualFileName('private-holiday.mov', 3)).toBe( + 'source-3.mov' + ); + expect(() => createSafeVirtualFileName('x.mp4', -1)).toThrow(); + }); + + it('creates deterministic download names and splits compound extensions', () => { + expect(safeOutputFileName('My export (1)', '.WEBM')).toBe( + 'My-export-1.webm' + ); + expect(safeOutputFileName('Project', 'avproject.json')).toBe( + 'Project.avproject.json' + ); + expect(safeOutputFileName('Project.v2', 'mp4')).toBe('Project.v2.mp4'); + expect(splitFileName('archive.tar.gz')).toEqual({ + stem: 'archive.tar', + extension: '.gz', + }); + }); +}); diff --git a/tests/unit/media/stream-playback.test.ts b/tests/unit/media/stream-playback.test.ts new file mode 100644 index 0000000..94157d8 --- /dev/null +++ b/tests/unit/media/stream-playback.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from 'vitest'; + +import { + assessBrowserPlayback, + assessGeneratedResultPreview, + buildPlaybackMimeCandidates, +} from '../../../src/media/browser-playback'; +import type { + MediaProbe, + MediaStreamProbe, +} from '../../../src/media/media.types'; +import { + selectDefaultStreams, + streamSelectionMapArgs, + validateStreamSelection, +} from '../../../src/media/stream-selection'; + +function stream( + index: number, + type: MediaStreamProbe['type'], + overrides: Partial = {} +): MediaStreamProbe { + return { + index, + type, + disposition: {}, + tags: {}, + ...overrides, + }; +} + +function probe(streams: MediaStreamProbe[]): MediaProbe { + return { + formatNames: ['mov', 'mp4'], + tags: {}, + streams, + chapters: [], + warnings: [], + }; +} + +describe('stream selection', () => { + const media = probe([ + stream(0, 'video', { codecName: 'h264' }), + stream(1, 'audio', { + language: 'eng', + disposition: { default: true }, + }), + stream(2, 'audio', { + language: 'de-DE', + }), + stream(3, 'subtitle', { language: 'deu' }), + stream(4, 'video', { disposition: { attached_pic: true } }), + ]); + + it('chooses default streams, avoids cover art and can include subtitles', () => { + expect( + selectDefaultStreams(media, { + preferredAudioLanguages: ['de'], + preferredSubtitleLanguages: ['deu'], + includeSubtitles: true, + }) + ).toEqual({ + videoStreamIndex: 0, + audioStreamIndexes: [2], + subtitleStreamIndexes: [3], + }); + }); + + it('validates stream existence, type and duplicate mappings', () => { + const result = validateStreamSelection(media, { + videoStreamIndex: 1, + audioStreamIndexes: [2, 2, 99], + subtitleStreamIndexes: [], + }); + expect(result.valid).toBe(false); + expect(result.issues.map((issue) => issue.code)).toEqual([ + 'wrong-stream-type', + 'duplicate-stream', + 'stream-not-found', + ]); + }); + + it('generates structured map arguments in deterministic order', () => { + expect( + streamSelectionMapArgs({ + videoStreamIndex: 0, + audioStreamIndexes: [2], + subtitleStreamIndexes: [3], + }) + ).toEqual(['-map', '0:0', '-map', '0:2', '-map', '0:3']); + }); +}); + +describe('browser playback assessment', () => { + const mp4 = probe([ + stream(0, 'video', { codecName: 'h264' }), + stream(1, 'audio', { codecName: 'aac' }), + ]); + + it('builds codec-qualified and container-only MIME candidates', () => { + expect(buildPlaybackMimeCandidates(mp4)).toEqual([ + 'video/mp4; codecs="avc1.42E01E, mp4a.40.2"', + 'video/mp4', + ]); + }); + + it('keeps browser support separate from FFmpeg readability', () => { + expect( + assessBrowserPlayback(mp4, (mime) => + mime.includes('codecs') ? 'probably' : '' + ) + ).toMatchObject({ + support: 'probably', + needsPreviewProxy: false, + }); + expect(assessBrowserPlayback(mp4, () => '')).toMatchObject({ + support: 'unsupported', + needsPreviewProxy: true, + }); + }); + + it('does not treat an audio/video MIME prefix as playable when the browser rejects it', () => { + expect( + assessGeneratedResultPreview( + { + fileName: 'generated.flac', + mimeType: 'audio/flac', + }, + () => '' + ) + ).toMatchObject({ + kind: 'audio', + previewable: false, + support: 'unsupported', + }); + expect( + assessGeneratedResultPreview( + { + fileName: 'generated.mp4', + mimeType: 'video/mp4', + probe: mp4, + }, + (mime) => (mime.includes('codecs') ? 'probably' : '') + ) + ).toMatchObject({ + kind: 'video', + previewable: true, + support: 'probably', + }); + expect( + assessGeneratedResultPreview( + { + fileName: 'frame.png', + mimeType: 'image/png', + }, + () => '' + ) + ).toMatchObject({ kind: 'image', previewable: true }); + expect( + assessGeneratedResultPreview( + { + fileName: 'frame.tiff', + mimeType: 'image/tiff', + }, + () => '' + ) + ).toMatchObject({ + kind: 'image', + previewable: false, + support: 'unsupported', + }); + }); +}); diff --git a/tests/unit/presets.test.ts b/tests/unit/presets.test.ts new file mode 100644 index 0000000..4ea07e8 --- /dev/null +++ b/tests/unit/presets.test.ts @@ -0,0 +1,436 @@ +import { describe, expect, it } from 'vitest'; +import { + AUDIO_PRESETS, + BUILT_IN_PRESETS, + MemoryUserPresetPersistence, + UserPresetRegistry, + VIDEO_PRESETS, + migrateUserPreset, + parseUserPresetJson, + presetAvailability, + presetEncodingArguments, + serializeUserPreset, + validateUserPreset, + type UserExportPreset, +} from '../../src/presets'; + +const USER_PRESET: UserExportPreset = { + schemaVersion: 1, + id: 'my-podcast', + name: 'My podcast', + description: 'Speech export', + kind: 'audio', + container: 'mp3', + fileExtension: 'mp3', + audio: { + codec: 'libmp3lame', + bitrateKbps: 128, + sampleRate: 44_100, + channels: 2, + }, + metadataPolicy: 'copy', + chapterPolicy: 'remove', + subtitlePolicy: 'none', +}; + +describe('built-in preset registry', () => { + it('contains immutable, uniquely identified video and audio definitions', () => { + expect(VIDEO_PRESETS.length).toBeGreaterThanOrEqual(6); + expect(AUDIO_PRESETS.length).toBeGreaterThanOrEqual(6); + expect(new Set(BUILT_IN_PRESETS.map((preset) => preset.id)).size).toBe( + BUILT_IN_PRESETS.length + ); + expect(Object.isFrozen(BUILT_IN_PRESETS[0])).toBe(true); + expect(Object.isFrozen(BUILT_IN_PRESETS[0]?.settingsSummary)).toBe(true); + }); + + it('disables a preset with exact missing capability reasons', () => { + const preset = BUILT_IN_PRESETS.find( + (candidate) => candidate.id === 'webm-vp9-opus' + )!; + const unavailable = presetAvailability(preset, { + muxers: new Set(['webm']), + encoders: new Set(['libvpx-vp9']), + decoders: new Set(), + filters: new Set(), + }); + expect(unavailable).toEqual({ + status: 'unavailable', + reasons: ['Missing encoders: libopus'], + }); + expect( + presetAvailability(preset, { + muxers: new Set(['webm']), + encoders: new Set(['libvpx-vp9', 'libopus']), + decoders: new Set(), + filters: new Set(), + }) + ).toEqual({ status: 'available', reasons: [] }); + expect( + presetAvailability( + preset, + { + muxers: new Set(['webm']), + encoders: new Set(['libvpx-vp9', 'libopus']), + decoders: new Set(), + filters: new Set(), + }, + { filters: ['drawtext'] } + ) + ).toEqual({ + status: 'degraded', + reasons: ['Optional filters unavailable: drawtext'], + }); + }); + + it('turns allowlisted settings into structured encoder arguments', () => { + expect(presetEncodingArguments(USER_PRESET)).toEqual([ + '-vn', + '-c:a', + 'libmp3lame', + '-b:a', + '128k', + '-ar', + '44100', + '-ac', + '2', + ]); + }); +}); + +describe('user preset validation and persistence', () => { + it('round-trips a versioned preset as JSON', () => { + const json = serializeUserPreset(USER_PRESET); + expect(parseUserPresetJson(json)).toEqual(USER_PRESET); + expect(validateUserPreset(USER_PRESET)).toMatchObject({ valid: true }); + }); + + it('migrates an allowlisted legacy schema without accepting raw fields', () => { + expect( + migrateUserPreset({ + schemaVersion: 0, + id: 'legacy', + name: 'Legacy', + kind: 'audio', + format: 'ipod', + audio: { codec: 'aac', bitrateKbps: 128 }, + includeChapters: true, + }) + ).toEqual({ + schemaVersion: 1, + id: 'legacy', + name: 'Legacy', + kind: 'audio', + container: 'ipod', + fileExtension: 'm4a', + audio: { codec: 'aac', bitrateKbps: 128 }, + metadataPolicy: 'copy', + chapterPolicy: 'keep', + subtitlePolicy: 'none', + }); + expect(() => + migrateUserPreset({ + schemaVersion: 0, + id: 'legacy', + name: 'Legacy', + kind: 'audio', + format: 'mp3', + args: ['-i', 'remote'], + }) + ).toThrow(/unsupported field/iu); + }); + + it.each([ + ['args', ['-i', 'https://example.invalid/a']], + ['filter', 'movie=https://example.invalid'], + ['inputPath', '/etc/passwd'], + ['protocol', 'https'], + ])('rejects imported arbitrary %s fields', (field, value) => { + expect( + validateUserPreset({ ...USER_PRESET, [field]: value }) + ).toMatchObject({ + valid: false, + errors: [expect.stringContaining(`"${field}"`)], + }); + }); + + it('rejects unknown codecs and unsafe identifiers', () => { + expect( + validateUserPreset({ + ...USER_PRESET, + id: '../../unsafe', + audio: { codec: 'shell', bitrateKbps: 128 }, + }) + ).toMatchObject({ valid: false }); + }); + + it.each([ + [ + 'MP4 H.264/AAC video', + { + ...USER_PRESET, + kind: 'video', + container: 'mp4', + fileExtension: 'mp4', + video: { codec: 'libx264', qualityMode: 'balanced', crf: 23 }, + audio: { codec: 'aac', bitrateKbps: 160 }, + chapterPolicy: 'keep', + subtitlePolicy: 'convert', + fastStart: true, + }, + ], + [ + 'WebM VP9/Opus video', + { + ...USER_PRESET, + kind: 'video', + container: 'webm', + fileExtension: 'webm', + video: { codec: 'libvpx-vp9', qualityMode: 'smaller-file', crf: 32 }, + audio: { codec: 'libopus', bitrateKbps: 128 }, + chapterPolicy: 'keep', + subtitlePolicy: 'convert', + }, + ], + [ + 'Matroska VP8/FLAC video', + { + ...USER_PRESET, + kind: 'video', + container: 'matroska', + fileExtension: 'mkv', + video: { codec: 'libvpx', qualityMode: 'compatibility', crf: 18 }, + audio: { codec: 'flac' }, + chapterPolicy: 'keep', + subtitlePolicy: 'copy-compatible', + }, + ], + [ + 'animated WebP derivative', + { + ...USER_PRESET, + kind: 'derivative', + container: 'webp', + fileExtension: 'webp', + video: { + codec: 'libwebp_anim', + qualityMode: 'balanced', + crf: 60, + }, + audio: undefined, + metadataPolicy: 'remove', + chapterPolicy: 'remove', + subtitlePolicy: 'none', + }, + ], + [ + 'GIF derivative', + { + ...USER_PRESET, + kind: 'derivative', + container: 'gif', + fileExtension: 'gif', + video: { codec: 'gif', qualityMode: 'compatibility' }, + audio: undefined, + metadataPolicy: 'remove', + chapterPolicy: 'remove', + subtitlePolicy: 'none', + }, + ], + ['MP3 audio', USER_PRESET], + [ + 'M4A/AAC audio', + { + ...USER_PRESET, + container: 'ipod', + fileExtension: 'm4a', + audio: { codec: 'aac', bitrateKbps: 160 }, + fastStart: true, + }, + ], + [ + 'Ogg/Vorbis audio', + { + ...USER_PRESET, + container: 'ogg', + fileExtension: 'ogg', + audio: { codec: 'libvorbis', bitrateKbps: 160 }, + }, + ], + [ + 'Opus audio', + { + ...USER_PRESET, + container: 'opus', + fileExtension: 'opus', + audio: { codec: 'libopus', bitrateKbps: 128 }, + }, + ], + [ + 'WAV/PCM audio', + { + ...USER_PRESET, + container: 'wav', + fileExtension: 'wav', + audio: { codec: 'pcm_s16le' }, + }, + ], + [ + 'FLAC audio', + { + ...USER_PRESET, + container: 'flac', + fileExtension: 'flac', + audio: { codec: 'flac' }, + }, + ], + ])('accepts the reviewed %s combination', (_label, preset) => { + expect(validateUserPreset(preset)).toMatchObject({ + valid: true, + errors: [], + }); + }); + + it.each([ + [ + 'an unknown container', + { ...USER_PRESET, container: 'avi', fileExtension: 'avi' }, + /container "avi".*allowlist/iu, + ], + [ + 'a mismatched extension', + { + ...USER_PRESET, + container: 'ipod', + fileExtension: 'mp4', + audio: { codec: 'aac' }, + }, + /fileExtension must be "m4a"/u, + ], + [ + 'the wrong media kind', + { + ...USER_PRESET, + kind: 'audio', + container: 'webm', + fileExtension: 'webm', + video: undefined, + audio: { codec: 'libopus' }, + }, + /container "webm".*kind "audio"/u, + ], + [ + 'an incompatible video codec', + { + ...USER_PRESET, + kind: 'video', + container: 'mp4', + fileExtension: 'mp4', + video: { codec: 'libvpx-vp9', qualityMode: 'balanced' }, + audio: { codec: 'aac' }, + }, + /video codec "libvpx-vp9".*container "mp4"/u, + ], + [ + 'an incompatible audio codec', + { + ...USER_PRESET, + kind: 'video', + container: 'webm', + fileExtension: 'webm', + video: { codec: 'libvpx', qualityMode: 'balanced' }, + audio: { codec: 'aac' }, + }, + /audio codec "aac".*container "webm"/u, + ], + [ + 'audio in an image derivative', + { + ...USER_PRESET, + kind: 'derivative', + container: 'gif', + fileExtension: 'gif', + video: { codec: 'gif', qualityMode: 'compatibility' }, + }, + /container "gif" does not support audio settings/u, + ], + [ + 'a video container without video', + { + ...USER_PRESET, + kind: 'video', + container: 'mp4', + fileExtension: 'mp4', + audio: { codec: 'aac' }, + video: undefined, + }, + /container "mp4" requires video settings/u, + ], + [ + 'fast-start outside MP4/M4A', + { ...USER_PRESET, fastStart: true }, + /fastStart is only supported.*not "mp3"/u, + ], + ])('rejects %s with a clear compatibility error', (_label, preset, error) => { + const result = validateUserPreset(preset); + expect(result.valid).toBe(false); + expect(result.errors.join('; ')).toMatch(error); + }); + + it.each([ + ['metadataPolicy', 'edit', /metadataPolicy "edit".*Quick presets/u], + ['chapterPolicy', 'replace', /chapterPolicy "replace".*Quick presets/u], + [ + 'subtitlePolicy', + 'burn-in', + /subtitlePolicy "burn-in".*explicit subtitle source/u, + ], + ])( + 'rejects the unsupported Quick preset policy %s=%s', + (field, value, error) => { + const result = validateUserPreset({ + ...USER_PRESET, + [field]: value, + }); + expect(result.valid).toBe(false); + expect(result.errors.join('; ')).toMatch(error); + } + ); + + it('applies relational compatibility checks to imported JSON', () => { + expect(() => + parseUserPresetJson( + JSON.stringify({ + ...USER_PRESET, + kind: 'video', + container: 'mp4', + fileExtension: 'mp4', + video: { codec: 'libvpx-vp9', qualityMode: 'balanced' }, + audio: { codec: 'aac' }, + }) + ) + ).toThrow(/video codec "libvpx-vp9".*container "mp4"/iu); + }); + + it('supports create, clone, rename, export, import, delete and reset', async () => { + const persistence = new MemoryUserPresetPersistence(); + const registry = new UserPresetRegistry(persistence); + await registry.initialize(); + await registry.save(USER_PRESET); + const clone = await registry.clone('my-podcast', 'my-podcast-copy', 'Copy'); + expect(clone.name).toBe('Copy'); + await registry.rename(clone.id, 'Renamed'); + expect(registry.get(clone.id)?.name).toBe('Renamed'); + const exported = registry.export('my-podcast'); + await registry.delete('my-podcast'); + expect(registry.get('my-podcast')).toBeUndefined(); + await registry.import(exported); + expect(registry.get('my-podcast')).toEqual(USER_PRESET); + await registry.reset(); + expect(registry.list()).toEqual([]); + + const reloaded = new UserPresetRegistry(persistence); + await reloaded.initialize(); + expect(reloaded.list()).toEqual([]); + }); +}); diff --git a/tests/unit/project/project-fixture.ts b/tests/unit/project/project-fixture.ts new file mode 100644 index 0000000..4e59854 --- /dev/null +++ b/tests/unit/project/project-fixture.ts @@ -0,0 +1,66 @@ +import { createEmptyProject } from '../../../src/project/project.schema'; +import type { + AvProjectV1, + MediaAssetReference, +} from '../../../src/project/project.types'; + +export const CREATED_AT = '2026-01-02T03:04:05.000Z'; +export const UPDATED_AT = '2026-01-02T04:00:00.000Z'; + +export function mediaAsset( + overrides: Partial = {} +): MediaAssetReference { + return { + id: 'asset-1', + originalName: 'source.mp4', + size: 1_024, + lastModified: 1_700_000_000_000, + mimeType: 'video/mp4', + sourceStatus: 'attached', + probe: { + durationSeconds: 10, + formatNames: ['mov', 'mp4'], + tags: {}, + streams: [ + { + index: 0, + type: 'video', + codecName: 'h264', + disposition: { default: true }, + tags: {}, + }, + ], + chapters: [], + warnings: [], + }, + ...overrides, + }; +} + +export function populatedProject(): AvProjectV1 { + const project = createEmptyProject({ + id: 'project-1', + name: 'Demo project', + timestamp: CREATED_AT, + }); + return { + ...project, + assets: [mediaAsset()], + timeline: [ + { + id: 'clip-1', + assetId: 'asset-1', + sourceInSeconds: 1, + sourceOutSeconds: 4.5, + audio: { enabled: true, fadeInSeconds: 0.2 }, + video: { enabled: true }, + }, + ], + ui: { + selectedAssetId: 'asset-1', + selectedClipId: 'clip-1', + inspectorPanel: 'clip', + timelineZoom: 1, + }, + }; +} diff --git a/tests/unit/project/project-reducer-selectors.test.ts b/tests/unit/project/project-reducer-selectors.test.ts new file mode 100644 index 0000000..834b3b2 --- /dev/null +++ b/tests/unit/project/project-reducer-selectors.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from 'vitest'; + +import { + ProjectReducerError, + projectReducer, +} from '../../../src/project/project.reducer'; +import { + selectCanExport, + selectMissingAssets, + selectTimelineDuration, + selectTimelineEntries, + selectUnusedAssets, +} from '../../../src/project/project.selectors'; +import { createEmptyProject } from '../../../src/project/project.schema'; +import { + CREATED_AT, + UPDATED_AT, + mediaAsset, + populatedProject, +} from './project-fixture'; + +describe('project reducer', () => { + it('adds assets and sequential clips without mutating prior state', () => { + const empty = createEmptyProject({ + id: 'p', + timestamp: CREATED_AT, + }); + const withAsset = projectReducer(empty, { + type: 'asset/add', + asset: mediaAsset(), + updatedAt: UPDATED_AT, + }); + const withClip = projectReducer(withAsset, { + type: 'clip/add', + clip: { + id: 'clip-1', + assetId: 'asset-1', + sourceInSeconds: 0, + sourceOutSeconds: 2, + }, + updatedAt: UPDATED_AT, + }); + + expect(empty.assets).toEqual([]); + expect(withAsset.timeline).toEqual([]); + expect(withClip.timeline).toHaveLength(1); + expect(withClip.updatedAt).toBe(UPDATED_AT); + }); + + it('reorders clips deterministically and returns identity for a no-op', () => { + const project = { + ...populatedProject(), + timeline: [ + populatedProject().timeline[0]!, + { + id: 'clip-2', + assetId: 'asset-1', + sourceInSeconds: 4.5, + sourceOutSeconds: 6, + }, + ], + }; + const moved = projectReducer(project, { + type: 'clip/move', + clipId: 'clip-2', + toIndex: 0, + updatedAt: UPDATED_AT, + }); + expect(moved.timeline.map((clip) => clip.id)).toEqual(['clip-2', 'clip-1']); + expect( + projectReducer(moved, { + type: 'clip/move', + clipId: 'clip-2', + toIndex: 0, + updatedAt: UPDATED_AT, + }) + ).toBe(moved); + }); + + it('removes dependent clips/subtitles and clears stale UI selection', () => { + const project = { + ...populatedProject(), + subtitles: [ + { id: 'subtitle-1', assetId: 'asset-1', mode: 'mux' as const }, + ], + }; + const result = projectReducer(project, { + type: 'asset/remove', + assetId: 'asset-1', + updatedAt: UPDATED_AT, + }); + + expect(result.assets).toEqual([]); + expect(result.timeline).toEqual([]); + expect(result.subtitles).toEqual([]); + expect(result.ui).toMatchObject({ + selectedAssetId: undefined, + selectedClipId: undefined, + }); + }); + + it('guards duplicate IDs, missing assets and invalid ranges', () => { + const project = populatedProject(); + expect(() => + projectReducer(project, { + type: 'asset/add', + asset: mediaAsset(), + updatedAt: UPDATED_AT, + }) + ).toThrow(ProjectReducerError); + expect(() => + projectReducer(project, { + type: 'clip/add', + clip: { + id: 'bad', + assetId: 'missing', + sourceInSeconds: 0, + sourceOutSeconds: 1, + }, + updatedAt: UPDATED_AT, + }) + ).toThrow('unknown asset'); + expect(() => + projectReducer(project, { + type: 'clip/update', + clipId: 'clip-1', + changes: { sourceOutSeconds: 0 }, + updatedAt: UPDATED_AT, + }) + ).toThrow(); + }); + + it('does not mark the document updated for transient UI state', () => { + const project = populatedProject(); + const result = projectReducer(project, { + type: 'ui/set', + ui: { inspectorPanel: 'output' }, + }); + expect(result.updatedAt).toBe(project.updatedAt); + }); +}); + +describe('project selectors', () => { + it('derives timeline positions and total duration for a sequential edit', () => { + const project = { + ...populatedProject(), + timeline: [ + populatedProject().timeline[0]!, + { + id: 'clip-2', + assetId: 'asset-1', + sourceInSeconds: 5, + sourceOutSeconds: 6.25, + }, + ], + }; + expect(selectTimelineDuration(project)).toBe(4.75); + expect(selectTimelineEntries(project)).toMatchObject([ + { + timelineStartSeconds: 0, + timelineEndSeconds: 3.5, + durationSeconds: 3.5, + }, + { + timelineStartSeconds: 3.5, + timelineEndSeconds: 4.75, + durationSeconds: 1.25, + }, + ]); + }); + + it('identifies missing, unused and export-blocking sources', () => { + const project = { + ...populatedProject(), + assets: [ + mediaAsset({ sourceStatus: 'missing' }), + mediaAsset({ id: 'asset-unused', originalName: 'unused.wav' }), + ], + }; + expect(selectMissingAssets(project).map((asset) => asset.id)).toEqual([ + 'asset-1', + ]); + expect(selectUnusedAssets(project).map((asset) => asset.id)).toEqual([ + 'asset-unused', + ]); + expect(selectCanExport(project)).toEqual({ + canExport: false, + reasons: ['1 referenced source file is missing.'], + }); + }); +}); diff --git a/tests/unit/project/project-schema.test.ts b/tests/unit/project/project-schema.test.ts new file mode 100644 index 0000000..a9232d9 --- /dev/null +++ b/tests/unit/project/project-schema.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; + +import { + createEmptyProject, + createMediaAssetReference, +} from '../../../src/project/project.schema'; +import { CREATED_AT } from './project-fixture'; + +describe('project factories', () => { + it('creates a complete, deterministic empty document when inputs are supplied', () => { + expect( + createEmptyProject({ + id: 'project-1', + name: ' Test ', + timestamp: CREATED_AT, + }) + ).toMatchObject({ + schemaVersion: 1, + id: 'project-1', + name: 'Test', + createdAt: CREATED_AT, + updatedAt: CREATED_AT, + assets: [], + timeline: [], + output: { + presetId: 'mp4-h264-balanced', + fileName: 'output.mp4', + container: 'mp4', + sampleRate: 48_000, + channelLayout: 'stereo', + missingAudioPolicy: 'insert-silence', + }, + }); + }); + + it('converts File-compatible identity metadata without retaining the File', () => { + const file = { + name: 'clip.mov', + size: 42, + lastModified: 123, + type: 'video/quicktime', + }; + const asset = createMediaAssetReference(file, { id: 'asset-1' }); + expect(asset).toEqual({ + id: 'asset-1', + originalName: 'clip.mov', + size: 42, + lastModified: 123, + mimeType: 'video/quicktime', + sourceStatus: 'attached', + }); + expect(asset).not.toHaveProperty('file'); + }); + + it('rejects malformed file identity metadata', () => { + expect(() => + createMediaAssetReference({ + name: '', + size: -1, + lastModified: 0, + type: '', + }) + ).toThrow(); + }); +}); diff --git a/tests/unit/project/project-serialization.test.ts b/tests/unit/project/project-serialization.test.ts new file mode 100644 index 0000000..4cc8d70 --- /dev/null +++ b/tests/unit/project/project-serialization.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from 'vitest'; + +import { + ProjectImportError, + importProjectDocument, + projectDocumentFileName, + serializeProject, +} from '../../../src/project/project.serialization'; +import { ProjectValidationError } from '../../../src/project/project.validation'; +import { populatedProject } from './project-fixture'; + +describe('project serialization', () => { + it('serializes readable, versioned JSON and can omit transient UI state', () => { + const project = populatedProject(); + const json = serializeProject(project, { includeUiState: false }); + const parsed = JSON.parse(json) as Record; + + expect(json.endsWith('\n')).toBe(true); + expect(parsed.schemaVersion).toBe(1); + expect(parsed.ui).toBeUndefined(); + expect(project.ui).toBeDefined(); + }); + + it('validates before serializing', () => { + const project = { + ...populatedProject(), + output: { ...populatedProject().output, fileName: '' }, + }; + expect(() => serializeProject(project)).toThrow(ProjectValidationError); + }); + + it('imports a round trip but correctly marks browser sources missing', () => { + const project = populatedProject(); + const result = importProjectDocument(serializeProject(project)); + + expect(result.fromVersion).toBe(1); + expect(result.project.assets[0]?.sourceStatus).toBe('missing'); + expect(result.warnings).toContainEqual( + expect.objectContaining({ code: 'source-reattachment-required' }) + ); + }); + + it('can retain runtime source status only when explicitly requested', () => { + const project = populatedProject(); + const result = importProjectDocument(serializeProject(project), { + markSourcesMissing: false, + }); + expect(result.project).toEqual(project); + expect(result.warnings).toEqual([]); + }); + + it('round-trips fade curves and editable chapter time bases', () => { + const project = populatedProject(); + const persisted = { + ...project, + timeline: [ + { + ...project.timeline[0]!, + audio: { + enabled: true, + fadeInSeconds: 0.2, + fadeCurve: 'qsin' as const, + }, + video: { + enabled: true, + fadeOutSeconds: 0.2, + fadeCurve: 'exp' as const, + }, + }, + ], + chapters: [ + { + id: 'chapter-1', + title: 'Intro', + startSeconds: 0, + endSeconds: 1, + timeBase: '1/48000', + }, + ], + }; + const result = importProjectDocument(serializeProject(persisted), { + markSourcesMissing: false, + }); + + expect(result.project.timeline[0]?.audio?.fadeCurve).toBe('qsin'); + expect(result.project.timeline[0]?.video?.fadeCurve).toBe('exp'); + expect(result.project.chapters[0]?.timeBase).toBe('1/48000'); + }); + + it('still reports reattachment when an exported project was already missing sources', () => { + const project = { + ...populatedProject(), + assets: [ + { ...populatedProject().assets[0]!, sourceStatus: 'missing' as const }, + ], + }; + const result = importProjectDocument(serializeProject(project)); + expect(result.warnings).toContainEqual( + expect.objectContaining({ code: 'source-reattachment-required' }) + ); + }); + + it('migrates the documented legacy version zero shape', () => { + const legacy = { + schemaVersion: 0, + id: 'legacy-project', + title: 'Legacy project', + createdAt: '2025-01-01T00:00:00.000Z', + updatedAt: '2025-01-01T00:00:00.000Z', + media: [ + { + id: 'legacy-source', + fileName: 'legacy.mp4', + size: 42, + lastModified: 100, + type: 'video/mp4', + sourceStatus: 'attached', + }, + ], + clips: [ + { + id: 'legacy-clip', + assetId: 'legacy-source', + inSeconds: 0, + outSeconds: 2, + }, + ], + }; + const result = importProjectDocument(JSON.stringify(legacy)); + + expect(result.fromVersion).toBe(0); + expect(result.project).toMatchObject({ + schemaVersion: 1, + name: 'Legacy project', + assets: [ + { + originalName: 'legacy.mp4', + mimeType: 'video/mp4', + sourceStatus: 'missing', + }, + ], + timeline: [ + { + sourceInSeconds: 0, + sourceOutSeconds: 2, + }, + ], + }); + expect(result.warnings.map((warning) => warning.code)).toEqual( + expect.arrayContaining([ + 'renamed-field', + 'defaulted-field', + 'source-reattachment-required', + ]) + ); + }); + + it('rejects malformed, unversioned, future and oversized documents', () => { + expect(() => importProjectDocument('{')).toThrow(ProjectImportError); + expect(() => importProjectDocument('{}')).toThrow( + 'does not declare a schema version' + ); + expect(() => importProjectDocument('{"schemaVersion":999}')).toThrow( + 'is not supported' + ); + expect(() => + importProjectDocument('{"schemaVersion":1}', { maximumBytes: 5 }) + ).toThrow('exceeds'); + }); + + it('creates a safe project-document filename', () => { + expect( + projectDocumentFileName({ + ...populatedProject(), + name: '../../My Project', + }) + ).toBe('My-Project.avproject.json'); + }); +}); diff --git a/tests/unit/project/project-validation.test.ts b/tests/unit/project/project-validation.test.ts new file mode 100644 index 0000000..dc57fe5 --- /dev/null +++ b/tests/unit/project/project-validation.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from 'vitest'; + +import { + ProjectValidationError, + assertValidProject, + validateProject, +} from '../../../src/project/project.validation'; +import { populatedProject } from './project-fixture'; + +describe('project validation', () => { + it('returns an isolated, typed copy for a valid version-1 project', () => { + const input = populatedProject(); + const result = validateProject(input); + + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.project).toEqual(input); + expect(result.project).not.toBe(input); + } + }); + + it('reports duplicate identities, missing references and bad ranges', () => { + const project = populatedProject(); + const invalid = { + ...project, + assets: [...project.assets, { ...project.assets[0] }], + timeline: [ + { + ...project.timeline[0], + assetId: 'missing', + sourceInSeconds: 5, + sourceOutSeconds: 3, + crop: { x: -1, y: 0, width: 0, height: 100 }, + }, + ], + }; + + const result = validateProject(invalid); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.issues.map((issue) => issue.code)).toEqual( + expect.arrayContaining([ + 'duplicate-id', + 'missing-reference', + 'invalid-range', + 'invalid-value', + ]) + ); + } + }); + + it('checks clip bounds, fade bounds, chapter bounds and output streams', () => { + const project = populatedProject(); + const invalid = { + ...project, + timeline: [ + { + ...project.timeline[0], + sourceOutSeconds: 11, + audio: { + enabled: true, + fadeInSeconds: 8, + fadeOutSeconds: 8, + }, + }, + ], + chapters: [ + { + id: 'chapter-1', + startSeconds: 0, + endSeconds: 20, + title: 'Too long', + }, + ], + output: { + ...project.output, + videoEnabled: false, + audioEnabled: false, + }, + }; + const result = validateProject(invalid); + + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'invalid-range', + path: 'timeline[0]', + }), + expect.objectContaining({ + code: 'out-of-bounds', + path: 'timeline[0].audio', + }), + expect.objectContaining({ + code: 'out-of-bounds', + path: 'chapters[0].endSeconds', + }), + expect.objectContaining({ + code: 'invalid-value', + path: 'output', + }), + ]) + ); + } + }); + + it('validates persisted fade curves and chapter time-base resolution', () => { + const project = populatedProject(); + const invalid = { + ...project, + timeline: [ + { + ...project.timeline[0], + audio: { + enabled: true, + fadeInSeconds: 0.2, + fadeCurve: 'log', + }, + }, + ], + chapters: [ + { + id: 'chapter-1', + startSeconds: 0, + endSeconds: 0.1, + title: 'Too short for this base', + timeBase: '1/1', + }, + ], + }; + + const result = validateProject(invalid); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'invalid-value', + path: 'timeline[0].audio.fadeCurve', + }), + expect.objectContaining({ + code: 'invalid-value', + path: 'chapters[0].timeBase', + }), + ]) + ); + } + }); + + it('rejects unsupported schema versions with a useful typed exception', () => { + const project = { ...populatedProject(), schemaVersion: 99 }; + expect(() => assertValidProject(project)).toThrow(ProjectValidationError); + const result = validateProject(project); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.issues[0]).toMatchObject({ + code: 'unsupported-version', + path: 'schemaVersion', + }); + } + }); + + it('does not trust malformed normalized probe fields from an import', () => { + const project = populatedProject(); + const invalid = structuredClone(project) as unknown as { + assets: Array<{ + probe?: { + streams: Array<{ + width?: unknown; + rotationDegrees?: unknown; + }>; + }; + }>; + }; + const stream = invalid.assets[0]?.probe?.streams[0]; + if (stream) { + stream.width = '1920'; + stream.rotationDegrees = '90'; + } + + const result = validateProject(invalid); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.issues).toContainEqual( + expect.objectContaining({ + code: 'invalid-type', + path: 'assets[0].probe.streams[0].width', + }) + ); + expect(result.issues).toContainEqual( + expect.objectContaining({ + code: 'invalid-type', + path: 'assets[0].probe.streams[0].rotationDegrees', + }) + ); + } + }); +}); diff --git a/tests/unit/project/source-reattachment.test.ts b/tests/unit/project/source-reattachment.test.ts new file mode 100644 index 0000000..e8d16d0 --- /dev/null +++ b/tests/unit/project/source-reattachment.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from 'vitest'; + +import { + SourceReattachmentError, + bestReattachmentCandidate, + confirmSourceReattachment, + rankReattachmentCandidates, +} from '../../../src/project/source-reattachment'; +import type { SourceHash } from '../../../src/project/project.types'; +import { UPDATED_AT, mediaAsset, populatedProject } from './project-fixture'; + +const HASH_A: SourceHash = { + algorithm: 'sha-256', + value: 'a'.repeat(64), + scope: 'full', +}; +const HASH_B: SourceHash = { + algorithm: 'sha-256', + value: 'b'.repeat(64), + scope: 'full', +}; + +describe('source reattachment matching', () => { + it('ranks size, timestamp, filename, MIME type and optional hash evidence', () => { + const asset = mediaAsset({ sourceStatus: 'missing', hash: HASH_A }); + const candidates = [ + { + name: 'renamed.mp4', + size: asset.size, + lastModified: asset.lastModified, + mimeType: asset.mimeType, + }, + { + name: asset.originalName, + size: asset.size, + lastModified: asset.lastModified, + mimeType: asset.mimeType, + hash: HASH_A, + }, + { + name: asset.originalName, + size: asset.size, + lastModified: asset.lastModified, + mimeType: asset.mimeType, + hash: HASH_B, + }, + ]; + const matches = rankReattachmentCandidates(asset, candidates); + + expect(matches.map((match) => match.confidence)).toEqual([ + 'exact', + 'likely', + 'none', + ]); + expect(matches[0]).toMatchObject({ + score: 190, + requiresExplicitConfirmation: true, + }); + expect( + matches[0]?.evidence.find((evidence) => evidence.field === 'hash') + ).toMatchObject({ matches: true }); + }); + + it('does not suggest ambiguous ties', () => { + const asset = mediaAsset({ sourceStatus: 'missing' }); + const candidates = [ + { + name: 'a.mp4', + size: asset.size, + lastModified: asset.lastModified, + mimeType: asset.mimeType, + }, + { + name: 'b.mp4', + size: asset.size, + lastModified: asset.lastModified, + mimeType: asset.mimeType, + }, + ]; + expect(bestReattachmentCandidate(asset, candidates)).toBeUndefined(); + }); + + it('allows filesystem timestamp tolerance but treats size/hash mismatch as unsafe', () => { + const asset = mediaAsset({ sourceStatus: 'missing', hash: HASH_A }); + expect( + rankReattachmentCandidates(asset, [ + { + name: asset.originalName, + size: asset.size, + lastModified: asset.lastModified + 1_500, + mimeType: asset.mimeType, + }, + ])[0]?.confidence + ).toBe('likely'); + expect( + rankReattachmentCandidates(asset, [ + { + name: asset.originalName, + size: asset.size + 1, + lastModified: asset.lastModified, + mimeType: asset.mimeType, + }, + ])[0]?.confidence + ).toBe('none'); + }); + + it('does not treat incomparable partial and full hashes as a mismatch', () => { + const asset = mediaAsset({ sourceStatus: 'missing', hash: HASH_A }); + const partial: SourceHash = { + algorithm: 'sha-256', + value: 'b'.repeat(64), + scope: 'partial', + bytesHashed: 1_024, + }; + expect( + rankReattachmentCandidates(asset, [ + { + name: asset.originalName, + size: asset.size, + lastModified: asset.lastModified, + mimeType: asset.mimeType, + hash: partial, + }, + ])[0]?.confidence + ).toBe('likely'); + }); + + it('rejects malformed file descriptors before scoring', () => { + expect(() => + rankReattachmentCandidates(mediaAsset(), [ + { + name: '', + size: -1, + lastModified: Number.NaN, + }, + ]) + ).toThrow(SourceReattachmentError); + }); + + it('only marks a source attached after an explicit confirmation call', () => { + const project = { + ...populatedProject(), + assets: [mediaAsset({ sourceStatus: 'missing' })], + }; + const candidate = { + name: 'renamed.mp4', + size: 1_024, + lastModified: 1_700_000_000_000, + mimeType: 'video/mp4', + }; + const result = confirmSourceReattachment( + project, + 'asset-1', + candidate, + UPDATED_AT + ); + + expect(project.assets[0]?.sourceStatus).toBe('missing'); + expect(result.assets[0]?.sourceStatus).toBe('attached'); + expect(() => + confirmSourceReattachment( + project, + 'asset-1', + { ...candidate, size: 1 }, + UPDATED_AT + ) + ).toThrow(SourceReattachmentError); + }); +}); diff --git a/tests/unit/release-package.test.mjs b/tests/unit/release-package.test.mjs new file mode 100644 index 0000000..c1b2436 --- /dev/null +++ b/tests/unit/release-package.test.mjs @@ -0,0 +1,251 @@ +import { + mkdtemp, + mkdir, + readFile, + rm, + symlink, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + assertLicenseReady, + collectDirectoryEntries, + createDeterministicZip, + parseReleaseArguments, + safeArchivePath, +} from '../../scripts/package-release.mjs'; +import { + checksumLine, + parseChecksumLine, + sha256, + verifyChecksum, + writeChecksum, +} from '../../scripts/checksum-release.mjs'; +import { + inspectPortalHeaders, + parsePortalArguments, +} from '../../scripts/portal-assembly-smoke.mjs'; + +const temporaryDirectories = []; + +async function temporaryDirectory() { + const directory = await mkdtemp( + path.join(tmpdir(), 'av-tools-release-test-') + ); + temporaryDirectories.push(directory); + return directory; +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { force: true, recursive: true })) + ); +}); + +describe('release path safety', () => { + it.each([ + '', + '/index.html', + '../index.html', + 'assets/../index.html', + 'assets//main.js', + 'C:/index.html', + 'assets\\main.js', + 'assets/\0main.js', + ])('rejects unsafe archive path %j', (unsafePath) => { + expect(() => safeArchivePath(unsafePath)).toThrow(); + }); + + it('accepts a canonical nested path', () => { + expect(safeArchivePath('vendor/ffmpeg/core.wasm')).toBe( + 'vendor/ffmpeg/core.wasm' + ); + }); + + it('rejects symlinks while collecting a directory', async () => { + const directory = await temporaryDirectory(); + await writeFile(path.join(directory, 'target.txt'), 'target'); + await symlink('target.txt', path.join(directory, 'alias.txt')); + await expect(collectDirectoryEntries(directory)).rejects.toThrow( + /symlink/i + ); + }); +}); + +describe('deterministic release ZIP', () => { + const entries = [ + { name: 'z.txt', data: Buffer.from('last') }, + { name: 'a.txt', data: Buffer.from('first') }, + ]; + + it('is byte-identical regardless of input order', () => { + const first = createDeterministicZip(entries); + const second = createDeterministicZip([...entries].reverse()); + expect(first.equals(second)).toBe(true); + expect(sha256(first)).toMatch(/^[a-f0-9]{64}$/); + }); + + it('stores sorted UTF-8 entries with the normalized epoch and file mode', () => { + const archive = createDeterministicZip(entries); + expect(archive.readUInt32LE(0)).toBe(0x04034b50); + expect(archive.readUInt16LE(10)).toBe(0); + expect(archive.readUInt16LE(12)).toBe(33); + expect(archive.subarray(30, 35).toString('utf8')).toBe('a.txt'); + + const centralOffset = archive.indexOf( + Buffer.from([0x50, 0x4b, 0x01, 0x02]) + ); + expect(centralOffset).toBeGreaterThan(0); + expect(archive.readUInt16LE(centralOffset + 4)).toBe(0x0314); + expect(archive.readUInt32LE(centralOffset + 38) >>> 16).toBe(0o100644); + }); + + it('rejects duplicate paths and local absolute paths in text', () => { + expect(() => + createDeterministicZip([ + { name: 'same.txt', data: Buffer.from('one') }, + { name: 'same.txt', data: Buffer.from('two') }, + ]) + ).toThrow(/duplicate/i); + expect(() => + createDeterministicZip([ + { name: 'bundle.js', data: Buffer.from('source: "/home/user/file"') }, + ]) + ).toThrow(/local absolute path/i); + }); +}); + +describe('licence release gate', () => { + it('blocks a normal release without an application licence', async () => { + const directory = await temporaryDirectory(); + await expect(assertLicenseReady(directory)).rejects.toThrow( + /RELEASE BLOCKED/ + ); + }); + + it('allows only the explicit development-smoke escape hatch', async () => { + const directory = await temporaryDirectory(); + await expect(assertLicenseReady(directory, true)).resolves.toBeNull(); + expect( + parseReleaseArguments(['--allow-unlicensed-development-smoke']) + ).toEqual({ + allowUnlicensedDevelopmentSmoke: true, + force: false, + }); + expect(() => parseReleaseArguments(['--publish'])).toThrow(/unknown/i); + }); + + it('recognizes a complete GPL version 3 root application licence', async () => { + const directory = await temporaryDirectory(); + const licence = path.join(directory, 'LICENSE'); + await writeFile( + licence, + [ + 'GNU GENERAL PUBLIC LICENSE', + 'Version 3, 29 June 2007', + 'END OF TERMS AND CONDITIONS', + ].join('\n') + ); + await expect(assertLicenseReady(directory)).resolves.toBe(licence); + }); + + it('rejects an arbitrary file named LICENSE', async () => { + const directory = await temporaryDirectory(); + await writeFile(path.join(directory, 'LICENSE'), 'example only'); + await expect(assertLicenseReady(directory)).rejects.toThrow( + /complete GNU GPL version 3/i + ); + }); +}); + +describe('release checksum', () => { + it('formats and parses the portable sidecar form', () => { + const digest = 'a'.repeat(64); + const line = checksumLine(digest, 'av-tools-0.1.0.zip'); + expect(line).toBe(`${digest} av-tools-0.1.0.zip\n`); + expect(parseChecksumLine(line)).toEqual({ + archiveName: 'av-tools-0.1.0.zip', + digest, + }); + }); + + it('writes and verifies a checksum sidecar', async () => { + const directory = await temporaryDirectory(); + const archive = path.join(directory, 'av-tools-0.1.0.zip'); + await writeFile(archive, 'deterministic bytes'); + const result = await writeChecksum(archive); + expect(await verifyChecksum(archive, result.sidecarPath)).toBe( + result.digest + ); + expect(await readFile(result.sidecarPath, 'utf8')).toContain( + ' av-tools-0.1.0.zip' + ); + }); + + it('rejects checksum mismatches', async () => { + const directory = await temporaryDirectory(); + const archive = path.join(directory, 'av-tools-0.1.0.zip'); + const sidecar = `${archive}.sha256`; + await writeFile(archive, 'changed'); + await mkdir(path.dirname(sidecar), { recursive: true }); + await writeFile( + sidecar, + checksumLine('0'.repeat(64), path.basename(archive)) + ); + await expect(verifyChecksum(archive, sidecar)).rejects.toThrow(/mismatch/i); + }); +}); + +describe('portal assembly smoke validation', () => { + const isolatedHeaders = ` + 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 Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; media-src 'self' blob:;" always; + types { application/wasm wasm; } + # vendor/ffmpeg/0.12.10 + `; + + it('accepts the required isolation, CSP and MIME profile', () => { + expect(inspectPortalHeaders(isolatedHeaders)).toEqual({ + errors: [], + warnings: [], + }); + }); + + it('detects the current media-preview CSP gap', () => { + const inspection = inspectPortalHeaders( + isolatedHeaders.replace("media-src 'self' blob:;", '') + ); + expect(inspection.errors).toContain( + "CSP media-src must contain 'self' and blob:" + ); + }); + + it('parses only explicit smoke overrides and paths', () => { + const parsed = parsePortalArguments( + [ + '--artifact', + 'release/example.zip', + '--allow-unlicensed-development-smoke', + '--allow-known-header-gap', + '--keep-temp', + ], + '/work/av-tools' + ); + expect(parsed).toEqual({ + allowKnownHeaderGap: true, + allowUnlicensedDevelopmentSmoke: true, + artifact: '/work/av-tools/release/example.zip', + keepTemp: true, + portalRoot: '/work/references/toolbox-portal', + }); + expect(() => parsePortalArguments(['--publish'], '/work/av-tools')).toThrow( + /unknown/i + ); + }); +}); diff --git a/tests/unit/storage.test.ts b/tests/unit/storage.test.ts new file mode 100644 index 0000000..6ad41a0 --- /dev/null +++ b/tests/unit/storage.test.ts @@ -0,0 +1,410 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_RESOURCE_POLICY, + MemoryDerivedCache, + ResilientDerivedCache, + createCacheKey, + createResourcePolicy, + estimatePlanResources, + estimateOutputBytes, + policyForDeviceMemory, + preflightResources, + readStorageQuota, + stableStringify, + type DerivedCache, +} from '../../src/storage'; +import type { FFmpegCommandPlan } from '../../src/commands/command-plan'; +import type { MediaProbe } from '../../src/media'; + +describe('cache identities', () => { + it('canonicalizes settings and produces deterministic path-safe keys', () => { + const one = createCacheKey({ + sourceFingerprint: 'source-123', + operation: 'waveform', + streamIndex: 1, + settings: { zoom: 2, channels: ['left', 'right'] }, + }); + const two = createCacheKey({ + sourceFingerprint: 'source-123', + operation: 'waveform', + streamIndex: 1, + settings: { channels: ['left', 'right'], zoom: 2 }, + }); + expect(one).toBe(two); + expect(one).toMatch(/^waveform-[0-9a-f]{16}$/u); + expect(stableStringify({ z: 1, a: { y: 2, x: 3 } })).toBe( + '{"a":{"x":3,"y":2},"z":1}' + ); + expect(() => + createCacheKey({ + sourceFingerprint: 'source', + operation: '../outside', + }) + ).toThrow(/safe operation/u); + }); +}); + +describe('resource preflight', () => { + it('allows small jobs and labels estimates as non-guarantees', () => { + const decision = preflightResources({ + inputBytes: [10 * 1024 * 1024], + expectedOutputBytes: estimateOutputBytes(10, 2_000, 128), + durationSeconds: 10, + width: 1920, + height: 1080, + frameRate: 25, + passes: 1, + }); + expect(decision.status).toBe('allowed'); + expect(decision.totals.pixelWorkEstimate).toBe(518_400_000); + expect(decision.estimateDisclaimer).toContain('not a guarantee'); + }); + + it('blocks hard limits and provides actionable suggestions', () => { + const decision = preflightResources({ + inputBytes: [DEFAULT_RESOURCE_POLICY.hardSingleInputBytes + 1], + width: 9000, + height: 9000, + thumbnailCount: 501, + contactSheetCells: 501, + expectedOutputBytes: DEFAULT_RESOURCE_POLICY.hardOutputEstimateBytes + 1, + timelineClips: DEFAULT_RESOURCE_POLICY.maxTimelineClips + 1, + streamsPerInput: [DEFAULT_RESOURCE_POLICY.maxStreamsPerInput + 1], + }); + expect(decision.status).toBe('blocked'); + expect(decision.issues.map((issue) => issue.code)).toEqual( + expect.arrayContaining([ + 'single-input-hard-limit', + 'total-input-hard-limit', + 'pixel-limit', + 'thumbnail-limit', + 'contact-sheet-limit', + 'output-hard-limit', + 'timeline-clip-limit', + 'stream-count-limit', + ]) + ); + expect(decision.issues.every((issue) => issue.suggestions.length > 0)).toBe( + true + ); + }); + + it('lowers policy for reliable low-memory signals', () => { + const policy = createResourcePolicy(); + expect(policyForDeviceMemory(policy, 2).softInputBytes).toBeLessThan( + policy.softInputBytes + ); + }); + + it('reserves collection and report ZIP capacity before running a batch', () => { + const policy = createResourcePolicy(); + const decision = preflightResources( + { + inputBytes: [], + generatedOutputs: 2, + retainedOutputs: policy.maxGeneratedOutputs - 1, + }, + policy + ); + expect(decision.status).toBe('blocked'); + expect(decision.issues).toContainEqual( + expect.objectContaining({ + code: 'generated-output-limit', + message: expect.stringMatching(/ZIP with an optional report/iu), + }) + ); + }); + + it('rejects malformed generated-image estimates before comparing limits', () => { + expect(() => + preflightResources({ + inputBytes: [], + thumbnailCount: -1, + }) + ).toThrow(/thumbnailCount/u); + expect(() => + preflightResources({ + inputBytes: [], + contactSheetCells: 1.5, + }) + ).toThrow(/contactSheetCells/u); + }); + + it('derives output size, geometry, frame rate, and generated-image semantics from plans', () => { + const contactEstimate = estimatePlanResources( + plan({ + operation: 'contact-sheet', + args: [ + '-vf', + 'fps=0.5,scale=320:-2:force_original_aspect_ratio=decrease,tile=3x2:padding=4:margin=4', + '/work/contact.jpg', + ], + outputs: [ + { + id: 'contact', + path: '/work/contact.jpg', + fileName: 'contact.jpg', + mediaType: 'image/jpeg', + role: 'contact-sheet', + }, + ], + expectedDurationSeconds: 12, + }), + [{ size: 2_000_000, probe: videoProbe() }] + ); + expect(contactEstimate).toMatchObject({ + width: 976, + height: 372, + frameRate: 0.5, + contactSheetCells: 6, + generatedOutputs: 1, + }); + + const remuxEstimate = estimatePlanResources( + plan({ + operation: 'remux', + args: ['-i', '/input/source.mp4', '-c', 'copy', '/work/output.mkv'], + outputs: [ + { + id: 'media', + path: '/work/output.mkv', + fileName: 'output.mkv', + mediaType: 'video/x-matroska', + role: 'media', + }, + ], + expectedDurationSeconds: 10, + }), + [{ size: 2_000_000, probe: videoProbe() }] + ); + expect(remuxEstimate.expectedOutputBytes).toBe(2_000_000); + + const bitrateEstimate = estimatePlanResources( + plan({ + operation: 'convert', + args: [ + '-c:v', + 'libx264', + '-b:v', + '500k', + '-c:a', + 'aac', + '-b:a', + '128k', + '/work/output.mp4', + ], + outputs: [ + { + id: 'media', + path: '/work/output.mp4', + fileName: 'output.mp4', + mediaType: 'video/mp4', + role: 'media', + }, + ], + expectedDurationSeconds: 10, + }), + [{ size: 2_000_000, probe: audioVideoProbe() }] + ); + expect(bitrateEstimate.expectedOutputBytes).toBe( + estimateOutputBytes(10, 628) + ); + + const thumbnailEstimate = estimatePlanResources( + plan({ + operation: 'thumbnail-series', + args: ['-vf', 'scale=320:-2', '/work/thumb.png'], + outputs: Array.from({ length: 4 }, (_, index) => ({ + id: `thumb-${index}`, + path: `/work/thumb-${index}.png`, + fileName: `thumb-${index}.png`, + mediaType: 'image/png', + role: 'thumbnail' as const, + })), + expectedDurationSeconds: 8, + }), + [{ size: 2_000_000, probe: videoProbe() }] + ); + expect(thumbnailEstimate).toMatchObject({ + width: 320, + height: 180, + frameRate: 0.5, + thumbnailCount: 4, + generatedOutputs: 4, + }); + }); +}); + +describe('derived cache lifecycle', () => { + it('lists sizes, expires entries, clears projects and clears all', async () => { + let now = new Date('2026-01-01T00:00:00.000Z'); + const cache = new MemoryDerivedCache({ + maxBytes: 1_024, + now: () => now, + }); + await cache.put( + { + key: 'waveform-a', + kind: 'waveform-peaks', + projectId: 'project-a', + mediaType: 'application/octet-stream', + expiresAt: '2026-01-02T00:00:00.000Z', + }, + new Uint8Array([1, 2, 3]) + ); + await cache.put( + { + key: 'thumb-b', + kind: 'thumbnail', + projectId: 'project-b', + mediaType: 'image/png', + }, + new Blob(['image'], { type: 'image/png' }) + ); + expect(await cache.sizeBytes()).toBe(8); + expect((await cache.list()).map((entry) => entry.key)).toHaveLength(2); + now = new Date('2026-01-03T00:00:00.000Z'); + expect(await cache.pruneExpired()).toBe(1); + expect(await cache.get('waveform-a')).toBeUndefined(); + expect(await cache.clearProject('project-b')).toBe(1); + expect(await cache.sizeBytes()).toBe(0); + await cache.clear(); + expect(await cache.list()).toEqual([]); + }); + + it('evicts least recently used memory entries under the configured limit', async () => { + let tick = 0; + const cache = new MemoryDerivedCache({ + maxBytes: 6, + now: () => new Date(1_000 + tick++), + }); + await cache.put({ key: 'one', kind: 'project' }, new Blob(['123'])); + await cache.put({ key: 'two', kind: 'project' }, new Blob(['456'])); + await cache.get('one'); + await cache.put({ key: 'three', kind: 'project' }, new Blob(['789'])); + expect(await cache.get('two')).toBeUndefined(); + expect(await cache.get('one')).toBeDefined(); + expect(await cache.get('three')).toBeDefined(); + }); + + it('switches explicitly to memory fallback when OPFS operations fail', async () => { + const primary = new ThrowingCache(); + const fallback = new MemoryDerivedCache(); + const cache = new ResilientDerivedCache(primary, fallback); + expect(cache.mode).toBe('opfs'); + await cache.put({ key: 'fallback', kind: 'project' }, new Blob(['ok'])); + expect(cache.mode).toBe('memory'); + expect(cache.fallbackReason).toContain('quota denied'); + expect(await cache.get('fallback')).toBeDefined(); + await cache.clear(); + expect(await cache.list()).toEqual([]); + }); + + it('reports quota and unavailable estimate failures', async () => { + expect( + await readStorageQuota({ + estimate: async () => ({ usage: 25, quota: 100 }), + persisted: async () => true, + }) + ).toEqual({ + available: true, + usageBytes: 25, + quotaBytes: 100, + remainingBytes: 75, + usageFraction: 0.25, + persisted: true, + }); + expect( + await readStorageQuota({ + estimate: async () => { + throw new Error('denied'); + }, + }) + ).toEqual({ available: false, error: 'denied' }); + }); +}); + +class ThrowingCache implements DerivedCache { + readonly mode = 'opfs' as const; + async list(): Promise { + throw new Error('quota denied'); + } + async get(): Promise { + throw new Error('quota denied'); + } + async put(): Promise { + throw new Error('quota denied'); + } + async delete(): Promise { + throw new Error('quota denied'); + } + async clear(): Promise { + throw new Error('quota denied'); + } + async clearProject(): Promise { + throw new Error('quota denied'); + } + async pruneExpired(): Promise { + throw new Error('quota denied'); + } + async sizeBytes(): Promise { + throw new Error('quota denied'); + } +} + +function plan( + options: Pick< + FFmpegCommandPlan, + 'operation' | 'args' | 'outputs' | 'expectedDurationSeconds' + > +): FFmpegCommandPlan { + return { + id: `job:${options.operation}`, + operation: options.operation, + inputs: [], + temporaryFiles: [], + args: options.args, + outputs: options.outputs, + expectedDurationSeconds: options.expectedDurationSeconds, + requiredCapabilities: {}, + diagnostics: [], + }; +} + +function videoProbe(): MediaProbe { + return { + durationSeconds: 10, + bitRate: 800_000, + formatNames: ['mov', 'mp4'], + tags: {}, + chapters: [], + warnings: [], + streams: [ + { + index: 0, + type: 'video', + width: 1920, + height: 1080, + frameRate: 25, + disposition: {}, + tags: {}, + }, + ], + }; +} + +function audioVideoProbe(): MediaProbe { + const probe = videoProbe(); + return { + ...probe, + streams: [ + ...probe.streams, + { + index: 1, + type: 'audio', + disposition: {}, + tags: {}, + }, + ], + }; +} diff --git a/tests/unit/waveform-runtime-aggregation.test.ts b/tests/unit/waveform-runtime-aggregation.test.ts new file mode 100644 index 0000000..273d216 --- /dev/null +++ b/tests/unit/waveform-runtime-aggregation.test.ts @@ -0,0 +1,329 @@ +import { describe, expect, it } from 'vitest'; +import { + aggregatePcmPeaks, + decodePcmSamples, + MAX_WAVEFORM_BUCKET_COUNT, + WaveformCancellationError, + WaveformDataError, +} from '../../src/waveform'; + +describe('waveform PCM aggregation', () => { + it('uses deterministic bucket boundaries for signed 16-bit PCM', () => { + const samples = new Int16Array([ + -32_768, -16_384, 0, 16_384, 32_767, 8_192, -8_192, 0, + ]); + + const peaks = aggregatePcmPeaks( + { + container: 'raw', + data: samples, + encoding: 's16le', + sampleRate: 8_000, + }, + { bucketCount: 4 } + ); + + expect(peaks.sampleCount).toBe(8); + expect(peaks.bucketCount).toBe(4); + expect([...peaks.minimum]).toEqual([-1, 0, 0.25, -0.25]); + expect([...peaks.maximum]).toEqual([-0.5, 0.5, 32_767 / 32_768, 0]); + expect([...peaks.rms]).toEqual( + expect.arrayContaining([ + expect.closeTo(Math.sqrt(0.625), 6), + expect.closeTo(Math.sqrt(0.125), 6), + expect.closeTo(Math.sqrt(((32_767 / 32_768) ** 2 + 0.25 ** 2) / 2), 6), + expect.closeTo(Math.sqrt(0.03125), 6), + ]) + ); + }); + + it('never creates empty buckets when fewer samples than requested', () => { + const peaks = aggregatePcmPeaks( + { + container: 'raw', + data: float32Bytes([0.1, -0.2, 0.3]), + encoding: 'f32le', + sampleRate: 10, + }, + { bucketCount: 100 } + ); + + expect(peaks.bucketCount).toBe(3); + expect([...peaks.minimum]).toEqual([ + expect.closeTo(0.1, 6), + expect.closeTo(-0.2, 6), + expect.closeTo(0.3, 6), + ]); + expect([...peaks.maximum]).toEqual([...peaks.minimum]); + }); + + it('assigns every sample exactly once when bucket sizes are uneven', () => { + const peaks = aggregatePcmPeaks( + { + container: 'raw', + data: new Int16Array([ + 1_000, 2_000, 3_000, -4_000, -5_000, -6_000, 7_000, 8_000, 9_000, + 10_000, + ]), + encoding: 's16le', + sampleRate: 10, + }, + { bucketCount: 3 } + ); + + expect([...peaks.minimum]).toEqual([ + 1_000 / 32_768, + -6_000 / 32_768, + 7_000 / 32_768, + ]); + expect([...peaks.maximum]).toEqual([ + 3_000 / 32_768, + -4_000 / 32_768, + 10_000 / 32_768, + ]); + }); + + it('handles finite float PCM above full scale without RMS overflow', () => { + const maximumFloat = 3.4028234663852886e38; + const peaks = aggregatePcmPeaks( + { + container: 'raw', + data: float32Bytes([maximumFloat, -maximumFloat]), + encoding: 'f32le', + sampleRate: 8_000, + }, + { bucketCount: 1 } + ); + + expect(peaks.minimum[0]).toBe(-maximumFloat); + expect(peaks.maximum[0]).toBe(maximumFloat); + expect(peaks.rms[0]).toBe(maximumFloat); + expect(Number.isFinite(peaks.rms[0])).toBe(true); + }); + + it('rejects malformed, non-finite, and resource-exceeding PCM', () => { + expect(() => + aggregatePcmPeaks( + { + container: 'raw', + data: new Uint8Array([0]), + encoding: 's16le', + sampleRate: 8_000, + }, + { bucketCount: 1 } + ) + ).toThrow(WaveformDataError); + + expect(() => + aggregatePcmPeaks( + { + container: 'raw', + data: float32Bytes([Number.NaN]), + encoding: 'f32le', + sampleRate: 8_000, + }, + { bucketCount: 1 } + ) + ).toThrow(/not finite/u); + + expect(() => + aggregatePcmPeaks( + { + container: 'raw', + data: new Int16Array([1, 2, 3]), + encoding: 's16le', + sampleRate: 8_000, + }, + { bucketCount: 1, maxSamples: 2 } + ) + ).toThrow(/sample analysis limit/u); + + expect(() => + aggregatePcmPeaks( + { + container: 'raw', + data: new Int16Array([1]), + encoding: 's16le', + sampleRate: 8_000, + }, + { bucketCount: MAX_WAVEFORM_BUCKET_COUNT + 1 } + ) + ).toThrow(RangeError); + }); + + it('parses FFmpeg-style mono RIFF/WAVE PCM and rejects stereo', () => { + const wav = createWaveFile({ + samples: new Int16Array([-32_768, 0, 16_384, 32_767]), + sampleRate: 8_000, + channels: 1, + addOddJunkChunk: true, + }); + + const decoded = decodePcmSamples({ container: 'wav', data: wav }); + expect(decoded.encoding).toBe('s16le'); + expect(decoded.sampleRate).toBe(8_000); + expect([...decoded.samples]).toEqual([-1, 0, 0.5, 32_767 / 32_768]); + + const stereo = createWaveFile({ + samples: new Int16Array([1, 2]), + sampleRate: 8_000, + channels: 2, + }); + expect(() => decodePcmSamples({ container: 'wav', data: stereo })).toThrow( + /requires mono/u + ); + }); + + it('parses little-endian float PCM from a sliced WAVE buffer', () => { + const wav = createFloatWaveFile([1.25, -0.5], 16_000); + const enclosing = new Uint8Array(wav.byteLength + 6); + enclosing.set(wav, 3); + const sliced = enclosing.subarray(3, 3 + wav.byteLength); + + const decoded = decodePcmSamples({ container: 'wav', data: sliced }); + expect(decoded.encoding).toBe('f32le'); + expect(decoded.sampleRate).toBe(16_000); + expect([...decoded.samples]).toEqual([1.25, -0.5]); + }); + + it('rejects truncated WAVE chunks', () => { + const wav = createWaveFile({ + samples: new Int16Array([1, 2]), + sampleRate: 8_000, + channels: 1, + }); + expect(() => + decodePcmSamples({ + container: 'wav', + data: wav.subarray(0, wav.byteLength - 1), + }) + ).toThrow(/truncated|invalid size/u); + }); + + it('supports cooperative cancellation and bounded progress callbacks', () => { + const pcm = new Int16Array(40_000); + let cancelled = false; + const progress: number[] = []; + + expect(() => + aggregatePcmPeaks( + { + container: 'raw', + data: pcm, + encoding: 's16le', + sampleRate: 8_000, + }, + { + bucketCount: 100, + isCancelled: () => cancelled, + onProgress: (value) => { + progress.push(value); + cancelled = true; + }, + } + ) + ).toThrow(WaveformCancellationError); + expect(progress[0]).toBe(0); + expect(progress.every((value) => value >= 0 && value <= 1)).toBe(true); + }); + + it('reports completion for empty PCM without allocating peak buckets', () => { + const progress: number[] = []; + const peaks = aggregatePcmPeaks( + { + container: 'raw', + data: new Uint8Array(), + encoding: 's16le', + sampleRate: 8_000, + }, + { bucketCount: 10, onProgress: (value) => progress.push(value) } + ); + + expect(peaks.bucketCount).toBe(0); + expect(peaks.durationSeconds).toBe(0); + expect(progress).toEqual([1]); + }); +}); + +function float32Bytes(values: readonly number[]): Uint8Array { + const bytes = new Uint8Array(values.length * 4); + const view = new DataView(bytes.buffer); + values.forEach((value, index) => view.setFloat32(index * 4, value, true)); + return bytes; +} + +function createWaveFile(options: { + readonly samples: Int16Array; + readonly sampleRate: number; + readonly channels: number; + readonly addOddJunkChunk?: boolean; +}): Uint8Array { + const junkBytes = options.addOddJunkChunk ? 10 : 0; + const result = new Uint8Array(44 + junkBytes + options.samples.byteLength); + const view = new DataView(result.buffer); + writeAscii(result, 0, 'RIFF'); + view.setUint32(4, result.byteLength - 8, true); + writeAscii(result, 8, 'WAVE'); + writeAscii(result, 12, 'fmt '); + view.setUint32(16, 16, true); + view.setUint16(20, 1, true); + view.setUint16(22, options.channels, true); + view.setUint32(24, options.sampleRate, true); + view.setUint32( + 28, + options.sampleRate * options.channels * Int16Array.BYTES_PER_ELEMENT, + true + ); + view.setUint16(32, options.channels * Int16Array.BYTES_PER_ELEMENT, true); + view.setUint16(34, 16, true); + let dataHeaderOffset = 36; + if (options.addOddJunkChunk) { + writeAscii(result, 36, 'JUNK'); + view.setUint32(40, 1, true); + result[44] = 0x2a; + result[45] = 0; + dataHeaderOffset += junkBytes; + } + writeAscii(result, dataHeaderOffset, 'data'); + view.setUint32(dataHeaderOffset + 4, options.samples.byteLength, true); + const sampleOffset = dataHeaderOffset + 8; + for (let index = 0; index < options.samples.length; index += 1) { + view.setInt16( + sampleOffset + index * Int16Array.BYTES_PER_ELEMENT, + options.samples[index] ?? 0, + true + ); + } + return result; +} + +function createFloatWaveFile( + samples: readonly number[], + sampleRate: number +): Uint8Array { + const result = new Uint8Array(44 + samples.length * 4); + const view = new DataView(result.buffer); + writeAscii(result, 0, 'RIFF'); + view.setUint32(4, result.byteLength - 8, true); + writeAscii(result, 8, 'WAVE'); + writeAscii(result, 12, 'fmt '); + view.setUint32(16, 16, true); + view.setUint16(20, 3, true); + view.setUint16(22, 1, true); + view.setUint32(24, sampleRate, true); + view.setUint32(28, sampleRate * 4, true); + view.setUint16(32, 4, true); + view.setUint16(34, 32, true); + writeAscii(result, 36, 'data'); + view.setUint32(40, samples.length * 4, true); + samples.forEach((sample, index) => + view.setFloat32(44 + index * 4, sample, true) + ); + return result; +} + +function writeAscii(target: Uint8Array, offset: number, text: string): void { + for (let index = 0; index < text.length; index += 1) { + target[offset + index] = text.charCodeAt(index); + } +} diff --git a/tests/unit/waveform-runtime-cache.test.ts b/tests/unit/waveform-runtime-cache.test.ts new file mode 100644 index 0000000..949a5ef --- /dev/null +++ b/tests/unit/waveform-runtime-cache.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest'; +import { + aggregatePcmPeaks, + createWaveformCacheKey, + deserializeWaveformPeaks, + serializeWaveformPeaks, + WaveformDataError, + WAVEFORM_PEAKS_MEDIA_TYPE, +} from '../../src/waveform'; + +describe('waveform peak cache format', () => { + it('round-trips versioned arrays without sharing input storage', () => { + const peaks = createPeaks(); + const serialized = serializeWaveformPeaks(peaks); + const restored = deserializeWaveformPeaks(serialized); + + expect(WAVEFORM_PEAKS_MEDIA_TYPE).toBe( + 'application/vnd.add-ideas.waveform-peaks' + ); + expect(restored).not.toBe(peaks); + expect(restored.minimum.buffer).not.toBe(peaks.minimum.buffer); + expect(restored).toMatchObject({ + version: 1, + sourceEncoding: 's16le', + sampleRate: 8_000, + sampleCount: 6, + durationSeconds: 6 / 8_000, + bucketCount: 3, + }); + expect([...restored.minimum]).toEqual([...peaks.minimum]); + expect([...restored.maximum]).toEqual([...peaks.maximum]); + expect([...restored.rms]).toEqual([...peaks.rms]); + }); + + it('detects payload corruption, truncation, and unknown versions', () => { + const serialized = serializeWaveformPeaks(createPeaks()); + + const corrupt = serialized.slice(); + const lastIndex = corrupt.length - 1; + corrupt[lastIndex] = (corrupt[lastIndex] ?? 0) ^ 0xff; + expect(() => deserializeWaveformPeaks(corrupt)).toThrow(/checksum/u); + + const corruptHeader = serialized.slice(); + new DataView(corruptHeader.buffer).setUint32(12, 44_100, true); + expect(() => deserializeWaveformPeaks(corruptHeader)).toThrow(/checksum/u); + + expect(() => + deserializeWaveformPeaks(serialized.subarray(0, serialized.length - 1)) + ).toThrow(/payload size/u); + + const unknownVersion = serialized.slice(); + new DataView(unknownVersion.buffer).setUint16(4, 999, true); + expect(() => deserializeWaveformPeaks(unknownVersion)).toThrow( + WaveformDataError + ); + }); + + it('builds deterministic cache keys from source, stream, and settings', () => { + const base = { + sourceSize: 1_234_567, + sourceLastModified: 1_720_000_000_000, + streamIndex: 1, + sampleRate: 8_000, + bucketCount: 2_048, + encoding: 's16le' as const, + partialHash: 'sha256-deadbeef', + }; + const first = createWaveformCacheKey(base); + const second = createWaveformCacheKey({ ...base }); + const changed = createWaveformCacheKey({ ...base, streamIndex: 2 }); + + expect(first).toBe(second); + expect(first).toMatch(/^waveform-peaks-[a-f0-9]{16}$/u); + expect(changed).not.toBe(first); + }); + + it('rejects unsafe or incomplete cache identities', () => { + expect(() => + createWaveformCacheKey({ + sourceSize: 1, + sourceLastModified: 2, + streamIndex: 0, + sampleRate: 8_000, + bucketCount: 100, + encoding: 's16le', + partialHash: '../../source', + }) + ).toThrow(TypeError); + }); +}); + +function createPeaks() { + return aggregatePcmPeaks( + { + container: 'raw', + data: new Int16Array([-20_000, 10_000, -1_000, 2_000, -30_000, 20_000]), + encoding: 's16le', + sampleRate: 8_000, + }, + { bucketCount: 3 } + ); +} diff --git a/tests/unit/waveform-runtime-canvas.test.ts b/tests/unit/waveform-runtime-canvas.test.ts new file mode 100644 index 0000000..2dc14a3 --- /dev/null +++ b/tests/unit/waveform-runtime-canvas.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + aggregatePcmPeaks, + describeWaveform, + renderWaveformToCanvas, +} from '../../src/waveform'; + +describe('waveform canvas rendering', () => { + it('renders bounded columns and supplies a text alternative', () => { + const peaks = aggregatePcmPeaks( + { + container: 'raw', + data: new Int16Array([-32_768, 0, 16_384, 32_767]), + encoding: 's16le', + sampleRate: 2, + }, + { bucketCount: 4 } + ); + const { canvas, context, attributes } = createCanvas(200, 80); + + const rendered = renderWaveformToCanvas(canvas, peaks, { + devicePixelRatio: 2, + startBucket: 1, + endBucket: 4, + selection: { startFraction: 0.25, endFraction: 0.75 }, + }); + + expect(canvas.width).toBe(400); + expect(canvas.height).toBe(160); + expect(rendered.startBucket).toBe(1); + expect(rendered.endBucket).toBe(4); + expect(rendered.accessibleLabel).toContain('Audio waveform'); + expect(attributes.get('role')).toBe('img'); + expect(attributes.get('aria-label')).toBe(rendered.accessibleLabel); + expect(context.moveTo).toHaveBeenCalled(); + expect(context.lineTo).toHaveBeenCalled(); + expect(context.fillRect).toHaveBeenCalledWith(50, 0, 100, 80); + }); + + it('describes empty waveforms and respects an explicit accessible label', () => { + const empty = aggregatePcmPeaks( + { + container: 'raw', + data: new Uint8Array(), + encoding: 's16le', + sampleRate: 8_000, + }, + { bucketCount: 1 } + ); + const { canvas, attributes } = createCanvas(100, 40); + const result = renderWaveformToCanvas(canvas, empty, { + accessibleLabel: 'No audible content', + }); + + expect(describeWaveform(empty)).toBe('Empty audio waveform.'); + expect(result.accessibleLabel).toBe('No audible content'); + expect(attributes.get('aria-label')).toBe('No audible content'); + }); + + it('rejects unsafe dimensions, ranges, and selections', () => { + const peaks = aggregatePcmPeaks( + { + container: 'raw', + data: new Int16Array([1, 2]), + encoding: 's16le', + sampleRate: 8_000, + }, + { bucketCount: 2 } + ); + const { canvas } = createCanvas(100, 40); + + expect(() => + renderWaveformToCanvas(canvas, peaks, { width: 100_000 }) + ).toThrow(/safe limits/u); + expect(() => + renderWaveformToCanvas(canvas, peaks, { + startBucket: 2, + endBucket: 1, + }) + ).toThrow(/bucket range/u); + expect(() => + renderWaveformToCanvas(canvas, peaks, { + selection: { startFraction: -1, endFraction: 0.5 }, + }) + ).toThrow(/selection/u); + }); +}); + +function createCanvas(width: number, height: number) { + const attributes = new Map(); + const context = { + setTransform: vi.fn(), + clearRect: vi.fn(), + fillRect: vi.fn(), + beginPath: vi.fn(), + moveTo: vi.fn(), + lineTo: vi.fn(), + stroke: vi.fn(), + fillStyle: '', + strokeStyle: '', + lineWidth: 1, + }; + const canvas = { + width, + height, + clientWidth: width, + clientHeight: height, + getContext: vi.fn(() => context), + setAttribute: vi.fn((name: string, value: string) => { + attributes.set(name, value); + }), + } as unknown as HTMLCanvasElement; + return { canvas, context, attributes }; +} diff --git a/tests/unit/waveform-runtime-worker.test.ts b/tests/unit/waveform-runtime-worker.test.ts new file mode 100644 index 0000000..bbfa161 --- /dev/null +++ b/tests/unit/waveform-runtime-worker.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + aggregatePcmPeaks, + createWaveformRuntime, + WaveformCancellationError, + WaveformRuntimeBusyError, + WaveformRuntimeTerminatedError, + type WaveformWorkerLike, +} from '../../src/waveform'; +import type { WaveformWorkerAggregateRequest } from '../../src/waveform/waveform.types'; + +describe('waveform worker runtime', () => { + it('offloads aggregation, reports progress, and preserves caller bytes', async () => { + const worker = new FakeWorker(); + const input = new Int16Array([-32_768, 0, 16_384, 32_767]); + const originalBytes = [...new Uint8Array(input.buffer)]; + const progress = vi.fn(); + const runtime = createWaveformRuntime({ workerFactory: () => worker }); + + const resultPromise = runtime.aggregate( + { + container: 'raw', + data: input, + encoding: 's16le', + sampleRate: 8_000, + }, + { bucketCount: 2, onProgress: progress } + ); + const request = worker.lastRequest; + expect(request).toBeDefined(); + expect(request?.input.data).not.toBe(input.buffer); + expect([...new Uint8Array(input.buffer)]).toEqual(originalBytes); + + worker.emitMessage({ + type: 'progress', + requestId: request?.requestId, + progress: 0.5, + }); + const peaks = aggregatePcmPeaks( + { + container: 'raw', + data: request?.input.data ?? new ArrayBuffer(0), + encoding: 's16le', + sampleRate: 8_000, + }, + { bucketCount: 2 } + ); + worker.emitMessage({ + type: 'result', + requestId: request?.requestId, + peaks, + }); + + await expect(resultPromise).resolves.toMatchObject({ bucketCount: 2 }); + expect(progress).toHaveBeenCalledWith(0.5); + expect(progress).toHaveBeenLastCalledWith(1); + expect(worker.terminated).toBe(true); + expect(runtime.isActive).toBe(false); + }); + + it('terminates the worker on AbortSignal cancellation and remains reusable', async () => { + const workers: FakeWorker[] = []; + const runtime = createWaveformRuntime({ + workerFactory: () => { + const worker = new FakeWorker(); + workers.push(worker); + return worker; + }, + }); + const controller = new AbortController(); + const first = runtime.aggregate(rawInput(), { + bucketCount: 2, + signal: controller.signal, + }); + controller.abort(); + + await expect(first).rejects.toBeInstanceOf(WaveformCancellationError); + expect(workers[0]?.terminated).toBe(true); + expect(runtime.isActive).toBe(false); + + const second = runtime.aggregate(rawInput(), { bucketCount: 2 }); + const request = workers[1]?.lastRequest; + const peaks = aggregatePcmPeaks(rawInput(), { bucketCount: 2 }); + workers[1]?.emitMessage({ + type: 'result', + requestId: request?.requestId, + peaks, + }); + await expect(second).resolves.toMatchObject({ bucketCount: 2 }); + }); + + it('supports explicit cancellation and rejects overlapping work', async () => { + const worker = new FakeWorker(); + const runtime = createWaveformRuntime({ workerFactory: () => worker }); + const active = runtime.aggregate(rawInput(), { bucketCount: 2 }); + + await expect( + runtime.aggregate(rawInput(), { bucketCount: 2 }) + ).rejects.toBeInstanceOf(WaveformRuntimeBusyError); + expect(runtime.cancelActive()).toBe(true); + await expect(active).rejects.toBeInstanceOf(WaveformCancellationError); + expect(runtime.cancelActive()).toBe(false); + }); + + it('falls back synchronously when workers are unavailable or blocked', async () => { + const runtime = createWaveformRuntime({ workerFactory: null }); + await expect( + runtime.aggregate(rawInput(), { bucketCount: 2 }) + ).resolves.toMatchObject({ bucketCount: 2 }); + + const blockedRuntime = createWaveformRuntime({ + workerFactory: () => { + throw new Error('CSP blocked worker'); + }, + }); + await expect( + blockedRuntime.aggregate(rawInput(), { bucketCount: 2 }) + ).resolves.toMatchObject({ bucketCount: 2 }); + expect(blockedRuntime.mode).toBe('synchronous'); + }); + + it('tracks and can cancel synchronous fallback before it starts', async () => { + const runtime = createWaveformRuntime({ workerFactory: null }); + const active = runtime.aggregate(rawInput(), { bucketCount: 2 }); + + expect(runtime.isActive).toBe(true); + const overlapping = runtime.aggregate(rawInput(), { bucketCount: 2 }); + expect(runtime.cancelActive()).toBe(true); + await expect(overlapping).rejects.toBeInstanceOf(WaveformRuntimeBusyError); + await expect(active).rejects.toBeInstanceOf(WaveformCancellationError); + expect(runtime.isActive).toBe(false); + }); + + it('makes terminate permanent and cancels active work', async () => { + const worker = new FakeWorker(); + const runtime = createWaveformRuntime({ workerFactory: () => worker }); + const active = runtime.aggregate(rawInput(), { bucketCount: 2 }); + runtime.terminate(); + + await expect(active).rejects.toBeInstanceOf(WaveformCancellationError); + await expect( + runtime.aggregate(rawInput(), { bucketCount: 2 }) + ).rejects.toBeInstanceOf(WaveformRuntimeTerminatedError); + }); +}); + +class FakeWorker implements WaveformWorkerLike { + readonly #messageListeners = new Set< + (event: MessageEvent) => void + >(); + readonly #errorListeners = new Set<(event: Event) => void>(); + readonly #messageErrorListeners = new Set<(event: Event) => void>(); + lastRequest: WaveformWorkerAggregateRequest | undefined; + terminated = false; + + postMessage(message: unknown): void { + this.lastRequest = message as WaveformWorkerAggregateRequest; + } + + addEventListener( + type: 'message' | 'error' | 'messageerror', + listener: + ((event: MessageEvent) => void) | ((event: Event) => void) + ): void { + if (type === 'message') { + this.#messageListeners.add( + listener as (event: MessageEvent) => void + ); + } else if (type === 'error') { + this.#errorListeners.add(listener as (event: Event) => void); + } else { + this.#messageErrorListeners.add(listener as (event: Event) => void); + } + } + + removeEventListener( + type: 'message' | 'error' | 'messageerror', + listener: + ((event: MessageEvent) => void) | ((event: Event) => void) + ): void { + if (type === 'message') { + this.#messageListeners.delete( + listener as (event: MessageEvent) => void + ); + } else if (type === 'error') { + this.#errorListeners.delete(listener as (event: Event) => void); + } else { + this.#messageErrorListeners.delete(listener as (event: Event) => void); + } + } + + terminate(): void { + this.terminated = true; + } + + emitMessage(data: unknown): void { + const event = { data } as MessageEvent; + for (const listener of this.#messageListeners) { + listener(event); + } + } +} + +function rawInput() { + return { + container: 'raw' as const, + data: new Int16Array([-100, 100, -200, 200]), + encoding: 's16le' as const, + sampleRate: 8_000, + }; +} diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..2b1ac18 --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023", "DOM", "DOM.Iterable", "WebWorker"], + "module": "ESNext", + "types": ["vite/client"], + "allowArbitraryExtensions": true, + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "useDefineForClassFields": true, + "resolveJsonModule": true, + "isolatedModules": true + }, + "include": ["src", "tests"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..06e47de --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "module": "NodeNext", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "strict": true + }, + "include": ["vite.config.ts", "playwright.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..aa9155a --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,29 @@ +/// +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + base: './', + plugins: [react()], + server: { + headers: { + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Cross-Origin-Embedder-Policy': 'require-corp', + 'Cross-Origin-Resource-Policy': 'same-origin', + }, + }, + preview: { + headers: { + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Cross-Origin-Embedder-Policy': 'require-corp', + 'Cross-Origin-Resource-Policy': 'same-origin', + }, + }, + test: { + environment: 'jsdom', + setupFiles: './src/test/setup.ts', + css: true, + restoreMocks: true, + exclude: ['tests/browser/**', 'node_modules/**', 'dist/**'], + }, +});