diff --git a/amll-local/.editorconfig b/amll-local/.editorconfig new file mode 100644 index 0000000..bf7033a --- /dev/null +++ b/amll-local/.editorconfig @@ -0,0 +1,8 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = tab +indent_size = 2 +insert_final_newline = true diff --git a/amll-local/.gitattributes b/amll-local/.gitattributes new file mode 100644 index 0000000..8c1c890 --- /dev/null +++ b/amll-local/.gitattributes @@ -0,0 +1,3 @@ +*.md linguist-documentation +*.mdx linguist-documentation +packages/playground/core/src/components/ui/** linguist-vendored diff --git a/amll-local/LICENSE b/amll-local/LICENSE new file mode 100644 index 0000000..3972c30 --- /dev/null +++ b/amll-local/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 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 Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are 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. + + 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. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + 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 Affero 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. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + 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 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 work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero 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 Affero 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 Affero 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 Affero 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) 2024 + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero 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 Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + 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 AGPL, see +. diff --git a/amll-local/README-CN.md b/amll-local/README-CN.md new file mode 100644 index 0000000..6deea18 --- /dev/null +++ b/amll-local/README-CN.md @@ -0,0 +1,119 @@ +
+ +![Apple Music-like Lyrics - 一个基于 Web 技术制作的类 Apple Music 歌词显示组件库](https://github.com/user-attachments/assets/cd6e4ba3-2640-4aab-aeb1-0762f97c8880) + +# Apple Music-like Lyrics + +[English](./README.md) / 简体中文 + +一个基于 Web 技术制作的类 Apple Music 歌词显示组件库,同时支持 [DOM 原生](./packages/core/README.md)、[React](./packages/react/README.md) 和 [Vue](./packages/react/README.md) 绑定。 + +这是你能在前端系里能见到的最像 iPad Apple Music 的播放页面了。 + +尽管这个项目的目标并非完全模仿,但是会更好地打磨一些细节,以优于现阶段最好的歌词播放器。 + +**—— AMLL 生态作品 ——** + +[AMLL TTML DB 逐词歌词仓库](https://github.com/amll-dev/amll-ttml-db) + +[AMLL TTML Tool 逐词歌词编辑器](https://github.com/amll-dev/amll-ttml-tool) +/ +[AMLL Editor 下一代逐词歌词编辑器](https://github.com/amll-dev/amll-editor) + +[AMLL Player 本地播放器](https://github.com/amll-dev/amll-player) +/ +[AMLL Page 网页播放器](https://github.com/apoint123/amll-page) + + +[引用了 AMLL 的项目汇总](https://github.com/amll-dev/applemusic-like-lyrics/discussions/397) + +
+ +> [!Warning] +> 致 AMLL Player 的开发/使用者: +> AMLL Player 已迁移至 [独立仓库](https://github.com/amll-dev/amll-player/blob/main/README-CN.md) +> +> 仓库链接已更新为 https://github.com/amll-dev/amll-player + +## AMLL 生态及源码结构 + +### 主要模块 + +- [![AMLL-Core](https://img.shields.io/badge/Core-%233178c6?label=Apple%20Music-like%20Lyrics&labelColor=%23FB5C74)](./packages/core/README-CN.md):AMLL 核心组件库,以 DOM 原生方式编写,提供歌词显示组件和动态流体背景组件 +- [![AMLL-React](https://img.shields.io/badge/React-%23149eca?label=Apple%20Music-like%20Lyrics&labelColor=%23FB5C74)](./packages/react/README-CN.md):AMLL React 绑定,提供 React 组件形式的歌词显示组件和动态流体背景组件 +- [![AMLL-React-Full](https://img.shields.io/badge/React%20Full-%23149eca?label=Apple%20Music-like%20Lyrics&labelColor=%23FB5C74)](./packages/react-full/README-CN.md):AMLL React 完整播放器组件库,提供可组合的播放页面组件 +- [![AMLL-Vue](https://img.shields.io/badge/Vue-%2342d392?label=Apple%20Music-like%20Lyrics&labelColor=%23FB5C74)](./packages/vue/README-CN.md):AMLL Vue 绑定,提供 Vue 组件形式的歌词显示组件和动态流体背景组件 +- [![AMLL-Lyric](https://img.shields.io/badge/Lyric-%23FB8C84?label=Apple%20Music-like%20Lyrics&labelColor=%23FB5C74)](./packages/lyric/README-CN.md):AMLL 歌词解析模块,提供对 LyRiC, YRC, QRC, Lyricify Syllable 各种歌词格式的解析和序列化支持 +- [![AMLL-TTML](https://img.shields.io/badge/TTML-%23FB8C84?label=Apple%20Music-like%20Lyrics&labelColor=%23FB5C74)](./packages/ttml/README-CN.md):AMLL TTML 处理模块,提供 TTML 的结构化解析、生成,以及与 AMLL 歌词数据的互转能力 + +## 浏览器兼容性提醒 + +本组件框架最低要求使用以下浏览器或更新版本: + +- Chromium/Edge 91+ +- Firefox 100+ +- Safari 9.1+ + +完整呈现组件所有效果需要使用以下浏览器或更新版本: + +- Chromium 120+ +- Firefox 100+ +- Safari 15.4+ + +参考链接: + +- [https://caniuse.com/mdn-css_properties_mask-image](https://caniuse.com/mdn-css_properties_mask-image) +- [https://caniuse.com/mdn-css_properties_mix-blend-mode_plus-lighter](https://caniuse.com/mdn-css_properties_mix-blend-mode_plus-lighter) + +## 性能配置参考 + +经过性能基准测试,五年内的主流 CPU 处理器均可以以 30FPS 正常带动歌词组件,但如果需要 60FPS 流畅运行,请确保 CPU 频率至少为 3.0Ghz 或以上。如果需要 144FPS 以上流畅运行,请确保 CPU 频率至少为 4.2Ghz 或以上。 + +GPU 性能在以下状况下能够以预期尺寸下满 60 帧运行: + +- `1080p (1920x1080)`: NVIDIA GTX 10 系列及以上 +- `2160p (3840x2160)`: NVIDIA RTX 2070 及以上 + +## 开发/构建/打包流程 + +### 前置依赖 + +- [Node.js](https://nodejs.org/) +- [pnpm](https://pnpm.io/) + +### 构建组件库 + +克隆本仓库后,在项目根目录执行以下指令: + +```bash +# 安装依赖 +pnpm install + +# 生产构建所有库包 +pnpm run build:libs +``` + +### 构建单个包 + +```bash +# 示例:仅构建 @applemusic-like-lyrics/core +pnpm nx run @applemusic-like-lyrics/core:build + +# 示例:开发构建 @applemusic-like-lyrics/lyric +pnpm nx run @applemusic-like-lyrics/lyric:build:dev +``` + +## 鸣谢 + +- [woshizja/sound-processor](https://github.com/woshizja/sound-processor) +- [FFmpeg](http://ffmpeg.org/) +- 还有很多被 AMLL 使用的框架和库,非常感谢! + +### 特别鸣谢 + +
+ +
+感谢 JetBrains 系列开发工具为 AMLL 项目提供的大力支持 +
+
diff --git a/amll-local/README.md b/amll-local/README.md new file mode 100644 index 0000000..f865c76 --- /dev/null +++ b/amll-local/README.md @@ -0,0 +1,112 @@ +
+ +![Apple Music-like Lyrics - A lyric page component library for Web](https://github.com/user-attachments/assets/cd6e4ba3-2640-4aab-aeb1-0762f97c8880) + +English / [简体中文](./README-CN.md) + +
+ +
+ +A lyric player component library that aims to look similar to iPad version of Apple Music. With [DOM](./packages/core/README.md), [React](./packages/react/README.md) and [Vue](./packages/vue/README.md) bindings. + +This is perhaps the most iPad Apple Music-like lyric page you've seen in frontend. + +Although the goal of this project is not to imitate it completely, it will polish some details to be better than the current best lyric players. + +**—— AMLL Series Projects ——** + +[AMLL TTML DB - TTML Syllable Lyric Database](https://github.com/amll-dev/amll-ttml-db) + +[AMLL TTML Tool - TTML Syllable Lyric Editor](https://github.com/amll-dev/amll-ttml-tool) +/ +[AMLL Editor - Next-Gen TTML Syllable Lyric Editor](https://github.com/amll-dev/amll-editor) + +[AMLL Player - Local Music Player](https://github.com/amll-dev/amll-player) +/ +[AMLL Page - Web Music Player](https://github.com/amll-dev/amll-page) + +[Projects that references AMLL](https://github.com/amll-dev/applemusic-like-lyrics/discussions/397) + +
+ +## AMLL Ecology and source code structure + +### Main modules + +- [![AMLL-Core](https://img.shields.io/badge/Core-%233178c6?label=Apple%20Music-like%20Lyrics&labelColor=%23FB5C74)](./packages/core/README.md): AMLL Core Component Library, written natively with DOM, provides lyric display component and dynamic fluid background component +- [![AMLL-React](https://img.shields.io/badge/React-%23149eca?label=Apple%20Music-like%20Lyrics&labelColor=%23FB5C74)](./packages/react/README.md): AMLL React binding, provides React component forms of lyric display and dynamic fluid background components +- [![AMLL-Vue](https://img.shields.io/badge/Vue-%2342d392?label=Apple%20Music-like%20Lyrics&labelColor=%23FB5C74)](./packages/vue/README.md): AMLL Vue binding, provides Vue component forms of lyric display and dynamic fluid background components +- [![AMLL-Lyric](https://img.shields.io/badge/Lyric-%23FB8C84?label=Apple%20Music-like%20Lyrics&labelColor=%23FB5C74)](./packages/lyric/README.md): AMLL lyric parsing module, provides parsing and serialization support for various lyric formats including LyRiC, YRC, QRC, and Lyricify Syllable + +## Browser compatibility alerts + +This component framework requires the following browsers or newer versions at a minimum: + +- Chromium/Edge 91+ +- Firefox 100+ +- Safari 9.1+ + +To fully render all component effects, the following browser versions or newer are required: + +- Chromium 120+ +- Firefox 100+ +- Safari 15.4+ + +Reference Links: + +- [https://caniuse.com/mdn-css_properties_mask-image](https://caniuse.com/mdn-css_properties_mask-image) +- [https://caniuse.com/mdn-css_properties_mix-blend-mode_plus-lighter](https://caniuse.com/mdn-css_properties_mix-blend-mode_plus-lighter) + +## Performance configuration reference + +Performance benchmarks have shown that mainstream CPU processors from the last five years can run the lyric component at 30FPS. However, if you need smooth 60FPS operation, ensure your CPU frequency is at least 3.0GHz or higher. For 144FPS or above, a CPU frequency of at least 4.2GHz is recommended. + +GPU performance capable of running at full 60 fps at the expected sizes under the following conditions: + +- `1080p (1920x1080)`: NVIDIA GTX 10 series and above +- `2160p (3840x2160)`: NVIDIA RTX 2070 and above + +## Development/build/packaging process + +### Prerequisites + +- [Node.js](https://nodejs.org/) +- [pnpm](https://pnpm.io/) + +### Building the component libraries + +Clone this repository, then run the following commands in the project root: + +```bash +# Install dependencies +pnpm install + +# Production build (all library packages) +pnpm run build:libs +``` + +### Building a single package + +```bash +# Example: build only @applemusic-like-lyrics/core +pnpm nx run @applemusic-like-lyrics/core:build + +# Example: development build of @applemusic-like-lyrics/lyric +pnpm nx run @applemusic-like-lyrics/lyric:build:dev +``` + +## Acknowledgements + +- [woshizja/sound-processor](https://github.com/woshizja/sound-processor) +- [FFmpeg](http://ffmpeg.org/) +- And many other frameworks and libraries used by AMLL, thank you very much! + +### Special Thanks + +
+ +
+Thanks to JetBrains for their development tools that provide great support to the AMLL project +
+
diff --git a/amll-local/biome.json b/amll-local/biome.json new file mode 100644 index 0000000..86bea6d --- /dev/null +++ b/amll-local/biome.json @@ -0,0 +1,55 @@ +{ + "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", + "files": { + "includes": ["**", "!packages/playground/core/src/components/ui"] + }, + "formatter": { + "enabled": true, + "formatWithErrors": false, + "attributePosition": "auto", + "indentStyle": "tab", + "indentWidth": 2, + "lineWidth": 80, + "lineEnding": "lf" + }, + "javascript": { + "formatter": { + "quoteStyle": "double", + "jsxQuoteStyle": "double" + } + }, + "html": { + "experimentalFullSupportEnabled": true, + "formatter": { + "enabled": true + }, + "linter": { + "enabled": true + } + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "a11y": "off" + } + }, + "assist": { + "actions": { + "source": { + "organizeImports": "on" + } + } + }, + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "css": { + "parser": { + "cssModules": true, + "tailwindDirectives": true + } + } +} diff --git a/amll-local/nx.json b/amll-local/nx.json new file mode 100644 index 0000000..b66a606 --- /dev/null +++ b/amll-local/nx.json @@ -0,0 +1,104 @@ +{ + "tasksRunnerOptions": { + "default": { + "runner": "nx/tasks-runners/default", + "options": { + "cacheableOperations": ["build:dev", "build"] + } + } + }, + "targetDefaults": { + "fmt": { + "cache": true, + "outputs": ["{projectRoot}/src"] + }, + "build": { + "cache": true, + "dependsOn": ["^build"], + "maxParallel": 1, + "outputs": ["{projectRoot}/dist", "{projectRoot}/pkg"] + }, + "build:dev": { + "cache": true, + "dependsOn": ["^build:dev"], + "maxParallel": 1, + "outputs": ["{projectRoot}/dist", "{projectRoot}/pkg"] + } + }, + "analytics": false, + "release": { + "versionPlans": { + "ignorePatternsForPlanCheck": [ + "**/*.md", + "**/*.mdx", + ".github/**", + ".nx/**", + ".editorconfig", + ".gitignore", + ".gitattributes", + "biome.json", + "Cargo.lock", + "Cargo.toml", + "lerna.json", + "LICENSE", + "nx.json", + "package.json", + "pnpm-lock.yaml", + "bun.lock", + "pnpm-workspace.yaml", + "tsconfig.json", + "tsconfig.base.json", + "packages/docs/**", + "packages/*/docs/**", + "packages/playground/**", + "packages/fft", + "packages/ws-protocol", + "patches/**", + "**/test/**", + "**/tests/**", + "**/tsconfig*", + "**/tsdown*" + ] + }, + "version": { + "conventionalCommits": false, + "preVersionCommand": "pnpm run build:libs" + }, + "changelog": { + "workspaceChangelog": false, + "projectChangelogs": { + "renderer": "{workspaceRoot}/.nx/scripts/changelog-render.ts" + } + }, + "git": { + "commit": true, + "tag": true + }, + "releaseTag": { + "pattern": "{projectName}@{version}", + "requireSemver": true + }, + "groups": { + "core-bundle": { + "projects": [ + "@applemusic-like-lyrics/core", + "@applemusic-like-lyrics/react", + "@applemusic-like-lyrics/vue" + ], + "projectsRelationship": "fixed", + "releaseTag": { + "pattern": "core-bundle@{version}", + "requireSemver": true + } + }, + "libraries": { + "projects": [ + "@applemusic-like-lyrics/lyric", + "@applemusic-like-lyrics/react-full", + "@applemusic-like-lyrics/ttml" + ], + "projectsRelationship": "independent" + } + } + } +} diff --git a/amll-local/package.json b/amll-local/package.json new file mode 100644 index 0000000..63903a4 --- /dev/null +++ b/amll-local/package.json @@ -0,0 +1,26 @@ +{ + "name": "applemusic-like-lyrics", + "private": true, + "license": "AGPL-3.0-only", + "type": "module", + "devDependencies": { + "@biomejs/biome": "catalog:", + "@nx/js": "^22.7.2", + "@typescript/native-preview": "7.0.0-dev.20260516.1", + "npm-run-all2": "^9.0.0", + "nx": "^22.7.2", + "swc-node": "^1.0.0", + "tsdown": "catalog:" + }, + "scripts": { + "build:libs": "nx run-many --target=build --projects=tag:library", + "ci:build:libs": "pnpm run build:libs", + "test:libs": "nx run-many --target=test --projects=ttml,lyric", + "ci:test:libs": "pnpm run test:libs", + "format": "biome format . --write", + "lint": "biome lint . --fix", + "ci:lint": "biome lint . --error-on-warnings", + "release:plan:check": "nx release plan:check" + }, + "packageManager": "pnpm@11.1.0" +} diff --git a/amll-local/packages/core/CHANGELOG.md b/amll-local/packages/core/CHANGELOG.md new file mode 100644 index 0000000..431473d --- /dev/null +++ b/amll-local/packages/core/CHANGELOG.md @@ -0,0 +1,76 @@ +## 0.5.1 (2026-05-17) + +### Patch Changes + +- **fix:** 修复含对唱时歌词错误提前导致的多行高亮 ([#521](https://github.com/amll-dev/applemusic-like-lyrics/pull/521)) +- **refactor:** 引入歌词组来包装主歌词和背景人声 & 前置背景人声 ([#531](https://github.com/amll-dev/applemusic-like-lyrics/pull/531)) +- **chore:** 更正 package.json 协议声明 ([#534](https://github.com/amll-dev/applemusic-like-lyrics/pull/534)) + + 仓库根目录的 LICENSE 文件为 AGPL v3.0 协议,但是 package.json 中的 `license` 字段为 `GPL-3.0`。经与原开发者确认,package.json 中的 `license` 字段有误。仓库与其所有产出的 npm 包均应为 AGPL v3 only 协议,SPDX: `AGPL-3.0-only`。因此,更正各包 `package.json` 的 `license` 字段为 `AGPL-3.0-only`。 +- **chore(core):** 优化类型定义 ([#519](https://github.com/amll-dev/applemusic-like-lyrics/pull/519)) + +### Contributors + +- apoint123 [@apoint123](https://github.com/apoint123) +- Linho [@Linho1219](https://github.com/Linho1219) + +## 0.5.0 (2026-05-12) + +### Minor Changes + +- **refactor:** 整理核心播放器代码结构,将抽象接口部分集中到统一目录 ([#508](https://github.com/amll-dev/applemusic-like-lyrics/pull/508)) +- **refactor:** 整理核心播放器抽象类中时间线、滚动与单行布局部分的结构与状态管理 ([#509](https://github.com/amll-dev/applemusic-like-lyrics/pull/509)) + +### Patch Changes + +- **fix:** 修复 setCurrentTime 在提供 isSeek 标志时,实际排版未遵守标志导致布局异常漂移的问题 ([#509](https://github.com/amll-dev/applemusic-like-lyrics/pull/509)) +- **fix:** 修复在同一行时间内拖拽进度条时逐字动画不同步的问题 ([#509](https://github.com/amll-dev/applemusic-like-lyrics/pull/509)) +- **fix:** 修复暂停状态下点击行跳转时仍播放逐字动画的问题 ([#509](https://github.com/amll-dev/applemusic-like-lyrics/pull/509)) + +### Contributors + +- Linho [@Linho1219](https://github.com/Linho1219) + +## 0.4.2 (2026-05-01) + +### Patch Changes + +- **feat(core):** 平衡行长度时优先在标点处换行 ([#503](https://github.com/amll-dev/applemusic-like-lyrics/pull/503)) +- **fix:** 修复背景行注音高度错误 ([#497](https://github.com/amll-dev/applemusic-like-lyrics/pull/497)) +- **fix(core):** 修正平衡行长度时的行宽度计算 ([#502](https://github.com/amll-dev/applemusic-like-lyrics/pull/502)) + +### Contributors + +- apoint123 [@apoint123](https://github.com/apoint123) +- Linho [@Linho1219](https://github.com/Linho1219) + +## 0.4.1 (2026-04-23) + +### Patch Changes + +- **fix:** 在各绑定中暴露歌词优化选项 ([#492](https://github.com/amll-dev/applemusic-like-lyrics/pull/492)) +- **fix(vue):** 修复掩码模式错误的类型 ([#496](https://github.com/amll-dev/applemusic-like-lyrics/pull/496)) +- **refactor(core):** 重构平均行长度实现 ([#494](https://github.com/amll-dev/applemusic-like-lyrics/pull/494)) + +### Contributors + +- apoint123 [@apoint123](https://github.com/apoint123) + +## 0.4.0 (2026-04-14) + +### Minor Changes + +- **chore:** 移除 canvas 歌词渲染器 ([#476](https://github.com/amll-dev/applemusic-like-lyrics/pull/476)) + +### Patch Changes + +- **refactor:** 重构核心库测试组织模式 ([3db83c93](https://github.com/amll-dev/applemusic-like-lyrics/commit/3db83c93)) +- **docs:** 修正 optimize-lyric.ts 和 OptimizeLyricOptions 里 cleanUnintentionalOverlaps 的文档和注释 ([75a8c0bb](https://github.com/amll-dev/applemusic-like-lyrics/commit/75a8c0bb)) +- **chore:** 更换工具链 ([#476](https://github.com/amll-dev/applemusic-like-lyrics/pull/476)) +- **chore:** 在项目范围内启用 isolatedDeclarations ([#480](https://github.com/amll-dev/applemusic-like-lyrics/pull/480)) + +### Contributors + +- apoint123 [@apoint123](https://github.com/apoint123) +- Linho [@Linho1219](https://github.com/Linho1219) +- MoYingJi [@MoYingJi](https://github.com/MoYingJi) diff --git a/amll-local/packages/core/README-CN.md b/amll-local/packages/core/README-CN.md new file mode 100644 index 0000000..900a374 --- /dev/null +++ b/amll-local/packages/core/README-CN.md @@ -0,0 +1,83 @@ +# AMLL Core + +[English](./README.md) / 简体中文 + +> 警告:此为个人项目,且尚未完成开发,可能仍有大量问题,所以请勿直接用于生产环境! + +![AMLL-Core](https://img.shields.io/badge/Core-%233178c6?label=Apple%20Music-like%20Lyrics&labelColor=%23FB5C74) +[![npm](https://img.shields.io/npm/dt/%40applemusic-like-lyrics/core)](https://www.npmjs.com/package/@applemusic-like-lyrics/core) +[![npm](https://img.shields.io/npm/v/%40applemusic-like-lyrics%2Fcore)](https://www.npmjs.com/package/@applemusic-like-lyrics/core) + +AMLL 的纯 JS 核心组件框架,包括歌词显示组件和背景组件等其它可以复用的组件。 + +此处的东西都是 UI 框架无关的,所以可以间接在各种动态页面框架下引用。 + +或者如果你需要使用组件绑定的话,这里有 [React 绑定版本](../react/README.md) 和 [Vue 绑定版本](../vue/README.md) + +## 特性 + +- 纯前端渲染的歌词展示与背景渲染 +- 动态逐字歌词、翻译与音译显示 +- 支持多行、对唱与背景行 +- 可通过 CSS 变量自定义颜色与部分表现 + +## 安装 + +安装使用的依赖(如果以下列出的依赖包没有安装的话需要自行安装): +```bash +npm install @pixi/app @pixi/core @pixi/display @pixi/filter-blur @pixi/filter-bulge-pinch @pixi/filter-color-matrix @pixi/sprite # 使用 npm +yarn add @pixi/app @pixi/core @pixi/display @pixi/filter-blur @pixi/filter-bulge-pinch @pixi/filter-color-matrix @pixi/sprite # 使用 yarn +``` + +安装本体框架: +```bash +npm install @applemusic-like-lyrics/core # 使用 npm +yarn add @applemusic-like-lyrics/core # 使用 yarn +``` + +## 使用方式摘要 + +详细的 API 文档请参考 [./docs/modules.md](./docs/modules.md) + +一个测试用途的程序可以在 [../playground/core/src/test.ts](../playground/core/src/test.ts) 里找到。 + +```typescript +import { LyricPlayer } from "@applemusic-like-lyrics/core"; +import "@applemusic-like-lyrics/core/style.css"; // 导入需要的样式 + +const player = new LyricPlayer(); // 创建歌词播放组件 +document.body.appendChild(player.getElement()); // 将组件的元素添加到页面 +player.setLyricLines([]) // 设置歌词 +player.setCurrentTime(0) // 设定当前播放时间(需要逐帧调用) +player.update(0) // 更新歌词组件动画(需要逐帧调用) +``` + +每次通过 `LyricPlayer.setLyricLines` 设置的歌词是一个 `LyricLine[]` 参数,具体可以参考 [./src/interfaces.ts](./src/interfaces.ts) 中的代码。 + +## 数据结构 + +歌词的输入结构为 `LyricLine[]`,其中每行包含: + +- `words`: 逐字歌词数组,每个单词包含 `startTime` / `endTime` / `word`,并可选包含 `romanWord`、`ruby`、`obscene` 等字段 +- `translatedLyric`: 翻译行文本 +- `romanLyric`: 音译行文本 +- `startTime` / `endTime`: 行级时间戳 +- `isBG` / `isDuet`: 背景行与对唱行标记 + +## 样式定制 + +主要样式由 `@applemusic-like-lyrics/core/style.css` 提供,常用自定义方式为覆写 CSS 变量,例如: + +```css +.amll-lyric-player { + --amll-lp-color: #ffffff; + --amll-lp-bg-color: rgba(0, 0, 0, 0.35); +} +``` + +## 开发与构建 + +```bash +pnpm --filter @applemusic-like-lyrics/core dev +pnpm --filter @applemusic-like-lyrics/core build +``` diff --git a/amll-local/packages/core/README.md b/amll-local/packages/core/README.md new file mode 100644 index 0000000..1b3d2af --- /dev/null +++ b/amll-local/packages/core/README.md @@ -0,0 +1,83 @@ +# AMLL Core + +English / [简体中文](./README-CN.md) + +> Warning: This is a personal project and is still under development. There may still be many issues, so please do not use it directly in production environments! + +![AMLL-Core](https://img.shields.io/badge/Core-%233178c6?label=Apple%20Music-like%20Lyrics&labelColor=%23FB5C74) +[![npm](https://img.shields.io/npm/dt/%40applemusic-like-lyrics/core)](https://www.npmjs.com/package/@applemusic-like-lyrics/core) +[![npm](https://img.shields.io/npm/v/%40applemusic-like-lyrics%2Fcore)](https://www.npmjs.com/package/@applemusic-like-lyrics/core) + +AMLL's pure JS core component framework, including lyric display components and background components and other reusable components. + +Everything here is UI framework-independent, so it can be indirectly referenced under various dynamic page frameworks. + +Or if you need to use component bindings, there's a [React binding version](../react/README.md) and a [Vue binding version](../vue/README.md) + +## Features + +- Pure frontend rendering for lyrics and background +- Word-level timed lyrics with translation and romanization +- Multi-line, duet, and background line support +- Style customization via CSS variables + +## Installation + +Install the required dependencies (if the dependencies listed below are not installed, you need to install them yourself): +```bash +npm install @pixi/app @pixi/core @pixi/display @pixi/filter-blur @pixi/filter-bulge-pinch @pixi/filter-color-matrix @pixi/sprite # using npm +yarn add @pixi/app @pixi/core @pixi/display @pixi/filter-blur @pixi/filter-bulge-pinch @pixi/filter-color-matrix @pixi/sprite # using yarn +``` + +Install the framework: +```bash +npm install @applemusic-like-lyrics/core # using npm +yarn add @applemusic-like-lyrics/core # using yarn +``` + +## Usage Summary + +For detailed API documentation, please refer to [./docs/modules.md](./docs/modules.md) + +A test program can be found in [../playground/core/src/test.ts](../playground/core/src/test.ts). + +```typescript +import { LyricPlayer } from "@applemusic-like-lyrics/core"; +import "@applemusic-like-lyrics/core/style.css"; // Import required styles + +const player = new LyricPlayer(); // Create a lyric player component +document.body.appendChild(player.getElement()); // Add the component's element to the page +player.setLyricLines([]) // Set lyrics +player.setCurrentTime(0) // Set current playback time (needs to be called every frame) +player.update(0) // Update lyric component animation (needs to be called every frame) +``` + +The lyrics set through `LyricPlayer.setLyricLines` is a `LyricLine[]` parameter. For details, please refer to the code in [./src/interfaces.ts](./src/interfaces.ts). + +## Data Model + +Lyrics input is `LyricLine[]`, with each line containing: + +- `words`: timed word array, each word includes `startTime` / `endTime` / `word`, with optional `romanWord`, `ruby`, and `obscene` +- `translatedLyric`: translation text +- `romanLyric`: romanization text +- `startTime` / `endTime`: line timestamps +- `isBG` / `isDuet`: background and duet flags + +## Styling + +The main styles are provided by `@applemusic-like-lyrics/core/style.css`. Common overrides are via CSS variables: + +```css +.amll-lyric-player { + --amll-lp-color: #ffffff; + --amll-lp-bg-color: rgba(0, 0, 0, 0.35); +} +``` + +## Development + +```bash +pnpm --filter @applemusic-like-lyrics/core dev +pnpm --filter @applemusic-like-lyrics/core build +``` diff --git a/amll-local/packages/core/package.json b/amll-local/packages/core/package.json new file mode 100644 index 0000000..d5d5912 --- /dev/null +++ b/amll-local/packages/core/package.json @@ -0,0 +1,88 @@ +{ + "name": "@applemusic-like-lyrics/core", + "version": "0.5.1", + "description": "AMLL 的纯 JS 核心组件框架,包括歌词显示组件和背景组件等其它可以复用的组件", + "repository": { + "url": "https://github.com/amll-dev/applemusic-like-lyrics.git", + "directory": "packages/core", + "type": "git" + }, + "license": "AGPL-3.0-only", + "nx": { + "tags": [ + "library" + ], + "targets": { + "nx-release-publish": { + "executor": "@nx/js:release-publish", + "dependsOn": [] + } + } + }, + "files": [ + "dist" + ], + "scripts": { + "build:docs": "typedoc --plugin typedoc-plugin-markdown --out docs src/index.ts", + "typecheck": "tsgo -b", + "build-only": "tsdown", + "build": "run-p typecheck \"build-only {@}\" --", + "build:dev": "tsdown", + "fmt": "biome format --write ./src", + "dev": "nx run @applemusic-like-lyrics/playground-core:dev" + }, + "type": "module", + "main": "./dist/amll-core.cjs", + "module": "./dist/amll-core.mjs", + "typings": "./dist/amll-core.d.mts", + "imports": { + "#interfaces": "./src/interfaces.ts", + "#utils/*": "./src/utils/*", + "#styles/*": "./src/styles/*", + "#lyric/*": "./src/lyric-player/*", + "#bg/*": "./src/bg-player/*" + }, + "exports": { + ".": { + "import": { + "types": "./dist/amll-core.d.mts", + "default": "./dist/amll-core.mjs" + }, + "require": { + "types": "./dist/amll-core.d.cts", + "default": "./dist/amll-core.cjs" + } + }, + "./style.css": { + "import": "./dist/style.css", + "require": "./dist/style.css" + } + }, + "devDependencies": { + "@biomejs/biome": "^2.4.8", + "@types/deep-freeze": "^0.1.5", + "@types/stats.js": "^0.17.3", + "@types/ungap__structured-clone": "^1.2.0", + "lil-gui": "^0.21.0", + "stats.js": "^0.17.0", + "typedoc": "^0.28.18", + "typedoc-plugin-markdown": "^4.11.0", + "@tsdown/css": "^0.22.0", + "tsdown": "^0.22.0" + }, + "peerDependencies": { + "@pixi/app": "*", + "@pixi/core": "*", + "@pixi/display": "*", + "@pixi/filter-blur": "*", + "@pixi/filter-bulge-pinch": "*", + "@pixi/filter-color-matrix": "*", + "@pixi/sprite": "*" + }, + "dependencies": { + "@ungap/structured-clone": "^1.3.0", + "bezier-easing": "^3.0.0", + "deep-freeze": "^0.0.1", + "gl-matrix": "4.0.0-beta.2" + } +} diff --git a/amll-local/packages/core/src/bg-render/base.ts b/amll-local/packages/core/src/bg-render/base.ts new file mode 100644 index 0000000..91d43cf --- /dev/null +++ b/amll-local/packages/core/src/bg-render/base.ts @@ -0,0 +1,154 @@ +import type { Disposable, HasElement } from "../interfaces.ts"; + +export abstract class AbstractBaseRenderer implements Disposable, HasElement { + /** + * 修改背景的流动速度,数字越大越快,默认为 8 + * @param speed 背景的流动速度,默认为 8 + */ + abstract setFlowSpeed(speed: number): void; + /** + * 修改背景的渲染比例,默认是 0.5 + * + * 一般情况下这个程度既没有明显瑕疵也不会特别吃性能 + * @param scale 背景的渲染比例 + */ + abstract setRenderScale(scale: number): void; + /** + * 是否启用静态模式,即图片在更换后就会保持静止状态并禁用更新,以节省性能 + * @param enable 是否启用静态模式 + */ + abstract setStaticMode(enable: boolean): void; + /** + * 修改背景动画帧率,默认是 30 FPS + * + * 如果设置成 0 则会停止动画 + * @param fps 目标帧率,默认 30 FPS + */ + abstract setFPS(fps: number): void; + /** + * 暂停背景动画,画面即便是更新了图片也不会发生变化 + */ + abstract pause(): void; + /** + * 恢复播放背景动画 + */ + abstract resume(): void; + /** + * 设置背景专辑资源,纹理加载并设置完成后会返回 + * @param albumSource 专辑的资源链接,可以是图片或视频链接,抑或是任意 img/video 元素,如果提供字符串链接且为视频则需要指定第二个参数 + */ + abstract setAlbum( + albumSource: string | HTMLImageElement | HTMLVideoElement, + isVideo?: boolean, + ): Promise; + /** + * 设置低频的音量大小,范围在 80hz-120hz 之间为宜,取值范围在 [0.0-1.0] 之间 + * + * 部分渲染器会根据音量大小调整背景效果(例如根据鼓点跳动) + * + * 如果无法获取到类似的数据,请传入 1.0 作为默认值,或不做任何处理(默认值即 1.0) + * @param volume 低频的音量大小,范围在 50hz-120hz 之间为宜,取值范围在 [0.0-1.0] 之间 + */ + abstract setLowFreqVolume(volume: number): void; + /** + * 设置背景是否根据“是否有歌词”这个特征调整自身效果,例如有歌词时会变得更加活跃 + * + * 部分渲染器会根据这个特征调整自身效果 + * + * 如果不确定是否需要赋值或无法知晓是否包含歌词,请传入 true 或不做任何处理(默认值为 true) + * + * @param hasLyric 是否有歌词,如不确定是否需要赋值,请传入 true 或不做任何处理(默认值为 true) + */ + abstract setHasLyric(hasLyric: boolean): void; + abstract dispose(): void; + abstract getElement(): HTMLElement; +} + +function clamp1(x: number): number { + return Math.max(1, x); +} + +export abstract class BaseRenderer extends AbstractBaseRenderer { + private observer: ResizeObserver; + protected flowSpeed = 1; + protected currerntRenderScale = 0.75; + constructor(protected canvas: HTMLCanvasElement) { + super(); + this.observer = new ResizeObserver(() => { + const width = clamp1( + canvas.clientWidth * window.devicePixelRatio * this.currerntRenderScale, + ); + const height = clamp1( + canvas.clientHeight * + window.devicePixelRatio * + this.currerntRenderScale, + ); + this.onResize(width, height); + }); + this.observer.observe(canvas); + } + setRenderScale(scale: number): void { + this.currerntRenderScale = scale; + this.onResize( + this.canvas.clientWidth * + window.devicePixelRatio * + this.currerntRenderScale, + this.canvas.clientHeight * + window.devicePixelRatio * + this.currerntRenderScale, + ); + } + /** + * 当画板元素大小发生变化时此函数会被调用 + * 可以在此处重设和渲染器相关的尺寸设置 + * 考虑到初始化的时候元素不一定在文档中或出于某些特殊样式状态,尺寸长宽有可能会为 0,请注意进行特判处理 + * @param width 画板元素实际的物理像素宽度,有可能为 0 + * @param height 画板元素实际的物理像素高度,有可能为 0 + */ + protected onResize(width: number, height: number): void { + this.canvas.width = width; + this.canvas.height = height; + } + /** + * 修改背景的流动速度,数字越大越快,默认为 1 + * @param speed 背景的流动速度,默认为 1 + */ + setFlowSpeed(speed: number): void { + this.flowSpeed = speed; + } + /** + * 是否启用静态模式,即图片在更换后就会保持静止状态并禁用更新,以节省性能 + * @param enable 是否启用静态模式 + */ + abstract override setStaticMode(enable: boolean): void; + /** + * 修改背景动画帧率,默认是 30 FPS + * + * 如果设置成 0 则会停止动画 + * @param fps 目标帧率,默认 30 FPS + */ + abstract override setFPS(fps: number): void; + /** + * 暂停背景动画,画面即便是更新了图片也不会发生变化 + */ + abstract override pause(): void; + /** + * 恢复播放背景动画 + */ + abstract override resume(): void; + /** + * 设置背景专辑资源,纹理加载并设置完成后会返回 + * @param albumSource 专辑的资源链接,可以是图片或视频链接,抑或是任意 img/video 元素,如果提供字符串链接且为视频则需要指定第二个参数 + */ + abstract override setAlbum( + albumSource: string | HTMLImageElement | HTMLVideoElement, + isVideo?: boolean, + ): Promise; + dispose(): void { + this.observer.disconnect(); + this.canvas.remove(); + } + override getElement(): HTMLElement { + return this.canvas; + } +} diff --git a/amll-local/packages/core/src/bg-render/img.ts b/amll-local/packages/core/src/bg-render/img.ts new file mode 100644 index 0000000..af8698c --- /dev/null +++ b/amll-local/packages/core/src/bg-render/img.ts @@ -0,0 +1,168 @@ +export function blurImage( + imageData: ImageData, + radius: number, + quality: number, +): void { + const pixels = imageData.data; + const width = imageData.width; + const height = imageData.height; + + let rsum: number; + let gsum: number; + let bsum: number; + let asum: number; + let x: number; + let y: number; + let i: number; + let p: number; + let p1: number; + let p2: number; + let yp: number; + let yi: number; + let yw: number; + const wm = width - 1; + const hm = height - 1; + const rad1x = radius + 1; + const divx = radius + rad1x; + const rad1y = radius + 1; + const divy = radius + rad1y; + const div2 = 1 / (divx * divy); + + const r: number[] = []; + const g: number[] = []; + const b: number[] = []; + const a: number[] = []; + + const vmin: number[] = []; + const vmax: number[] = []; + + while (quality-- > 0) { + yw = yi = 0; + + for (y = 0; y < height; y++) { + rsum = pixels[yw] * rad1x; + gsum = pixels[yw + 1] * rad1x; + bsum = pixels[yw + 2] * rad1x; + asum = pixels[yw + 3] * rad1x; + + for (i = 1; i <= radius; i++) { + p = yw + ((i > wm ? wm : i) << 2); + rsum += pixels[p++]; + gsum += pixels[p++]; + bsum += pixels[p++]; + asum += pixels[p]; + } + + for (x = 0; x < width; x++) { + r[yi] = rsum; + g[yi] = gsum; + b[yi] = bsum; + a[yi] = asum; + + if (y === 0) { + vmin[x] = Math.min(x + rad1x, wm) << 2; + vmax[x] = Math.max(x - radius, 0) << 2; + } + + p1 = yw + vmin[x]; + p2 = yw + vmax[x]; + + rsum += pixels[p1++] - pixels[p2++]; + gsum += pixels[p1++] - pixels[p2++]; + bsum += pixels[p1++] - pixels[p2++]; + asum += pixels[p1] - pixels[p2]; + + yi++; + } + yw += width << 2; + } + + for (x = 0; x < width; x++) { + yp = x; + rsum = r[yp] * rad1y; + gsum = g[yp] * rad1y; + bsum = b[yp] * rad1y; + asum = a[yp] * rad1y; + + for (i = 1; i <= radius; i++) { + yp += i > hm ? 0 : width; + rsum += r[yp]; + gsum += g[yp]; + bsum += b[yp]; + asum += a[yp]; + } + + yi = x << 2; + for (y = 0; y < height; y++) { + pixels[yi] = (rsum * div2 + 0.5) | 0; + pixels[yi + 1] = (gsum * div2 + 0.5) | 0; + pixels[yi + 2] = (bsum * div2 + 0.5) | 0; + pixels[yi + 3] = (asum * div2 + 0.5) | 0; + + if (x === 0) { + vmin[y] = Math.min(y + rad1y, hm) * width; + vmax[y] = Math.max(y - radius, 0) * width; + } + + p1 = x + vmin[y]; + p2 = x + vmax[y]; + + rsum += r[p1] - r[p2]; + gsum += g[p1] - g[p2]; + bsum += b[p1] - b[p2]; + asum += a[p1] - a[p2]; + + yi += width << 2; + } + } + } +} + +export function saturateImage(imageData: ImageData, saturation: number): void { + const pixels = imageData.data; + + for (let i = 0; i < pixels.length; i += 4) { + const r = pixels[i]; + const g = pixels[i + 1]; + const b = pixels[i + 2]; + const a = pixels[i + 3]; + const gray = r * 0.3 + g * 0.59 + b * 0.11; + pixels[i] = gray * (1 - saturation) + r * saturation; + pixels[i + 1] = gray * (1 - saturation) + g * saturation; + pixels[i + 2] = gray * (1 - saturation) + b * saturation; + pixels[i + 3] = a; + } +} + +export function brightnessImage( + imageData: ImageData, + brightness: number, +): void { + const pixels = imageData.data; + + for (let i = 0; i < pixels.length; i += 4) { + const r = pixels[i]; + const g = pixels[i + 1]; + const b = pixels[i + 2]; + const a = pixels[i + 3]; + pixels[i] = r * brightness; + pixels[i + 1] = g * brightness; + pixels[i + 2] = b * brightness; + pixels[i + 3] = a; + } +} + +export function contrastImage(imageData: ImageData, contrast: number): void { + const pixels = imageData.data; + + for (let i = 0; i < pixels.length; i += 4) { + const r = pixels[i]; + const g = pixels[i + 1]; + const b = pixels[i + 2]; + const a = pixels[i + 3]; + pixels[i] = (r - 128) * contrast + 128; + pixels[i + 1] = (g - 128) * contrast + 128; + pixels[i + 2] = (b - 128) * contrast + 128; + pixels[i + 3] = a; + } +} diff --git a/amll-local/packages/core/src/bg-render/index.ts b/amll-local/packages/core/src/bg-render/index.ts new file mode 100644 index 0000000..52e24f3 --- /dev/null +++ b/amll-local/packages/core/src/bg-render/index.ts @@ -0,0 +1,71 @@ +/** + * @fileoverview + * 一个播放歌词的组件 + * @author SteveXMH + */ + +export { AbstractBaseRenderer, BaseRenderer } from "./base.ts"; +export { MeshGradientRenderer } from "./mesh-renderer/index.ts"; +export { PixiRenderer } from "./pixi-renderer.ts"; +import type { AbstractBaseRenderer, BaseRenderer } from "./base.ts"; + +export class BackgroundRender + implements AbstractBaseRenderer +{ + private element: HTMLCanvasElement; + private renderer: Renderer; + constructor(renderer: Renderer, canvas: HTMLCanvasElement) { + this.renderer = renderer; + + this.element = canvas; + canvas.style.pointerEvents = "none"; + canvas.style.zIndex = "-1"; + canvas.style.contain = "strict"; + } + + static new(type: { + new (canvas: HTMLCanvasElement): Renderer; + }): BackgroundRender { + const newCanvas = document.createElement("canvas"); + return new BackgroundRender(new type(newCanvas), newCanvas); + } + + setRenderScale(scale: number): void { + this.renderer.setRenderScale(scale); + } + + setFlowSpeed(speed: number): void { + this.renderer.setFlowSpeed(speed); + } + setStaticMode(enable: boolean): void { + this.renderer.setStaticMode(enable); + } + setFPS(fps: number): void { + this.renderer.setFPS(fps); + } + pause(): void { + this.renderer.pause(); + } + resume(): void { + this.renderer.resume(); + } + setLowFreqVolume(volume: number): void { + this.renderer.setLowFreqVolume(volume); + } + setHasLyric(hasLyric: boolean): void { + this.renderer.setHasLyric(hasLyric); + } + setAlbum( + albumSource: string | HTMLImageElement | HTMLVideoElement, + isVideo?: boolean, + ): Promise { + return this.renderer.setAlbum(albumSource, isVideo); + } + getElement(): HTMLCanvasElement { + return this.element; + } + dispose(): void { + this.renderer.dispose(); + this.element.remove(); + } +} diff --git a/amll-local/packages/core/src/bg-render/mesh-renderer/cp-generate.ts b/amll-local/packages/core/src/bg-render/mesh-renderer/cp-generate.ts new file mode 100644 index 0000000..4f463b4 --- /dev/null +++ b/amll-local/packages/core/src/bg-render/mesh-renderer/cp-generate.ts @@ -0,0 +1,211 @@ +/** + * @fileoverview + * 实验性的随机控制点生成函数算法 + * 目的是取代原先大量的预设控制点代码 + */ + +import { clamp01 } from "#utils/clamp.ts"; +import { + type ControlPointConf, + type ControlPointPreset, + p, + preset, +} from "./cp-presets.ts"; + +const randomRange = (min: number, max: number): number => + Math.random() * (max - min) + min; + +function smoothstep(edge0: number, edge1: number, x: number): number { + const t = clamp01((x - edge0) / (edge1 - edge0)); + return t * t * (3 - 2 * t); +} + +function smoothifyControlPoints( + conf: ControlPointConf[], + w: number, + h: number, + iterations = 2, + factor = 0.5, + factorIterationModifier = 0.1, +): void { + let grid: ControlPointConf[][] = []; + let f = factor; + + for (let j = 0; j < h; j++) { + grid[j] = []; + for (let i = 0; i < w; i++) { + grid[j][i] = conf[j * w + i]; + } + } + + const kernel = [ + [1, 2, 1], + [2, 4, 2], + [1, 2, 1], + ]; + const kernelSum = 16; + + for (let iter = 0; iter < iterations; iter++) { + const newGrid: ControlPointConf[][] = []; + for (let j = 0; j < h; j++) { + newGrid[j] = []; + for (let i = 0; i < w; i++) { + if (i === 0 || i === w - 1 || j === 0 || j === h - 1) { + newGrid[j][i] = grid[j][i]; + continue; + } + let sumX = 0; + let sumY = 0; + let sumUR = 0; + let sumVR = 0; + let sumUP = 0; + let sumVP = 0; + for (let dj = -1; dj <= 1; dj++) { + for (let di = -1; di <= 1; di++) { + const weight = kernel[dj + 1][di + 1]; + const nb = grid[j + dj][i + di]; + sumX += nb.x * weight; + sumY += nb.y * weight; + sumUR += nb.ur * weight; + sumVR += nb.vr * weight; + sumUP += nb.up * weight; + sumVP += nb.vp * weight; + } + } + const avgX = sumX / kernelSum; + const avgY = sumY / kernelSum; + const avgUR = sumUR / kernelSum; + const avgVR = sumVR / kernelSum; + const avgUP = sumUP / kernelSum; + const avgVP = sumVP / kernelSum; + + const cur = grid[j][i]; + const newX = cur.x * (1 - f) + avgX * f; + const newY = cur.y * (1 - f) + avgY * f; + const newUR = cur.ur * (1 - f) + avgUR * f; + const newVR = cur.vr * (1 - f) + avgVR * f; + const newUP = cur.up * (1 - f) + avgUP * f; + const newVP = cur.vp * (1 - f) + avgVP * f; + newGrid[j][i] = p(i, j, newX, newY, newUR, newVR, newUP, newVP); + } + } + grid = newGrid; + f = clamp01(f + factorIterationModifier); + } + + for (let j = 0; j < h; j++) { + for (let i = 0; i < w; i++) { + conf[j * w + i] = grid[j][i]; + } + } +} + +function noise(x: number, y: number): number { + return fract(Math.sin(x * 12.9898 + y * 78.233) * 43758.5453); +} + +function fract(x: number): number { + return x - Math.floor(x); +} + +function smoothNoise(x: number, y: number): number { + const x0 = Math.floor(x); + const y0 = Math.floor(y); + const x1 = x0 + 1; + const y1 = y0 + 1; + + const xf = x - x0; + const yf = y - y0; + + const u = xf * xf * (3 - 2 * xf); + const v = yf * yf * (3 - 2 * yf); + + const n00 = noise(x0, y0); + const n10 = noise(x1, y0); + const n01 = noise(x0, y1); + const n11 = noise(x1, y1); + + const nx0 = n00 * (1 - u) + n10 * u; + const nx1 = n01 * (1 - u) + n11 * u; + + return nx0 * (1 - v) + nx1 * v; +} + +function computeNoiseGradient( + perlinFn: (x: number, y: number) => number, + x: number, + y: number, + epsilon = 0.001, +): [number, number] { + const n1 = perlinFn(x + epsilon, y); + const n2 = perlinFn(x - epsilon, y); + const n3 = perlinFn(x, y + epsilon); + const n4 = perlinFn(x, y - epsilon); + const dx = (n1 - n2) / (2 * epsilon); + const dy = (n3 - n4) / (2 * epsilon); + const len = Math.sqrt(dx * dx + dy * dy) || 1; + return [dx / len, dy / len]; +} + +export function generateControlPoints( + width: number, + height: number, + variationFraction: number = randomRange(0.4, 0.6), // = 0.2, + normalOffset: number = randomRange(0.3, 0.6), // = 0.3, + blendFactor = 0.8, + smoothIters: number = Math.floor(randomRange(3, 5)), // = 3, + smoothFactor: number = randomRange(0.2, 0.3), // = 0.3, + smoothModifier: number = randomRange(-0.1, -0.05), // = -0.05, +): ControlPointPreset { + const w = width ?? Math.floor(randomRange(3, 6)); + const h = height ?? Math.floor(randomRange(3, 6)); + + const conf: ControlPointConf[] = []; + const dx = w === 1 ? 0 : 2 / (w - 1); + const dy = h === 1 ? 0 : 2 / (h - 1); + + for (let j = 0; j < h; j++) { + for (let i = 0; i < w; i++) { + const baseX = (w === 1 ? 0 : i / (w - 1)) * 2 - 1; + const baseY = (h === 1 ? 0 : j / (h - 1)) * 2 - 1; + + const isBorder = i === 0 || i === w - 1 || j === 0 || j === h - 1; + const pertX = isBorder + ? 0 + : randomRange(-variationFraction * dx, variationFraction * dx); + const pertY = isBorder + ? 0 + : randomRange(-variationFraction * dy, variationFraction * dy); + let x = baseX + pertX; + let y = baseY + pertY; + + const ur = isBorder ? 0 : randomRange(-60, 60); + const vr = isBorder ? 0 : randomRange(-60, 60); + const up = isBorder ? 1 : randomRange(0.8, 1.2); + const vp = isBorder ? 1 : randomRange(0.8, 1.2); + + if (!isBorder) { + const uNorm = (baseX + 1) / 2; + const vNorm = (baseY + 1) / 2; + + const [nx, ny] = computeNoiseGradient(smoothNoise, uNorm, vNorm, 0.001); + let offsetX = nx * normalOffset; + let offsetY = ny * normalOffset; + + const distToBorder = Math.min(uNorm, 1 - uNorm, vNorm, 1 - vNorm); // in [0,0.5] + + const weight = smoothstep(0, 1.0, distToBorder); + offsetX *= weight; + offsetY *= weight; + + x = x * (1 - blendFactor) + (x + offsetX) * blendFactor; + y = y * (1 - blendFactor) + (y + offsetY) * blendFactor; + } + conf.push(p(i, j, x, y, ur, vr, up, vp)); + } + } + + smoothifyControlPoints(conf, w, h, smoothIters, smoothFactor, smoothModifier); + + return preset(w, h, conf); +} diff --git a/amll-local/packages/core/src/bg-render/mesh-renderer/cp-presets.ts b/amll-local/packages/core/src/bg-render/mesh-renderer/cp-presets.ts new file mode 100644 index 0000000..9a35d22 --- /dev/null +++ b/amll-local/packages/core/src/bg-render/mesh-renderer/cp-presets.ts @@ -0,0 +1,185 @@ +/** @internal */ +export interface ControlPointConf { + cx: number; + cy: number; + x: number; + y: number; + ur: number; + vr: number; + up: number; + vp: number; +} + +/** @internal */ +export interface ControlPointPreset { + width: number; + height: number; + conf: ControlPointConf[]; +} + +/** @internal */ +export const p = ( + cx: number, + cy: number, + x: number, + y: number, + ur = 0, + vr = 0, + up = 1, + vp = 1, +) => Object.freeze({ cx, cy, x, y, ur, vr, up, vp }) as ControlPointConf; +/** @internal */ +export const preset = ( + width: number, + height: number, + conf: ControlPointConf[], +) => Object.freeze({ width, height, conf }) as ControlPointPreset; + +export const CONTROL_POINT_PRESETS: ControlPointPreset[] = [ + // TODO: 竖屏推荐 + preset(5, 5, [ + p(0, 0, -1, -1, 0, 0, 1, 1), + p(1, 0, -0.5, -1, 0, 0, 1, 1), + p(2, 0, 0, -1, 0, 0, 1, 1), + p(3, 0, 0.5, -1, 0, 0, 1, 1), + p(4, 0, 1, -1, 0, 0, 1, 1), + p(0, 1, -1, -0.5, 0, 0, 1, 1), + p(1, 1, -0.5, -0.5, 0, 0, 1, 1), + p(2, 1, -0.0052029684413368305, -0.6131420587090777, 0, 0, 1, 1), + p(3, 1, 0.5884227308309977, -0.3990805107556692, 0, 0, 1, 1), + p(4, 1, 1, -0.5, 0, 0, 1, 1), + p(0, 2, -1, 0, 0, 0, 1, 1), + p(1, 2, -0.4210024670505933, -0.11895058380429502, 0, 0, 1, 1), + p(2, 2, -0.1019613423315412, -0.023812118047224606, 0, -47, 0.629, 0.849), + p(3, 2, 0.40275125660925437, -0.06345314544600389, 0, 0, 1, 1), + p(4, 2, 1, 0, 0, 0, 1, 1), + p(0, 3, -1, 0.5, 0, 0, 1, 1), + p(1, 3, 0.06801958477287173, 0.5205913248960121, -31, -45, 1, 1), + p(2, 3, 0.21446469120128908, 0.29331610114301043, 6, -56, 0.566, 1.321), + p(3, 3, 0.5, 0.5, 0, 0, 1, 1), + p(4, 3, 1, 0.5, 0, 0, 1, 1), + p(0, 4, -1, 1, 0, 0, 1, 1), + p(1, 4, -0.31378372841550195, 1, 0, 0, 1, 1), + p(2, 4, 0.26153633255328046, 1, 0, 0, 1, 1), + p(3, 4, 0.5, 1, 0, 0, 1, 1), + p(4, 4, 1, 1, 0, 0, 1, 1), + ]), + // TODO: 横屏推荐 + preset(4, 4, [ + p(0, 0, -1, -1, 0, 0, 1, 1), + p(1, 0, -0.33333333333333337, -1, 0, 0, 1, 1), + p(2, 0, 0.33333333333333326, -1, 0, 0, 1, 1), + p(3, 0, 1, -1, 0, 0, 1, 1), + p(0, 1, -1, -0.04495399932657351, 0, 0, 1, 1), + p(1, 1, -0.24056117520129328, -0.22465999020104, 0, 0, 1, 1), + p(2, 1, 0.334758885767489, -0.00531297192779423, 0, 0, 1, 1), + p(3, 1, 0.9989920470678106, -0.3382976020775408, 8, 0, 0.566, 1.792), + p(0, 2, -1, 0.33333333333333326, 0, 0, 1, 1), + p(1, 2, -0.3425497314639411, -0.000027501607956947893, 0, 0, 1, 1), + p(2, 2, 0.3321437945812673, 0.1981776353859399, 0, 0, 1, 1), + p(3, 2, 1, 0.0766118180296832, 0, 0, 1, 1), + p(0, 3, -1, 1, 0, 0, 1, 1), + p(1, 3, -0.33333333333333337, 1, 0, 0, 1, 1), + p(2, 3, 0.33333333333333326, 1, 0, 0, 1, 1), + p(3, 3, 1, 1, 0, 0, 1, 1), + ]), + preset(4, 4, [ + p(0, 0, -1, -1, 0, 0, 1, 2.075), + p(1, 0, -0.33333333333333337, -1, 0, 0, 1, 1), + p(2, 0, 0.33333333333333326, -1, 0, 0, 1, 1), + p(3, 0, 1, -1, 0, 0, 1, 1), + p(0, 1, -1, -0.4545779491139603, 0, 0, 1, 1), + p(1, 1, -0.33333333333333337, -0.33333333333333337, 0, 0, 1, 1), + p(2, 1, 0.0889403142626457, -0.6025711180694033, -32, 45, 1, 1), + p(3, 1, 1, -0.33333333333333337, 0, 0, 1, 1), + p(0, 2, -1, -0.07402408608567845, 1, 0, 1, 0.094), + p(1, 2, -0.2719422694359541, 0.09775369930903222, 25, -18, 1.321, 0), + p(2, 2, 0.19877414408395877, 0.4307383294587789, 48, -40, 0.755, 0.975), + p(3, 2, 1, 0.33333333333333326, -37, 0, 1, 1), + p(0, 3, -1, 1, 0, 0, 1, 1), + p(1, 3, -0.33333333333333337, 1, 0, 0, 1, 1), + p(2, 3, 0.5125850864305672, 1, -20, -18, 0, 1.604), + p(3, 3, 1, 1, 0, 0, 1, 1), + ]), + preset(5, 5, [ + p(0, 0, -1, -1, 0, 0, 1, 1), + p(1, 0, -0.4501953125, -1, 0, 55, 1, 2.075), + p(2, 0, 0.1953125, -1, 0, 0, 1, 1), + p(3, 0, 0.4580078125, -1, 0, -25, 1, 1), + p(4, 0, 1, -1, 0, 0, 1, 1), + p(0, 1, -1, -0.2514475377525607, -16, 0, 2.327, 0.943), + p(1, 1, -0.55859375, -0.6609325945787148, 47, 0, 2.358, 0.377), + p(2, 1, 0.232421875, -0.5244375756366635, -66, -25, 1.855, 1.164), + p(3, 1, 0.685546875, -0.3753706470552125, 0, 0, 1, 1), + p(4, 1, 1, -0.6699125300354287, 0, 0, 1, 1), + p(0, 2, -1, 0.035910396862284255, 0, 0, 1, 1), + p(1, 2, -0.4921875, 0.005378616309457018, 90, 23, 1, 1.981), + p(2, 2, 0.021484375, -0.1365043639066228, 0, 42, 1, 1), + p(3, 2, 0.4765625, 0.05925822904974043, -30, 0, 1.95, 0.44), + p(4, 2, 1, 0.251428847823418, 0, 0, 1, 1), + p(0, 3, -1, 0.6968336464764276, -68, 0, 1, 0.786), + p(1, 3, -0.6904296875, 0.5890744209958608, -68, 0, 1, 1), + p(2, 3, 0.1845703125, 0.3879238667654693, 61, 0, 1, 1), + p(3, 3, 0.60546875, 0.4633553246018661, -47, -59, 0.849, 1.73), + p(4, 3, 1, 0.6214021886400309, -33, 0, 0.377, 1.604), + p(0, 4, -1, 1, 0, 0, 1, 1), + p(1, 4, -0.5, 1, 0, -73, 1, 1), + p(2, 4, -0.3271484375, 1, 0, -24, 0.314, 2.704), + p(3, 4, 0.5, 1, 0, 0, 1, 1), + p(4, 4, 1, 1, 0, 0, 1, 1), + ]), + preset(5, 5, [ + p(0, 0, -1, -1), + p(1, 0, -0.6393, -1, 0, 0, 1, 2.3884), + p(2, 0, 0, -1), + p(3, 0, 0.5, -1), + p(4, 0, 1, -1), + p(0, 1, -1, -0.2301), + p(1, 1, -0.6934, -0.331, 0, -0.7188, 1, 1.063), + p(2, 1, -0.0082, -0.6814, -0.2583, 0, 1.0964, 1), + p(3, 1, 0.5836, -0.531, 0.7029, 0, 1.5466, 1), + p(4, 1, 1, -0.6407), + p(0, 2, -1, 0.2973, 0, 0, 1.8352, 1), + p(1, 2, -0.4082, 0.0602), + p(2, 2, -0.1803, -0.3646, -0.2998, 0, 1.1513, 1), + p(3, 2, 0.477, -0.1027, 0.8903, -0.1882, 1.0807, 0.8551), + p(4, 2, 1, -0.2973), + p(0, 3, -1, 0.7628, 0, 0, 2.3868, 1), + p(1, 3, -0.2525, 0.4814, -0.8406, -1.6199, 1.4093, 1.2215), + p(2, 3, 0.3607, 0.2814, -1.0713, -0.0529, 1.0025, 0.7611), + p(3, 3, 0.4885, 0.623, 0, 0.8184, 1, 1.2876), + p(4, 3, 1, 0.5), + p(0, 4, -1, 1), + p(1, 4, -0.4033, 1), + p(2, 4, 0.2672, 1), + p(3, 4, 0.5967, 1), + p(4, 4, 1, 1), + ]), + preset(5, 5, [ + p(0, 0, -1, -1), + p(1, 0, -0.2197, -1), + p(2, 0, 0.0197, -1), + p(3, 0, 0.8033, -1), + p(4, 0, 1, -1), + p(0, 1, -1, -0.5451), + p(1, 1, -0.4885, -0.4035, -1.0246, -0.2268, 1.1936, 0.8005), + p(2, 1, -0.1213, -0.2867, 0, -0.6981, 1, 0.809), + p(3, 1, 0.3246, -0.5628, 0, -1.2188, 1, 1.044), + p(4, 1, 1, -0.3292), + p(0, 2, -1, 0.1416), + p(1, 2, -0.341, -0.0142, 0, -0.4004, 1, 1.1293), + p(2, 2, -0.0393, -0.023, 0.2915, -0.373, 1.044, 0.9879), + p(3, 2, 0.3148, -0.0673, -0.7853, -0.8962, 1.4709, 1.0247), + p(4, 2, 1, 0.1912), + p(0, 3, -1, 0.5), + p(1, 3, -0.2689, 0.2743, 0.3404, -0.5248, 1.0184, 0.4391), + p(2, 3, 0.0721, 0.269, 0.5302, 0.1244, 0.6723, 0.3225), + p(3, 3, 0.4148, 0.3894, -0.6977, -0.6783, 0.8094, 0.9247), + p(4, 3, 1, 0.446), + p(0, 4, -1, 1), + p(1, 4, -0.7311, 1), + p(2, 4, 0.323, 1), + p(3, 4, 0.6393, 1), + p(4, 4, 1, 1), + ]), +] as const; diff --git a/amll-local/packages/core/src/bg-render/mesh-renderer/index.ts b/amll-local/packages/core/src/bg-render/mesh-renderer/index.ts new file mode 100644 index 0000000..c6d1504 --- /dev/null +++ b/amll-local/packages/core/src/bg-render/mesh-renderer/index.ts @@ -0,0 +1,1352 @@ +/** + * @fileoverview + * 基于 Mesh Gradient 渐变渲染的渲染器 + * 此渲染应该是 Apple Music 使用的背景渲染方式了 + * 参考内容 https://movingparts.io/gradient-meshes + */ + +import { Mat4, Vec2, Vec3, Vec4 } from "gl-matrix"; +import type { Disposable } from "../../interfaces.ts"; +import { + loadResourceFromElement, + loadResourceFromUrl, +} from "../../utils/resource.ts"; +import { BaseRenderer } from "../base.ts"; +import { blurImage } from "../img.ts"; +import { generateControlPoints } from "./cp-generate.ts"; +import { CONTROL_POINT_PRESETS } from "./cp-presets.ts"; +import meshFragShader from "./mesh.frag.glsl?raw"; +import meshVertShader from "./mesh.vert.glsl?raw"; +import { clamp01 } from "#utils/clamp.ts"; + +const quadVertShader = ` +attribute vec2 a_pos; +varying vec2 v_uv; +void main() { + gl_Position = vec4(a_pos, 0.0, 1.0); + v_uv = a_pos * 0.5 + 0.5; +} +`; + +const quadFragShader = ` +precision mediump float; +varying vec2 v_uv; +uniform sampler2D u_texture; +uniform float u_alpha; +void main() { + vec4 color = texture2D(u_texture, v_uv); + gl_FragColor = vec4(color.rgb, color.a * u_alpha); +} +`; + +function easeInOutSine(x: number): number { + return -(Math.cos(Math.PI * x) - 1) / 2; +} + +type RenderingContext = WebGLRenderingContext; + +class GLProgram implements Disposable { + private gl: RenderingContext; + program: WebGLProgram; + private vertexShader: WebGLShader; + private fragmentShader: WebGLShader; + readonly attrs: { [name: string]: number }; + constructor( + gl: RenderingContext, + vertexShaderSource: string, + fragmentShaderSource: string, + private readonly label = "unknown", + ) { + this.gl = gl; + this.vertexShader = this.createShader(gl.VERTEX_SHADER, vertexShaderSource); + this.fragmentShader = this.createShader( + gl.FRAGMENT_SHADER, + fragmentShaderSource, + ); + this.program = this.createProgram(); + + const num = gl.getProgramParameter(this.program, gl.ACTIVE_ATTRIBUTES); + const attrs: { [name: string]: number } = {}; + for (let i = 0; i < num; i++) { + const info = gl.getActiveAttrib(this.program, i); + if (!info) continue; + const location = gl.getAttribLocation(this.program, info.name); + if (location === -1) continue; + attrs[info.name] = location; + } + this.attrs = attrs; + } + private createShader(type: number, source: string) { + const gl = this.gl; + const shader = gl.createShader(type); + if (!shader) throw new Error("Failed to create shader"); + gl.shaderSource(shader, source); + gl.compileShader(shader); + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + throw new Error( + `Failed to compile shader for type ${type} "${ + this.label + }": ${gl.getShaderInfoLog(shader)}`, + ); + } + return shader; + } + private createProgram() { + const gl = this.gl; + const program = gl.createProgram(); + if (!program) throw new Error("Failed to create program"); + gl.attachShader(program, this.vertexShader); + gl.attachShader(program, this.fragmentShader); + gl.linkProgram(program); + gl.validateProgram(program); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + const errLog = gl.getProgramInfoLog(program); + gl.deleteProgram(program); + throw new Error(`Failed to link program "${this.label}": ${errLog}`); + } + return program; + } + use() { + const gl = this.gl; + gl.useProgram(this.program); + } + private notFoundUniforms: Set = new Set(); + private warnUniformNotFound(name: string) { + if (this.notFoundUniforms.has(name)) return; + this.notFoundUniforms.add(name); + console.warn( + `Failed to get uniform location for program "${this.label}": ${name}`, + ); + } + setUniform1f(name: string, value: number) { + const gl = this.gl; + const location = gl.getUniformLocation(this.program, name); + if (!location) this.warnUniformNotFound(name); + else gl.uniform1f(location, value); + } + setUniform2f(name: string, value1: number, value2: number) { + const gl = this.gl; + const location = gl.getUniformLocation(this.program, name); + if (!location) this.warnUniformNotFound(name); + else gl.uniform2f(location, value1, value2); + } + setUniform1i(name: string, value: number) { + const gl = this.gl; + const location = gl.getUniformLocation(this.program, name); + if (!location) this.warnUniformNotFound(name); + else gl.uniform1i(location, value); + } + dispose() { + const gl = this.gl; + gl.deleteShader(this.vertexShader); + gl.deleteShader(this.fragmentShader); + gl.deleteProgram(this.program); + } +} + +class Mesh implements Disposable { + protected vertexWidth = 0; + protected vertexHeight = 0; + private vertexBuffer: WebGLBuffer; + private indexBuffer: WebGLBuffer; + private vertexData: Float32Array; + private indexData: Uint16Array; + private vertexIndexLength = 0; + // 调试用途,开启线框模式 + private wireFrame = false; + constructor( + private readonly gl: RenderingContext, + private readonly attrPos: number | undefined, + private readonly attrColor: number | undefined, + private readonly attrUV: number | undefined, + ) { + const vertexBuf = gl.createBuffer(); + if (!vertexBuf) throw new Error("Failed to create vertex buffer"); + this.vertexBuffer = vertexBuf; + const indexBuf = gl.createBuffer(); + if (!indexBuf) throw new Error("Failed to create index buffer"); + this.indexBuffer = indexBuf; + + this.bind(); + + this.vertexData = new Float32Array(0); + this.indexData = new Uint16Array(0); + + this.resize(2, 2); + this.update(); + } + + setWireFrame(enable: boolean) { + this.wireFrame = enable; + this.resize(this.vertexWidth, this.vertexHeight); + } + + setVertexPos(vx: number, vy: number, x: number, y: number): void { + const idx = (vx + vy * this.vertexWidth) * 7; + if (idx >= this.vertexData.length - 1) { + console.warn("Vertex position out of range", idx, this.vertexData.length); + return; + } + this.vertexData[idx] = x; + this.vertexData[idx + 1] = y; + } + + setVertexColor( + vx: number, + vy: number, + r: number, + g: number, + b: number, + ): void { + const idx = (vx + vy * this.vertexWidth) * 7 + 2; + if (idx >= this.vertexData.length - 2) { + console.warn("Vertex color out of range", idx, this.vertexData.length); + return; + } + this.vertexData[idx] = r; + this.vertexData[idx + 1] = g; + this.vertexData[idx + 2] = b; + } + + setVertexUV(vx: number, vy: number, x: number, y: number): void { + const idx = (vx + vy * this.vertexWidth) * 7 + 5; + if (idx >= this.vertexData.length - 1) { + console.warn("Vertex UV out of range", idx, this.vertexData.length); + return; + } + this.vertexData[idx] = x; + this.vertexData[idx + 1] = y; + } + + // 批量设置顶点数据的优化方法 + setVertexData( + vx: number, + vy: number, + x: number, + y: number, + r: number, + g: number, + b: number, + u: number, + v: number, + ): void { + const idx = (vx + vy * this.vertexWidth) * 7; + if (idx >= this.vertexData.length - 6) { + console.warn("Vertex data out of range", idx, this.vertexData.length); + return; + } + const data = this.vertexData; + data[idx] = x; + data[idx + 1] = y; + data[idx + 2] = r; + data[idx + 3] = g; + data[idx + 4] = b; + data[idx + 5] = u; + data[idx + 6] = v; + } + + getVertexIndexLength(): number { + return this.vertexIndexLength; + } + + draw() { + const gl = this.gl; + + if (this.wireFrame) { + gl.drawElements(gl.LINES, this.vertexIndexLength, gl.UNSIGNED_SHORT, 0); + } else { + gl.drawElements( + gl.TRIANGLES, + this.vertexIndexLength, + gl.UNSIGNED_SHORT, + 0, + ); + } + } + + resize(vertexWidth: number, vertexHeight: number): void { + this.vertexWidth = vertexWidth; + this.vertexHeight = vertexHeight; + // 2 个顶点坐标 + 3 个颜色值 + 2 个 UV 坐标 + this.vertexIndexLength = vertexWidth * vertexHeight * 6; + if (this.wireFrame) { + this.vertexIndexLength = vertexWidth * vertexHeight * 10; + } + const vertexData = new Float32Array( + vertexWidth * vertexHeight * (2 + 3 + 2), + ); + const indexData = new Uint16Array(this.vertexIndexLength); + this.vertexData = vertexData; + this.indexData = indexData; + for (let y = 0; y < vertexHeight; y++) { + for (let x = 0; x < vertexWidth; x++) { + const px = (x / (vertexWidth - 1)) * 2 - 1; + const py = (y / (vertexHeight - 1)) * 2 - 1; + this.setVertexPos(x, y, px || 0, py || 0); + this.setVertexColor(x, y, 1, 1, 1); + this.setVertexUV(x, y, x / (vertexWidth - 1), y / (vertexHeight - 1)); + } + } + for (let y = 0; y < vertexHeight - 1; y++) { + for (let x = 0; x < vertexWidth - 1; x++) { + if (this.wireFrame) { + const idx = (y * vertexWidth + x) * 10; + + indexData[idx] = y * vertexWidth + x; + indexData[idx + 1] = y * vertexWidth + x + 1; + + indexData[idx + 2] = y * vertexWidth + x + 1; + indexData[idx + 3] = (y + 1) * vertexWidth + x; + + indexData[idx + 4] = (y + 1) * vertexWidth + x; + indexData[idx + 5] = (y + 1) * vertexWidth + x + 1; + + indexData[idx + 6] = (y + 1) * vertexWidth + x + 1; + indexData[idx + 7] = y * vertexWidth + x + 1; + + indexData[idx + 8] = y * vertexWidth + x; + indexData[idx + 9] = (y + 1) * vertexWidth + x; + } else { + const idx = (y * vertexWidth + x) * 6; + indexData[idx] = y * vertexWidth + x; + indexData[idx + 1] = y * vertexWidth + x + 1; + indexData[idx + 2] = (y + 1) * vertexWidth + x; + indexData[idx + 3] = y * vertexWidth + x + 1; + indexData[idx + 4] = (y + 1) * vertexWidth + x + 1; + indexData[idx + 5] = (y + 1) * vertexWidth + x; + } + } + } + const gl = this.gl; + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.indexBuffer); + gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, this.indexData, gl.STATIC_DRAW); + } + + bind() { + const gl = this.gl; + gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer); + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.indexBuffer); + + if (this.attrPos !== undefined) { + gl.vertexAttribPointer(this.attrPos, 2, gl.FLOAT, false, 4 * 7, 0); + gl.enableVertexAttribArray(this.attrPos); + } + if (this.attrColor !== undefined) { + gl.vertexAttribPointer(this.attrColor, 3, gl.FLOAT, false, 4 * 7, 4 * 2); + gl.enableVertexAttribArray(this.attrColor); + } + if (this.attrUV !== undefined) { + gl.vertexAttribPointer(this.attrUV, 2, gl.FLOAT, false, 4 * 7, 4 * 5); + gl.enableVertexAttribArray(this.attrUV); + } + } + + update() { + const gl = this.gl; + gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer); + gl.bufferData(gl.ARRAY_BUFFER, this.vertexData, gl.DYNAMIC_DRAW); + } + + dispose(): void { + this.gl.deleteBuffer(this.vertexBuffer); + this.gl.deleteBuffer(this.indexBuffer); + } +} + +class ControlPoint { + color: Vec3 = Vec3.fromValues(1, 1, 1); + location: Vec2 = Vec2.fromValues(0, 0); + uTangent: Vec2 = Vec2.fromValues(0, 0); + vTangent: Vec2 = Vec2.fromValues(0, 0); + private _uRot = 0; + private _vRot = 0; + private _uScale = 1; + private _vScale = 1; + + constructor() { + Object.seal(this); + } + + get uRot() { + return this._uRot; + } + + get vRot() { + return this._vRot; + } + + set uRot(value: number) { + this._uRot = value; + this.updateUTangent(); + } + + set vRot(value: number) { + this._vRot = value; + this.updateVTangent(); + } + + get uScale() { + return this._uScale; + } + + get vScale() { + return this._vScale; + } + + set uScale(value: number) { + this._uScale = value; + this.updateUTangent(); + } + + set vScale(value: number) { + this._vScale = value; + this.updateVTangent(); + } + + private updateUTangent() { + this.uTangent[0] = Math.cos(this._uRot) * this._uScale; + this.uTangent[1] = Math.sin(this._uRot) * this._uScale; + } + + private updateVTangent() { + this.vTangent[0] = -Math.sin(this._vRot) * this._vScale; + this.vTangent[1] = Math.cos(this._vRot) * this._vScale; + } +} + +const H = Mat4.fromValues(2, -2, 1, 1, -3, 3, -2, -1, 0, 0, 1, 0, 1, 0, 0, 0); +const H_T = Mat4.clone(H).transpose(); + +function meshCoefficients( + p00: ControlPoint, + p01: ControlPoint, + p10: ControlPoint, + p11: ControlPoint, + axis: "x" | "y", + output = Mat4.create(), +): Mat4 { + const l = (p: ControlPoint) => p.location[axis]; + const u = (p: ControlPoint) => p.uTangent[axis]; + const v = (p: ControlPoint) => p.vTangent[axis]; + + output[0] = l(p00); + output[1] = l(p01); + output[2] = v(p00); + output[3] = v(p01); + output[4] = l(p10); + output[5] = l(p11); + output[6] = v(p10); + output[7] = v(p11); + output[8] = u(p00); + output[9] = u(p01); + output[10] = 0; + output[11] = 0; + output[12] = u(p10); + output[13] = u(p11); + output[14] = 0; + output[15] = 0; + + return output; +} + +function colorCoefficients( + p00: ControlPoint, + p01: ControlPoint, + p10: ControlPoint, + p11: ControlPoint, + axis: "r" | "g" | "b", + output = Mat4.create(), +): Mat4 { + const c = (p: ControlPoint) => p.color[axis]; + output.fill(0); + output[0] = c(p00); + output[1] = c(p01); + output[4] = c(p10); + output[5] = c(p11); + // return Mat4.fromValues( + // c(p00), c(p01), 0, 0, + // c(p10), c(p11), 0, 0, + // 0, 0, 0, 0, + // 0, 0, 0, 0, + // ); + return output; +} + +class Map2D { + private _width = 0; + private _height = 0; + private _data: T[] = []; + constructor(width: number, height: number) { + this.resize(width, height); + Object.seal(this); + } + resize(width: number, height: number) { + this._width = width; + this._height = height; + this._data = new Array(width * height).fill(0); + } + set(x: number, y: number, value: T) { + this._data[x + y * this._width] = value; + } + get(x: number, y: number) { + return this._data[x + y * this._width]; + } + get width() { + return this._width; + } + get height() { + return this._height; + } +} + +// Bicubic Hermite Patch Mesh +class BHPMesh extends Mesh { + /** + * 细分级别,越大曲线越平滑,但是性能消耗也越大 + */ + private _subDivisions = 10; + private _controlPoints: Map2D = new Map2D(3, 3); + + constructor( + gl: RenderingContext, + attrPos: number, + attrColor: number, + attrUV: number, + ) { + super(gl, attrPos, attrColor, attrUV); + this.resizeControlPoints(3, 3); + Object.seal(this); + } + override setWireFrame(enable: boolean) { + super.setWireFrame(enable); + this.updateMesh(); + } + /** + * 以当前的控制点矩阵大小和细分级别为参考重新设置细分级别,此操作不会重设控制点数据 + * @param subDivisions 细分级别 + */ + resetSubdivition(subDivisions: number) { + this._subDivisions = subDivisions; + super.resize( + (this._controlPoints.width - 1) * subDivisions, + (this._controlPoints.height - 1) * subDivisions, + ); + } + /** + * 重设控制点矩阵尺寸,将会重置所有控制点的颜色和坐标数据 + * 请在调用此方法后重新设置颜色和坐标,并调用 updateMesh 方法更新网格 + * @param width 控制点宽度数量,必须大于等于 2 + * @param height 控制点高度数量,必须大于等于 2 + */ + resizeControlPoints(width: number, height: number) { + if (!(width >= 2 && height >= 2)) { + throw new Error("Control points must be larger than 3x3 or equal"); + } + this._controlPoints.resize(width, height); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const point = new ControlPoint(); + point.location.x = (x / (width - 1)) * 2 - 1; + point.location.y = (y / (height - 1)) * 2 - 1; + point.uTangent.x = 2 / (width - 1); + point.vTangent.y = 2 / (height - 1); + this._controlPoints.set(x, y, point); + } + } + this.resetSubdivition(this._subDivisions); + } + /** + * 获取指定位置的控制点,然后可以设置颜色和坐标属性 + * 留意颜色属性和坐标属性的值范围均参考 WebGL 的定义 + * 即颜色各个组件取值 [0-1],坐标取值 [-1, 1] + * 点的位置以画面左下角为原点 (0,0) + * @param x 需要获取的控制点的 x 坐标 + * @param y 需要获取的控制点的 y 坐标 + * @returns 控制点对象 + */ + getControlPoint(x: number, y: number) { + return this._controlPoints.get(x, y); + } + // 预分配重复使用的矩阵,避免频繁创建 + private tempX = Mat4.create(); + private tempY = Mat4.create(); + private tempR = Mat4.create(); + private tempG = Mat4.create(); + private tempB = Mat4.create(); + + private tempXAcc = Mat4.create(); + private tempYAcc = Mat4.create(); + private tempRAcc = Mat4.create(); + private tempGAcc = Mat4.create(); + private tempBAcc = Mat4.create(); + + private tempUx = Vec4.create(); + private tempUy = Vec4.create(); + private tempUr = Vec4.create(); + private tempUg = Vec4.create(); + private tempUb = Vec4.create(); + + private precomputeMatrix(M: Mat4, output: Mat4) { + output.copy(M).transpose(); + Mat4.mul(output, output, H); + Mat4.mul(output, H_T, output); + return output; + } + + /** + * 更新最终呈现的网格数据,此方法应在所有控制点或细分参数的操作完成后调用 + */ + updateMesh() { + const subDivM1 = this._subDivisions - 1; + const tW = subDivM1 * (this._controlPoints.height - 1); + const tH = subDivM1 * (this._controlPoints.width - 1); + const controlPointsWidth = this._controlPoints.width; + const controlPointsHeight = this._controlPoints.height; + const subDivisions = this._subDivisions; + + // 预计算常用值 + const invSubDivM1 = 1 / subDivM1; + const invTH = 1 / tH; + const invTW = 1 / tW; + + // 预计算 u 和 v 的幂次 + const normPowers = new Float32Array(subDivisions * 4); + for (let i = 0; i < subDivisions; i++) { + const norm = i * invSubDivM1; + const idx = i * 4; + normPowers[idx] = norm ** 3; + normPowers[idx + 1] = norm ** 2; + normPowers[idx + 2] = norm; + normPowers[idx + 3] = 1; + } + + for (let x = 0; x < controlPointsWidth - 1; x++) { + for (let y = 0; y < controlPointsHeight - 1; y++) { + const p00 = this._controlPoints.get(x, y); + const p01 = this._controlPoints.get(x, y + 1); + const p10 = this._controlPoints.get(x + 1, y); + const p11 = this._controlPoints.get(x + 1, y + 1); + + // 复用预分配的矩阵 + meshCoefficients(p00, p01, p10, p11, "x", this.tempX); + meshCoefficients(p00, p01, p10, p11, "y", this.tempY); + colorCoefficients(p00, p01, p10, p11, "r", this.tempR); + colorCoefficients(p00, p01, p10, p11, "g", this.tempG); + colorCoefficients(p00, p01, p10, p11, "b", this.tempB); + + // 预计算累加矩阵 + this.precomputeMatrix(this.tempX, this.tempXAcc); + this.precomputeMatrix(this.tempY, this.tempYAcc); + this.precomputeMatrix(this.tempR, this.tempRAcc); + this.precomputeMatrix(this.tempG, this.tempGAcc); + this.precomputeMatrix(this.tempB, this.tempBAcc); + + const sX = x / (controlPointsWidth - 1); + const sY = y / (controlPointsHeight - 1); + const baseVx = y * subDivisions; + const baseVy = x * subDivisions; + + for (let u = 0; u < subDivisions; u++) { + const vxOffset = baseVx + u; + const uIdx = u * 4; + + this.tempUx[0] = normPowers[uIdx]; + this.tempUx[1] = normPowers[uIdx + 1]; + this.tempUx[2] = normPowers[uIdx + 2]; + this.tempUx[3] = normPowers[uIdx + 3]; + Vec4.transformMat4(this.tempUx, this.tempUx, this.tempXAcc); + + this.tempUy[0] = normPowers[uIdx]; + this.tempUy[1] = normPowers[uIdx + 1]; + this.tempUy[2] = normPowers[uIdx + 2]; + this.tempUy[3] = normPowers[uIdx + 3]; + Vec4.transformMat4(this.tempUy, this.tempUy, this.tempYAcc); + + this.tempUr[0] = normPowers[uIdx]; + this.tempUr[1] = normPowers[uIdx + 1]; + this.tempUr[2] = normPowers[uIdx + 2]; + this.tempUr[3] = normPowers[uIdx + 3]; + Vec4.transformMat4(this.tempUr, this.tempUr, this.tempRAcc); + + this.tempUg[0] = normPowers[uIdx]; + this.tempUg[1] = normPowers[uIdx + 1]; + this.tempUg[2] = normPowers[uIdx + 2]; + this.tempUg[3] = normPowers[uIdx + 3]; + Vec4.transformMat4(this.tempUg, this.tempUg, this.tempGAcc); + + this.tempUb[0] = normPowers[uIdx]; + this.tempUb[1] = normPowers[uIdx + 1]; + this.tempUb[2] = normPowers[uIdx + 2]; + this.tempUb[3] = normPowers[uIdx + 3]; + Vec4.transformMat4(this.tempUb, this.tempUb, this.tempBAcc); + + for (let v = 0; v < subDivisions; v++) { + const vy = baseVy + v; + const vIdx = v * 4; + + const v0 = normPowers[vIdx]; + const v1 = normPowers[vIdx + 1]; + const v2 = normPowers[vIdx + 2]; + const v3 = normPowers[vIdx + 3]; + + const px = + v0 * this.tempUx[0] + + v1 * this.tempUx[1] + + v2 * this.tempUx[2] + + v3 * this.tempUx[3]; + const py = + v0 * this.tempUy[0] + + v1 * this.tempUy[1] + + v2 * this.tempUy[2] + + v3 * this.tempUy[3]; + const pr = + v0 * this.tempUr[0] + + v1 * this.tempUr[1] + + v2 * this.tempUr[2] + + v3 * this.tempUr[3]; + const pg = + v0 * this.tempUg[0] + + v1 * this.tempUg[1] + + v2 * this.tempUg[2] + + v3 * this.tempUg[3]; + const pb = + v0 * this.tempUb[0] + + v1 * this.tempUb[1] + + v2 * this.tempUb[2] + + v3 * this.tempUb[3]; + + const uvX = sX + v * invTH; + const uvY = 1 - sY - u * invTW; + + // 使用批量设置方法减少数组访问次数 + this.setVertexData(vxOffset, vy, px, py, pr, pg, pb, uvX, uvY); + } + } + } + } + this.update(); + } +} + +class GLTexture implements Disposable { + readonly tex: WebGLTexture; + + constructor( + private gl: WebGLRenderingContext, + albumImageData: ImageData, + ) { + const albumTexture = gl.createTexture(); + if (!albumTexture) throw new Error("Failed to create texture"); + this.tex = albumTexture; + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, albumTexture); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGBA, + gl.RGBA, + gl.UNSIGNED_BYTE, + albumImageData, + ); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.MIRRORED_REPEAT); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.MIRRORED_REPEAT); + } + + bind() { + this.gl.bindTexture(this.gl.TEXTURE_2D, this.tex); + } + + dispose(): void { + this.gl.deleteTexture(this.tex); + } +} + +function createOffscreenCanvas(width: number, height: number) { + if ("OffscreenCanvas" in window) return new OffscreenCanvas(width, height); + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + return canvas; +} + +interface MeshState { + mesh: BHPMesh; + texture: GLTexture; + alpha: number; +} + +export class MeshGradientRenderer extends BaseRenderer { + private gl: RenderingContext; + private lastFrameTime = 0; + private frameTime = 0; + // private currentImageData?: ImageData; + private lastTickTime = 0; + private smoothedVolume = 0; + private volume = 0; + private tickHandle = 0; + private maxFPS = 60; + private paused = false; + private staticMode = false; + private mainProgram: GLProgram; + private quadProgram: GLProgram; + private quadBuffer: WebGLBuffer; + private fbo: WebGLFramebuffer | null = null; + private fboTexture: WebGLTexture | null = null; + private manualControl = false; + private reduceImageSizeCanvas = createOffscreenCanvas( + 32, + 32, + ) as HTMLCanvasElement; + private targetSize = Vec2.fromValues(0, 0); + private currentSize = Vec2.fromValues(0, 0); + private isNoCover = true; + private meshStates: MeshState[] = []; + private _disposed = false; + // 性能监控 + private frameCount = 0; + private lastFPSUpdate = 0; + private currentFPS = 0; + private enablePerformanceMonitoring = false; + + setManualControl(enable: boolean): void { + this.manualControl = enable; + } + + setWireFrame(enable: boolean): void { + for (const state of this.meshStates) { + state.mesh.setWireFrame(enable); + } + } + + getControlPoint(x: number, y: number): ControlPoint | undefined { + return this.meshStates[this.meshStates.length - 1]?.mesh?.getControlPoint( + x, + y, + ); + } + + resizeControlPoints(width: number, height: number): void { + this.meshStates[this.meshStates.length - 1]?.mesh?.resizeControlPoints( + width, + height, + ); + } + + resetSubdivition(subDivisions: number): void { + this.meshStates[this.meshStates.length - 1]?.mesh?.resetSubdivition( + subDivisions, + ); + } + + private onTick(tickTime: number) { + this.tickHandle = 0; + if (this.paused) return; + if (this._disposed) return; + + // 更新性能统计 + this.updatePerformanceStats(tickTime); + + const interval = 1000 / this.maxFPS; + const delta = tickTime - this.lastTickTime; + if (delta < interval) { + this.requestTick(); + return; + } + + if (Number.isNaN(this.lastFrameTime)) { + this.lastFrameTime = tickTime; + } + const frameDelta = tickTime - this.lastFrameTime; + this.lastFrameTime = tickTime; + // 减去多余的时间,避免帧率漂移(例如高刷显示器限制低帧率时) + this.lastTickTime = tickTime - (delta % interval); + + this.frameTime += frameDelta * this.flowSpeed; + + if (!(this.onRedraw(this.frameTime, frameDelta) && this.staticMode)) { + this.requestTick(); + } else if (this.staticMode) { + this.lastFrameTime = Number.NaN; + } + } + + private checkIfResize() { + const [tW, tH] = [this.targetSize.x, this.targetSize.y]; + const [cW, cH] = [this.currentSize.x, this.currentSize.y]; + if (tW !== cW || tH !== cH) { + super.onResize(tW, tH); + const gl = this.gl; + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.viewport(0, 0, tW, tH); + this.currentSize.x = tW; + this.currentSize.y = tH; + if (tW > 0 && tH > 0) { + this.updateFBO(tW, tH); + } + } + } + + private updateFBO(width: number, height: number) { + const gl = this.gl; + if (this.fbo) gl.deleteFramebuffer(this.fbo); + if (this.fboTexture) gl.deleteTexture(this.fboTexture); + + this.fboTexture = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, this.fboTexture); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGBA, + width, + height, + 0, + gl.RGBA, + gl.UNSIGNED_BYTE, + null, + ); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + + this.fbo = gl.createFramebuffer(); + gl.bindFramebuffer(gl.FRAMEBUFFER, this.fbo); + gl.framebufferTexture2D( + gl.FRAMEBUFFER, + gl.COLOR_ATTACHMENT0, + gl.TEXTURE_2D, + this.fboTexture, + 0, + ); + + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + } + + private onRedraw(tickTime: number, delta: number) { + const latestMeshState = this.meshStates[this.meshStates.length - 1]; + let canBeStatic = false; + + // 预计算常用值 + const deltaFactor = delta / 500; + + if (latestMeshState) { + latestMeshState.mesh.bind(); + // 考虑到我们并不逐帧更新网格控制点,因此也不需要重复调用 updateMesh + if (this.manualControl) latestMeshState.mesh.updateMesh(); + + if (this.isNoCover) { + // 批量处理alpha更新,减少循环开销 + let hasActiveStates = false; + for (let i = this.meshStates.length - 1; i >= 0; i--) { + const state = this.meshStates[i]; + // 增加一个小的容错范围,避免浮点误差导致的过早删除 + if (state.alpha <= -0.1) { + // 立即释放资源 + state.mesh.dispose(); + state.texture.dispose(); + this.meshStates.splice(i, 1); + } else { + state.alpha = Math.max(-0.1, state.alpha - deltaFactor); + hasActiveStates = true; + } + } + canBeStatic = !hasActiveStates; + } else { + // 同样增加容错范围,允许稍微超过1以确保完全过渡完成 + if (latestMeshState.alpha >= 1.1) { + // 批量清理旧状态 + const deleted = this.meshStates.splice(0, this.meshStates.length - 1); + for (const state of deleted) { + state.mesh.dispose(); + state.texture.dispose(); + } + } else { + latestMeshState.alpha = Math.min( + 1.1, + latestMeshState.alpha + deltaFactor, + ); + } + canBeStatic = + this.meshStates.length === 1 && latestMeshState.alpha >= 1.1; + } + } + + const gl = this.gl; + this.checkIfResize(); + + if (!this.fbo) return canBeStatic; + + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT); + + const lerpFactor = Math.min(1.0, delta / 100.0); + this.smoothedVolume += (this.volume - this.smoothedVolume) * lerpFactor; + + // 渲染所有网格状态 + for (const state of this.meshStates) { + // 1. 渲染到 FBO + gl.bindFramebuffer(gl.FRAMEBUFFER, this.fbo); + gl.disable(gl.BLEND); + gl.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT); + + this.mainProgram.use(); + gl.activeTexture(gl.TEXTURE0); + this.mainProgram.setUniform1f("u_time", tickTime / 10000); + this.mainProgram.setUniform1f( + "u_aspect", + this.manualControl ? 1 : this.canvas.width / this.canvas.height, + ); + this.mainProgram.setUniform1i("u_texture", 0); + this.mainProgram.setUniform1f("u_volume", this.volume); + this.mainProgram.setUniform1f("u_alpha", 1.0); + + state.texture.bind(); + state.mesh.bind(); + state.mesh.draw(); + + // 2. 渲染 FBO 到屏幕 + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.enable(gl.BLEND); + gl.blendFuncSeparate( + gl.SRC_ALPHA, + gl.ONE_MINUS_SRC_ALPHA, + gl.ONE, + gl.ONE_MINUS_SRC_ALPHA, + ); + this.quadProgram.use(); + this.quadProgram.setUniform1i("u_texture", 0); + this.quadProgram.setUniform1f( + "u_alpha", + easeInOutSine(clamp01(state.alpha)), + ); + + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.fboTexture); + + gl.bindBuffer(gl.ARRAY_BUFFER, this.quadBuffer); + const a_pos = this.quadProgram.attrs.a_pos; + gl.vertexAttribPointer(a_pos, 2, gl.FLOAT, false, 0, 0); + gl.enableVertexAttribArray(a_pos); + + gl.drawArrays(gl.TRIANGLES, 0, 6); + gl.disableVertexAttribArray(a_pos); + } + + gl.flush(); + + return canBeStatic; + } + + private onTickBinded = this.onTick.bind(this); + + private requestTick() { + if (this._disposed) return; + if (this.tickHandle === 0) + this.tickHandle = requestAnimationFrame(this.onTickBinded); + } + + // private supportTextureFloat = true; + + constructor(canvas: HTMLCanvasElement) { + super(canvas); + + const gl = canvas.getContext("webgl", { antialias: true }); + if (!gl) throw new Error("WebGL not supported"); + if (!gl.getExtension("EXT_color_buffer_float")) + console.warn("EXT_color_buffer_float not supported"); + if (!gl.getExtension("EXT_float_blend")) { + console.warn("EXT_float_blend not supported"); + // this.supportTextureFloat = false; + } + if (!gl.getExtension("OES_texture_float_linear")) + console.warn("OES_texture_float_linear not supported"); + if (!gl.getExtension("OES_texture_float")) { + // this.supportTextureFloat = false; + console.warn("OES_texture_float not supported"); + } + + this.gl = gl; + gl.enable(gl.BLEND); + gl.blendFuncSeparate( + gl.SRC_ALPHA, + gl.ONE_MINUS_SRC_ALPHA, + gl.ONE, + gl.ONE_MINUS_SRC_ALPHA, + ); + gl.enable(gl.DEPTH_TEST); + gl.depthFunc(gl.ALWAYS); + + this.mainProgram = new GLProgram( + gl, + meshVertShader, + meshFragShader, + "main-program-mg", + ); + + this.quadProgram = new GLProgram( + gl, + quadVertShader, + quadFragShader, + "quad-program", + ); + const quadBuffer = gl.createBuffer(); + if (!quadBuffer) throw new Error("Failed to create quad buffer"); + this.quadBuffer = quadBuffer; + gl.bindBuffer(gl.ARRAY_BUFFER, this.quadBuffer); + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), + gl.STATIC_DRAW, + ); + + this.requestTick(); + } + + protected override onResize(width: number, height: number): void { + this.targetSize.x = Math.ceil(width); + this.targetSize.y = Math.ceil(height); + this.requestTick(); + } + + override setStaticMode(enable: boolean): void { + this.staticMode = enable; + this.lastFrameTime = performance.now(); + this.requestTick(); + } + override setFPS(fps: number): void { + this.maxFPS = fps; + } + override pause(): void { + if (this.tickHandle) { + cancelAnimationFrame(this.tickHandle); + this.tickHandle = 0; + } + this.paused = true; + } + override resume(): void { + this.paused = false; + this.requestTick(); + } + override async setAlbum( + albumSource?: string | HTMLImageElement | HTMLVideoElement, + isVideo?: boolean, + ): Promise { + if ( + albumSource === undefined || + (typeof albumSource === "string" && albumSource.trim().length === 0) + ) { + this.isNoCover = true; + return; + } + let res: HTMLImageElement | HTMLVideoElement | null = null; + let blob: Blob | null = null; + let remainRetryTimes = 5; + while (!res && remainRetryTimes > 0) { + try { + if (typeof albumSource === "string") { + if (!isVideo && "createImageBitmap" in window) { + // 如果支持 createImageBitmap 且是图片,直接 fetch blob + const response = await fetch(albumSource); + blob = await response.blob(); + // 仍然需要一个 HTMLImageElement 来获取原始宽高(如果后续需要) + // 但这里我们主要依赖 blob 来创建 bitmap + res = await loadResourceFromUrl(URL.createObjectURL(blob), false); + } else { + res = await loadResourceFromUrl(albumSource, isVideo); + } + } else { + res = await loadResourceFromElement(albumSource); + } + } catch (error) { + console.warn( + `failed on loading album resource, retrying (${remainRetryTimes})`, + { + albumSource, + error, + }, + ); + remainRetryTimes--; + } + } + if (!res) { + console.error("Failed to load album resource", albumSource); + this.isNoCover = true; + return; + } + this.isNoCover = false; + // resize image + const c = this.reduceImageSizeCanvas; + const ctx = c.getContext("2d", { + willReadFrequently: true, + }); + if (!ctx) throw new Error("Failed to create canvas context"); + ctx.clearRect(0, 0, c.width, c.height); + // Safari 不支持 filter + // ctx.filter = baseFilter; + const imgw = + res instanceof HTMLVideoElement ? res.videoWidth : res.naturalWidth; + const imgh = + res instanceof HTMLVideoElement ? res.videoHeight : res.naturalHeight; + if (imgw * imgh === 0) throw new Error("Invalid image size"); + + let bitmap: ImageBitmap | null = null; + try { + if ("createImageBitmap" in window) { + // 避免在主线程进行同步解码,使用 fetch 获取 blob 后再创建 ImageBitmap + if (blob) { + bitmap = await createImageBitmap(blob, { + resizeWidth: c.width, + resizeHeight: c.height, + resizeQuality: "low", + }); + URL.revokeObjectURL(res.src); // 释放 object URL + } else { + bitmap = await createImageBitmap(res, { + resizeWidth: c.width, + resizeHeight: c.height, + resizeQuality: "low", + }); + } + } + } catch (e) { + console.warn("createImageBitmap failed", e); + } + + if (bitmap) { + ctx.drawImage(bitmap, 0, 0); + bitmap.close(); + } else { + ctx.drawImage(res, 0, 0, imgw, imgh, 0, 0, c.width, c.height); + } + + const imageData = ctx.getImageData(0, 0, c.width, c.height); + + // 合并对比度、饱和度、亮度的处理,减少循环次数 + const pixels = imageData.data; + for (let i = 0; i < pixels.length; i += 4) { + let r = pixels[i]; + let g = pixels[i + 1]; + let b = pixels[i + 2]; + + // contrast 0.4 + r = (r - 128) * 0.4 + 128; + g = (g - 128) * 0.4 + 128; + b = (b - 128) * 0.4 + 128; + + // saturate 3.0 + const gray = r * 0.3 + g * 0.59 + b * 0.11; + r = gray * -2.0 + r * 3.0; + g = gray * -2.0 + g * 3.0; + b = gray * -2.0 + b * 3.0; + + // contrast 1.7 + r = (r - 128) * 1.7 + 128; + g = (g - 128) * 1.7 + 128; + b = (b - 128) * 1.7 + 128; + + // brightness 0.75 + pixels[i] = r * 0.75; + pixels[i + 1] = g * 0.75; + pixels[i + 2] = b * 0.75; + } + + blurImage(imageData, 2, 4); + + if (this.manualControl && this.meshStates.length > 0) { + this.meshStates[0].texture.dispose(); + this.meshStates[0].texture = new GLTexture(this.gl, imageData); + } else { + const newMesh = new BHPMesh( + this.gl, + this.mainProgram.attrs.a_pos, + this.mainProgram.attrs.a_color, + this.mainProgram.attrs.a_uv, + ); + newMesh.resetSubdivition(50); + + const chosenPreset = + Math.random() > 0.8 + ? generateControlPoints(6, 6) + : CONTROL_POINT_PRESETS[ + Math.floor(Math.random() * CONTROL_POINT_PRESETS.length) + ]; + + newMesh.resizeControlPoints(chosenPreset.width, chosenPreset.height); + const uPower = 2 / (chosenPreset.width - 1); + const vPower = 2 / (chosenPreset.height - 1); + for (const cp of chosenPreset.conf) { + const p = newMesh.getControlPoint(cp.cx, cp.cy); + p.location.x = cp.x; + p.location.y = cp.y; + p.uRot = (cp.ur * Math.PI) / 180; + p.vRot = (cp.vr * Math.PI) / 180; + p.uScale = uPower * cp.up; + p.vScale = vPower * cp.vp; + } + + newMesh.updateMesh(); + // this.currentImageData = imageData; + + const albumTexture = new GLTexture(this.gl, imageData); + const newState: MeshState = { + mesh: newMesh, + texture: albumTexture, + alpha: 0, + }; + this.meshStates.push(newState); + } + + this.requestTick(); + } + override setLowFreqVolume(volume: number): void { + this.volume = volume / 10; + } + override setHasLyric(_hasLyric: boolean): void { + // 不再考虑实现 + } + + override dispose(): void { + super.dispose(); + if (this.tickHandle) { + cancelAnimationFrame(this.tickHandle); + this.tickHandle = 0; + } + this._disposed = true; + this.mainProgram.dispose(); + this.quadProgram.dispose(); + this.gl.deleteBuffer(this.quadBuffer); + if (this.fbo) this.gl.deleteFramebuffer(this.fbo); + if (this.fboTexture) this.gl.deleteTexture(this.fboTexture); + for (const state of this.meshStates) { + state.mesh.dispose(); + state.texture.dispose(); + } + } + + enablePerformanceMonitor(enable: boolean): void { + this.enablePerformanceMonitoring = enable; + if (enable) { + this.frameCount = 0; + this.lastFPSUpdate = performance.now(); + } + } + + getCurrentFPS(): number { + return this.currentFPS; + } + + private updatePerformanceStats(tickTime: number) { + if (!this.enablePerformanceMonitoring) return; + + this.frameCount++; + if (tickTime - this.lastFPSUpdate > 1000) { + this.currentFPS = this.frameCount; + this.frameCount = 0; + this.lastFPSUpdate = tickTime; + } + } +} diff --git a/amll-local/packages/core/src/bg-render/mesh-renderer/mesh.frag.glsl b/amll-local/packages/core/src/bg-render/mesh-renderer/mesh.frag.glsl new file mode 100644 index 0000000..45580d8 --- /dev/null +++ b/amll-local/packages/core/src/bg-render/mesh-renderer/mesh.frag.glsl @@ -0,0 +1,53 @@ +precision highp float; + +varying vec3 v_color; +varying vec2 v_uv; +uniform sampler2D u_texture; +uniform float u_time; +uniform float u_volume; +uniform float u_alpha; + +// 预计算常量 +const float INV_255 = 1.0 / 255.0; +const float HALF_INV_255 = 0.5 / 255.0; +const float GRADIENT_NOISE_A = 52.9829189; +const vec2 GRADIENT_NOISE_B = vec2(0.06711056, 0.00583715); + +/* Gradient noise from Jorge Jimenez's presentation: */ +/* http://www.iryoku.com/next-generation-post-processing-in-call-of-duty-advanced-warfare */ +float gradientNoise(in vec2 uv) { + return fract(GRADIENT_NOISE_A * fract(dot(uv, GRADIENT_NOISE_B))); +} + +// 优化的旋转函数,避免重复计算sin/cos +vec2 rot(vec2 v, float angle) { + float s = sin(angle); + float c = cos(angle); + return vec2(c * v.x - s * v.y, s * v.x + c * v.y); +} + +void main() { + // 合并计算以减少指令数 + float volumeEffect = u_volume * 2.0; + float timeVolume = u_time + u_volume; + + float dither = INV_255 * gradientNoise(gl_FragCoord.xy) - HALF_INV_255; + vec2 centeredUV = v_uv - vec2(0.2); + vec2 rotatedUV = rot(centeredUV, timeVolume * 2.0); + vec2 finalUV = rotatedUV * max(0.001, 1.0 - volumeEffect) + vec2(0.5); + + vec4 result = texture2D(u_texture, finalUV); + + float alphaVolumeFactor = u_alpha * max(0.5, 1.0 - u_volume * 0.5); + result.rgb *= v_color * alphaVolumeFactor; + result.a *= alphaVolumeFactor; + + result.rgb += vec3(dither); + + float dist = distance(v_uv, vec2(0.5)); + float vignette = smoothstep(0.8, 0.3, dist); + float mask = 0.6 + vignette * 0.4; + result.rgb *= mask; + + gl_FragColor = result; +} diff --git a/amll-local/packages/core/src/bg-render/mesh-renderer/mesh.vert.glsl b/amll-local/packages/core/src/bg-render/mesh-renderer/mesh.vert.glsl new file mode 100644 index 0000000..104369b --- /dev/null +++ b/amll-local/packages/core/src/bg-render/mesh-renderer/mesh.vert.glsl @@ -0,0 +1,21 @@ +precision highp float; + +attribute vec2 a_pos; +attribute vec3 a_color; +attribute vec2 a_uv; +varying vec3 v_color; +varying vec2 v_uv; + +uniform float u_aspect; + +void main() { + v_color = a_color; + v_uv = a_uv; + vec2 pos = a_pos; + if (u_aspect > 1.0) { + pos.y *= u_aspect; + } else { + pos.x /= u_aspect; + } + gl_Position = vec4(pos, 0.0, 1.0); +} diff --git a/amll-local/packages/core/src/bg-render/pixi-renderer.ts b/amll-local/packages/core/src/bg-render/pixi-renderer.ts new file mode 100644 index 0000000..d707c6d --- /dev/null +++ b/amll-local/packages/core/src/bg-render/pixi-renderer.ts @@ -0,0 +1,264 @@ +import { Application } from "@pixi/app"; +import { Texture } from "@pixi/core"; +import { Container } from "@pixi/display"; +import { BlurFilter } from "@pixi/filter-blur"; +import { BulgePinchFilter } from "@pixi/filter-bulge-pinch"; +import { ColorMatrixFilter } from "@pixi/filter-color-matrix"; +import { Sprite } from "@pixi/sprite"; +import { + loadResourceFromElement, + loadResourceFromUrl, +} from "../utils/resource"; +import { BaseRenderer } from "./base"; +import { clampPositive } from "#utils/clamp.ts"; + +class TimedContainer extends Container { + public time = 0; +} + +export class PixiRenderer extends BaseRenderer { + private app: Application; + private curContainer?: TimedContainer; + private staticMode = false; + private lastContainer: Set = new Set(); + private onTick = (delta: number): void => { + for (const lastContainer of this.lastContainer) { + lastContainer.alpha = clampPositive(lastContainer.alpha - delta / 60); + if (lastContainer.alpha <= 0) { + this.app.stage.removeChild(lastContainer); + this.lastContainer.delete(lastContainer); + lastContainer.destroy(true); + } + } + + if (this.curContainer) { + this.curContainer.alpha = Math.min( + 1, + this.curContainer.alpha + delta / 60, + ); + const [s1, s2, s3, s4] = this.curContainer.children as Sprite[]; + const maxSize = Math.max(this.app.screen.width, this.app.screen.height); + s1.position.set(this.app.screen.width / 2, this.app.screen.height / 2); + s2.position.set( + this.app.screen.width / 2.5, + this.app.screen.height / 2.5, + ); + s3.position.set(this.app.screen.width / 2, this.app.screen.height / 2); + s4.position.set(this.app.screen.width / 2, this.app.screen.height / 2); + s1.width = maxSize * Math.sqrt(2); + s1.height = s1.width; + s2.width = maxSize * 0.8; + s2.height = s2.width; + s3.width = maxSize * 0.5; + s3.height = s3.width; + s4.width = maxSize * 0.25; + s4.height = s4.width; + + this.curContainer.time += delta * this.flowSpeed; + + s1.rotation += (delta / 1000) * this.flowSpeed; + s2.rotation -= (delta / 500) * this.flowSpeed; + s3.rotation += (delta / 1000) * this.flowSpeed; + s4.rotation -= (delta / 750) * this.flowSpeed; + + s3.x = + this.app.screen.width / 2 + + (this.app.screen.width / 4) * + Math.cos((this.curContainer.time / 1000) * 0.75); + s3.y = + this.app.screen.height / 2 + + (this.app.screen.width / 4) * + Math.cos((this.curContainer.time / 1000) * 0.75); + + s4.x = + this.app.screen.width / 2 + + (this.app.screen.width / 4) * 0.1 + + Math.cos(this.curContainer.time * 0.006 * 0.75); + s4.y = + this.app.screen.height / 2 + + (this.app.screen.width / 4) * 0.1 + + Math.cos(this.curContainer.time * 0.006 * 0.75); + + if ( + this.curContainer.alpha >= 1 && + this.lastContainer.size === 0 && + this.staticMode + ) { + this.app.ticker.stop(); + } + } + }; + constructor(protected override canvas: HTMLCanvasElement) { + super(canvas); + this.app = new Application({ + view: canvas, + resizeTo: this.canvas, + powerPreference: "low-power", + backgroundAlpha: 1, + }); + this.rebuildFilters(); + this.app.ticker.maxFPS = 30; + this.app.ticker.add(this.onTick); + this.app.ticker.start(); + } + + protected override onResize(width: number, height: number): void { + super.onResize(width, height); + this.app.resize(); + this.rebuildFilters(); + } + + override setRenderScale(scale: number): void { + super.setRenderScale(scale); + this.rebuildFilters(); + } + private rebuildFilters() { + const minBorder = Math.min(this.canvas.width, this.canvas.height); + const maxBorder = Math.max(this.canvas.width, this.canvas.height); + const c0 = new ColorMatrixFilter(); + c0.saturate(1.2, false); + const c1 = new ColorMatrixFilter(); + c1.brightness(0.6, false); + const c2 = new ColorMatrixFilter(); + c2.contrast(0.3, true); + for (const filter of this.app.stage.filters ?? []) { + filter.destroy(); + } + this.app.stage.filters = []; + this.app.stage.filters.push(new BlurFilter(5, 1)); + this.app.stage.filters.push(new BlurFilter(10, 1)); + this.app.stage.filters.push(new BlurFilter(20, 2)); + this.app.stage.filters.push(new BlurFilter(40, 2)); + this.app.stage.filters.push(new BlurFilter(80, 2)); + if (minBorder > 768) this.app.stage.filters.push(new BlurFilter(160, 4)); + if (minBorder > 768 * 2) + this.app.stage.filters.push(new BlurFilter(320, 4)); + + this.app.stage.filters.push(c0, c1, c2); + this.app.stage.filters.push(new BlurFilter(5, 1)); + if (Math.random() > 0.5) { + this.app.stage.filters.push( + new BulgePinchFilter({ + radius: (maxBorder + minBorder) / 2, + strength: 1, + center: [0.25, 1], + }), + ); + this.app.stage.filters.push( + new BulgePinchFilter({ + radius: (maxBorder + minBorder) / 2, + strength: 1, + center: [0.75, 0], + }), + ); + } else { + this.app.stage.filters.push( + new BulgePinchFilter({ + radius: (maxBorder + minBorder) / 2, + strength: 1, + center: [0.75, 1], + }), + ); + this.app.stage.filters.push( + new BulgePinchFilter({ + radius: (maxBorder + minBorder) / 2, + strength: 1, + center: [0.25, 0], + }), + ); + } + } + + override setStaticMode(enable = false): void { + this.staticMode = enable; + this.app.ticker.start(); + } + + override setFPS(fps: number): void { + this.app.ticker.maxFPS = fps; + } + + override pause(): void { + this.app.ticker.stop(); + this.app.render(); + } + + override resume(): void { + this.app.ticker.start(); + } + + override setLowFreqVolume(_volume: number): void { + // NOOP + } + + override setHasLyric(_hasLyric: boolean): void { + // NOOP + } + + override async setAlbum( + albumSource?: string | HTMLImageElement | HTMLVideoElement, + isVideo?: boolean, + ): Promise { + if ( + !albumSource || + (typeof albumSource === "string" && albumSource.trim().length === 0) + ) + return; + let res: HTMLImageElement | HTMLVideoElement | null = null; + let remainRetryTimes = 5; + let tex: Texture | null = null; + while (!tex?.baseTexture?.resource?.valid && remainRetryTimes > 0) { + try { + if (typeof albumSource === "string") { + res = await loadResourceFromUrl(albumSource, isVideo); + } else { + res = await loadResourceFromElement(albumSource); + } + tex = Texture.from(res, { + resourceOptions: { + autoLoad: false, + }, + }); + await tex.baseTexture.resource.load(); + } catch (error) { + console.warn( + `failed on loading album image, retrying (${remainRetryTimes})`, + albumSource, + error, + ); + tex = null; + remainRetryTimes--; + } + } + if (!tex) return; + const container = new TimedContainer(); + const s1 = new Sprite(tex); + const s2 = new Sprite(tex); + const s3 = new Sprite(tex); + const s4 = new Sprite(tex); + s1.anchor.set(0.5, 0.5); + s2.anchor.set(0.5, 0.5); + s3.anchor.set(0.5, 0.5); + s4.anchor.set(0.5, 0.5); + s1.rotation = Math.random() * Math.PI * 2; + s2.rotation = Math.random() * Math.PI * 2; + s3.rotation = Math.random() * Math.PI * 2; + s4.rotation = Math.random() * Math.PI * 2; + container.addChild(s1, s2, s3, s4); + if (this.curContainer) this.lastContainer.add(this.curContainer); + this.curContainer = container; + this.app.stage.addChild(container); + this.curContainer.alpha = 0; + this.app.ticker.start(); + } + + override dispose(): void { + super.dispose(); + this.app.ticker.remove(this.onTick); + this.app.destroy(true); + } + + override getElement(): HTMLElement { + return this.canvas; + } +} diff --git a/amll-local/packages/core/src/bg-render/shaders/base.frag.glsl b/amll-local/packages/core/src/bg-render/shaders/base.frag.glsl new file mode 100644 index 0000000..2397e6a --- /dev/null +++ b/amll-local/packages/core/src/bg-render/shaders/base.frag.glsl @@ -0,0 +1,11 @@ +#version 300 es +precision highp float; + +uniform sampler2D src; +in vec2 f_v_coord; +out vec4 fragColor; + +void main() { + vec2 coord = f_v_coord; + fragColor = texture(src, coord); +} diff --git a/amll-local/packages/core/src/bg-render/shaders/base.vert.glsl b/amll-local/packages/core/src/bg-render/shaders/base.vert.glsl new file mode 100644 index 0000000..82e2c6e --- /dev/null +++ b/amll-local/packages/core/src/bg-render/shaders/base.vert.glsl @@ -0,0 +1,10 @@ +#version 300 es +precision highp float; + +in vec2 v_coord; +out vec2 f_v_coord; + +void main() { + gl_Position = vec4(v_coord, 0.0f, 1.0f); + f_v_coord = (vec2(v_coord.x, v_coord.y) + vec2(1.0f, 1.0f)) / 2.0f; +} diff --git a/amll-local/packages/core/src/bg-render/shaders/blend.frag.glsl b/amll-local/packages/core/src/bg-render/shaders/blend.frag.glsl new file mode 100644 index 0000000..ab28ac6 --- /dev/null +++ b/amll-local/packages/core/src/bg-render/shaders/blend.frag.glsl @@ -0,0 +1,17 @@ +#version 300 es +precision highp float; + +uniform sampler2D src; +uniform float lerp; +uniform float scale; +in vec2 f_v_coord; +out vec4 fragColor; + +void main() { + vec2 tex_coord = f_v_coord; + if(scale < 1.0f) { + tex_coord /= scale; + } + vec4 srcColor = texture(src, tex_coord); + fragColor = mix(vec4(srcColor.xyz, 0.0f), srcColor, lerp); +} diff --git a/amll-local/packages/core/src/bg-render/shaders/noise.frag.glsl b/amll-local/packages/core/src/bg-render/shaders/noise.frag.glsl new file mode 100644 index 0000000..478c63c --- /dev/null +++ b/amll-local/packages/core/src/bg-render/shaders/noise.frag.glsl @@ -0,0 +1,20 @@ +#version 300 es +precision highp float; + +uniform sampler2D src; +in vec2 f_v_coord; +out vec4 fragColor; + +/* Gradient noise from Jorge Jimenez's presentation: */ +/* http://www.iryoku.com/next-generation-post-processing-in-call-of-duty-advanced-warfare */ +float gradientNoise(in vec2 uv) { + return fract(52.9829189f * fract(dot(uv, vec2(0.06711056f, 0.00583715f)))); +} + +void main() { + vec2 tex_coord = f_v_coord; + float dither = (1.0f / 255.0f) * gradientNoise(gl_FragCoord.xy) - (0.5f / 255.0f); + vec4 color = texture(src, tex_coord); + color += dither; + fragColor = color; +} diff --git a/amll-local/packages/core/src/bg-render/shaders/taa.frag.glsl b/amll-local/packages/core/src/bg-render/shaders/taa.frag.glsl new file mode 100644 index 0000000..c7ba71b --- /dev/null +++ b/amll-local/packages/core/src/bg-render/shaders/taa.frag.glsl @@ -0,0 +1,42 @@ +#version 300 es +precision highp float; + +uniform sampler2D src; +uniform sampler2D historyFrame0; +uniform vec2 texSize; +in vec2 f_v_coord; +out vec4 fragColor; + +void main() { + fragColor = texture(src, f_v_coord); + return; + // get the neighborhood min / max from this frame's render + vec3 center = texture(src, f_v_coord).rgb; + vec3 minColor = center; + vec3 maxColor = center; + for (int iy = -1; iy <= 1; ++iy) + { + for (int ix = -1; ix <= 1; ++ix) + { + if (ix == 0 && iy == 0) + continue; + + + vec2 offsetUV = ((f_v_coord * texSize + vec2(ix, iy))) / texSize; + vec3 color = texture(src, offsetUV).rgb; + minColor = min(minColor, color); + maxColor = max(maxColor, color); + } + } + + // get last frame's pixel and clamp it to the neighborhood of this frame + vec3 old = texture(historyFrame0, f_v_coord).rgb; + old = max(minColor, old); + old = min(maxColor, old); + + // interpolate from the clamped old color to the new color. + // Reject all history when the mouse moves. + float lerpAmount = 0.1f; + vec3 pixelColor = mix(old, center, lerpAmount); + fragColor = vec4(pixelColor, 1.0f); +} \ No newline at end of file diff --git a/amll-local/packages/core/src/index.ts b/amll-local/packages/core/src/index.ts new file mode 100644 index 0000000..791317d --- /dev/null +++ b/amll-local/packages/core/src/index.ts @@ -0,0 +1,5 @@ +/// +export * from "./bg-render/index.ts"; +export type * from "./interfaces.ts"; +export * from "./lyric-player/index.ts"; +export type * as spring from "./utils/spring.ts"; diff --git a/amll-local/packages/core/src/interfaces.ts b/amll-local/packages/core/src/interfaces.ts new file mode 100644 index 0000000..edf02ad --- /dev/null +++ b/amll-local/packages/core/src/interfaces.ts @@ -0,0 +1,111 @@ +/** + * 拥有一个 HTML 元素的接口 + * + * 可以通过 `getElement` 获取这个类所对应的 HTML 元素实例 + */ +export interface HasElement { + /** 获取这个类所对应的 HTML 元素实例 */ + getElement(): HTMLElement; +} + +/** + * 实现了这个接口的东西需要在使用完毕后 + * + * 手动调用 `dispose` 函数来销毁清除占用资源 + * + * 以免产生泄露 + */ +export interface Disposable { + /** + * 销毁实现了该接口的对象实例,释放占用的资源 + * + * 一般情况下,调用本函数后就不可以再调用对象的任何函数了 + */ + dispose(): void; +} + +/** 一个歌词单词 */ +export interface LyricWordBase { + /** 单词的起始时间,单位为毫秒 */ + startTime: number; + /** 单词的结束时间,单位为毫秒 */ + endTime: number; + /** 单词内容 */ + word: string; +} + +export interface LyricWord extends LyricWordBase { + /** 单词的音译内容 */ + romanWord?: string; + /** 单词内容是否包含冒犯性的不雅用语 */ + obscene?: boolean; + /** 单词的注音内容 */ + ruby?: LyricWordBase[]; +} + +/** 一行歌词,存储多个单词 */ +export interface LyricLine { + /** + * 该行的所有单词 + * 如果是 LyRiC 等只能表达一行歌词的格式,这里就只会有一个单词且通常其始末时间和本结构的 `startTime` 和 `endTime` 相同 + */ + words: LyricWord[]; + /** 该行的翻译歌词,将会显示在主歌词行的下方 */ + translatedLyric: string; + /** 该行的音译歌词,将会显示在翻译歌词行的下方 */ + romanLyric: string; + /** 句子的起始时间,单位为毫秒 */ + startTime: number; + /** 句子的结束时间,单位为毫秒 */ + endTime: number; + /** 该行是否为背景歌词行,当该行歌词的上一句非背景歌词被激活时,这行歌词将会显示出来,注意每个非背景歌词下方只能拥有一个背景歌词 */ + isBG: boolean; + /** 该行是否为对唱歌词行(即歌词行靠右对齐) */ + isDuet: boolean; +} + +/** + * 优化歌词行的配置选项 + */ +export interface OptimizeLyricOptions { + /** + * 规范化歌词中的空格 + * + * 将多个连续空格替换为一个空格 + * @default true + */ + normalizeSpaces?: boolean; + /** + * 是否将行级时间戳强行设为字级时间戳 + * @default true + */ + resetLineTimestamps?: boolean; + /** + * 把多行背景人声转换为单行背景人声 + 主歌词行的形式 + * @default true + */ + convertExcessiveBackgroundLines?: boolean; + /** + * 是否同步主歌词与背景人声的时间 + * @default true + */ + syncMainAndBackgroundLines?: boolean; + /** + * 清洗非刻意的重叠,以免不必要的多行高亮效果 + * + * 如果两行时间轴有重叠的歌词满足下列条件之一: + * * 重叠小于 100ms + * * 重叠时长不足下一行时长的 10% + * + * 则截断上一行歌词的结束时间为下一行歌词的开始时间 + * @default true + */ + cleanUnintentionalOverlaps?: boolean; + /** + * 尝试让歌词提前最多 1 秒开始 + * + * 有重叠则尝试最多提前 400ms 或上一行时长的 30% + * @default true + */ + tryAdvanceStartTime?: boolean; +} diff --git a/amll-local/packages/core/src/lyric-player/base/bottom-line.ts b/amll-local/packages/core/src/lyric-player/base/bottom-line.ts new file mode 100644 index 0000000..0b7c76a --- /dev/null +++ b/amll-local/packages/core/src/lyric-player/base/bottom-line.ts @@ -0,0 +1,128 @@ +import type { Disposable, HasElement } from "#interfaces"; +import styles from "#styles/lyric-player.module.css"; +import { measure } from "#utils/schedule.ts"; +import { Spring } from "#utils/spring.ts"; +import type { LyricPlayerBase } from "."; + +interface LineTransforms { + posX: Spring; + posY: Spring; +} + +export class BottomLineEl implements HasElement, Disposable { + private element: HTMLElement = document.createElement("div"); + private left = 0; + private top = 0; + private delay = 0; + // 由 LyricPlayer 来设置 + lineSize: [number, number] = [0, 0]; + readonly lineTransforms: LineTransforms = { + posX: new Spring(0), + posY: new Spring(0), + }; + private isFocused = false; + private blur = 0; + constructor(private lyricPlayer: LyricPlayerBase) { + this.element.setAttribute( + "class", + `${styles.lyricLine} ${styles.bottomLine}`, + ); + this.element.dataset.bottomLine = "true"; + this.rebuildStyle(); + } + async measureSize(): Promise<[number, number]> { + const size: [number, number] = await measure(() => [ + this.element.clientWidth, + this.element.clientHeight, + ]); + return size; + } + private lastStyle = ""; + show(): void { + this.rebuildStyle(); + } + hide(): void { + this.rebuildStyle(); + } + setFocused(focused: boolean): void { + if (this.isFocused !== focused) { + this.isFocused = focused; + if (focused) { + this.element.dataset.focused = "true"; + } else { + delete this.element.dataset.focused; + } + } + } + private rebuildStyle() { + let style = `transform:translate(${this.lineTransforms.posX + .getCurrentPosition() + .toFixed(2)}px,${this.lineTransforms.posY + .getCurrentPosition() + .toFixed(2)}px);`; + + if (!this.lyricPlayer.getEnableSpring() && this.isInSight) { + style += `transition-delay:${this.delay}ms;`; + } + + style += `filter:blur(${Math.min(5, this.blur)}px);`; + + if (style !== this.lastStyle) { + this.lastStyle = style; + this.element.setAttribute("style", style); + } + } + getElement(): HTMLElement { + return this.element; + } + setTransform( + left: number = this.left, + top: number = this.top, + blur = 0, + force = false, + delay = 0, + ): void { + this.left = left; + this.top = top; + this.delay = (delay * 1000) | 0; + + if (force || !this.lyricPlayer.getEnableSpring()) { + this.blur = Math.min(32, blur); + if (force) this.element.classList.add(styles.tmpDisableTransition); + this.lineTransforms.posX.setPosition(left); + this.lineTransforms.posY.setPosition(top); + if (!this.lyricPlayer.getEnableSpring()) this.show(); + else this.rebuildStyle(); + if (force) + requestAnimationFrame(() => { + this.element.classList.remove(styles.tmpDisableTransition); + }); + } else { + this.blur = Math.min(5, blur); + this.lineTransforms.posX.setTargetPosition(left, delay); + this.lineTransforms.posY.setTargetPosition(top, delay); + } + } + update(delta = 0): void { + if (!this.lyricPlayer.getEnableSpring()) return; + this.lineTransforms.posX.update(delta); + this.lineTransforms.posY.update(delta); + if (this.isInSight) { + this.show(); + } else { + this.hide(); + } + } + get isInSight(): boolean { + const l = this.lineTransforms.posX.getCurrentPosition(); + const t = this.lineTransforms.posY.getCurrentPosition(); + const r = l + this.lineSize[0]; + const b = t + this.lineSize[1]; + const pr = this.lyricPlayer.size[0]; + const pb = this.lyricPlayer.size[1]; + return !(l > pr || t > pb || r < 0 || b < 0); + } + dispose(): void { + this.element.remove(); + } +} diff --git a/amll-local/packages/core/src/lyric-player/base/consts.ts b/amll-local/packages/core/src/lyric-player/base/consts.ts new file mode 100644 index 0000000..11f50f9 --- /dev/null +++ b/amll-local/packages/core/src/lyric-player/base/consts.ts @@ -0,0 +1,39 @@ +type ValueOf> = T[keyof T]; + +/** 歌词中不雅用语的掩码模式 */ +export const MaskObsceneWordsMode = { + /** 禁用任何不雅用语掩码 */ + Disabled: "", + /** 完全掩码所有不雅用语 */ + FullMask: "full-mask", + /** 保留首尾字符,屏蔽中间字符 */ + PartialMask: "partial-mask", +} as const; + +/** 歌词中不雅用语的掩码模式枚举类型,见 {@link MaskObsceneWordsMode} */ +export type MaskObsceneWordsMode = ValueOf; + +/** + * 歌词行的渲染模式 + * @internal + */ +export const LyricLineRenderMode = { + SOLID: 0, + GRADIENT: 1, +} as const; + +/** + * 歌词行的渲染模式枚举类型,见 {@link LyricLineRenderMode} + * @internal + */ +export type LyricLineRenderMode = ValueOf; + +/** 布局对齐锚点 */ +export const LayoutAlignAnchor = { + Top: "top", + Center: "center", + Bottom: "bottom", +} as const; + +/** 布局对齐锚点枚举类型,见 {@link LayoutAlignAnchor} */ +export type LayoutAlignAnchor = ValueOf; diff --git a/amll-local/packages/core/src/lyric-player/base/group.ts b/amll-local/packages/core/src/lyric-player/base/group.ts new file mode 100644 index 0000000..1790111 --- /dev/null +++ b/amll-local/packages/core/src/lyric-player/base/group.ts @@ -0,0 +1,142 @@ +import type { Disposable } from "#interfaces"; +import { Spring } from "#utils/spring.ts"; +import { LyricLineRenderMode } from "./consts.ts"; +import type { LyricLineBase } from "./line.ts"; + +export interface LyricPlayerFlags { + getEnableSpring(): boolean; + getEnableScale(): boolean; + getIsPlaying(): boolean; + getAlwaysPostpositionBackground(): boolean; +} + +export abstract class LyricLineGroupBase< + T extends LyricLineBase = LyricLineBase, +> implements Disposable +{ + protected abstract readonly lyricPlayer: LyricPlayerFlags; + + public posY: Spring = new Spring(0); + public bgSlideY: Spring = new Spring(-80); + public top = 0; + public delay = 0; + + public isActive = false; + public opacity = 1; + public blur = 0; + + public isBgFirst = false; + + constructor( + public mainLine: T, + public bgLine?: T | undefined, + ) {} + + get startTime(): number { + // 优化歌词时 `syncMainAndBackgroundLines` 已经把时间同步好了,直接读取主歌词的即可 + // 要是用户关掉了这个优化,我们认为在这种情况下主歌词和背景人声显示不同步是符合用户预期的 + return this.mainLine.getLine().startTime; + } + + get endTime(): number { + return this.mainLine.getLine().endTime; + } + + onLineSizeChange(size: [number, number]): void { + this.mainLine.onLineSizeChange(size); + this.bgLine?.onLineSizeChange(size); + } + + setTransform( + top: number, + force: boolean, + delay: number, + isActive: boolean, + opacity: number, + blur: number, + ): void { + this.top = top; + this.delay = delay; + this.isActive = isActive; + this.opacity = opacity; + this.blur = blur; + + this.setLineTransformations(force, delay); + + const enableSpring = this.lyricPlayer.getEnableSpring(); + const alwaysPostposition = + this.lyricPlayer.getAlwaysPostpositionBackground(); + const shouldBgFirst = alwaysPostposition ? false : this.isBgFirst; + const hiddenSlideY = shouldBgFirst ? 80 : -80; + + const isPlaying = this.lyricPlayer.getIsPlaying(); + + const targetBgSlideY = isActive || !isPlaying ? 0 : hiddenSlideY; + + if (force || !enableSpring) { + this.posY.setPosition(top); + this.bgSlideY.setPosition(targetBgSlideY); + this.renderStyles(); + } else { + this.posY.setTargetPosition(top, delay); + this.bgSlideY.setTargetPosition(targetBgSlideY, delay); + } + } + + private setLineTransformations(force: boolean, delay: number) { + const enableScale = this.lyricPlayer.getEnableScale(); + const isPlaying = this.lyricPlayer.getIsPlaying(); + + const renderMode = this.isActive + ? LyricLineRenderMode.GRADIENT + : LyricLineRenderMode.SOLID; + + const SCALE_ASPECT = enableScale ? 97 : 100; + let mainScale = 100; + if (!this.isActive && isPlaying) { + mainScale = SCALE_ASPECT; + } + this.mainLine.setTransform(mainScale, 1, 0, force, delay, renderMode); + + let bgScale = 100; + if (!this.isActive && isPlaying) { + bgScale = 75; + } + this.bgLine?.setTransform(bgScale, 1, 0, force, delay, renderMode); + } + + protected abstract renderStyles(): void; + + abstract get isInSight(): boolean; + + update(delta: number): void { + if (this.lyricPlayer.getEnableSpring()) { + this.posY.update(delta); + this.bgSlideY.update(delta); + this.renderStyles(); + } + + this.mainLine.update(delta); + this.bgLine?.update(delta); + } + + rebuildAllLines(): void { + this.mainLine.rebuildElement(); + this.bgLine?.rebuildElement(); + } + + enable(time?: number, shouldPlay?: boolean): void { + this.mainLine.enable(time, shouldPlay); + this.bgLine?.enable(time, shouldPlay); + } + + disable(): void { + this.mainLine.disable(); + this.bgLine?.disable(); + } + + dispose(): void { + this.mainLine.dispose(); + this.bgLine?.dispose(); + } +} diff --git a/amll-local/packages/core/src/lyric-player/base/index.ts b/amll-local/packages/core/src/lyric-player/base/index.ts new file mode 100644 index 0000000..a84bdaa --- /dev/null +++ b/amll-local/packages/core/src/lyric-player/base/index.ts @@ -0,0 +1,854 @@ +import structuredClone from "@ungap/structured-clone"; +import type { + Disposable, + HasElement, + LyricLine, + LyricWord, + OptimizeLyricOptions, +} from "#interfaces"; +import styles from "#styles/lyric-player.module.css"; +import { clampPositive } from "#utils/clamp.ts"; +import { optimizeLyricLines } from "#utils/optimize-lyric.ts"; +import type { SpringParams } from "#utils/spring.ts"; +import { InterludeDots } from "../dom/interlude-dots.ts"; +import { BottomLineEl } from "./bottom-line.ts"; +import { LayoutAlignAnchor, MaskObsceneWordsMode } from "./consts.ts"; +import type { LyricLineGroupBase } from "./group.ts"; +import { + computeCurrentInterlude, + computeGroupPresentation, + computeLineBlur, + computeLinePosYSpringParams, + type PlayerLayoutState, +} from "./layout.ts"; +import type { LyricLineBase } from "./line.ts"; +import { + attachPlayerScrollHandlers, + type PlayerScrollState, + resetPlayerScrollState, +} from "./scroll.ts"; +import { + commitPlayerTimeState, + computePlayerTimeState, + type PlayerTimelineState, +} from "./timeline.ts"; + +export type { PlayerLayoutState } from "./layout.ts"; +export type { LyricLineBase } from "./line.ts"; +export type { PlayerScrollState } from "./scroll.ts"; +export type { PlayerTimelineState } from "./timeline.ts"; + +/** + * 歌词播放器的基类,已经包含了有关歌词操作和排版的功能, + * 子类需要为其实现对应的显示展示操作 + */ +export abstract class LyricPlayerBase + extends EventTarget + implements HasElement, Disposable +{ + protected element: HTMLElement = document.createElement("div"); + abstract get baseFontSize(): number; + + /** 播放时间线状态 */ + protected timelineState: PlayerTimelineState = { + currentTime: 0, + lastCurrentTime: 0, + hotGroups: new Set(), + bufferedGroups: new Set(), + scrollToIndex: 0, + isSeeking: false, + isPlaying: true, + initialLayoutFinished: false, + }; + /** @internal */ + lyricGroupElementMap: WeakMap = new WeakMap(); + protected currentLyricLines: LyricLine[] = []; + protected processedLines: LyricLine[] = []; + protected lyricLinesIndexes: WeakMap = new WeakMap(); + protected isNonDynamic = false; + protected hasDuetLine = false; + protected disableSpring = false; + protected layoutState: PlayerLayoutState = { + interludeDotsSize: [0, 0], + targetAlignIndex: 0, + lastInterludeState: false, + alignAnchor: LayoutAlignAnchor.Center, + alignPosition: 0.35, + overscanPx: 300, + }; + protected interludeDots: InterludeDots = new InterludeDots(); + protected bottomLine: BottomLineEl = new BottomLineEl(this); + protected enableBlur = true; + protected enableScale = true; + protected maskObsceneWords: MaskObsceneWordsMode = + MaskObsceneWordsMode.Disabled; + protected maskObsceneWordChar = "*"; + protected hidePassedLines = false; + protected scrollState: PlayerScrollState = { + scrollBoundary: { minOffset: 0, maxOffset: 0 }, + scrollOffset: 0, + allowScroll: true, + isScrolled: false, + isUserScrolling: false, + resetToken: 0, + }; + public currentLyricGroups: LyricLineGroupBase[] = []; + lyricGroupSize: WeakMap = new WeakMap(); + readonly size: [number, number] = [0, 0]; + protected isPageVisible = true; + protected optimizeOptions: OptimizeLyricOptions = {}; + + /** 是否强制让背景人声行始终后置(即始终在主歌词下方显示,不前置背景人声) */ + protected alwaysPostpositionBackground = false; + + protected posXSpringParams: Partial = { + mass: 1, + damping: 10, + stiffness: 100, + }; + protected posYSpringParams: Partial = { + mass: 0.9, + damping: 15, + stiffness: 90, + }; + protected scaleSpringParams: Partial = { + mass: 2, + damping: 25, + stiffness: 100, + }; + protected scaleForBGSpringParams: Partial = { + mass: 1, + damping: 20, + stiffness: 50, + }; + private onPageShow = () => { + this.isPageVisible = true; + this.setCurrentTime(this.timelineState.currentTime, true); + }; + private onPageHide = () => { + this.isPageVisible = false; + }; + private scrolledHandler: ReturnType | undefined; + /** @internal */ + resizeObserver: ResizeObserver = new ResizeObserver(((entries) => { + let shouldRelayout = false; + let shouldRebuildPlayerStyle = false; + for (const entry of entries) { + if (entry.target === this.element) { + const rect = entry.contentRect; + this.size[0] = rect.width; + this.size[1] = rect.height; + shouldRebuildPlayerStyle = true; + } else if (entry.target === this.interludeDots.getElement()) { + this.layoutState.interludeDotsSize[0] = entry.target.clientWidth; + this.layoutState.interludeDotsSize[1] = entry.target.clientHeight; + shouldRelayout = true; + } else if (entry.target === this.bottomLine.getElement()) { + const newSize: [number, number] = [ + entry.target.clientWidth, + entry.target.clientHeight, + ]; + const oldSize: [number, number] = this.bottomLine.lineSize; + + if (newSize[0] !== oldSize[0] || newSize[1] !== oldSize[1]) { + this.bottomLine.lineSize = newSize; + shouldRelayout = true; + } + } else { + const groupObj = this.lyricGroupElementMap.get(entry.target); + if (groupObj) { + const newSize: [number, number] = [ + entry.target.clientWidth, + entry.target.clientHeight, + ]; + + const oldSize: [number, number] = this.lyricGroupSize.get( + groupObj, + ) ?? [0, 0]; + + if (newSize[0] !== oldSize[0] || newSize[1] !== oldSize[1]) { + this.lyricGroupSize.set(groupObj, newSize); + groupObj.onLineSizeChange(newSize); + shouldRelayout = true; + } + } + } + } + if (shouldRelayout) { + this.calcLayout(true); + } + if (shouldRebuildPlayerStyle) { + this.onResize(); + } + }) as ResizeObserverCallback); + protected wordFadeWidth = 0.5; + + constructor(element?: HTMLElement) { + super(); + if (element) this.element = element; + this.element.classList.add("amll-lyric-player"); + + this.resizeObserver.observe(this.element); + this.resizeObserver.observe(this.interludeDots.getElement()); + + this.element.appendChild(this.interludeDots.getElement()); + this.element.appendChild(this.bottomLine.getElement()); + this.interludeDots.setTransform(0, 200); + + window.addEventListener("pageshow", this.onPageShow); + window.addEventListener("pagehide", this.onPageHide); + attachPlayerScrollHandlers(this.element, this.scrollState, { + onBeginScroll: () => this.beginScrollHandler(), + onEndScroll: () => this.endScrollHandler(), + onLayout: (sync, force) => this.calcLayout(sync, force), + containsTarget: (target) => this.element.contains(target), + clickTarget: (target) => target.click(), + }); + } + + private beginScrollHandler() { + const allowed = this.scrollState.allowScroll; + if (allowed) { + this.scrollState.isScrolled = true; + clearTimeout(this.scrolledHandler); + this.scrolledHandler = setTimeout(() => { + resetPlayerScrollState(this.scrollState); + }, 5000); + } + return allowed; + } + private endScrollHandler() {} + + /** + * 设置文字动画的渐变宽度,单位以歌词行的主文字字体大小的倍数为单位,默认为 0.5,即一个全角字符的一半宽度 + * + * 如果要模拟 Apple Music for Android 的效果,可以设置为 1 + * + * 如果要模拟 Apple Music for iPad 的效果,可以设置为 0.5 + * + * 如果想要近乎禁用渐变效果,可以设置成非常接近 0 的小数(例如 `0.0001` ),但是**不可以为 0** + * + * @param value 需要设置的渐变宽度,单位以歌词行的主文字字体大小的倍数为单位,默认为 0.5 + */ + setWordFadeWidth(value = 0.5): void { + this.wordFadeWidth = Math.max(0.0001, value); + } + + /** + * 是否启用歌词行缩放效果,默认启用 + * + * 如果启用,非选中的歌词行会轻微缩小以凸显当前播放歌词行效果 + * + * 此效果对性能影响微乎其微,推荐启用 + * @param enable 是否启用歌词行缩放效果 + */ + setEnableScale(enable = true): void { + this.enableScale = enable; + this.calcLayout(); + } + /** + * 获取当前是否启用了歌词行缩放效果 + * @returns 是否启用歌词行缩放效果 + */ + getEnableScale(): boolean { + return this.enableScale; + } + + /** + * 获取当前文字动画的渐变宽度,单位以歌词行的主文字字体大小的倍数为单位 + * @returns 当前文字动画的渐变宽度,单位以歌词行的主文字字体大小的倍数为单位 + */ + getWordFadeWidth(): number { + return this.wordFadeWidth; + } + + setIsSeeking(isSeeking: boolean): void { + this.timelineState.isSeeking = isSeeking; + } + /** + * 设置是否隐藏已经播放过的歌词行,默认不隐藏 + * @param hide 是否隐藏已经播放过的歌词行,默认不隐藏 + */ + setHidePassedLines(hide: boolean): void { + this.hidePassedLines = hide; + this.calcLayout(); + } + /** + * 设置是否启用歌词行的模糊效果 + * @param enable 是否启用 + */ + setEnableBlur(enable: boolean): void { + if (this.enableBlur === enable) return; + this.enableBlur = enable; + this.calcLayout(); + } + + /** + * 设置歌词中不雅用语的掩码模式 + * @param mode 掩码模式 + * @see {@link MaskObsceneWordsMode} + */ + setMaskObsceneWords(mode: MaskObsceneWordsMode): void { + if (this.maskObsceneWords === mode) return; + this.maskObsceneWords = mode; + this.rebuildLyricLines(); + this.calcLayout(); + } + + /** + * 设置不雅用语掩码使用的字符,默认为 `*` + * @param char 单个字符,用于替换不雅用语中的字符 + */ + setMaskObsceneWordChar(char: string): void { + const c = char.charAt(0) || "*"; + if (this.maskObsceneWordChar === c) return; + this.maskObsceneWordChar = c; + if (this.maskObsceneWords !== MaskObsceneWordsMode.Disabled) { + this.rebuildLyricLines(); + this.calcLayout(); + } + } + + rebuildLyricLines(): void { + for (const group of this.currentLyricGroups) { + group.rebuildAllLines(); + } + } + /** + * 根据当前配置处理不雅用语单词 + * @param word 单词对象 + * @internal + */ + processObsceneWord(word: LyricWord): string { + const text = word.word; + + if ( + !word.obscene || + this.maskObsceneWords === MaskObsceneWordsMode.Disabled + ) { + return text; + } + + const maskChar = this.maskObsceneWordChar; + + if (this.maskObsceneWords === MaskObsceneWordsMode.FullMask) { + return text.replace(/\S/g, maskChar); + } + + if (this.maskObsceneWords === MaskObsceneWordsMode.PartialMask) { + const trimmed = text.trim(); + + if (trimmed.length <= 2) { + return text.replace(/\S/g, maskChar); + } + + const startPos = text.indexOf(trimmed); + const endPos = startPos + trimmed.length - 1; + + return ( + text.slice(0, startPos + 1) + + text.slice(startPos + 1, endPos).replace(/\S/g, maskChar) + + text.slice(endPos) + ); + } + + return text; + } + /** + * 设置目标歌词行的对齐方式,默认为 `center` + * + * - 设置成 `top` 的话将会向目标歌词行的顶部对齐 + * - 设置成 `bottom` 的话将会向目标歌词行的底部对齐 + * - 设置成 `center` 的话将会向目标歌词行的垂直中心对齐 + * @param alignAnchor 歌词行对齐方式,详情见函数说明 + */ + setAlignAnchor(alignAnchor: LayoutAlignAnchor): void { + this.layoutState.alignAnchor = alignAnchor; + } + /** + * 设置默认的歌词行对齐位置,相对于整个歌词播放组件的大小位置,默认为 `0.5` + * @param alignPosition 一个 `[0.0-1.0]` 之间的任意数字,代表组件高度由上到下的比例位置 + */ + setAlignPosition(alignPosition: number): void { + this.layoutState.alignPosition = alignPosition; + } + + /** + * 设置 overscan(视图上下额外缓冲渲染区)距离,单位:像素。 + * @param px 像素值,默认 300 + */ + setOverscanPx(px: number): void { + this.layoutState.overscanPx = clampPositive(px | 0); + } + /** 获取当前 overscan 像素距离 */ + getOverscanPx(): number { + return this.layoutState.overscanPx; + } + /** + * 设置是否使用物理弹簧算法实现歌词动画效果,默认启用 + * + * 如果启用,则会通过弹簧算法实时处理歌词位置,但是需要性能足够强劲的电脑方可流畅运行 + * + * 如果不启用,则会回退到基于 `transition` 的过渡效果,对低性能的机器比较友好,但是效果会比较单一 + */ + setEnableSpring(enable = true): void { + this.disableSpring = !enable; + if (enable) { + this.element.classList.remove(styles.disableSpring); + } else { + this.element.classList.add(styles.disableSpring); + } + this.calcLayout(true); + } + /** + * 获取当前是否启用了物理弹簧 + * @returns 是否启用物理弹簧 + */ + getEnableSpring(): boolean { + return !this.disableSpring; + } + + /** + * 设置歌词的优化配置项,这些配置项默认全部开启 + * + * 注意,如果在 `setLyricLines` 之后修改此配置,需要重新调用 `setLyricLines()` 才能对当前歌词生效 + * @param options 优化配置选项 + * @see {@link OptimizeLyricOptions} + */ + setOptimizeOptions(options: OptimizeLyricOptions): void { + this.optimizeOptions = { ...this.optimizeOptions, ...options }; + } + + /** + * 设置当前播放歌词,要注意传入后这个数组内的信息不得修改,否则会发生错误 + * @param lines 歌词数组 + * @param initialTime 初始时间,默认为 0 + */ + setLyricLines(lines: LyricLine[], initialTime = 0): void { + if (import.meta.env.DEV) { + console.log("设置歌词行", lines, initialTime); + } + + this.timelineState.initialLayoutFinished = true; + this.timelineState.lastCurrentTime = initialTime; + this.timelineState.currentTime = initialTime; + + this.currentLyricLines = structuredClone(lines); + this.processedLines = structuredClone(this.currentLyricLines); + optimizeLyricLines(this.processedLines, this.optimizeOptions); + + this.isNonDynamic = true; + for (const line of this.processedLines) { + if (line.words.length > 1) { + this.isNonDynamic = false; + break; + } + } + + this.hasDuetLine = this.processedLines.some((line) => line.isDuet); + + for (const group of this.currentLyricGroups) { + group.dispose(); + } + this.currentLyricGroups = []; + + this.interludeDots.setInterlude(undefined); + this.timelineState.hotGroups.clear(); + this.timelineState.bufferedGroups.clear(); + + if (import.meta.env.DEV) { + console.log("歌词处理完成", this); + } + } + + /** + * 获取当前是否在播放 + * @returns 当前是否在播放 + */ + public getIsPlaying(): boolean { + return this.timelineState.isPlaying; + } + + /** + * 设置当前播放进度,此时将会更新内部的歌词进度信息。 + * + * 内部会根据调用间隔和播放进度自动决定如何滚动和显示歌词,所以这个的调用频率越快越准确越好。 + * 调用完成后,应每帧调用 {@link update} 方法来执行歌词动画效果。**此函数本身不会触发动画效果**。 + * + * @param time 当前播放进度,单位为毫秒 + */ + setCurrentTime(time: number, isSeek = false): void { + // 歌词行为如下: + // 如果当前仍有缓冲行的情况下加入新热行,则不会解除当前缓冲行,且也不会修改当前滚动位置 + // 如果当前所有缓冲行都将被删除且没有新热行加入,则删除所有缓冲行,且也不会修改当前滚动位置 + // 如果当前所有缓冲行都将被删除且有新热行加入,则删除所有缓冲行并加入新热行作为缓冲行,然后修改当前滚动位置 + + time = Math.round(time); + + const { timelineState } = this; + timelineState.isSeeking = Boolean(isSeek); + timelineState.currentTime = time; + + if (!timelineState.initialLayoutFinished && !timelineState.isSeeking) + return; + + const stateResult = computePlayerTimeState({ + time, + currentGroups: this.currentLyricGroups, + timelineState, + }); + + const bottomEl = this.bottomLine.getElement(); + const hasBottomContent = bottomEl.innerHTML.trim().length > 0; + const commitResult = commitPlayerTimeState({ + timelineState: timelineState, + time, + currentGroups: this.currentLyricGroups, + hasBottomContent, + stateResult, + }); + + for (const id of commitResult.groupsToDisable) + this.currentLyricGroups[id]?.disable(); + + for (const id of commitResult.groupsToEnable) + this.currentLyricGroups[id]?.enable(); + + if (commitResult.shouldResetScroll) this.resetScroll(); + if (commitResult.shouldLayout) { + this.calcLayout(timelineState.isSeeking, timelineState.isSeeking); + } + } + + /** + * 重新布局定位歌词行的位置,调用完成后再逐帧调用 `update` + * 函数即可让歌词通过动画移动到目标位置。 + * + * 函数有一个 `force` 参数,用于指定是否强制修改布局,也就是不经过动画直接调整元素位置和大小。 + * + * 此函数还有一个 `reflow` 参数,用于指定是否需要重新计算布局 + * + * 因为计算布局必定会导致浏览器重排布局,所以会大幅度影响流畅度和性能,故请只在以下情况下将其​设置为 true: + * + * 1. 歌词页面大小发生改变时(这个组件会自行处理) + * 2. 加载了新的歌词时(不论前后歌词是否完全一样) + * 3. 用户自行跳转了歌曲播放位置(不论距离远近) + * + * @param sync 是否同步执行,通常用于初始化或 Resize 时立即布局 + * @param force 是否绕过弹簧效果强制更新位置 + */ + async calcLayout(sync = false, force = false): Promise { + const interlude = computeCurrentInterlude({ + currentTime: this.timelineState.currentTime, + scrollToIndex: this.timelineState.scrollToIndex, + currentGroups: this.currentLyricGroups, + }); + const isInterludeActive = !!interlude; + + if ( + this.layoutState.targetAlignIndex !== this.timelineState.scrollToIndex || + this.layoutState.lastInterludeState !== isInterludeActive + ) { + this.layoutState.lastInterludeState = isInterludeActive; + + const springParams = computeLinePosYSpringParams({ + enabled: this.getEnableSpring(), + currentGroups: this.currentLyricGroups, + scrollToIndex: this.timelineState.scrollToIndex, + isSeeking: this.timelineState.isSeeking, + isInterludeActive, + }); + if (springParams.shouldUpdate && springParams.params) { + this.setLinePosYSpringParams(springParams.params); + } + } + + let curPos = -this.scrollState.scrollOffset; + const targetAlignIndex = this.timelineState.scrollToIndex; + let isNextDuet = false; + if (interlude) { + isNextDuet = interlude.isNextDuet; + } else { + this.interludeDots.setInterlude(undefined); + } + + const fontSize = this.baseFontSize || 24; + const dotMargin = fontSize * 0.4; + const totalInterludeHeight = + this.layoutState.interludeDotsSize[1] + dotMargin * 2; + + if (interlude) { + if (interlude.anchorLineIndex !== -1) { + curPos -= totalInterludeHeight; + } + } + // 避免一开始就让所有歌词行挤在一起 + const LINE_HEIGHT_FALLBACK = this.size[1] / 5; + const scrollOffset = this.currentLyricGroups + .slice(0, targetAlignIndex) + .reduce( + (acc, group) => + acc + (this.lyricGroupSize.get(group)?.[1] ?? LINE_HEIGHT_FALLBACK), + 0, + ); + + this.scrollState.scrollBoundary.minOffset = -scrollOffset; + curPos -= scrollOffset; + curPos += this.size[1] * this.layoutState.alignPosition; + + const curGroup = this.currentLyricGroups[targetAlignIndex]; + this.layoutState.targetAlignIndex = targetAlignIndex; + + const isBottomFocused = targetAlignIndex === this.currentLyricGroups.length; + this.bottomLine.setFocused(isBottomFocused); + + const targetLineHeight = curGroup + ? (this.lyricGroupSize.get(curGroup)?.[1] ?? LINE_HEIGHT_FALLBACK) + : isBottomFocused + ? this.bottomLine.lineSize[1] + : 0; + + if (targetLineHeight > 0) { + switch (this.layoutState.alignAnchor) { + case LayoutAlignAnchor.Bottom: + curPos -= targetLineHeight; + break; + case LayoutAlignAnchor.Center: + curPos -= targetLineHeight / 2; + break; + case LayoutAlignAnchor.Top: + break; + } + } + + const latestIndex = Math.max(...this.timelineState.bufferedGroups); + let delay = 0; + let baseDelay = sync ? 0 : 0.05; + let setDots = false; + + this.currentLyricGroups.forEach((group, i) => { + const hasBuffered = this.timelineState.bufferedGroups.has(i); + + const shouldShowDots = interlude && i === interlude.anchorLineIndex + 1; + + if (!setDots && shouldShowDots) { + setDots = true; + + curPos += dotMargin; + + let targetX = 0; + if (interlude && isNextDuet) { + targetX = this.size[0] - this.layoutState.interludeDotsSize[0]; + } + + this.interludeDots.setTransform(targetX, curPos); + + if (interlude) { + this.interludeDots.setInterlude([ + interlude.startTime, + interlude.endTime, + ]); + } + curPos += this.layoutState.interludeDotsSize[1]; + curPos += dotMargin; + } + + const presentation = computeGroupPresentation({ + groupIndex: i, + scrollToIndex: this.timelineState.scrollToIndex, + latestIndex, + hasBuffered, + hidePassedLines: this.hidePassedLines, + isPlaying: this.timelineState.isPlaying, + isNonDynamic: this.isNonDynamic, + enableBlur: this.enableBlur, + isUserScrolling: this.scrollState.isUserScrolling, + isCompact: window.innerWidth <= 1024, + interlude, + }); + + group.setTransform( + curPos, + force, + delay, + presentation.isActive, + presentation.targetOpacity, + presentation.blurLevel, + ); + + curPos += this.lyricGroupSize.get(group)?.[1] ?? LINE_HEIGHT_FALLBACK; + + if (curPos >= 0 && !this.timelineState.isSeeking) { + delay += baseDelay; + if (i >= this.timelineState.scrollToIndex) baseDelay /= 1.05; + } + }); + this.scrollState.scrollBoundary.maxOffset = + curPos + this.scrollState.scrollOffset - this.size[1] / 2; + + const bottomIndex = this.currentLyricGroups.length; + const finalBottomBlur = computeLineBlur({ + enableBlur: this.enableBlur, + isUserScrolling: this.scrollState.isUserScrolling, + isActive: isBottomFocused, + itemIndex: bottomIndex, + scrollToIndex: this.timelineState.scrollToIndex, + latestIndex, + isCompact: window.innerWidth <= 1024, + }); + + this.bottomLine.setTransform(0, curPos, finalBottomBlur, force, delay); + } + + /** + * 设置所有歌词行在横坐标上的弹簧属性,包括重量、弹力和阻力。 + * + * @param params 需要设置的弹簧属性,提供的属性将会覆盖原来的属性,未提供的属性将会保持原样 + * @deprecated 考虑到横向弹簧效果并不常见,所以这个函数将会在未来的版本中移除 + */ + setLinePosXSpringParams(_params: Partial = {}): void {} + /** + * 设置所有歌词行在​纵坐标上的弹簧属性,包括重量、弹力和阻力。 + * + * @param params 需要设置的弹簧属性,提供的属性将会覆盖原来的属性,未提供的属性将会保持原样 + */ + setLinePosYSpringParams(params: Partial = {}): void { + this.posYSpringParams = { + ...this.posYSpringParams, + ...params, + }; + this.bottomLine.lineTransforms.posY.updateParams(this.posYSpringParams); + for (const group of this.currentLyricGroups) { + group.posY.updateParams(this.posYSpringParams); + group.bgSlideY.updateParams(this.posYSpringParams); + } + } + /** + * 设置所有歌词行在​缩放大小上的弹簧属性,包括重量、弹力和阻力。 + * + * @param params 需要设置的弹簧属性,提供的属性将会覆盖原来的属性,未提供的属性将会保持原样 + */ + setLineScaleSpringParams(params: Partial = {}): void { + this.scaleSpringParams = { + ...this.scaleSpringParams, + ...params, + }; + this.scaleForBGSpringParams = { + ...this.scaleForBGSpringParams, + ...params, + }; + for (const group of this.currentLyricGroups) { + group.mainLine.lineTransforms.scale.updateParams(this.scaleSpringParams); + + group.bgLine?.lineTransforms.scale.updateParams( + this.scaleForBGSpringParams, + ); + } + } + /** + * 暂停部分效果演出,目前会暂停播放间奏点的动画,且将背景歌词显示出来 + */ + pause(): void { + this.interludeDots.pause(); + if (this.timelineState.isPlaying) { + this.timelineState.isPlaying = false; + this.calcLayout(); + } + } + /** + * 恢复部分效果演出,目前会恢复播放间奏点的动画 + */ + resume(): void { + this.interludeDots.resume(); + if (!this.timelineState.isPlaying) { + this.timelineState.isPlaying = true; + this.calcLayout(); + } + } + /** + * 更新动画,这个函数应该被逐帧调用或者在以下情况下调用一次: + * + * 1. 刚刚调用完设置歌词函数的时候 + * @param delta 距离上一次被调用到现在的时长,单位为毫秒(可为浮点数) + */ + + update(delta = 0): void { + this.bottomLine.update(delta / 1000); + this.interludeDots.update(delta); + } + + protected onResize(): void {} + + /** + * 获取一个特殊的底栏元素,默认是空白的,可以往内部添加任意元素 + * + * 这个元素始终在歌词的底部,可以用于显示歌曲创作者等信息 + * + * 但是请勿删除该元素,只能在内部存放元素 + * + * @returns 一个元素,可以往内部添加任意元素 + */ + getBottomLineElement(): HTMLElement { + return this.bottomLine.getElement(); + } + /** + * 重置用户滚动状态 + * + * 请在用户完成滚动点击跳转歌词时调用本事件再调用 `calcLayout` 以正确滚动到目标位置 + */ + resetScroll(): void { + resetPlayerScrollState(this.scrollState); + clearTimeout(this.scrolledHandler); + this.scrolledHandler = undefined; + } + /** + * 获取当前歌词数组 + * + * 一般和最后调用 `setLyricLines` 给予的参数一样 + * @returns 当前歌词数组 + */ + getLyricLines(): LyricLine[] { + return this.currentLyricLines; + } + /** + * 获取当前歌词的播放位置 + * + * 一般和最后调用 `setCurrentTime` 给予的参数一样 + * @returns 当前播放位置 + */ + getCurrentTime(): number { + return this.timelineState.currentTime; + } + + /** + * 设置是否让背景人声行始终后置显示 + * + * 默认情况下,如果背景歌词开始时间早于主歌词,会在主歌词上方展示; + * 如果设置为 `true`,则无论时间顺序如何,背景歌词都会始终在主歌词下方展示 + * @param enable 是否启用始终后置 + */ + setAlwaysPostpositionBackground(enable: boolean): void { + if (this.alwaysPostpositionBackground === enable) { + return; + } + + this.alwaysPostpositionBackground = enable; + + this.rebuildLyricLines(); + this.calcLayout(); + } + + /** 获取当前是否设置了让背景人声行始终后置显示 */ + getAlwaysPostpositionBackground(): boolean { + return this.alwaysPostpositionBackground; + } + + getElement(): HTMLElement { + return this.element; + } + dispose(): void { + this.element.remove(); + window.removeEventListener("pageshow", this.onPageShow); + window.removeEventListener("pagehide", this.onPageHide); + } +} diff --git a/amll-local/packages/core/src/lyric-player/base/layout.ts b/amll-local/packages/core/src/lyric-player/base/layout.ts new file mode 100644 index 0000000..7ce0dee --- /dev/null +++ b/amll-local/packages/core/src/lyric-player/base/layout.ts @@ -0,0 +1,333 @@ +import { clamp } from "#utils/clamp.ts"; +import type { SpringParams } from "#utils/spring.ts"; +import type { LayoutAlignAnchor } from "./consts.ts"; +import type { LyricLineGroupBase } from "./group.ts"; +import type { PlayerTimelineState } from "./timeline.ts"; + +/** + * 播放器布局状态。 + * + * 这部分状态保存布局计算阶段所需的配置项与缓存值, + * 例如对齐方式、间奏点尺寸、上一轮布局命中的目标行等。 + * 不描述播放时间线或用户滚动交互,仅记录当前歌词排布。 + */ +export interface PlayerLayoutState { + /** 间奏点元素当前测量得到的尺寸 */ + interludeDotsSize: [number, number]; + /** 上一轮布局实际对齐的目标歌词行索引 */ + targetAlignIndex: number; + /** 上一轮布局时是否处于间奏区间 */ + lastInterludeState: boolean; + /** 当前歌词目标行的对齐锚点 */ + alignAnchor: LayoutAlignAnchor; + /** 当前歌词目标行在播放器高度中的相对对齐位置 */ + alignPosition: number; + /** 视口上下额外保留的预渲染距离,单位为像素 */ + overscanPx: number; +} + +/** + * 当前命中的间奏区间信息。 + * + * 当播放器检测到当前时间处于两句歌词之间的较长空档期时, + * 会生成该结构,用于驱动间奏点动画的显示位置与时间范围。 + */ +export interface PlayerInterlude { + /** 间奏动画的开始时间 */ + startTime: number; + /** 间奏动画的结束时间 */ + endTime: number; + /** 间奏点应插入到哪一行之后;`-1` 表示位于第一行之前 */ + anchorLineIndex: number; + /** 间奏结束后的下一句是否为对唱歌词 */ + isNextDuet: boolean; +} + +/** {@link computeCurrentInterlude} 的参数类型 */ +export interface ComputeCurrentInterludeInput { + currentTime: number; + scrollToIndex: number; + currentGroups: LyricLineGroupBase[]; +} + +/** + * 根据当前时间与当前目标行,计算当前是否处于某个可展示的间奏区间。 + * + * 仅识别时间轴上的间奏空档,不涉及具体 DOM 元素的创建与摆放。 + * 若当前不应展示间奏动画,则返回 `undefined`。 + */ +export function computeCurrentInterlude( + input: ComputeCurrentInterludeInput, +): PlayerInterlude | undefined { + const currentTime = input.currentTime + 20; + const currentIndex = input.scrollToIndex; + const groups = input.currentGroups; + + const checkGap = (k: number): PlayerInterlude | undefined => { + if (k < -1 || k >= groups.length - 1) return undefined; + + const prevGroup = k === -1 ? null : groups[k]; + const nextGroup = groups[k + 1]; + + const gapStart = prevGroup ? prevGroup.endTime : 0; + const gapEnd = Math.max(gapStart, nextGroup.startTime - 250); + + if (gapEnd - gapStart < 4000) return undefined; + + if (gapEnd > currentTime && gapStart < currentTime) { + return { + startTime: Math.max(gapStart, currentTime), + endTime: gapEnd, + anchorLineIndex: k, + isNextDuet: nextGroup.mainLine.getLine().isDuet, + }; + } + return undefined; + }; + + return ( + checkGap(currentIndex - 1) || + checkGap(currentIndex) || + checkGap(currentIndex + 1) + ); +} + +/** + * {@link computeLinePosYSpringParams} 的参数类型, + * 用于决定当前歌词纵向滚动动画的弹簧参数。 + */ +export interface ComputeLinePosYSpringParamsInput { + /** 是否启用弹簧动画 */ + enabled: boolean; + /** 当前用于布局的歌词数据 */ + currentGroups: LyricLineGroupBase[]; + /** 当前目标对齐行索引 */ + scrollToIndex: number; + /** 是否处于 seeking 模式 */ + isSeeking: boolean; + /** 是否处于间奏区间 */ + isInterludeActive: boolean; +} + +/** {@link computeLinePosYSpringParams} 的结果类型 */ +export interface ComputeLinePosYSpringParamsResult { + /** 是否需要更新纵向弹簧参数 */ + shouldUpdate: boolean; + /** 若需要更新,则返回新的参数 */ + params?: Partial; +} + +/** + * 根据当前播放上下文计算歌词纵向滚动动画的弹簧参数。 + * + * 其策略为: + * - seeking 或间奏时使用更稳定的固定参数 + * - 普通播放时根据相邻歌词的时间间隔动态调整 stiffness / damping + */ +export function computeLinePosYSpringParams( + input: ComputeLinePosYSpringParamsInput, +): ComputeLinePosYSpringParamsResult { + const { + enabled, + currentGroups, + scrollToIndex, + isSeeking, + isInterludeActive, + } = input; + + if (!enabled || currentGroups.length === 0) { + return { shouldUpdate: false }; + } + + if (isSeeking || isInterludeActive) { + return { + shouldUpdate: true, + params: { stiffness: 90, damping: 15 }, + }; + } + + const currentGroup = currentGroups[scrollToIndex]; + const prevGroup = currentGroups[scrollToIndex - 1]; + + if (!currentGroup || !prevGroup) { + return { shouldUpdate: false }; + } + + const interval = currentGroup.startTime - prevGroup.startTime; + + const MIN_INTERVAL = 100; + const MAX_INTERVAL = 800; + const clampedInterval = clamp(interval, MIN_INTERVAL, MAX_INTERVAL); + + const MAX_STIFFNESS = 220; + const MIN_STIFFNESS = 170; + + let ratio = + 1 - (clampedInterval - MIN_INTERVAL) / (MAX_INTERVAL - MIN_INTERVAL); + + ratio = ratio ** 0.2; + + const targetStiffness = + MIN_STIFFNESS + ratio * (MAX_STIFFNESS - MIN_STIFFNESS); + + const dampingMultiplier = 2.2; + const targetDamping = Math.sqrt(targetStiffness) * dampingMultiplier; + + return { + shouldUpdate: true, + params: { + stiffness: targetStiffness, + damping: targetDamping, + }, + }; +} + +/** + * {@link computeGroupPresentation} 的参数类型。 + * + * 描述一行歌词在当前布局上下文中的全部关键信息, + * 用于计算其视觉呈现结果。 + */ +export interface ComputeGroupPresentationInput { + /** 当前歌词组索引 */ + groupIndex: number; + /** 当前目标对齐行索引 */ + scrollToIndex: number; + /** 当前缓冲区({@link PlayerTimelineState.bufferedGroups})中最靠后的歌词行索引 */ + latestIndex: number; + /** 当前歌词行是否在缓冲集合内 */ + hasBuffered: boolean; + /** 是否启用隐藏已播放行 */ + hidePassedLines: boolean; + /** 是否处于播放状态 */ + isPlaying: boolean; + /** 当前歌词是否为非逐词歌词 */ + isNonDynamic: boolean; + /** 是否启用模糊效果 */ + enableBlur: boolean; + /** 是否正在进行滚动交互 */ + isUserScrolling: boolean; + /** 是否处于紧凑布局环境,例如窄屏 */ + isCompact: boolean; + /** 当前命中的间奏区间信息 */ + interlude?: PlayerInterlude; +} + +/** {@link computeGroupPresentation} 的结果类型 */ +export interface ComputeGroupPresentationResult { + /** 当前歌词行是否应视为活跃行 */ + isActive: boolean; + /** 当前歌词行的目标不透明度 */ + targetOpacity: number; + /** 当前歌词行的目标模糊值 */ + blurLevel: number; +} + +/** + * 计算一组歌词在当前布局中的视觉呈现参数。 + * + * 根据播放状态、缓冲状态、布局模式与间奏信息, + * 生成一组歌词最终应使用的活跃状态、不透明度与模糊值。 + */ +export function computeGroupPresentation( + input: ComputeGroupPresentationInput, +): ComputeGroupPresentationResult { + const { + groupIndex, + scrollToIndex, + latestIndex, + hasBuffered, + hidePassedLines, + isPlaying, + isNonDynamic, + enableBlur, + isUserScrolling, + isCompact, + interlude, + } = input; + + const isActive = + hasBuffered || (groupIndex >= scrollToIndex && groupIndex < latestIndex); + + const blurLevel = computeLineBlur({ + enableBlur, + isUserScrolling, + isActive, + itemIndex: groupIndex, + scrollToIndex, + latestIndex, + isCompact, + }); + + let targetOpacity: number; + if (hidePassedLines) { + if ( + groupIndex < + (interlude ? interlude.anchorLineIndex + 1 : scrollToIndex) && + isPlaying + ) { + // 为了避免浏览器优化,这里使用了一个极小但不为零的值(几乎不可见) + targetOpacity = 1e-4; + } else if (hasBuffered) { + targetOpacity = 0.85; + } else { + targetOpacity = isNonDynamic ? 0.2 : 1; + } + } else if (hasBuffered) { + targetOpacity = 0.85; + } else { + targetOpacity = isNonDynamic ? 0.2 : 1; + } + + return { isActive, targetOpacity, blurLevel }; +} + +/** {@link computeLineBlur} 的参数类型 */ +export interface ComputeLineBlurInput { + /** 是否启用了模糊效果 */ + enableBlur: boolean; + /** 用户是否正在滚动 */ + isUserScrolling: boolean; + /** 当前项是否活跃 */ + isActive: boolean; + /** 当前项索引 */ + itemIndex: number; + /** 当前目标对齐行索引 */ + scrollToIndex: number; + /** 缓冲区中最靠后的歌词行索引 */ + latestIndex: number; + /** 是否处于紧凑布局环境,例如窄屏 */ + isCompact: boolean; +} + +/** + * 计算一行歌词在当前布局中的模糊等级。 + * + * 越远离当前对齐区域的歌词会得到更高的模糊值; + * 活跃行、滚动交互中或关闭模糊效果时返回 `0`。 + */ +export function computeLineBlur(input: ComputeLineBlurInput): number { + const { + enableBlur, + isUserScrolling, + isActive, + itemIndex, + scrollToIndex, + latestIndex, + isCompact, + } = input; + + if (!enableBlur || isUserScrolling || isActive) { + return 0; + } + + let blurLevel = 1; + + if (itemIndex < scrollToIndex) { + blurLevel += Math.abs(scrollToIndex - itemIndex) + 1; + } else { + blurLevel += Math.abs(itemIndex - Math.max(scrollToIndex, latestIndex)); + } + + return isCompact ? blurLevel * 0.8 : blurLevel; +} diff --git a/amll-local/packages/core/src/lyric-player/base/line.ts b/amll-local/packages/core/src/lyric-player/base/line.ts new file mode 100644 index 0000000..3cdaa70 --- /dev/null +++ b/amll-local/packages/core/src/lyric-player/base/line.ts @@ -0,0 +1,85 @@ +import type { Disposable, LyricLine, LyricWord } from "#interfaces"; +import { isCJK } from "#utils/is-cjk.ts"; +import { Spring } from "#utils/spring.ts"; +import { LyricLineRenderMode } from "./consts.ts"; + +interface LineTransforms { + scale: Spring; +} + +/** + * 所有标准歌词行的基类 + * @internal + */ +export abstract class LyricLineBase extends EventTarget implements Disposable { + protected top = 0; + protected scale = 1; + protected blur = 0; + protected opacity = 1; + protected delay = 0; + readonly lineTransforms: LineTransforms = { + scale: new Spring(100), + }; + + /** + * 用于 CJK 词语边界检测的分词器 + */ + static readonly wordSegmenter: Intl.Segmenter | null = + typeof Intl !== "undefined" && Intl.Segmenter + ? new Intl.Segmenter(undefined, { granularity: "word" }) + : null; + + /** + * Unicode 标准的全局 Grapheme Cluster 分词器 + * 用于正确处理 emoji、复合字符等 + */ + static readonly graphemeSegmenter: Intl.Segmenter | null = + typeof Intl !== "undefined" && Intl.Segmenter + ? new Intl.Segmenter(undefined, { granularity: "grapheme" }) + : null; + + abstract getLine(): LyricLine; + abstract enable(time?: number, shouldPlay?: boolean): void; + abstract disable(): void; + abstract resume(): void; + abstract pause(): void; + abstract onLineSizeChange(size: [number, number]): void; + + setTransform( + scale: number = this.scale, + opacity: number = this.opacity, + blur: number = this.blur, + _force = false, + delay = 0, + _mode: LyricLineRenderMode = LyricLineRenderMode.SOLID, + ): void { + this.scale = scale; + this.opacity = opacity; + this.blur = blur; + this.delay = delay; + } + + rebuildElement(): void {} + + /** + * 判定歌词是否可以应用强调辉光效果 + * + * 果子在对辉光效果的解释是一种强调(emphasized)效果 + * + * 条件是一个单词时长大于等于 1s 且长度小于等于 7 + * + * @param word 单词 + * @returns 是否可以应用强调辉光效果 + */ + static shouldEmphasize(word: LyricWord): boolean { + if (isCJK(word.word)) return word.endTime - word.startTime >= 1000; + + return ( + word.endTime - word.startTime >= 1000 && + word.word.trim().length <= 7 && + word.word.trim().length > 1 + ); + } + abstract update(delta?: number): void; + dispose(): void {} +} diff --git a/amll-local/packages/core/src/lyric-player/base/scroll.ts b/amll-local/packages/core/src/lyric-player/base/scroll.ts new file mode 100644 index 0000000..ebf1851 --- /dev/null +++ b/amll-local/packages/core/src/lyric-player/base/scroll.ts @@ -0,0 +1,231 @@ +import { clamp } from "#utils/clamp.ts"; + +/** + * 播放器滚动状态。 + * + * 这部分状态描述用户手势/滚轮滚动产生的临时偏移,以及当前允许滚动的范围。 + * 改状态仅记录用户如何把当前视图上下拖动,不决定应该滚动到哪一行, + * 后者由时间线状态与布局计算共同决定。 + */ +export interface PlayerScrollState { + /** 允许的滚动偏移范围 */ + scrollBoundary: { + /** 允许的最小偏移量 */ + minOffset: number; + /** 允许的最大偏移量 */ + maxOffset: number; + }; + /** 当前用户滚动带来的额外偏移量 */ + scrollOffset: number; + /** 是否允许用户通过手势或滚轮滚动歌词视图 */ + allowScroll: boolean; + /** 是否处于用户滚动过,尚未回归自动对齐的状态 */ + isScrolled: boolean; + /** 是否正在进行滚动交互或惯性滚动 */ + isUserScrolling: boolean; + /** Bumped when scroll state is reset so pending inertial frames can stop. */ + resetToken?: number; +} + +/** + * 将滚动偏移量限制在当前允许的滚动边界内。 + * + * 当手势滚动、滚轮滚动或惯性滚动更新了 {@link PlayerScrollState.scrollOffset} + * 后,应调用本函数以避免视图越界。 + */ +export function clampPlayerScrollOffset(scrollState: PlayerScrollState): void { + scrollState.scrollOffset = clamp( + scrollState.scrollOffset, + scrollState.scrollBoundary.minOffset, + scrollState.scrollBoundary.maxOffset, + ); +} + +/** + * 重置滚动状态到未发生用户滚动时的初始状态。 + * + * 本函数会清除当前偏移,并结束“已滚动”与“正在滚动”的标记; + * **不会清理**外部持有的计时器或事件监听器。 + */ +export function resetPlayerScrollState(scrollState: PlayerScrollState): void { + scrollState.isScrolled = false; + scrollState.scrollOffset = 0; + scrollState.isUserScrolling = false; + scrollState.resetToken = (scrollState.resetToken ?? 0) + 1; +} + +/** + * {@link attachPlayerScrollHandlers} 所需的宿主回调。 + * + * 这些回调将滚动模块与具体播放器实现解耦: + * 滚动模块只负责处理输入事件和更新滚动状态,布局刷新、点击转发等副作用 + * 由宿主决定如何执行。 + */ +export interface AttachPlayerScrollHandlersCallbacks { + /** 开始一次滚动处理前调用,返回 `false` 可阻止本次滚动 */ + onBeginScroll: () => boolean; + /** 一次滚动交互或惯性滚动结束时调用 */ + onEndScroll: () => void; + /** 请求宿主重新布局 */ + onLayout: (sync: boolean, force: boolean) => void; + /** 判断某个点击目标是否仍属于当前播放器视图 */ + containsTarget: (target: Node) => boolean; + /** 将点击事件转发给命中的目标元素 */ + clickTarget: (target: HTMLElement) => void; +} + +/** + * 向指定元素挂载歌词滚动相关的交互处理器。 + * + * 该函数会处理: + * - 触摸拖拽滚动 + * - 触摸结束后的惯性滚动 + * - 滚轮滚动 + * - 轻触时的点击透传 + * + * 只更新 {@link PlayerScrollState} 并通过回调通知宿主执行布局或其它副作用, + * 不直接依赖具体的播放器类实现。 + */ +export function attachPlayerScrollHandlers( + element: HTMLElement, + scrollState: PlayerScrollState, + callbacks: AttachPlayerScrollHandlersCallbacks, +): void { + let startScrollY = 0; + + let startTouchPosY = 0; + let startTouchStartX = 0; + let startTouchStartY = 0; + + let lastMoveY = 0; + let startScrollTime = 0; + let scrollSpeed = 0; + let curScrollId = 0; + + element.addEventListener("touchstart", (evt) => { + if (callbacks.onBeginScroll()) { + curScrollId += 1; + scrollState.isUserScrolling = true; + + evt.preventDefault(); + startScrollY = scrollState.scrollOffset; + + startTouchPosY = evt.touches[0].screenY; + lastMoveY = startTouchPosY; + + startTouchStartX = evt.touches[0].screenX; + startTouchStartY = evt.touches[0].screenY; + + startScrollTime = Date.now(); + scrollSpeed = 0; + + callbacks.onLayout(true, true); + } + }); + + element.addEventListener("touchmove", (evt) => { + if (callbacks.onBeginScroll()) { + evt.preventDefault(); + const currentY = evt.touches[0].screenY; + + const deltaY = currentY - startTouchPosY; + scrollState.scrollOffset = startScrollY - deltaY; + clampPlayerScrollOffset(scrollState); + + const now = Date.now(); + const dt = now - startScrollTime; + if (dt > 0) { + scrollSpeed = (currentY - lastMoveY) / dt; + } + lastMoveY = currentY; + startScrollTime = now; + + callbacks.onLayout(true, true); + } + }); + + element.addEventListener("touchend", (evt) => { + if (callbacks.onBeginScroll()) { + evt.preventDefault(); + + const touch = evt.changedTouches[0]; + const moveX = Math.abs(touch.screenX - startTouchStartX); + const moveY = Math.abs(touch.screenY - startTouchStartY); + + if (moveX < 10 && moveY < 10) { + const target = document.elementFromPoint(touch.clientX, touch.clientY); + if (target instanceof HTMLElement && callbacks.containsTarget(target)) { + callbacks.clickTarget(target); + } + scrollState.isUserScrolling = false; + callbacks.onEndScroll(); + return; + } + + startTouchPosY = 0; + const scrollId = ++curScrollId; + const resetToken = scrollState.resetToken ?? 0; + + if (Math.abs(scrollSpeed) < 0.1) scrollSpeed = 0; + + let lastFrameTime = performance.now(); + + const onScrollFrame = (time: number) => { + if ( + scrollId !== curScrollId || + resetToken !== (scrollState.resetToken ?? 0) + ) { + return; + } + + const dt = time - lastFrameTime; + lastFrameTime = time; + + if (dt <= 0 || dt > 100) { + requestAnimationFrame(onScrollFrame); + return; + } + + if (Math.abs(scrollSpeed) > 0.05) { + scrollState.scrollOffset -= scrollSpeed * dt; + + clampPlayerScrollOffset(scrollState); + + const frictionFactor = 0.95 ** (dt / 16); + scrollSpeed *= frictionFactor; + + callbacks.onLayout(true, true); + + requestAnimationFrame(onScrollFrame); + } else { + scrollState.isUserScrolling = false; + callbacks.onEndScroll(); + } + }; + + requestAnimationFrame(onScrollFrame); + } else { + scrollState.isUserScrolling = false; + } + }); + + element.addEventListener( + "wheel", + (evt) => { + if (callbacks.onBeginScroll()) { + evt.preventDefault(); + + if (evt.deltaMode === evt.DOM_DELTA_PIXEL) { + scrollState.scrollOffset += evt.deltaY; + clampPlayerScrollOffset(scrollState); + callbacks.onLayout(true, false); + } else { + scrollState.scrollOffset += evt.deltaY * 50; + clampPlayerScrollOffset(scrollState); + callbacks.onLayout(false, false); + } + } + }, + { passive: false }, + ); +} diff --git a/amll-local/packages/core/src/lyric-player/base/timeline.ts b/amll-local/packages/core/src/lyric-player/base/timeline.ts new file mode 100644 index 0000000..e0766ce --- /dev/null +++ b/amll-local/packages/core/src/lyric-player/base/timeline.ts @@ -0,0 +1,236 @@ +import { eqSet } from "#utils/eq-set.ts"; +import type { LyricLineGroupBase } from "./group.ts"; + +/** + * 播放时间线状态。 + * + * 描述播放器在时间轴上的当前位置,当前处于激活状态的歌词组信息 + */ +export interface PlayerTimelineState { + /** 当前播放时间,单位为毫秒 */ + currentTime: number; + /** 上一次提交到时间线状态的播放时间,单位为毫秒 */ + lastCurrentTime: number; + /** 热行:当前时间 {@link currentTime} 正在命中的组(含主行+可能的背景行) */ + hotGroups: Set; + /** 缓冲组:UI 上还保持激活表现的组索引,通常包含热组,和刚结束仍在过渡中的组 */ + bufferedGroups: Set; + /** 当前应滚动对齐到的歌词组索引 */ + scrollToIndex: number; + /** 是否正在拖拽进度条。若是,更新时丢弃缓冲行,并根据当前时间直接计算热行 */ + isSeeking: boolean; + /** 是否处于播放状态 */ + isPlaying: boolean; + /** 是否已经完成至少一次初始布局 */ + initialLayoutFinished: boolean; +} + +/** {@link computePlayerTimeState} 的参数类型 */ +export interface ComputePlayerTimeStateInput { + time: number; + currentGroups: LyricLineGroupBase[]; + timelineState: Readonly; +} + +/** {@link computePlayerTimeState} 的返回类型 */ +export interface ComputePlayerTimeStateResult { + /** 计算后的新热组集合 */ + nextHotGroups: Set; + /** 需要新加入热组集合的组索引 */ + addedIds: Set; + /** 需要从热组集合中移除的组索引 */ + removedHotIds: Set; + /** 需要从缓冲组集合中移除的组索引 */ + removedBufferedIds: Set; +} + +/** + * 计算指定时间点的热行/缓冲行状态转移的纯函数。其行为包括: + * + * - 根据当前时间和已有的热行状态,计算出新的热行状态,并返回应新增的热行 ID 和应移除的热行 ID + * - 根据新的热行状态和已有的缓冲行状态,计算出应移除的缓冲行 ID + */ +export function computePlayerTimeState( + input: ComputePlayerTimeStateInput, +): ComputePlayerTimeStateResult { + const { + time, + currentGroups, + timelineState: { hotGroups, bufferedGroups }, + } = input; + + const nextHotGroups = new Set(hotGroups); + const addedIds = new Set(); + const removedHotIds = new Set(); + const removedBufferedIds = new Set(); + + for (const lastHotId of hotGroups) { + const group = currentGroups[lastHotId]; + if (!group || time < group.startTime || group.endTime <= time) { + nextHotGroups.delete(lastHotId); + removedHotIds.add(lastHotId); + } + } + + for (let id = 0; id < currentGroups.length; id++) { + const group = currentGroups[id]; + if (!group) continue; + + if ( + group.startTime <= time && + group.endTime > time && + !nextHotGroups.has(id) + ) { + nextHotGroups.add(id); + addedIds.add(id); + } + } + + for (const id of bufferedGroups) { + if (!nextHotGroups.has(id)) { + removedBufferedIds.add(id); + } + } + + return { + nextHotGroups, + addedIds, + removedHotIds, + removedBufferedIds, + }; +} + +/** + * 在 seeking 场景下,根据当前时间选出应对齐滚动到的目标行索引。 + * + * 若当前仍存在缓冲行,则优先对齐到最靠前的缓冲行; + * 否则对齐到第一条开始时间不小于当前时间的歌词行。 + */ +export function pickScrollToIndexForSeek( + time: number, + currentGroups: LyricLineGroupBase[], + bufferedGroups: ReadonlySet, +): number { + if (bufferedGroups.size > 0) { + return Math.min(...bufferedGroups); + } + const foundIndex = currentGroups.findIndex( + (group) => group.startTime >= time, + ); + return foundIndex === -1 ? currentGroups.length : foundIndex; +} + +/** + * {@link commitPlayerTimeState} 的参数类型。 + * + * 用于将一次时间线状态转移提交回 {@link PlayerTimelineState}, + * 并生成供宿主执行的副作用应用计划。 + */ +export interface CommitPlayerTimeStateInput { + /** 要被更新的时间线状态对象 */ + timelineState: PlayerTimelineState; + /** 当前播放时间,单位为毫秒 */ + time: number; + /** 当前用于计算的歌词数据 */ + currentGroups: LyricLineGroupBase[]; + /** 底部附加区域当前是否有可见内容 */ + hasBottomContent: boolean; + /** 由 {@link computePlayerTimeState} 得到的状态转移结果 */ + stateResult: ComputePlayerTimeStateResult; +} + +/** {@link commitPlayerTimeState} 的返回类型 */ +export interface CommitPlayerTimeStateResult { + /** 提交后是否需要重新布局 */ + shouldLayout: boolean; + /** 提交后是否需要重置用户滚动状态 */ + shouldResetScroll: boolean; + /** 需要启用的歌词组索引列表 */ + groupsToEnable: number[]; + /** 需要禁用的歌词组索引列表 */ + groupsToDisable: number[]; +} + +/** + * 提交时间线状态转移的纯函数。 + * + * 把一次时间线状态转移写回 {@link PlayerTimelineState}, + * 并返回一份供宿主执行的副作用应用计划,例如启用/禁用哪些歌词行、 + * 是否需要重置用户滚动状态、是否需要触发布局。 + */ +export function commitPlayerTimeState( + input: CommitPlayerTimeStateInput, +): CommitPlayerTimeStateResult { + const { timelineState, time, currentGroups, hasBottomContent, stateResult } = + input; + const { addedIds, removedHotIds, removedBufferedIds } = stateResult; + const { isSeeking } = timelineState; + + timelineState.currentTime = time; + timelineState.hotGroups = stateResult.nextHotGroups; + + let shouldLayout = false; + let shouldResetScroll = false; + const groupsToEnable: number[] = []; + const groupsToDisable = new Set(); + + if (isSeeking) { + timelineState.bufferedGroups = new Set([...timelineState.hotGroups]); + timelineState.scrollToIndex = pickScrollToIndexForSeek( + time, + currentGroups, + timelineState.bufferedGroups, + ); + for (const id of removedHotIds) groupsToDisable.add(id); + for (const id of timelineState.hotGroups) groupsToEnable.push(id); + for (const id of removedBufferedIds) groupsToDisable.add(id); + + shouldResetScroll = true; + shouldLayout = true; + } else if (addedIds.size > 0) { + for (const id of addedIds) { + timelineState.bufferedGroups.add(id); + groupsToEnable.push(id); + } + for (const id of removedBufferedIds) { + timelineState.bufferedGroups.delete(id); + groupsToDisable.add(id); + } + if (timelineState.bufferedGroups.size > 0) { + timelineState.scrollToIndex = Math.min(...timelineState.bufferedGroups); + } + shouldLayout = true; + } else if ( + removedBufferedIds.size > 0 && + eqSet(removedBufferedIds, timelineState.bufferedGroups) + ) { + for (const id of timelineState.bufferedGroups) { + if (timelineState.hotGroups.has(id)) continue; + timelineState.bufferedGroups.delete(id); + groupsToDisable.add(id); + } + shouldLayout = true; + } + + if (timelineState.bufferedGroups.size === 0 && currentGroups.length > 0) { + const lastGroup = currentGroups[currentGroups.length - 1]; + if (time >= lastGroup.endTime) { + const targetIndex = hasBottomContent + ? currentGroups.length + : currentGroups.length - 1; + if (timelineState.scrollToIndex !== targetIndex) { + timelineState.scrollToIndex = targetIndex; + shouldLayout = true; + } + } + } + + timelineState.lastCurrentTime = time; + + return { + shouldLayout, + shouldResetScroll, + groupsToEnable, + groupsToDisable: [...groupsToDisable], + }; +} diff --git a/amll-local/packages/core/src/lyric-player/dom/index.ts b/amll-local/packages/core/src/lyric-player/dom/index.ts new file mode 100644 index 0000000..85389a1 --- /dev/null +++ b/amll-local/packages/core/src/lyric-player/dom/index.ts @@ -0,0 +1,247 @@ +/** + * @fileoverview + * 一个播放歌词的组件 + * @author SteveXMH + */ + +import type { LyricLine } from "#interfaces"; +import "#styles/index.css"; +import { LyricPlayerBase } from "#lyric/base/index.ts"; +import type { LyricLineBase } from "#lyric/base/line.ts"; +import styles from "#styles/lyric-player.module.css"; +import { LyricLineGroup } from "./lyric-group.ts"; +import { LyricLineEl } from "./lyric-line.ts"; + +/** + * 歌词行鼠标相关事件,可以获取到歌词行的索引、主歌词行以及背景歌词行(如果有)元素 + */ +export class LyricLineMouseEvent extends MouseEvent { + /** + * 自定义标志位,用于记录外部是否调用了 `stopPropagation` + */ + public isPropagationStopped = false; + + constructor( + /** + * 歌词行索引 + */ + public readonly lineIndex: number, + /** + * 歌词行元素 + */ + public readonly line: LyricLineBase, + /** + * 背景人声歌词行元素 (如果存在) + */ + public readonly bgLine: LyricLineBase | undefined, + event: MouseEvent, + ) { + super(`line-${event.type}`, event); + } + + override stopPropagation(): void { + this.isPropagationStopped = true; + super.stopPropagation(); + } + + override stopImmediatePropagation(): void { + this.isPropagationStopped = true; + super.stopImmediatePropagation(); + } +} + +export type LyricLineMouseEventListener = (evt: LyricLineMouseEvent) => void; + +/** + * 歌词播放组件,本框架的核心组件 + * + * 尽可能贴切 Apple Music for iPad 的歌词效果设计,且做了力所能及的优化措施 + */ +export class DomLyricPlayer extends LyricPlayerBase { + private abortController = new AbortController(); + override currentLyricGroups: LyricLineGroup[] = []; + + override onResize(): void { + const computedStyles = getComputedStyle(this.element); + this._baseFontSize = Number.parseFloat(computedStyles.fontSize); + this.rebuildStyle(); + } + + readonly supportPlusLighter: boolean = CSS.supports( + "mix-blend-mode", + "plus-lighter", + ); + readonly supportMaskImage: boolean = CSS.supports("mask-image", "none"); + readonly innerSize: [number, number] = [0, 0]; + + private readonly onMouseEventHandler = (e: MouseEvent) => { + const target = e.target; + if (!(target instanceof Element)) return; + + const groupEl = target.closest(`.${styles.lyricLineWrapper}`); + if (!groupEl) return; + + const group = this.lyricGroupElementMap.get(groupEl); + if (!group) return; + + const mainLine = group.mainLine; + const bgLine = group.bgLine; + const lineIndex = this.lyricLinesIndexes.get(mainLine) ?? -1; + + if (e.type === "click") { + this.resetScroll(); + } + + const evt = new LyricLineMouseEvent(lineIndex, mainLine, bgLine, e); + const isDispatched = this.dispatchEvent(evt); + + if (!isDispatched || evt.defaultPrevented) { + e.preventDefault(); + } + + if (evt.isPropagationStopped) { + e.stopPropagation(); + e.stopImmediatePropagation(); + } + }; + + /** + * 是否为非逐词歌词 + * @internal + */ + _getIsNonDynamic(): boolean { + return this.isNonDynamic; + } + private _baseFontSize = Number.parseFloat( + getComputedStyle(this.element).fontSize, + ); + public get baseFontSize(): number { + return this._baseFontSize; + } + constructor() { + super(); + this.onResize(); + this.element.classList.add("amll-lyric-player", "dom"); + if (this.disableSpring) { + this.element.classList.add(styles.disableSpring); + } + + this.element.addEventListener("click", this.onMouseEventHandler, { + signal: this.abortController.signal, + }); + this.element.addEventListener("contextmenu", this.onMouseEventHandler, { + signal: this.abortController.signal, + }); + } + + private rebuildStyle() { + // const width = this.innerSize[0]; + // const height = this.innerSize[1]; + // this.element.style.setProperty("--amll-lp-width", `${width.toFixed(4)}px`); + // this.element.style.setProperty( + // "--amll-lp-height", + // `${height.toFixed(4)}px`, + // ); + } + + override setWordFadeWidth(value = 0.5): void { + super.setWordFadeWidth(value); + for (const group of this.currentLyricGroups) { + group.mainLine.updateMaskImageSync(); + group.bgLine?.updateMaskImageSync(); + } + } + + /** + * 设置当前播放歌词,要注意传入后这个数组内的信息不得修改,否则会发生错误 + * @param lines 歌词数组 + * @param initialTime 初始时间,默认为 0 + */ + override setLyricLines(lines: LyricLine[], initialTime = 0): void { + super.setLyricLines(lines, initialTime); + if (this.hasDuetLine) { + this.element.classList.add(styles.hasDuetLine); + } else { + this.element.classList.remove(styles.hasDuetLine); + } + if (!this.supportMaskImage) { + this.element.style.setProperty("--amll-player-time", `${initialTime}`); + } + + for (const group of this.currentLyricGroups) { + group.dispose(); + } + this.currentLyricGroups = []; + + let currentGroup: LyricLineGroup | null = null; + + for (let i = 0; i < this.processedLines.length; i++) { + const line = this.processedLines[i]; + const lineEl = new LyricLineEl(this, line); + + this.lyricLinesIndexes.set(lineEl, i); + + if (!line.isBG || !currentGroup) { + currentGroup = new LyricLineGroup(this, lineEl); + this.currentLyricGroups.push(currentGroup); + this.lyricGroupElementMap.set(currentGroup.element, currentGroup); + } else { + currentGroup.addBgLine(lineEl); + } + } + + this.setLinePosXSpringParams({}); + this.setLinePosYSpringParams({}); + this.setLineScaleSpringParams({}); + this.setCurrentTime(initialTime, true); + this.calcLayout(true); + this.update(0); + } + + override pause(): void { + super.pause(); + this.element.classList.remove(styles.playing); + this.interludeDots.pause(); + for (const group of this.currentLyricGroups) { + group.mainLine.pause(); + group.bgLine?.pause(); + } + } + + override resume(): void { + super.resume(); + this.element.classList.add(styles.playing); + this.interludeDots.resume(); + for (const group of this.currentLyricGroups) { + group.mainLine.resume(); + group.bgLine?.resume(); + } + } + + override update(delta = 0): void { + if (!this.timelineState.initialLayoutFinished) return; + super.update(delta); + if (!this.supportMaskImage) { + this.element.style.setProperty( + "--amll-player-time", + `${this.timelineState.currentTime}`, + ); + } + if (!this.isPageVisible) return; + const deltaS = delta / 1000; + for (const group of this.currentLyricGroups) { + group.update(deltaS); + } + } + + override dispose(): void { + super.dispose(); + this.abortController.abort(); + this.element.remove(); + for (const group of this.currentLyricGroups) { + group.dispose(); + } + this.bottomLine.dispose(); + this.interludeDots.dispose(); + } +} diff --git a/amll-local/packages/core/src/lyric-player/dom/interlude-dots.ts b/amll-local/packages/core/src/lyric-player/dom/interlude-dots.ts new file mode 100644 index 0000000..c805c74 --- /dev/null +++ b/amll-local/packages/core/src/lyric-player/dom/interlude-dots.ts @@ -0,0 +1,153 @@ +import type { Disposable, HasElement } from "#interfaces"; +import styles from "#styles/lyric-player.module.css"; +import { clamp, clamp01, clampPositive } from "#utils/clamp.ts"; + +function easeInOutBack(x: number): number { + const c1 = 1.70158; + const c2 = c1 * 1.525; + + return x < 0.5 + ? ((2 * x) ** 2 * ((c2 + 1) * 2 * x - c2)) / 2 + : ((2 * x - 2) ** 2 * ((c2 + 1) * (x * 2 - 2) + c2) + 2) / 2; +} + +function easeOutExpo(x: number): number { + return x === 1 ? 1 : 1 - 2 ** (-10 * x); +} + +export class InterludeDots implements HasElement, Disposable { + private element: HTMLElement = document.createElement("div"); + private dot0: HTMLElement = document.createElement("span"); + private dot1: HTMLElement = document.createElement("span"); + private dot2: HTMLElement = document.createElement("span"); + private left = 0; + private top = 0; + private playing = true; + private lastStyle = ""; + private currentInterlude?: [number, number]; + private currentTime = 0; + private targetBreatheDuration = 1500; + constructor() { + this.element.className = styles.interludeDots; + this.element.appendChild(this.dot0); + this.element.appendChild(this.dot1); + this.element.appendChild(this.dot2); + } + getElement(): HTMLElement { + return this.element; + } + setTransform(left: number = this.left, top: number = this.top): void { + this.left = left; + this.top = top; + this.update(); + } + setInterlude(interlude?: [number, number]): void { + this.currentInterlude = interlude; + this.currentTime = interlude?.[0] ?? 0; + if (interlude) { + this.element.classList.add(styles.enabled); + } else { + this.element.classList.remove(styles.enabled); + } + } + pause(): void { + this.playing = false; + this.element.classList.remove(styles.playing); + } + resume(): void { + this.playing = true; + this.element.classList.add(styles.playing); + } + update(delta = 0): void { + if (!this.playing) return; + this.currentTime += delta; + let curStyle = ""; + + curStyle += `transform:translate(${this.left.toFixed( + 2, + )}px, ${this.top.toFixed(2)}px)`; + + // 计算缩放大小 + + if (this.currentInterlude) { + const interludeDuration = + this.currentInterlude[1] - this.currentInterlude[0]; + const currentDuration = this.currentTime - this.currentInterlude[0]; + if (currentDuration <= interludeDuration) { + const breatheDuration = + interludeDuration / + Math.ceil(interludeDuration / this.targetBreatheDuration); + let scale = 1; + let globalOpacity = 1; + + scale *= + Math.sin(1.5 * Math.PI - (currentDuration / breatheDuration) * 2) / + 20 + + 1; + + if (currentDuration < 2000) { + scale *= easeOutExpo(currentDuration / 2000); + } + + if (currentDuration < 500) { + globalOpacity = 0; + } else if (currentDuration < 1000) { + globalOpacity *= (currentDuration - 500) / 500; + } + + if (interludeDuration - currentDuration < 750) { + scale *= + 1 - + easeInOutBack( + (750 - (interludeDuration - currentDuration)) / 750 / 2, + ); + } + if (interludeDuration - currentDuration < 375) { + globalOpacity *= clamp01((interludeDuration - currentDuration) / 375); + } + + const dotsDuration = clampPositive(interludeDuration - 750); + + scale = clampPositive(scale) * 0.7; + + curStyle += ` scale(${scale})`; + + const dot0Opacity = clamp( + 0.25, + ((currentDuration * 3) / dotsDuration) * 0.75, + 1, + ); + const dot1Opacity = clamp( + 0.25, + (((currentDuration - dotsDuration / 3) * 3) / dotsDuration) * 0.75, + 1, + ); + const dot2Opacity = clamp( + 0.25, + (((currentDuration - (dotsDuration / 3) * 2) * 3) / dotsDuration) * + 0.75, + 1, + ); + + this.dot0.style.opacity = `${clamp01(globalOpacity * dot0Opacity)}`; + this.dot1.style.opacity = `${clamp01(globalOpacity * dot1Opacity)}`; + this.dot2.style.opacity = `${clamp01(globalOpacity * dot2Opacity)}`; + } else { + curStyle += " scale(0)"; + this.dot0.style.opacity = "0"; + this.dot1.style.opacity = "0"; + this.dot2.style.opacity = "0"; + } + + curStyle += ";"; + + if (this.lastStyle !== curStyle) { + this.element.setAttribute("style", curStyle); + this.lastStyle = curStyle; + } + } + } + dispose(): void { + this.element.remove(); + } +} diff --git a/amll-local/packages/core/src/lyric-player/dom/lyric-group.ts b/amll-local/packages/core/src/lyric-player/dom/lyric-group.ts new file mode 100644 index 0000000..92eadde --- /dev/null +++ b/amll-local/packages/core/src/lyric-player/dom/lyric-group.ts @@ -0,0 +1,173 @@ +import { LyricLineGroupBase } from "#lyric/base/group.ts"; +import styles from "#styles/lyric-player.module.css"; +import { clamp01 } from "#utils/clamp.ts"; +import type { DomLyricPlayer } from "./index.ts"; +import type { LyricLineEl } from "./lyric-line.ts"; + +export class LyricLineGroup extends LyricLineGroupBase { + public element: HTMLElement; + public bgWrapper?: HTMLElement; + private lastIsActive?: boolean; + + constructor( + public lyricPlayer: DomLyricPlayer, + mainLine: LyricLineEl, + ) { + super(mainLine); + this.element = document.createElement("div"); + this.element.className = styles.lyricLineWrapper; + this.element.appendChild(mainLine.getElement()); + this.posY.setPosition(window.innerHeight * 2); + + lyricPlayer.resizeObserver.observe(this.element); + } + + get isInSight(): boolean { + const t = this.posY.getCurrentPosition(); + + let h = this.lyricPlayer.lyricGroupSize?.get(this)?.[1]; + if (h === undefined || h === 0) { + h = this.element.clientHeight || 0; + } + + const pb = this.lyricPlayer.size[1]; + const ov = this.lyricPlayer.getOverscanPx(); + + return !(t > pb + h + ov || t < -h - ov); + } + + show(): void { + if (!this.element.parentElement) { + const playerEl = this.lyricPlayer.getElement(); + const groups = this.lyricPlayer.currentLyricGroups; + const myIndex = groups.indexOf(this); + + let referenceNode: HTMLElement | null = null; + if (myIndex !== -1) { + for (let i = myIndex + 1; i < groups.length; i++) { + if (groups[i].element.parentElement === playerEl) { + referenceNode = groups[i].element; + break; + } + } + } + + playerEl.insertBefore(this.element, referenceNode); + + this.lyricPlayer.resizeObserver.observe(this.element); + } + + this.mainLine.show(); + this.bgLine?.show(); + } + + hide(): void { + if (this.element.parentElement) { + this.lyricPlayer.resizeObserver.unobserve(this.element); + this.element.remove(); + + this.mainLine.teardownContent(); + this.bgLine?.teardownContent(); + } + } + + override update(delta: number): void { + if (this.isInSight) { + this.show(); + } else { + this.hide(); + } + + super.update(delta); + } + + addBgLine(bgLine: LyricLineEl): void { + if (this.bgLine) { + this.bgLine.dispose(); + } + if (this.bgWrapper) { + this.bgWrapper.remove(); + } + + this.bgLine = bgLine; + + // 需要对比第一个词的开始时间而不是行起始时间,因为行的起始时间已经被 + // `syncMainAndBackgroundLines` 同步过了 + const bgStartTime = + bgLine.getLine().words[0]?.startTime ?? bgLine.getLine().startTime; + const mainStartTime = + this.mainLine.getLine().words[0]?.startTime ?? + this.mainLine.getLine().startTime; + + this.isBgFirst = bgStartTime < mainStartTime; + + if (this.mainLine.getLine().isDuet) { + bgLine.getElement().classList.add(styles.lyricDuetLine); + } + + this.bgWrapper = document.createElement("div"); + this.bgWrapper.className = styles.bgWrapper; + + this.bgWrapper.appendChild(bgLine.getElement()); + + const alwaysPostposition = + this.lyricPlayer.getAlwaysPostpositionBackground(); + const shouldBgFirst = !alwaysPostposition && this.isBgFirst; + + if (shouldBgFirst) { + this.bgWrapper.classList.add(styles.bgWrapperTop); + this.element.insertBefore(this.bgWrapper, this.mainLine.getElement()); + this.bgSlideY.setPosition(80); + } else { + this.element.appendChild(this.bgWrapper); + } + } + + protected renderStyles(): void { + const y = this.posY.getCurrentPosition().toFixed(1); + + this.element.style.transform = `translateY(${y}px)`; + this.element.style.opacity = this.opacity.toString(); + this.element.style.filter = `blur(${Math.min(5, this.blur)}px)`; + + if (!this.lyricPlayer.getEnableSpring()) { + this.element.style.transitionDelay = `${this.delay}ms`; + } + + if (this.bgWrapper) { + if (this.lastIsActive !== this.isActive) { + this.lastIsActive = this.isActive; + this.bgWrapper.classList.toggle(styles.bgWrapperActive, this.isActive); + } + + const slideY = this.bgSlideY.getCurrentPosition(); + const slideYStr = slideY.toFixed(1); + const activeProgress = clamp01(1 - Math.abs(slideY) / 80); + + const scaleStr = (0.8 + activeProgress * 0.2).toFixed(3); + this.bgWrapper.style.transform = `translateY(${slideYStr}%) scale(${scaleStr})`; + + const alwaysPostposition = + this.lyricPlayer.getAlwaysPostpositionBackground(); + const shouldBgFirst = !alwaysPostposition && this.isBgFirst; + + if (shouldBgFirst) { + const bgHeight = this.bgWrapper.clientHeight || 0; + const currentMarginTop = -bgHeight * (1 - activeProgress); + this.bgWrapper.style.marginTop = `${currentMarginTop.toFixed(1)}px`; + } else { + this.bgWrapper.style.marginTop = ""; + } + + const targetHiddenYStr = shouldBgFirst ? "80.0" : "-80.0"; + const isHidden = slideYStr === targetHiddenYStr && !this.isActive; + this.bgWrapper.classList.toggle(styles.bgWrapperHidden, isHidden); + } + } + + override dispose(): void { + super.dispose(); + this.lyricPlayer.resizeObserver.unobserve(this.element); + this.element.remove(); + } +} diff --git a/amll-local/packages/core/src/lyric-player/dom/lyric-line.ts b/amll-local/packages/core/src/lyric-player/dom/lyric-line.ts new file mode 100644 index 0000000..66109db --- /dev/null +++ b/amll-local/packages/core/src/lyric-player/dom/lyric-line.ts @@ -0,0 +1,1080 @@ +import bezier from "bezier-easing"; +import type { LyricLine, LyricWord } from "#interfaces"; +import { LyricLineRenderMode } from "#lyric/base/consts.ts"; +import { LyricLineBase } from "#lyric/base/line.ts"; +import styles from "#styles/lyric-player.module.css"; +import { clamp, clamp01, clampPositive } from "#utils/clamp.ts"; +import { isCJK } from "#utils/is-cjk.ts"; +import { LineBalancer } from "#utils/line-balancer.ts"; +import { chunkAndSplitLyricWords } from "#utils/lyric-split-words.ts"; +import { createMatrix4, matrix4ToCSS, scaleMatrix4 } from "#utils/matrix.ts"; +import type { DomLyricPlayer } from "."; + +interface RealWord extends LyricWord { + mainElement: HTMLSpanElement; + subElements: HTMLSpanElement[]; + elementAnimations: Animation[]; + maskAnimations: Animation[]; + width: number; + height: number; + padding: number; + shouldEmphasize: boolean; +} + +const ANIMATION_FRAME_QUANTITY = 32; + +const norNum = (min: number, max: number) => (x: number) => + clamp01((x - min) / (max - min)); +const EMP_EASING_MID = 0.5; +const beginNum = norNum(0, EMP_EASING_MID); +const endNum = norNum(EMP_EASING_MID, 1); + +const bezIn = bezier(0.2, 0.4, 0.58, 1.0); +const bezOut = bezier(0.3, 0.0, 0.58, 1.0); + +const makeEmpEasing = (mid: number) => { + return (x: number) => (x < mid ? bezIn(beginNum(x)) : 1 - bezOut(endNum(x))); +}; + +function generateFadeGradient( + width: number, + padding = 0, + bright = "rgba(0,0,0,var(--bright-mask-alpha, 1.0))", + dark = "rgba(0,0,0,var(--dark-mask-alpha, 1.0))", +): [string, number] { + const totalAspect = 2 + width + padding; + const widthInTotal = width / totalAspect; + const leftPos = (1 - widthInTotal) / 2; + return [ + `linear-gradient(to right,${bright} ${leftPos * 100}%,${dark} ${ + (leftPos + widthInTotal) * 100 + }%)`, + totalAspect, + ]; +} + +export class LyricLineEl extends LyricLineBase { + private element: HTMLElement = document.createElement("div"); + private splittedWords: RealWord[] = []; + // 标记是否已经构建了行内的实际 DOM(单词与动画等) + private built = false; + + // 由 LyricPlayer 来设置 + lineSize: number[] = [0, 0]; + + private renderMode: LyricLineRenderMode = LyricLineRenderMode.SOLID; + + private currentBrightAlpha = 1.0; + private currentDarkAlpha = 0.2; + + private targetBrightAlpha = 1.0; + private targetDarkAlpha = 0.2; + + /** + * 用于平衡换行、尽量减少各行长度差异的类 + */ + private balancer?: LineBalancer; + + constructor( + private lyricPlayer: DomLyricPlayer, + private lyricLine: LyricLine = { + words: [], + translatedLyric: "", + romanLyric: "", + startTime: 0, + endTime: 0, + isBG: false, + isDuet: false, + }, + ) { + super(); + this.element.setAttribute("class", styles.lyricLine); + if (this.lyricLine.isBG) { + this.element.classList.add(styles.lyricBgLine); + } + if (this.lyricLine.isDuet) { + this.element.classList.add(styles.lyricDuetLine); + } + this.element.appendChild(document.createElement("div")); // 歌词行 + this.element.appendChild(document.createElement("div")); // 翻译行 + this.element.appendChild(document.createElement("div")); // 音译行 + const main = this.element.children[0] as HTMLDivElement; + const trans = this.element.children[1] as HTMLDivElement; + const roman = this.element.children[2] as HTMLDivElement; + main.setAttribute("class", styles.lyricMainLine); + trans.setAttribute("class", styles.lyricSubLine); + roman.setAttribute("class", styles.lyricSubLine); + if (LyricLineBase.wordSegmenter) { + this.balancer = new LineBalancer(main); + } + // 延迟构建具体行内容,进入可视区(含 overscan)时再构建 + this.rebuildStyle(); + } + + areWordsOnSameLine(word1: RealWord, word2: RealWord): boolean { + if (word1?.mainElement && word2?.mainElement) { + const word1el = word1.mainElement; + const word2el = word2.mainElement; + + const rect1 = word1el.getBoundingClientRect(); + const rect2 = word2el.getBoundingClientRect(); + + // 检查两个单词的顶部距离是否相等(或者差值很小) + const topDifference = Math.abs(rect1.top - rect2.top); + + // 如果顶部距离相差很小,可以认为它们在同一行上 + return topDifference < 10; + } + + return true; + } + + private isEnabled = false; + async enable( + maskAnimationTime: number = this.lyricPlayer.getCurrentTime(), + shouldPlay: boolean = this.lyricPlayer.getIsPlaying(), + ): Promise { + this.isEnabled = true; + this.element.classList.add(styles.active); + const main = this.element.children[0] as HTMLDivElement; + + const relativeTime = clampPositive( + maskAnimationTime - this.lyricLine.startTime, + ); + + for (const word of this.splittedWords) { + for (const a of word.elementAnimations) { + a.currentTime = relativeTime; + a.playbackRate = 1; + + const timing = a.effect?.getComputedTiming(); + const duration = Number(timing?.duration ?? 0); + const delay = Number(timing?.delay ?? 0); + const endTime = delay + duration; + + if (shouldPlay && relativeTime < endTime) a.play(); + else a.pause(); + } + + for (const a of word.maskAnimations) { + const t = Math.min(this.totalDuration, relativeTime); + a.currentTime = t; + a.playbackRate = 1; + + const timing = a.effect?.getComputedTiming(); + const duration = Number(timing?.duration ?? 0); + const delay = Number(timing?.delay ?? 0); + const endTime = delay + duration; + + if (shouldPlay && t < endTime) a.play(); + else a.pause(); + } + } + main.classList.add(styles.active); + } + + disable(): void { + this.isEnabled = false; + this.element.classList.remove(styles.active); + this.renderMode = LyricLineRenderMode.SOLID; + + const main = this.element.children[0] as HTMLDivElement; + + for (const word of this.splittedWords) { + for (const a of word.elementAnimations) { + if ( + a.id === "float-word" || + a.id.includes("emphasize-word-float-only") + ) { + a.playbackRate = -1; + a.play(); + } + } + + for (const a of word.maskAnimations) { + a.pause(); + } + } + main.classList.remove(styles.active); + } + + private lastWord?: RealWord; + + async resume(): Promise { + if (!this.isEnabled) return; + for (const word of this.splittedWords) { + for (const a of word.elementAnimations) { + if ( + !this.lastWord || + this.splittedWords.indexOf(this.lastWord) < + this.splittedWords.indexOf(word) + ) { + const timing = a.effect?.getComputedTiming(); + const duration = (timing?.duration as number) || 0; + const delay = (timing?.delay as number) || 0; + const endTime = delay + duration; + const currentTime = (a.currentTime as number) || 0; + + if (a.playState !== "finished" && currentTime < endTime) { + a.play(); + } + } + } + + for (const a of word.maskAnimations) { + if ( + !this.lastWord || + this.splittedWords.indexOf(this.lastWord) < + this.splittedWords.indexOf(word) + ) { + const timing = a.effect?.getComputedTiming(); + const duration = (timing?.duration as number) || 0; + const delay = (timing?.delay as number) || 0; + const endTime = delay + duration; + + const currentTime = (a.currentTime as number) || 0; + + if (a.playState !== "finished" && currentTime < endTime) { + a.play(); + } + } + } + } + } + + async pause(): Promise { + if (!this.isEnabled) return; + for (const word of this.splittedWords) { + for (const a of word.elementAnimations) { + a.pause(); + } + for (const a of word.maskAnimations) { + a.pause(); + } + } + } + setMaskAnimationState(maskAnimationTime = 0): void { + const t = maskAnimationTime - this.lyricLine.startTime; + for (const word of this.splittedWords) { + for (const a of word.maskAnimations) { + a.currentTime = clamp(t, 0, this.totalDuration); + a.playbackRate = 1; + if (t >= 0 && t < this.totalDuration) a.play(); + else a.pause(); + } + } + } + + getLine(): LyricLine { + return this.lyricLine; + } + // private _hide = true; + private lastStyle = ""; + show(): void { + if (!this.built) { + this.rebuildElement(); + this.built = true; + this.updateMaskImageSync(); + } + } + + private rebuildStyle() { + let style = ""; + style += `transform: scale(${(this.lineTransforms.scale.getCurrentPosition() / 100).toFixed(4)});`; + + if (!this.lyricPlayer.getEnableSpring()) { + style += `transition-delay:${this.delay}ms;`; + } + + style += `filter:blur(${Math.min(5, this.blur)}px);`; + if (style !== this.lastStyle) { + this.lastStyle = style; + this.element.setAttribute("style", style); + } + } + + override rebuildElement(): void { + this.disposeElements(); + const main = this.element.children[0] as HTMLDivElement; + const trans = this.element.children[1] as HTMLDivElement; + const roman = this.element.children[2] as HTMLDivElement; + // 非动态歌词,直接渲染整行与副行 + if (this.lyricPlayer._getIsNonDynamic()) { + main.textContent = this.lyricLine.words + .map((w) => this.lyricPlayer.processObsceneWord(w)) + .join(""); + this.setSubLinesText(trans, roman); + return; + } + + const chunkedWords = chunkAndSplitLyricWords(this.lyricLine.words); + const hasRubyLine = this.lyricLine.words.some( + (word) => (word.ruby?.length ?? 0) > 0, + ); + const hasRomanLine = this.lyricLine.words.some( + (word) => (word.romanWord?.trim().length ?? 0) > 0, + ); + main.innerHTML = ""; + + for (const chunk of chunkedWords) { + this.buildWord(chunk, main, hasRubyLine, hasRomanLine); + } + + this.setSubLinesText(trans, roman); + } + + /** 设置翻译与音译行文本 */ + private setSubLinesText(trans: HTMLDivElement, roman: HTMLDivElement) { + trans.textContent = this.lyricLine.translatedLyric; + roman.textContent = this.lyricLine.romanLyric; + } + + private getRubyCharCount(word: LyricWord) { + return (word.ruby ?? []).reduce( + (total, ruby) => total + ruby.word.length, + 0, + ); + } + + private getRubySegments(word: LyricWord) { + return (word.ruby ?? []).filter( + (ruby) => (ruby?.word?.trim().length ?? 0) > 0, + ); + } + + private createWord( + word: LyricWord, + shouldEmphasize: boolean, + hasRubyLine: boolean, + hasRomanLine: boolean, + ): RealWord { + const mainWordEl = document.createElement("span"); + const subElements: HTMLSpanElement[] = []; + const romanWord = word.romanWord?.trim() ?? ""; + const wordContainer = hasRubyLine + ? document.createElement("div") + : mainWordEl; + + if (hasRubyLine) { + const rubyWordEl = document.createElement("div"); + const rubySegments = this.getRubySegments(word); + for (const ruby of rubySegments) { + const rubyPartEl = document.createElement("span"); + rubyPartEl.textContent = ruby.word; + rubyPartEl.dataset.startTime = String(ruby.startTime); + rubyPartEl.dataset.endTime = String(ruby.endTime); + rubyWordEl.appendChild(rubyPartEl); + } + rubyWordEl.classList.add(styles.rubyWord); + mainWordEl.classList.add(styles.wordWithRuby); + wordContainer.classList.add(styles.wordBody); + mainWordEl.appendChild(rubyWordEl); + mainWordEl.appendChild(wordContainer); + } + + const displayWord = this.lyricPlayer.processObsceneWord(word); + + if (shouldEmphasize) { + mainWordEl.classList.add(styles.emphasize); + const trimmedWord = displayWord.trim(); + + if (LyricLineBase.graphemeSegmenter) { + for (const { segment } of LyricLineBase.graphemeSegmenter.segment( + trimmedWord, + )) { + const charEl = document.createElement("span"); + charEl.textContent = segment; + subElements.push(charEl); + wordContainer.appendChild(charEl); + } + } else { + for (const segment of Array.from(trimmedWord)) { + const charEl = document.createElement("span"); + charEl.textContent = segment; + subElements.push(charEl); + wordContainer.appendChild(charEl); + } + } + } else { + if (hasRomanLine) { + const wordEl = document.createElement("div"); + wordEl.textContent = displayWord.trim(); + wordContainer.appendChild(wordEl); + } else if (romanWord.length === 0) { + wordContainer.textContent = displayWord.trim(); + } + } + + if (hasRomanLine) { + const romanWordEl = document.createElement("div"); + romanWordEl.textContent = romanWord.length > 0 ? romanWord : "\u00A0"; + romanWordEl.classList.add(styles.romanWord); + wordContainer.appendChild(romanWordEl); + } + + const realWord: RealWord = { + ...word, + mainElement: mainWordEl, + subElements: subElements, + elementAnimations: [this.initFloatAnimation(word, mainWordEl)], + maskAnimations: [], + width: 0, + height: 0, + padding: 0, + shouldEmphasize: shouldEmphasize, + }; + + return realWord; + } + + private buildWord( + input: LyricWord | LyricWord[], + main: HTMLDivElement, + hasRubyLine: boolean, + hasRomanLine: boolean, + ) { + const chunk = Array.isArray(input) ? input : [input]; + if (chunk.length === 0) return; + + const isPureSpace = chunk.every((w) => !w.word.trim()); + if (isPureSpace) { + const textContent = chunk.map((w) => w.word).join(""); + main.appendChild(document.createTextNode(textContent)); + return; + } + + const merged = chunk.reduce( + (a, b) => { + a.endTime = Math.max(a.endTime, b.endTime); + a.startTime = Math.min(a.startTime, b.startTime); + a.word += b.word; + return a; + }, + { + word: "", + romanWord: "", + startTime: Number.POSITIVE_INFINITY, + endTime: Number.NEGATIVE_INFINITY, + wordType: "normal", + obscene: false, + } as LyricWord, + ); + + let emp = chunk.some((word) => LyricLineBase.shouldEmphasize(word)); + if (!isCJK(merged.word)) { + emp = emp || LyricLineBase.shouldEmphasize(merged); + } + + const wrapperWordEl = document.createElement("span"); + wrapperWordEl.classList.add(styles.emphasizeWrapper); + + const characterElements: HTMLElement[] = []; + + for (const word of chunk) { + if (!word.word.trim()) { + wrapperWordEl.appendChild(document.createTextNode(word.word)); + continue; + } + + const realWord = this.createWord(word, emp, hasRubyLine, hasRomanLine); + + if (emp) { + characterElements.push(...realWord.subElements); + } + + this.splittedWords.push(realWord); + wrapperWordEl.appendChild(realWord.mainElement); + } + + if (emp && this.splittedWords.length > 0) { + const lastWordOfChunk = this.splittedWords[this.splittedWords.length - 1]; + const rubyCharCount = chunk.reduce( + (total, word) => total + this.getRubyCharCount(word), + 0, + ); + + lastWordOfChunk.elementAnimations.push( + ...this.initEmphasizeAnimation( + merged, + characterElements, + merged.endTime - merged.startTime, + merged.startTime - this.lyricLine.startTime, + rubyCharCount, + ), + ); + } + + main.appendChild(wrapperWordEl); + } + + private initFloatAnimation(word: LyricWord, wordEl: HTMLSpanElement) { + const delay = word.startTime - this.lyricLine.startTime; + const duration = Math.max(1000, word.endTime - word.startTime); + let up = 0.05; + if (this.lyricLine.isBG) { + up *= 2; + } + const a = wordEl.animate( + [ + { + transform: "translateY(0px)", + }, + { + transform: `translateY(${-up}em)`, + }, + ], + { + duration: Number.isFinite(duration) ? duration : 0, + delay: Number.isFinite(delay) ? delay : 0, + id: "float-word", + composite: "add", + fill: "both", + easing: "ease-out", + }, + ); + a.pause(); + return a; + } + // 按照原 Apple Music 参考,强调效果只应用缩放、轻微左右位移和辉光效果,原主要的悬浮位移效果不变 + // 为了避免产生锯齿抖动感,使用 matrix3d 来实现缩放和位移 + private initEmphasizeAnimation( + word: LyricWord, + characterElements: HTMLElement[], + duration: number, + delay: number, + rubyCharCount: number, + ): Animation[] { + const de = clampPositive(delay); + let du = Math.max(1000, duration); + const anchorCharCount = + rubyCharCount > 0 ? rubyCharCount : Math.max(1, characterElements.length); + + let result: Animation[] = []; + + let amount = du / 2000; + amount = amount > 1 ? Math.sqrt(amount) : amount ** 3; + let blur = du / 3000; + blur = blur > 1 ? Math.sqrt(blur) : blur ** 3; + amount *= 0.6; + blur *= 0.5; + if ( + this.lyricLine.words.length > 0 && + word.word.includes( + this.lyricLine.words[this.lyricLine.words.length - 1].word, + ) + ) { + amount *= 1.6; + blur *= 1.5; + du *= 1.2; + } + amount = Math.min(1.2, amount); + blur = Math.min(0.8, blur); + + const animateDu = Number.isFinite(du) ? du : 0; + const empEasing = makeEmpEasing(EMP_EASING_MID); + + result = characterElements.flatMap((el, i, arr) => { + const wordDe = de + (du / 2.5 / anchorCharCount) * i; + const result: Animation[] = []; + + const frames: Keyframe[] = new Array(ANIMATION_FRAME_QUANTITY) + .fill(0) + .map((_, j) => { + const x = (j + 1) / ANIMATION_FRAME_QUANTITY; + const transX = empEasing(x); + const glowLevel = empEasing(x) * blur; + + const mat = scaleMatrix4(createMatrix4(), 1 + transX * 0.1 * amount); + const offsetX = -transX * 0.03 * amount * (arr.length / 2 - i); + const offsetY = -transX * 0.025 * amount; + + return { + offset: x, + transform: `${matrix4ToCSS( + mat, + 4, + )} translate(${offsetX}em, ${offsetY}em)`, + textShadow: `0 0 ${Math.min( + 0.3, + blur * 0.3, + )}em rgba(255, 255, 255, ${glowLevel})`, + }; + }); + + const glow = el.animate(frames, { + duration: animateDu, + delay: Number.isFinite(wordDe) ? wordDe : 0, + id: `emphasize-word-${el.textContent}-${i}`, + iterations: 1, + composite: "replace", + fill: "both", + }); + glow.onfinish = () => { + glow.pause(); + }; + glow.pause(); + result.push(glow); + + const floatFrame: Keyframe[] = new Array(ANIMATION_FRAME_QUANTITY) + .fill(0) + .map((_, j) => { + const x = (j + 1) / ANIMATION_FRAME_QUANTITY; + let y = Math.sin(x * Math.PI); + // y = x < 0.5 ? y : Math.max(y, 1.0); + if (this.lyricLine.isBG) { + y *= 2; + } + + return { + offset: x, + transform: `translateY(${-y * 0.05}em)`, + }; + }); + const float = el.animate(floatFrame, { + duration: animateDu * 1.4, + delay: Number.isFinite(wordDe) ? wordDe - 400 : 0, + id: "emphasize-word-float", + iterations: 1, + composite: "add", + fill: "both", + }); + float.onfinish = () => { + float.pause(); + }; + float.pause(); + result.push(float); + + return result; + }); + + return result; + } + + private get totalDuration() { + return this.lyricLine.endTime - this.lyricLine.startTime; + } + + override onLineSizeChange(_size: [number, number]): void { + this.updateMaskImageSync(); + } + updateMaskImageSync(): void { + for (const word of this.splittedWords) { + const el = word.mainElement; + if (el) { + word.padding = Number.parseFloat(getComputedStyle(el).paddingLeft); + word.width = el.clientWidth - word.padding * 2; + word.height = el.clientHeight - word.padding * 2; + } else { + word.width = 0; + word.height = 0; + word.padding = 0; + } + } + if (this.balancer && LyricLineBase.wordSegmenter) { + this.balancer.balanceLineBreaks( + this.lyricPlayer._getIsNonDynamic(), + this.splittedWords.length > 0, + LyricLineBase.wordSegmenter, + ); + } + if (this.lyricPlayer.supportMaskImage) { + this.generateWebAnimationBasedMaskImage(); + } else { + this.generateCalcBasedMaskImage(); + } + if (this.isEnabled) { + const isPlayerRunning = this.lyricPlayer.getIsPlaying?.() ?? true; + this.enable(this.lyricPlayer.getCurrentTime(), isPlayerRunning); + } + } + + private generateCalcBasedMaskImage() { + for (const word of this.splittedWords) { + const wordEl = word.mainElement; + if (wordEl) { + word.width = wordEl.clientWidth; + word.height = wordEl.clientHeight; + const fadeWidth = word.height * this.lyricPlayer.getWordFadeWidth(); + const [maskImage, totalAspect] = generateFadeGradient( + fadeWidth / word.width, + ); + const totalAspectStr = `${totalAspect * 100}% 100%`; + if (this.lyricPlayer.supportMaskImage) { + wordEl.style.maskImage = maskImage; + wordEl.style.maskRepeat = "no-repeat"; + wordEl.style.maskOrigin = "left"; + wordEl.style.maskSize = totalAspectStr; + } else { + wordEl.style.webkitMaskImage = maskImage; + wordEl.style.webkitMaskRepeat = "no-repeat"; + wordEl.style.webkitMaskOrigin = "left"; + wordEl.style.webkitMaskSize = totalAspectStr; + } + const w = word.width + fadeWidth; + const maskPos = `clamp(${-w}px,calc(${-w}px + (var(--amll-player-time) - ${ + word.startTime + })*${ + w / Math.abs(word.endTime - word.startTime) + }px),0px) 0px, left top`; + wordEl.style.maskPosition = maskPos; + wordEl.style.webkitMaskPosition = maskPos; + } + } + } + + private generateWebAnimationBasedMaskImage() { + // 因为歌词行有可能比行内单词的结束时间早,有可能导致过渡动画提早停止出现瑕疵 + // 所以要以单词的结束时间为准 + const totalFadeDuration = + Math.max( + 0, + ...this.splittedWords.map((w) => w.endTime), + this.lyricLine.endTime, + ) - this.lyricLine.startTime; + this.splittedWords.forEach((word, i) => { + const wordEl = word.mainElement; + if (wordEl) { + const fadeWidth = word.height * this.lyricPlayer.getWordFadeWidth(); + const [maskImage, totalAspect] = generateFadeGradient( + fadeWidth / (word.width + word.padding * 2), + ); + const totalAspectStr = `${totalAspect * 100}% 100%`; + if (this.lyricPlayer.supportMaskImage) { + wordEl.style.maskImage = maskImage; + wordEl.style.maskRepeat = "no-repeat"; + wordEl.style.maskOrigin = "left"; + wordEl.style.maskSize = totalAspectStr; + } else { + wordEl.style.webkitMaskImage = maskImage; + wordEl.style.webkitMaskRepeat = "no-repeat"; + wordEl.style.webkitMaskOrigin = "left"; + wordEl.style.webkitMaskSize = totalAspectStr; + } + // 为了尽可能将渐变动画在相连的每个单词间近似衔接起来 + // 要综合每个单词的效果时间和间隙生成动画帧数组 + const widthBeforeSelf = + this.splittedWords.slice(0, i).reduce((a, b) => a + b.width, 0) + + (this.splittedWords[0] ? fadeWidth : 0); + const minOffset = -(word.width + word.padding * 2 + fadeWidth); + const clampOffset = (x: number) => clamp(x, minOffset, 0); + let curPos = -widthBeforeSelf - word.width - word.padding - fadeWidth; + let timeOffset = 0; + const frames: Keyframe[] = []; + let lastPos = curPos; + let lastTime = 0; + const pushFrame = () => { + // 此处如果添加过渡函数,会导致单词时序不准确,所以不添加 + // const easing = "cubic-bezier(.33,.12,.83,.9)"; + const moveOffset = curPos - lastPos; + const time = clamp01(timeOffset); + const duration = time - lastTime; + const d = Math.abs(duration / moveOffset); + // 因为有可能会和之前的动画有边界 + if (curPos > minOffset && lastPos < minOffset) { + const staticTime = Math.abs(lastPos - minOffset) * d; + const value = `${clampOffset(lastPos)}px 0`; + const frame: Keyframe = { + offset: lastTime + staticTime, + maskPosition: value, + }; + frames.push(frame); + } + if (curPos > 0 && lastPos < 0) { + const staticTime = Math.abs(lastPos) * d; + const value = `${clampOffset(curPos)}px 0`; + const frame: Keyframe = { + offset: lastTime + staticTime, + maskPosition: value, + }; + frames.push(frame); + } + const value = `${clampOffset(curPos)}px 0`; + const frame: Keyframe = { + offset: time, + maskPosition: value, + }; + frames.push(frame); + lastPos = curPos; + lastTime = time; + }; + pushFrame(); + let lastTimeStamp = 0; + this.splittedWords.forEach((otherWord, j) => { + // 停顿 + { + const curTimeStamp = otherWord.startTime - this.lyricLine.startTime; + const staticDuration = curTimeStamp - lastTimeStamp; + timeOffset += staticDuration / totalFadeDuration; + if (staticDuration > 0) pushFrame(); + lastTimeStamp = curTimeStamp; + } + // 移动 + { + const fadeDuration = clampPositive( + otherWord.endTime - otherWord.startTime, + ); + const rubySegments = this.getRubySegments(otherWord); + const rubyCharCount = rubySegments.reduce( + (total, ruby) => total + ruby.word.length, + 0, + ); + if (rubyCharCount > 0) { + const widthPerChar = otherWord.width / rubyCharCount; + let charIndex = 0; + for (const ruby of rubySegments) { + const rubyStartTime = Number.isFinite(ruby.startTime) + ? ruby.startTime + : otherWord.startTime; + const rubyEndTime = Number.isFinite(ruby.endTime) + ? ruby.endTime + : otherWord.endTime; + const rubyStart = Math.max(rubyStartTime, otherWord.startTime); + const rubyEnd = Math.min( + Math.max(rubyEndTime, rubyStart), + otherWord.endTime, + ); + const rubyStartStamp = rubyStart - this.lyricLine.startTime; + const rubyStaticDuration = rubyStartStamp - lastTimeStamp; + timeOffset += rubyStaticDuration / totalFadeDuration; + if (rubyStaticDuration > 0) pushFrame(); + lastTimeStamp = rubyStartStamp; + const rubyDuration = clampPositive(rubyEnd - rubyStart); + const perCharDuration = rubyDuration / ruby.word.length; + for ( + let rubyCharIndex = 0; + rubyCharIndex < ruby.word.length; + rubyCharIndex++ + ) { + timeOffset += perCharDuration / totalFadeDuration; + curPos += widthPerChar; + if (j === 0 && charIndex === 0) { + curPos += fadeWidth * 1.5; + } + if ( + j === this.splittedWords.length - 1 && + charIndex === rubyCharCount - 1 + ) { + curPos += fadeWidth * 0.5; + } + if (perCharDuration > 0) pushFrame(); + lastTimeStamp += perCharDuration; + charIndex++; + } + } + const wordEndStamp = Math.max( + otherWord.endTime - this.lyricLine.startTime, + lastTimeStamp, + ); + const wordTailDuration = wordEndStamp - lastTimeStamp; + timeOffset += wordTailDuration / totalFadeDuration; + if (wordTailDuration > 0) pushFrame(); + lastTimeStamp = wordEndStamp; + } else { + const segmentCount = 1; + const segmentWidth = otherWord.width / segmentCount; + const segmentDuration = fadeDuration / segmentCount; + for ( + let segmentIndex = 0; + segmentIndex < segmentCount; + segmentIndex++ + ) { + timeOffset += segmentDuration / totalFadeDuration; + curPos += segmentWidth; + if (j === 0 && segmentIndex === 0) { + curPos += fadeWidth * 1.5; + } + if ( + j === this.splittedWords.length - 1 && + segmentIndex === segmentCount - 1 + ) { + curPos += fadeWidth * 0.5; + } + if (segmentDuration > 0) pushFrame(); + lastTimeStamp += segmentDuration; + } + } + } + }); + for (const a of word.maskAnimations) { + a.cancel(); + } + try { + // TODO: 如果此处动画帧计算出错,需要一个后备方案 + // 此处如果添加过渡函数,会导致单词时序不准确,所以不添加 + const ani = wordEl.animate(frames, { + duration: totalFadeDuration || 1, + id: `fade-word-${word.word}-${i}`, + fill: "both", + }); + ani.pause(); + word.maskAnimations = [ani]; + } catch (err) { + console.warn("应用渐变动画发生错误", frames, totalFadeDuration, err); + } + } + }); + } + getElement(): HTMLElement { + return this.element; + } + + private updateMaskAlphaTargets(scale: number) { + const factor = clamp01((scale - 0.97) / 0.03); + const dynamicDarkAlpha = factor * 0.2 + 0.2; + const dynamicBrightAlpha = factor * 0.8 + 0.2; + + if (this.renderMode === LyricLineRenderMode.SOLID) { + this.targetBrightAlpha = dynamicDarkAlpha; + this.targetDarkAlpha = dynamicDarkAlpha; + } else { + this.targetBrightAlpha = dynamicBrightAlpha; + this.targetDarkAlpha = dynamicDarkAlpha; + } + } + + private applyAlphaToDom(delta: number) { + const dt = delta || 0.016; + const ATTACK_SPEED = 50.0; + const RELEASE_SPEED = 7.0; + const getFactor = (speed: number) => 1 - Math.exp(-speed * dt); + + // 根据即将变亮还是变暗选择速度 + // 如果即将变亮,让速度非常快,以免播放到第一个字的时候透明度还在慢慢增加导致看不清 + const isBrightening = this.targetBrightAlpha > this.currentBrightAlpha; + const brightSpeed = isBrightening ? ATTACK_SPEED : RELEASE_SPEED; + const brightFactor = getFactor(brightSpeed); + + if (Math.abs(this.targetBrightAlpha - this.currentBrightAlpha) < 0.001) { + this.currentBrightAlpha = this.targetBrightAlpha; + } else { + this.currentBrightAlpha += + (this.targetBrightAlpha - this.currentBrightAlpha) * brightFactor; + } + + const isDarkening = this.targetDarkAlpha > this.currentDarkAlpha; + const darkSpeed = isDarkening ? ATTACK_SPEED : RELEASE_SPEED; + const darkFactor = getFactor(darkSpeed); + + if (Math.abs(this.targetDarkAlpha - this.currentDarkAlpha) < 0.001) { + this.currentDarkAlpha = this.targetDarkAlpha; + } else { + this.currentDarkAlpha += + (this.targetDarkAlpha - this.currentDarkAlpha) * darkFactor; + } + + this.element.style.setProperty( + "--bright-mask-alpha", + this.currentBrightAlpha.toFixed(3), + ); + this.element.style.setProperty( + "--dark-mask-alpha", + this.currentDarkAlpha.toFixed(3), + ); + } + + override setTransform( + scale: number = this.scale, + opacity = 1, + blur = 0, + force = false, + delay = 0, + mode: LyricLineRenderMode = LyricLineRenderMode.SOLID, + ): void { + super.setTransform(scale, opacity, blur, force, delay); + + this.renderMode = mode; + const enableSpring = this.lyricPlayer.getEnableSpring(); + + this.top = 0; + this.scale = scale; + this.delay = (delay * 1000) | 0; + + const main = this.element.children[0] as HTMLDivElement; + main.style.opacity = `${opacity}`; + + if (force || !enableSpring) { + this.blur = Math.min(32, blur); + this.lineTransforms.scale.setPosition(scale); + + this.rebuildStyle(); + + const currentScale = this.lineTransforms.scale.getCurrentPosition(); + this.updateMaskAlphaTargets(currentScale / 100); + this.currentBrightAlpha = this.targetBrightAlpha; + this.currentDarkAlpha = this.targetDarkAlpha; + this.element.style.setProperty( + "--bright-mask-alpha", + String(this.currentBrightAlpha), + ); + this.element.style.setProperty( + "--dark-mask-alpha", + String(this.currentDarkAlpha), + ); + } else { + this.lineTransforms.scale.setTargetPosition(scale); + if (this.blur !== Math.min(5, blur)) { + this.blur = Math.min(5, blur); + this.element.style.filter = `blur(${blur.toFixed(3)}px)`; + } + } + } + + update(delta = 0): void { + if (!this.lyricPlayer.getEnableSpring()) return; + + this.lineTransforms.scale.update(delta); + this.rebuildStyle(); + + if (!this.built) return; + + const currentScale = this.lineTransforms.scale.getCurrentPosition() / 100; + this.updateMaskAlphaTargets(currentScale); + this.applyAlphaToDom(delta); + } + + /** @internal */ + _getDebugTargetPos(): string { + return `[位移: ${this.top}; 缩放: ${this.scale}; 延时: ${this.delay}]`; + } + + teardownContent(): void { + if (this.built) { + this.disposeElements(); + this.built = false; + } + } + + private disposeElements() { + this.balancer?.reset(); + for (const realWord of this.splittedWords) { + for (const a of realWord.elementAnimations) { + a.cancel(); + } + for (const a of realWord.maskAnimations) { + a.cancel(); + } + for (const sub of realWord.subElements) { + sub.remove(); + sub.parentNode?.removeChild(sub); + } + realWord.elementAnimations = []; + realWord.maskAnimations = []; + realWord.subElements = []; + if (realWord.mainElement?.parentNode) { + realWord.mainElement.parentNode.removeChild(realWord.mainElement); + } + } + this.splittedWords = []; + const main = this.element.children[0] as HTMLDivElement; + const trans = this.element.children[1] as HTMLDivElement; + const roman = this.element.children[2] as HTMLDivElement; + if (main) main.innerHTML = ""; + if (trans) trans.innerHTML = ""; + if (roman) roman.innerHTML = ""; + } + override dispose(): void { + this.disposeElements(); + this.lyricPlayer.resizeObserver.unobserve(this.element); + this.element.remove(); + } +} diff --git a/amll-local/packages/core/src/lyric-player/index.ts b/amll-local/packages/core/src/lyric-player/index.ts new file mode 100644 index 0000000..7be9d7c --- /dev/null +++ b/amll-local/packages/core/src/lyric-player/index.ts @@ -0,0 +1,13 @@ +import { DomLyricPlayer } from "./dom/index.ts"; + +export * from "./base/consts.ts"; +export * from "./base/index.ts"; + +export * from "./dom/index.ts"; + +export { + /** + * 默认导出的歌词播放组件 + */ + DomLyricPlayer as LyricPlayer, +}; diff --git a/amll-local/packages/core/src/styles/index.css b/amll-local/packages/core/src/styles/index.css new file mode 100644 index 0000000..0e72f2c --- /dev/null +++ b/amll-local/packages/core/src/styles/index.css @@ -0,0 +1,36 @@ +/* + 给不需要重命名的全局类名的样式 +*/ + +/* 给歌词视图元素应用的样式 */ +.amll-lyric-player { + width: 100%; + height: 100%; + overflow: hidden; + max-width: 100%; + color: var(--amll-lp-color, white); + contain: strict; + mix-blend-mode: plus-lighter; + font-size: var(--amll-lp-font-size, max(max(5vh, 2.5vw), 12px)); + + @media screen and (max-width: 768px) { + font-size: var(--amll-lp-font-size, max(8vw, 12px)); + } + + &.dom { + --amll-lp-line-width-aspect: 0.8; + --amll-lp-line-padding-x: 1em; + --amll-lp-bg-line-scale: 0.7; + user-select: none; + box-sizing: content-box; + z-index: 1; + line-height: 1.2; + } +} + +@media screen and (max-width: 768px) { + .amll-lyric-player { + --amll-lp-line-width-aspect: 1; + --amll-lp-line-padding-x: 0; + } +} diff --git a/amll-local/packages/core/src/styles/lyric-player.module.css b/amll-local/packages/core/src/styles/lyric-player.module.css new file mode 100644 index 0000000..75cf0c7 --- /dev/null +++ b/amll-local/packages/core/src/styles/lyric-player.module.css @@ -0,0 +1,298 @@ +.lyricLineWrapper { + position: absolute; + top: 0; + left: 0; + display: flex; + flex-direction: column; + align-items: flex-start; + width: 100%; + box-sizing: border-box; + padding: 0.4em var(--lyric-line-padding-x); + border-radius: 0.25em; + + gap: 0.3em; + + will-change: transform, opacity, filter; + transition: + opacity 0.4s ease, + filter 0.4s ease, + background-color 0.25s ease; + + &:hover { + background-color: var(--amll-lp-hover-bg-color, #fff1); + } + + &:active { + background-color: var(--amll-lp-hover-bg-color, #ffffff05); + } +} + +.lyricLine { + position: relative; + box-sizing: border-box; + width: var(--amll-lp-width, 100%); + min-width: var(--amll-lp-width, 100%); + max-width: var(--amll-lp-width, 100%); + height: fit-content; + + /* Keep font descenders inside the paint containment without changing layout. */ + padding: 0.2em 0.2em 0.32em; + margin: -0.2em -0.2em -0.32em; + + contain: layout style paint; + content-visibility: auto; + contain-intrinsic-size: auto 40px; + backface-visibility: hidden; + transform-origin: left; + will-change: transform, filter, opacity; + + transition: box-shadow 0.25s; +} + +.lyricDuetLine { + text-align: right; + transform-origin: right; +} + +.lyricMainLine { + margin: -1em; + padding: 1em; + transition: opacity 0.3s 0.1s; + contain: layout style; + + & span { + display: inline-block; + text-align: start; + vertical-align: bottom; + } + + .romanWord { + display: flex; + padding-inline-end: 0.3em; + font-size: 0.5em; + line-height: 1em; + } + + .rubyWord { + display: flex; + justify-content: center; + min-height: 1em; + font-size: 0.5em; + line-height: 1em; + } + + .wordWithRuby { + display: inline-flex; + flex-direction: column; + align-items: center; + vertical-align: bottom; + } + + .wordBody { + display: flex; + flex-direction: column; + align-items: center; + } + + & > span, + span.emphasizeWrapper { + display: inline-block; + margin: -1em; + padding: 1em; + white-space: pre-wrap; + vertical-align: bottom; + will-change: transform, text-shadow; + contain: layout style; + + &.emphasize, + span.emphasize { + margin: -1em; + padding: 1em; + backface-visibility: hidden; + + & > span { + margin: -1em; + padding: 1em; + will-change: transform, text-shadow; + backface-visibility: hidden; + } + } + } +} + +.lyricBgLine { + opacity: 0.4; + font-size: max(calc(1em * var(--amll-lp-bg-line-scale, 0.7)), 10px); + transition: + background-color 0.25s, + box-shadow 0.25s; + + .lyricMainLine { + padding: 1.2em 1em; + } + + &.active { + opacity: 0.4; + transition: + background-color 0.25s, + box-shadow 0.25s; + } +} + +.lyricSubLine { + opacity: 0.3; + font-size: max(0.5em, 10px); + line-height: 1.5em; + transition: opacity 0.2s 0.25s; + + @supports (mix-blend-mode: plus-lighter) { + opacity: 0.3; + } +} + +.bottomLine { + padding-top: 0; + padding-bottom: 0; + line-height: 1.8em; + cursor: default; + + &:empty { + display: none; + height: 0; + margin: 0; + padding: 0; + } +} + +.bgWrapper { + position: absolute; + top: 100%; + left: var(--lyric-line-padding-x); + z-index: -1; + display: flex; + flex-direction: column; + align-items: inherit; + width: calc(100% - var(--lyric-line-padding-x) * 2); + + visibility: visible; + pointer-events: auto; + opacity: 0; + transform-origin: left top; + transition: opacity 0.3s ease; +} + +.bgWrapperTop { + position: relative; + left: 0; + width: 100%; + top: auto; + bottom: auto; + margin-top: -999px; + transform-origin: left bottom; +} + +.bgWrapperActive { + position: relative; + left: 0; + width: 100%; + top: auto; + bottom: auto; + opacity: 1; +} + +.bgWrapperHidden { + visibility: hidden; + pointer-events: none; +} + +.interludeDots { + position: absolute; + left: 0; + display: flex; + gap: 0.25em; + width: fit-content; + height: clamp(0.5em, 1vh, 3em); + padding: 2.5% 0.75em; + + opacity: 0; + transform-origin: center; + transition: opacity 0.25s; + + &.enabled { + opacity: 1; + } + + & > * { + display: inline-block; + width: clamp(0.5em, 1vh, 3em); + height: clamp(0.5em, 1vh, 3em); + margin-right: 4px; + border-radius: 50%; + background-color: var(--amll-lp-color, white); + aspect-ratio: 1 / 1; + } + + &.duet { + right: 0; + transform-origin: center; + } +} + +.disableSpring > *, +.disableSpring .lyricLine { + transition: + filter 0.25s, + transform 0.5s, + background-color 0.25s, + box-shadow 0.25s; +} + +.tmpDisableTransition { + /* biome-ignore lint/complexity/noImportantStyles: 覆盖内联样式 */ + transition: none !important; +} + +:global(.amll-lyric-player) { + --lyric-line-padding-x: 1em; + + @media screen and (max-width: 500px) { + --lyric-line-padding-x: 20px; + } + + &:hover .lyricLine, + &:hover .lyricLineWrapper { + /* biome-ignore lint/complexity/noImportantStyles: 覆盖内联样式 */ + filter: unset !important; + } + + &:not(.playing) .bgWrapper { + position: relative; + top: auto; + bottom: auto; + opacity: 1; + left: 0; + width: 100%; + } + + &.hasDuetLine { + .lyricLine:not(.lyricDuetLine) { + padding-right: 15%; + } + + .lyricDuetLine { + padding-left: 15%; + } + + .lyricLineWrapper:has(.lyricDuetLine) { + align-items: flex-end; + + .bgWrapper { + transform-origin: right top; + } + + .bgWrapperTop { + transform-origin: right bottom; + } + } + } +} diff --git a/amll-local/packages/core/src/types.d.ts b/amll-local/packages/core/src/types.d.ts new file mode 100644 index 0000000..42622dd --- /dev/null +++ b/amll-local/packages/core/src/types.d.ts @@ -0,0 +1,29 @@ +/** biome-ignore-all lint/correctness/noUnusedVariables: used */ +declare module "*.glsl" { + const content: string; + export default content; +} + +declare module "*.glsl?raw" { + const content: string; + export default content; +} + +declare module "*.module.css" { + const classes: Record; + export default classes; +} + +declare module "*.css" { + const css: string; + export default css; +} + +interface ImportMetaEnv { + readonly DEV: boolean; + readonly PROD: boolean; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/amll-local/packages/core/src/utils/clamp.ts b/amll-local/packages/core/src/utils/clamp.ts new file mode 100644 index 0000000..f329087 --- /dev/null +++ b/amll-local/packages/core/src/utils/clamp.ts @@ -0,0 +1,11 @@ +export function clamp(x: number, min: number, max: number): number { + return Math.min(Math.max(x, min), max); +} + +export function clamp01(x: number): number { + return clamp(x, 0, 1); +} + +export function clampPositive(x: number): number { + return Math.max(0, x); +} diff --git a/amll-local/packages/core/src/utils/debounce.ts b/amll-local/packages/core/src/utils/debounce.ts new file mode 100644 index 0000000..3b7666f --- /dev/null +++ b/amll-local/packages/core/src/utils/debounce.ts @@ -0,0 +1,34 @@ +// biome-ignore lint/suspicious/noExplicitAny: for debounce function +export function debounce any>( + cb: T, + wait = 20, +): (...args: Parameters) => void { + let h: ReturnType | undefined; + const callable = (...args: Parameters) => { + clearTimeout(h); + h = setTimeout(() => cb(...args), wait); + }; + return callable; +} + +// biome-ignore lint/suspicious/noExplicitAny: function can be any +export function debounceFrame any>( + cb: T, + frameTime = 1, +): (...args: Parameters) => void { + let h = 0; + let ft = frameTime; + const callable = (...args: Parameters) => { + ft = frameTime; + cancelAnimationFrame(h); + const onCB = () => { + if (--ft <= 0) { + cb(...args); + } else { + h = requestAnimationFrame(onCB); + } + }; + h = requestAnimationFrame(onCB); + }; + return callable; +} diff --git a/amll-local/packages/core/src/utils/derivative.ts b/amll-local/packages/core/src/utils/derivative.ts new file mode 100644 index 0000000..11ce2ec --- /dev/null +++ b/amll-local/packages/core/src/utils/derivative.ts @@ -0,0 +1,8 @@ +export function derivative(f: (x: number) => number) { + const h = 0.001; + return (x: number): number => (f(x + h) - f(x - h)) / (2 * h); +} + +export function getVelocity(f: (t: number) => number): (t: number) => number { + return derivative(f); +} diff --git a/amll-local/packages/core/src/utils/eq-set.ts b/amll-local/packages/core/src/utils/eq-set.ts new file mode 100644 index 0000000..0befe68 --- /dev/null +++ b/amll-local/packages/core/src/utils/eq-set.ts @@ -0,0 +1,4 @@ +export const eqSet: (xs: Set, ys: Set) => boolean = ( + xs, + ys, +): boolean => xs.size === ys.size && [...xs].every((x) => ys.has(x)); diff --git a/amll-local/packages/core/src/utils/flagsets.ts b/amll-local/packages/core/src/utils/flagsets.ts new file mode 100644 index 0000000..004fc38 --- /dev/null +++ b/amll-local/packages/core/src/utils/flagsets.ts @@ -0,0 +1,59 @@ +const SAFE_FLAG_CHUNK_SIZE = Number.MAX_SAFE_INTEGER.toString(2).length; + +export class FlagSets { + private flags: number[] = []; + private _size = 0; + + get size(): number { + return this._size; + } + + [Symbol.iterator]() { + let flag = 0; + let chunkIndex = 0; + let chunkOffset = 0; + return { + next: (): IteratorResult => { + while (chunkIndex < this.flags.length) { + while (chunkOffset < SAFE_FLAG_CHUNK_SIZE) { + const flagBit = 1 << chunkOffset; + if (this.flags[chunkIndex] & flagBit) { + return { value: flag, done: false }; + } + flag++; + chunkOffset++; + } + chunkIndex++; + chunkOffset = 0; + } + return { value: undefined, done: true }; + }, + }; + } + + add(flag: number): void { + const chunkIndex = (flag / SAFE_FLAG_CHUNK_SIZE) | 0; + const chunkOffset = flag % SAFE_FLAG_CHUNK_SIZE; + const flagBit = 1 << chunkOffset; + if (!(this.flags[chunkIndex] & flagBit)) { + this._size++; + this.flags[chunkIndex] |= flagBit; + } + } + + delete(flag: number): void { + const chunkIndex = (flag / SAFE_FLAG_CHUNK_SIZE) | 0; + const chunkOffset = flag % SAFE_FLAG_CHUNK_SIZE; + const flagBit = 1 << chunkOffset; + if (!(this.flags[chunkIndex] & flagBit)) { + this._size--; + this.flags[chunkIndex] &= ~(1 << chunkOffset); + } + } + + has(flag: number): boolean { + const chunkIndex = (flag / SAFE_FLAG_CHUNK_SIZE) | 0; + const chunkOffset = flag % SAFE_FLAG_CHUNK_SIZE; + return (this.flags[chunkIndex] & (1 << chunkOffset)) !== 0; + } +} diff --git a/amll-local/packages/core/src/utils/is-cjk.ts b/amll-local/packages/core/src/utils/is-cjk.ts new file mode 100644 index 0000000..6d7d5e5 --- /dev/null +++ b/amll-local/packages/core/src/utils/is-cjk.ts @@ -0,0 +1,3 @@ +export const isCJK = (char: string): boolean => { + return /^[\p{Unified_Ideograph}\u0800-\u9FFC]+$/u.test(char); +}; diff --git a/amll-local/packages/core/src/utils/line-balancer.ts b/amll-local/packages/core/src/utils/line-balancer.ts new file mode 100644 index 0000000..fa9e71b --- /dev/null +++ b/amll-local/packages/core/src/utils/line-balancer.ts @@ -0,0 +1,283 @@ +import { clampPositive } from "./clamp.ts"; +import { type ChildNodeInfo, calcBalancedBreaks } from "./lyric-line-break.ts"; + +let sharedCanvasCtx: CanvasRenderingContext2D | null = null; +export function getMeasurementContext(): CanvasRenderingContext2D | null { + if (!sharedCanvasCtx) { + const canvas = document.createElement("canvas"); + sharedCanvasCtx = canvas.getContext("2d"); + } + return sharedCanvasCtx; +} + +interface LineBalanceAdapter { + resetDOM(): void; + buildChildInfos(): { childInfos: ChildNodeInfo[]; fullText: string }; + applyBreaks(breaks: number[], childInfos: ChildNodeInfo[]): void; + needsCalibration: boolean; +} + +/** + * 用于平衡歌词行在换行后的各行长度 + */ +export class LineBalancer { + private isBalancing = false; + private lastBalancedContainerWidth = -1; + + constructor(private mainElement: HTMLDivElement) {} + + public balanceLineBreaks( + isNonDynamic: boolean, + hasSplittedWords: boolean, + wordSegmenter: Intl.Segmenter, + ): void { + if (this.isBalancing || !this.mainElement) return; + + const computedStyle = getComputedStyle(this.mainElement); + const paddingLeft = Number.parseFloat(computedStyle.paddingLeft) || 0; + const paddingRight = Number.parseFloat(computedStyle.paddingRight) || 0; + const containerWidth = + this.mainElement.clientWidth - paddingLeft - paddingRight; + + if (containerWidth <= 0) return; + + if (isNonDynamic) { + this.balanceNonDynamicLineBreaks( + containerWidth, + computedStyle, + wordSegmenter, + ); + return; + } + + if (!hasSplittedWords) return; + this.balanceDynamicLineBreaks(containerWidth, wordSegmenter); + } + + public reset(): void { + this.lastBalancedContainerWidth = -1; + } + + private executeLineBalance( + containerWidth: number, + adapter: LineBalanceAdapter, + wordSegmenter: Intl.Segmenter, + ): void { + const existingBrs = this.mainElement.querySelectorAll("br"); + if ( + containerWidth === this.lastBalancedContainerWidth && + existingBrs.length > 0 + ) { + return; + } + + adapter.resetDOM(); + + // 临时设置 white-space: nowrap 以便测量单个歌词行的宽度 + const prevWhiteSpace = this.mainElement.style.whiteSpace; + this.mainElement.style.whiteSpace = "nowrap"; + + // 临时移除父级的 transform 以便让 getBoundingClientRect 返回纯粹的布局尺寸 + // 基类在 enableScale 时设置的 0.97 缩放倍率会影响到计算的宽度 + const parentElement = this.mainElement.parentElement; + let prevTransform = ""; + let transformChanged = false; + + if (parentElement) { + prevTransform = parentElement.style.transform; + if (prevTransform && prevTransform !== "none") { + parentElement.style.transform = "none"; + transformChanged = true; + } + } + + let lockAcquired = false; + + try { + const { childInfos, fullText } = adapter.buildChildInfos(); + + let layoutWidth = childInfos.reduce((sum, c) => sum + c.width, 0); + + // 非动态歌词(用 Canvas 测量)才用 range 来缩放校准;动态歌词的强调 wrapper 有 1em 的 padding 和 + // margin,用 range 测会把首尾溢出的 1em 也加进来,极大地增大了行长度,视觉上就是非常激进地换行 + if (adapter.needsCalibration) { + const range = document.createRange(); + range.selectNodeContents(this.mainElement); + const visualWidth = range.getBoundingClientRect().width; + + if (layoutWidth > 0 && visualWidth > 0) { + const scale = visualWidth / layoutWidth; + for (const info of childInfos) { + info.width *= scale; + } + } + layoutWidth = visualWidth; + } + + const safeContainerWidth = Math.max(1, containerWidth); + + if (layoutWidth <= safeContainerWidth) { + this.lastBalancedContainerWidth = containerWidth; + return; + } + + const breaks = calcBalancedBreaks( + childInfos, + safeContainerWidth, + fullText, + wordSegmenter, + ); + + if (breaks.length === 0) { + this.lastBalancedContainerWidth = containerWidth; + return; + } + + this.isBalancing = true; + lockAcquired = true; + + adapter.applyBreaks(breaks, childInfos); + this.lastBalancedContainerWidth = containerWidth; + this.isBalancing = false; + } finally { + this.mainElement.style.whiteSpace = prevWhiteSpace; + if (transformChanged && parentElement) { + parentElement.style.transform = prevTransform; + } + + if (lockAcquired) { + this.isBalancing = false; + } + } + } + + private balanceDynamicLineBreaks( + containerWidth: number, + wordSegmenter: Intl.Segmenter, + ): void { + const infoToNode: Node[] = []; + + const dynamicAdapter: LineBalanceAdapter = { + resetDOM: () => { + this.mainElement.querySelectorAll("br").forEach((br) => { + br.remove(); + }); + }, + buildChildInfos: () => { + infoToNode.length = 0; + const childNodes = Array.from(this.mainElement.childNodes); + const childInfos: ChildNodeInfo[] = []; + const range = document.createRange(); + + for (const node of childNodes) { + if (node.nodeType === Node.TEXT_NODE) { + const text = node.textContent ?? ""; + if (text.length === 0) continue; + range.selectNodeContents(node); + childInfos.push({ + width: range.getBoundingClientRect().width, + text, + isSpace: text.trim().length === 0, + }); + infoToNode.push(node); + } else if (node.nodeType === Node.ELEMENT_NODE) { + const el = node as HTMLElement; + const rect = el.getBoundingClientRect(); + const elStyle = getComputedStyle(el); + const marginLeft = Number.parseFloat(elStyle.marginLeft) || 0; + const marginRight = Number.parseFloat(elStyle.marginRight) || 0; + childInfos.push({ + width: clampPositive(rect.width + marginLeft + marginRight), + text: el.textContent ?? "", + isSpace: false, + }); + infoToNode.push(node); + } + } + return { childInfos, fullText: childInfos.map((c) => c.text).join("") }; + }, + applyBreaks: (breaks) => { + for (let i = breaks.length - 1; i >= 0; i--) { + const breakIndex = breaks[i]; + if (breakIndex >= 0 && breakIndex < infoToNode.length) { + this.mainElement.insertBefore( + document.createElement("br"), + infoToNode[breakIndex], + ); + } + } + }, + needsCalibration: false, + }; + + this.executeLineBalance(containerWidth, dynamicAdapter, wordSegmenter); + } + + private balanceNonDynamicLineBreaks( + containerWidth: number, + computedStyle: CSSStyleDeclaration, + wordSegmenter: Intl.Segmenter, + ): void { + const fullText = this.mainElement.textContent ?? ""; + if (fullText.trim().length === 0) return; + + const nonDynamicAdapter: LineBalanceAdapter = { + resetDOM: () => { + this.mainElement.innerHTML = ""; + this.mainElement.textContent = fullText; + }, + buildChildInfos: () => { + const ctx = getMeasurementContext(); + + if (!ctx) { + console.debug( + "Canvas 2D context is not supported, skipping line balancing", + ); + return { childInfos: [], fullText }; + } + + ctx.font = `${computedStyle.fontWeight} ${computedStyle.fontSize} ${computedStyle.fontFamily}`; + + if ("letterSpacing" in ctx) { + ctx.letterSpacing = + computedStyle.letterSpacing !== "normal" + ? computedStyle.letterSpacing + : "0px"; + } + if ("wordSpacing" in ctx) { + ctx.wordSpacing = + computedStyle.wordSpacing !== "normal" + ? computedStyle.wordSpacing + : "0px"; + } + + const childInfos: ChildNodeInfo[] = []; + for (const { segment } of wordSegmenter.segment(fullText)) { + childInfos.push({ + width: ctx.measureText(segment).width, + text: segment, + isSpace: segment.trim().length === 0, + }); + } + + return { childInfos, fullText }; + }, + applyBreaks: (breaks, childInfos) => { + this.mainElement.innerHTML = ""; + const breakSet = new Set(breaks); + const fragment = document.createDocumentFragment(); + + for (let i = 0; i < childInfos.length; i++) { + if (breakSet.has(i)) { + fragment.appendChild(document.createElement("br")); + } + fragment.appendChild(document.createTextNode(childInfos[i].text)); + } + this.mainElement.appendChild(fragment); + }, + needsCalibration: true, + }; + + this.executeLineBalance(containerWidth, nonDynamicAdapter, wordSegmenter); + } +} diff --git a/amll-local/packages/core/src/utils/linear.ts b/amll-local/packages/core/src/utils/linear.ts new file mode 100644 index 0000000..0bd8a2c --- /dev/null +++ b/amll-local/packages/core/src/utils/linear.ts @@ -0,0 +1,120 @@ +type seconds = number; + +export interface LinearParams { + duration: number; // Duration of the transition in seconds +} + +export class Linear { + private currentPosition = 0; + private targetPosition = 0; + private currentTime = 0; + private params: Partial = {}; + private currentSolver: (t: seconds) => number; + private startTime = 0; + private queueParams: + | (Partial & { + time: number; + }) + | undefined; + private queuePosition: + | { + time: number; + position: number; + } + | undefined; + constructor(currentPosition = 0) { + this.targetPosition = currentPosition; + this.currentPosition = this.targetPosition; + this.currentSolver = () => this.targetPosition; + } + private resetSolver() { + this.currentTime = 0; + this.startTime = 0; + this.currentSolver = solveLinear( + this.currentPosition, + this.targetPosition, + this.params, + ); + } + arrived(): boolean { + return ( + Math.abs(this.targetPosition - this.currentPosition) < 0.01 && + this.queueParams === undefined && + this.queuePosition === undefined + ); + } + setPosition(targetPosition: number): void { + this.targetPosition = targetPosition; + this.currentPosition = targetPosition; + this.currentSolver = () => this.targetPosition; + } + update(delta = 0): void { + this.currentTime += delta; + this.currentPosition = this.currentSolver( + this.currentTime - this.startTime, + ); + if (this.queueParams) { + this.queueParams.time -= delta; + if (this.queueParams.time <= 0) { + this.updateParams({ + ...this.queueParams, + }); + } + } + if (this.queuePosition) { + this.queuePosition.time -= delta; + if (this.queuePosition.time <= 0) { + this.setTargetPosition(this.queuePosition.position); + } + } + if (this.arrived()) { + this.setPosition(this.targetPosition); + } + } + updateParams(params: Partial, delay = 0): void { + if (delay > 0) { + this.queueParams = { + ...(this.queuePosition ?? {}), + ...params, + time: delay, + }; + } else { + this.queuePosition = undefined; + this.params = { + ...this.params, + ...params, + }; + this.resetSolver(); + } + } + setTargetPosition(targetPosition: number, delay = 0): void { + if (delay > 0) { + this.queuePosition = { + ...(this.queuePosition ?? {}), + position: targetPosition, + time: delay, + }; + } else { + this.queuePosition = undefined; + this.targetPosition = targetPosition; + this.resetSolver(); + } + } + getCurrentPosition(): number { + return this.currentPosition; + } +} + +function solveLinear( + from: number, + to: number, + params?: Partial, +): (t: seconds) => number { + const duration = params?.duration ?? 1; + const delta = to - from; + return (t: seconds) => { + if (t < 0) return from; + if (t > duration) return to; + return from + (delta * t) / duration; + }; +} diff --git a/amll-local/packages/core/src/utils/lyric-line-break.ts b/amll-local/packages/core/src/utils/lyric-line-break.ts new file mode 100644 index 0000000..0678426 --- /dev/null +++ b/amll-local/packages/core/src/utils/lyric-line-break.ts @@ -0,0 +1,145 @@ +import { isCJK } from "./is-cjk.ts"; + +export interface ChildNodeInfo { + width: number; + text: string; + isSpace: boolean; +} + +/** + * 单个词超过容器宽度时的大惩罚倍数 + */ +const OVERFLOW_PENALTY_MULTIPLIER = 1000; +/** + * 截断 CJK 词组边界的惩罚比例 + * + * 相对于容器宽度 + */ +const CJK_BREAK_PENALTY_RATIO = 0.15; +/** + * 截断普通文本(非空格、非 CJK 词界)的惩罚比例 + */ +const NORMAL_BREAK_PENALTY_RATIO = 0.5; +/** + * 在空格处断开的奖励比例 + */ +const SPACE_BREAK_REWARD_RATIO = 0.4; +/** + * 在标点符号处断开的奖励比例 + * + * 比空格更高以便优先一点在标点处换行 + */ +const PUNCTUATION_BREAK_REWARD_RATIO = 0.6; +const PUNCTUATION_REGEX = /[,.;:!?,。;:!?、)】》」』’”)[\]}>~…]$/; + +/** + * 计算平均行长度的断点位置 + * @param children 子节点信息 + * @param containerWidth 容器可用内容宽度 + * @param fullText 完整的行文本 + * @param segmenter 预创建的 Intl.Segmenter 分词器 + * @returns 需要在其前面插入 `
` 的子节点索引数组,升序 + */ +export function calcBalancedBreaks( + children: ChildNodeInfo[], + containerWidth: number, + fullText: string, + segmenter: Intl.Segmenter, +): number[] { + const n = children.length; + if (n === 0 || containerWidth <= 0) { + return []; + } + + // 计算哪里是 CJK 词组的边界 + const cjkBoundaries = new Set(); + let offset = 0; + for (const { segment, isWordLike } of segmenter.segment(fullText)) { + if (offset > 0 && isWordLike) { + if ([...segment].some((ch) => isCJK(ch))) { + cjkBoundaries.add(offset); + } + } + offset += segment.length; + } + + // 计算前缀宽和字符偏移量用于快速查询 + const charOffsets = new Int32Array(n + 1); + const prefixWidth = new Float64Array(n + 1); + for (let i = 0; i < n; i++) { + charOffsets[i + 1] = charOffsets[i] + children[i].text.length; + prefixWidth[i + 1] = prefixWidth[i] + children[i].width; + } + + if (prefixWidth[n] <= containerWidth) { + return []; + } + + /** + * dp[i] 表示将 index i 到 n-1 的节点进行排版的最小代价 + */ + const dp = new Float64Array(n + 1).fill(Number.POSITIVE_INFINITY); + const nextBreak = new Int32Array(n + 1).fill(-1); + dp[n] = 0; + + const PENALTY_CJK = (containerWidth * CJK_BREAK_PENALTY_RATIO) ** 2; + const PENALTY_NORMAL = (containerWidth * NORMAL_BREAK_PENALTY_RATIO) ** 2; + + for (let i = n - 1; i >= 0; i--) { + for (let j = i + 1; j <= n; j++) { + const w = prefixWidth[j] - prefixWidth[i]; + + let lineCost = 0; + + if (w > containerWidth) { + if (j === i + 1) { + // 单个无法分割的词自身就比容器宽,被迫独立成行,给大惩罚 + lineCost = (w - containerWidth) ** 2 * OVERFLOW_PENALTY_MULTIPLIER; + } else { + // 行内包含多个超过物理宽度的词就跳过 + continue; + } + } else { + // 迫使所有行的长度方差最小化 + lineCost = (containerWidth - w) ** 2; + } + + let breakPenalty = 0; + if (j < n) { + const prevChild = children[j - 1]; + + // 优先级:标点 > 空格 > CJK 词界 > 普通文本 + if (PUNCTUATION_REGEX.test(prevChild.text)) { + breakPenalty = -( + (containerWidth * PUNCTUATION_BREAK_REWARD_RATIO) ** + 2 + ); + } else if (prevChild.isSpace) { + breakPenalty = -((containerWidth * SPACE_BREAK_REWARD_RATIO) ** 2); + } else if (cjkBoundaries.has(charOffsets[j])) { + breakPenalty = PENALTY_CJK; + } else { + breakPenalty = PENALTY_NORMAL; + } + } + + const totalCost = lineCost + breakPenalty + dp[j]; + + if (totalCost < dp[i]) { + dp[i] = totalCost; + nextBreak[i] = j; + } + } + } + + const breaks: number[] = []; + let curr = 0; + while (curr < n) { + curr = nextBreak[curr]; + if (curr > 0 && curr < n) { + breaks.push(curr); + } + } + + return breaks; +} diff --git a/amll-local/packages/core/src/utils/lyric-split-words.ts b/amll-local/packages/core/src/utils/lyric-split-words.ts new file mode 100644 index 0000000..20bbcdb --- /dev/null +++ b/amll-local/packages/core/src/utils/lyric-split-words.ts @@ -0,0 +1,112 @@ +import type { LyricWord } from "../interfaces.ts"; +import { isCJK } from "./is-cjk.ts"; + +const SPLIT_WHITESPACE_RE = /(\s+)/; +const WHITESPACE_RE = /\s/g; + +/** + * 将输入的单词重新分组,之间没有空格的单词将会组合成一个单词数组 + * + * 例如输入:`["Life", " ", "is", " a", " su", "gar so", "sweet"]` + * + * 应该返回:`["Life", " ", "is", " a", [" su", "gar"], "so", "sweet"]` + * @param words 输入的单词数组 + * @returns 重新分组后的单词数组 + */ +export function chunkAndSplitLyricWords( + words: LyricWord[], +): (LyricWord | LyricWord[])[] { + const result: (LyricWord | LyricWord[])[] = []; + let currentGroup: LyricWord[] = []; + + const flushGroup = () => { + if (currentGroup.length > 0) { + result.push( + currentGroup.length === 1 ? currentGroup[0] : [...currentGroup], + ); + currentGroup = []; + } + }; + + const processAtom = (atom: LyricWord) => { + const isSpace = atom.word.trim().length === 0; + const hasRuby = (atom.ruby?.length ?? 0) > 0; + const isCJKChar = isCJK(atom.word); + + const isMergeable = !isSpace && !hasRuby && !isCJKChar; + + if (isMergeable) { + currentGroup.push(atom); + } else { + flushGroup(); + result.push(atom); + } + }; + + for (const w of words) { + const content = w.word.trim(); + const isSpace = content.length === 0; + const romanWord = w.romanWord ?? ""; + const obscene = w.obscene ?? false; + const hasRuby = (w.ruby?.length ?? 0) > 0; + + if (isSpace || hasRuby) { + processAtom({ ...w }); + continue; + } + + const parts = w.word.split(SPLIT_WHITESPACE_RE).filter((p) => p.length > 0); + + const totalLength = w.word.replace(WHITESPACE_RE, "").length || 1; + const timeSpan = w.endTime - w.startTime; + const timePerUnit = timeSpan / totalLength; + + let currentOffset = 0; + + for (const part of parts) { + if (!part.trim()) { + const startTime = w.startTime + currentOffset * timePerUnit; + processAtom({ + word: part, + romanWord: "", + startTime: startTime, + endTime: startTime, + obscene: obscene, + }); + continue; + } + + if (isCJK(part) && part.length > 1 && romanWord.trim().length === 0) { + const chars = part.split(""); + for (const char of chars) { + const startTime = w.startTime + currentOffset * timePerUnit; + processAtom({ + word: char, + romanWord: "", + startTime: startTime, + endTime: startTime + timePerUnit, + obscene: obscene, + }); + currentOffset += 1; + } + } else { + const partRealLen = part.length; + const startTime = w.startTime + currentOffset * timePerUnit; + const duration = partRealLen * timePerUnit; + + processAtom({ + word: part, + romanWord: romanWord, + startTime: startTime, + endTime: startTime + duration, + obscene: obscene, + }); + currentOffset += partRealLen; + } + } + } + + flushGroup(); + + return result; +} diff --git a/amll-local/packages/core/src/utils/matrix.ts b/amll-local/packages/core/src/utils/matrix.ts new file mode 100644 index 0000000..0cc030d --- /dev/null +++ b/amll-local/packages/core/src/utils/matrix.ts @@ -0,0 +1,47 @@ +// biome-ignore format: matrix +export type Matrix4 = [ + number, number, number, number, + number, number, number, number, + number, number, number, number, + number, number, number, number, +]; + +export function createMatrix4(): Matrix4 { + // biome-ignore format: matrix + return [ + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1 + ]; +} + +export function scaleMatrix4( + m: Matrix4, + scale = 1, + origin = { x: 0, y: 0 }, +): Matrix4 { + const [ox, oy] = [origin.x, origin.y]; + // biome-ignore format: matrix + return [ + m[0] * scale , m[1] * scale , m[2] * scale , m[3], + m[4] * scale , m[5] * scale , m[6] * scale , m[7], + m[8] * scale , m[9] * scale , m[10] * scale, m[11], + m[12] - ox * scale + ox, m[13] - oy * scale + oy, m[14] , m[15] + ]; +} + +export function translateMatrix4(m: Matrix4, x = 0, y = 0, z = 0): Matrix4 { + // biome-ignore format: matrix + return [ + m[0] , m[1] , m[2] , m[3] , + m[4] , m[5] , m[6] , m[7] , + m[8] , m[9] , m[10] , m[11], + m[12] + x, m[13] + y, m[14] + z, m[15] + ]; +} + +export function matrix4ToCSS(m: Matrix4, fractionDigits = 4): string { + const format = (n: number, _: number) => n.toFixed(fractionDigits); + return `matrix3d(${m.map(format).join(", ")})`; +} diff --git a/amll-local/packages/core/src/utils/optimize-lyric.ts b/amll-local/packages/core/src/utils/optimize-lyric.ts new file mode 100644 index 0000000..807b4ce --- /dev/null +++ b/amll-local/packages/core/src/utils/optimize-lyric.ts @@ -0,0 +1,251 @@ +import type { LyricLine, OptimizeLyricOptions } from "../interfaces.ts"; + +const DEFAULT_OPTIMIZE_OPTIONS: OptimizeLyricOptions = { + normalizeSpaces: true, + resetLineTimestamps: true, + convertExcessiveBackgroundLines: true, + syncMainAndBackgroundLines: true, + cleanUnintentionalOverlaps: true, + tryAdvanceStartTime: true, +}; + +/** + * 规范化歌词中的空格,将多个连续空格替换为一个空格 + */ +function normalizeSpaces(lines: LyricLine[]) { + for (const line of lines) { + for (const word of line.words) { + word.word = word.word.replace(/\s+/g, " "); + } + } +} + +/** + * 将行级时间戳强行设为字级时间戳 + */ +function resetLineTimestamps(lines: LyricLine[]) { + for (const line of lines) { + // 主要是给 TTML 解析器打补丁,其解析逐行歌词时获得的词时间戳均为0 + // 如果只有一个词,且该词的起止时间均为0,且行时间戳不全为0,则将行时间戳同步给词时间戳 + if ( + line.words.length === 1 && + line.words[0].startTime === 0 && + line.words[0].endTime === 0 && + (line.startTime !== 0 || line.endTime !== 0) + ) { + line.words[0].startTime = line.startTime; + line.words[0].endTime = line.endTime; + } else if (line.words.length > 0) { + const firstWord = line.words[0]; + const lastWord = line.words[line.words.length - 1]; + + line.startTime = firstWord.startTime; + line.endTime = lastWord.endTime; + } + } +} + +/** + * 把多行背景人声转换为单行背景人声 + 主歌词行的形式 + */ +function convertExcessiveBackgroundLines(lines: LyricLine[]) { + let consecutiveBgCount = 0; + + for (const line of lines) { + if (line.isBG) { + consecutiveBgCount++; + if (consecutiveBgCount > 1) { + line.isBG = false; + } + } else { + consecutiveBgCount = 0; + } + } +} + +/** + * 同步主歌词与背景人声的时间 + * + * 取两者中最早的开始时间和最晚的结束时间,应用给双方 + */ +function syncMainAndBackgroundLines(lines: LyricLine[]) { + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i]; + if (line.isBG) continue; + + const nextLine = lines[i + 1]; + if (nextLine?.isBG) { + const allWords = [...line.words, ...nextLine.words].filter( + (w) => w.word.trim().length > 0, + ); + + if (allWords.length > 0) { + const minStart = Math.min(...allWords.map((w) => w.startTime)); + const maxEnd = Math.max(...allWords.map((w) => w.endTime)); + + const finalStart = Math.min( + minStart, + line.startTime, + nextLine.startTime, + ); + const finalEnd = Math.max(maxEnd, line.endTime, nextLine.endTime); + + line.startTime = finalStart; + line.endTime = finalEnd; + nextLine.startTime = finalStart; + nextLine.endTime = finalEnd; + } + } + } +} + +/** + * 清洗非刻意的重叠 + * + * 如果重叠大于100ms 且 重叠超过下一行时长的10%,则视为刻意重叠,否则将结束时间设为下一行的开始时间 + */ +function cleanUnintentionalOverlaps(lines: LyricLine[]) { + for (let i = 0; i < lines.length - 1; i++) { + const line = lines[i]; + if (line.isBG) continue; + + let nextMainIndex = i + 1; + while (nextMainIndex < lines.length && lines[nextMainIndex].isBG) { + nextMainIndex++; + } + + if (nextMainIndex < lines.length) { + const nextLine = lines[nextMainIndex]; + const overlap = line.endTime - nextLine.startTime; + + if (overlap > 0) { + const nextDuration = nextLine.endTime - nextLine.startTime; + const percentageThreshold = nextDuration * 0.1; + + // 重叠大于100ms 且 重叠超过下一行时长的10% + const isIntentionalOverlap = + overlap > 100 && overlap > percentageThreshold; + + if (!isIntentionalOverlap) { + line.endTime = nextLine.startTime; + + const attachedBgLine = lines[i + 1]; + if (attachedBgLine?.isBG) { + attachedBgLine.endTime = nextLine.startTime; + } + } + } + } + } +} + +/** + * 尝试让歌词提前最多 600ms 开始,如果有重叠则尝试最多提前 400ms 或上一行时长的 30% + */ +function tryAdvanceStartTime(lines: LyricLine[]) { + const defaultAdvanceAmount = 600; + const fallbackAdvanceAmount = 400; + const fallbackAdvanceRatio = 0.3; + + let prevLineStartTime = 0; + let prevLineEndTime = 0; + let prevMainGroupStartTime = 0; + let prevMainGroupEndTime = 0; + let hasPrevLine = false; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (line.isBG) continue; + + const originalStartTime = line.startTime; + const originalEndTime = line.endTime; + + let targetAdvanceAmount = 0; + let safeBoundary = 0; + + if (hasPrevLine) { + const originallyHadGap = originalStartTime >= prevLineEndTime; + + if (originallyHadGap) { + targetAdvanceAmount = defaultAdvanceAmount; + safeBoundary = prevMainGroupEndTime; + } else { + targetAdvanceAmount = fallbackAdvanceAmount; + const prevDuration = prevLineEndTime - prevLineStartTime; + safeBoundary = prevLineStartTime + prevDuration * fallbackAdvanceRatio; + } + } else { + targetAdvanceAmount = defaultAdvanceAmount; + safeBoundary = 0; + } + + const targetTime = line.startTime - targetAdvanceAmount; + const newStartTime = Math.max(safeBoundary, targetTime); + + if (newStartTime < line.startTime) { + line.startTime = newStartTime; + } + + const nextLine = lines[i + 1]; + if (nextLine?.isBG) { + nextLine.startTime = line.startTime; + } + + if (hasPrevLine) { + const overlapsPrevGroup = + originalStartTime < prevMainGroupEndTime && + originalEndTime > prevMainGroupStartTime; + + if (overlapsPrevGroup) { + prevMainGroupStartTime = Math.min( + prevMainGroupStartTime, + originalStartTime, + ); + prevMainGroupEndTime = Math.max(prevMainGroupEndTime, originalEndTime); + } else { + prevMainGroupStartTime = originalStartTime; + prevMainGroupEndTime = originalEndTime; + } + } else { + prevMainGroupStartTime = originalStartTime; + prevMainGroupEndTime = originalEndTime; + } + + prevLineStartTime = originalStartTime; + prevLineEndTime = originalEndTime; + hasPrevLine = true; + } +} + +/** + * 优化歌词行的展示效果 + * + * 注意会直接原地修改入参,确保你已经提前深克隆了歌词行数组 + * @param lines 歌词行数组 + * @param options 优化的可选配置,默认全部开启 + */ +export function optimizeLyricLines( + lines: LyricLine[], + options?: OptimizeLyricOptions, +): void { + const config = { ...DEFAULT_OPTIMIZE_OPTIONS, ...options }; + + if (config.normalizeSpaces) { + normalizeSpaces(lines); + } + if (config.resetLineTimestamps) { + resetLineTimestamps(lines); + } + if (config.convertExcessiveBackgroundLines) { + convertExcessiveBackgroundLines(lines); + } + if (config.syncMainAndBackgroundLines) { + syncMainAndBackgroundLines(lines); + } + if (config.cleanUnintentionalOverlaps) { + cleanUnintentionalOverlaps(lines); + } + if (config.tryAdvanceStartTime) { + tryAdvanceStartTime(lines); + } +} diff --git a/amll-local/packages/core/src/utils/resource.ts b/amll-local/packages/core/src/utils/resource.ts new file mode 100644 index 0000000..3f33fe2 --- /dev/null +++ b/amll-local/packages/core/src/utils/resource.ts @@ -0,0 +1,79 @@ +export function loadImage(imageUrl: string): Promise { + return new Promise((resolve, reject) => { + const img = document.createElement("img"); + img.onload = () => resolve(img); + img.onerror = reject; + img.src = imageUrl; + img.crossOrigin = "anonymous"; + img.loading = "eager"; + }); +} + +export function loadVideo(videoUrl: string): Promise { + return new Promise((resolve, reject) => { + const video = document.createElement("video"); + let playing = false; + let timeupdate = false; + let rejected = false; + video.addEventListener( + "playing", + () => { + playing = true; + checkReady(); + }, + true, + ); + video.addEventListener( + "timeupdate", + () => { + timeupdate = true; + checkReady(); + }, + true, + ); + video.addEventListener( + "error", + (err) => { + rejected = true; + reject(err); + }, + true, + ); + function checkReady() { + if (playing && timeupdate && !rejected) { + resolve(video); + } + } + video.src = videoUrl; + video.playsInline = true; + video.crossOrigin = "anonymous"; + video.autoplay = true; + video.loop = true; + video.muted = true; + video.play(); + }); +} + +export function loadResourceFromUrl( + url: string, + isVideo = false, +): Promise { + return isVideo ? loadVideo(url) : loadImage(url); +} + +export function loadResourceFromElement( + element: HTMLImageElement | HTMLVideoElement, +): Promise { + return new Promise((resolve, reject) => { + if ( + element instanceof HTMLImageElement + ? element.complete + : element.readyState >= 3 + ) { + resolve(element); + } else { + element.onload = () => resolve(element); + element.onerror = reject; + } + }); +} diff --git a/amll-local/packages/core/src/utils/schedule.ts b/amll-local/packages/core/src/utils/schedule.ts new file mode 100644 index 0000000..0242c55 --- /dev/null +++ b/amll-local/packages/core/src/utils/schedule.ts @@ -0,0 +1,75 @@ +/** + * @fileoverview + * @see https://github.com/wilsonpage/fastdom/blob/master/fastdom.js + */ + +interface Task { + task: () => T; + resolve: (value: T) => void; + reject: (reason?: unknown) => void; +} + +// biome-ignore lint/suspicious/noExplicitAny: util functions +const measureTasks: Task[] = []; +// biome-ignore lint/suspicious/noExplicitAny: util functions +const mutateTasks: Task[] = []; +let scheduled = false; + +function onFlush() { + let tmp = mutateTasks.shift(); + while (tmp) { + try { + tmp.resolve(tmp.task()); + } catch (error) { + tmp.reject(error); + } + tmp = mutateTasks.shift(); + } + tmp = measureTasks.shift(); + while (tmp) { + try { + tmp.resolve(tmp.task()); + } catch (error) { + tmp.reject(error); + } + tmp = measureTasks.shift(); + } + scheduled = false; +} + +function scheduleFlush() { + if (!scheduled) { + scheduled = true; + requestAnimationFrame(onFlush); + } +} + +export function measure(callback: () => T): Promise { + const task: Task = { + task: callback, + resolve: () => {}, + reject: () => {}, + }; + const promise = new Promise((resolve, reject) => { + task.resolve = resolve; + task.reject = reject; + }); + measureTasks.push(task); + scheduleFlush(); + return promise; +} + +export function mutate(callback: () => void): Promise { + const task: Task = { + task: callback, + resolve: () => {}, + reject: () => {}, + }; + const promise = new Promise((resolve, reject) => { + task.resolve = resolve; + task.reject = reject; + }); + mutateTasks.push(task); + scheduleFlush(); + return promise; +} diff --git a/amll-local/packages/core/src/utils/spring.ts b/amll-local/packages/core/src/utils/spring.ts new file mode 100644 index 0000000..32da1e4 --- /dev/null +++ b/amll-local/packages/core/src/utils/spring.ts @@ -0,0 +1,158 @@ +import { getVelocity } from "./derivative.ts"; + +/** MIT License github.com/pushkine/ */ +export interface SpringParams { + mass: number; // = 1.0 + damping: number; // = 10.0 + stiffness: number; // = 100.0 + soft: boolean; // = false +} + +type seconds = number; + +export class Spring { + private currentPosition = 0; + private targetPosition = 0; + private currentTime = 0; + private params: Partial = {}; + private currentSolver: (t: seconds) => number; + private getV: (t: seconds) => number; + private getV2: (t: seconds) => number; + private queueParams: + | (Partial & { + time: number; + }) + | undefined; + private queuePosition: + | { + time: number; + position: number; + } + | undefined; + constructor(currentPosition = 0) { + this.targetPosition = currentPosition; + this.currentPosition = this.targetPosition; + this.currentSolver = () => this.targetPosition; + this.getV = () => 0; + this.getV2 = () => 0; + } + private resetSolver() { + const curV = this.getV(this.currentTime); + this.currentTime = 0; + this.currentSolver = solveSpring( + this.currentPosition, + curV, + this.targetPosition, + 0, + this.params, + ); + this.getV = getVelocity(this.currentSolver); + this.getV2 = getVelocity(this.getV); + } + arrived(): boolean { + return ( + Math.abs(this.targetPosition - this.currentPosition) < 0.01 && + this.getV(this.currentTime) < 0.01 && + this.getV2(this.currentTime) < 0.01 && + this.queueParams === undefined && + this.queuePosition === undefined + ); + } + setPosition(targetPosition: number): void { + this.targetPosition = targetPosition; + this.currentPosition = targetPosition; + this.currentSolver = () => this.targetPosition; + this.getV = () => 0; + this.getV2 = () => 0; + } + update(delta = 0): void { + this.currentTime += delta; + this.currentPosition = this.currentSolver(this.currentTime); + if (this.queueParams) { + this.queueParams.time -= delta; + if (this.queueParams.time <= 0) { + this.updateParams({ + ...this.queueParams, + }); + } + } + if (this.queuePosition) { + this.queuePosition.time -= delta; + if (this.queuePosition.time <= 0) { + this.setTargetPosition(this.queuePosition.position); + } + } + if (this.arrived()) { + this.setPosition(this.targetPosition); + } + } + updateParams(params: Partial, delay = 0): void { + if (delay > 0) { + this.queueParams = { + ...(this.queuePosition ?? {}), + ...params, + time: delay, + }; + } else { + this.queuePosition = undefined; + this.params = { + ...this.params, + ...params, + }; + this.resetSolver(); + } + } + setTargetPosition(targetPosition: number, delay = 0): void { + if (delay > 0) { + this.queuePosition = { + ...(this.queuePosition ?? {}), + position: targetPosition, + time: delay, + }; + } else { + this.queuePosition = undefined; + this.targetPosition = targetPosition; + this.resetSolver(); + } + } + getCurrentPosition(): number { + return this.currentPosition; + } +} + +function solveSpring( + from: number, + velocity: number, + to: number, + delay: seconds = 0, + params?: Partial, +): (t: seconds) => number { + const soft = params?.soft ?? false; + const stiffness = params?.stiffness ?? 100; + const damping = params?.damping ?? 10; + const mass = params?.mass ?? 1; + const delta = to - from; + if (soft || 1.0 <= damping / (2.0 * Math.sqrt(stiffness * mass))) { + const angular_frequency = -Math.sqrt(stiffness / mass); + const leftover = -angular_frequency * delta - velocity; + return (t: seconds) => { + t -= delay; + if (t < 0) return from; + return to - (delta + t * leftover) * Math.E ** (t * angular_frequency); + }; + } + const damping_frequency = Math.sqrt(4.0 * mass * stiffness - damping ** 2.0); + const leftover = + (damping * delta - 2.0 * mass * velocity) / damping_frequency; + const dfm = (0.5 * damping_frequency) / mass; + const dm = -(0.5 * damping) / mass; + return (t: seconds) => { + t -= delay; + if (t < 0) return from; + return ( + to - + (Math.cos(t * dfm) * delta + Math.sin(t * dfm) * leftover) * + Math.E ** (t * dm) + ); + }; +} diff --git a/amll-local/packages/core/src/utils/wa-spring.ts b/amll-local/packages/core/src/utils/wa-spring.ts new file mode 100644 index 0000000..2f621d4 --- /dev/null +++ b/amll-local/packages/core/src/utils/wa-spring.ts @@ -0,0 +1,139 @@ +import bezier from "bezier-easing"; +import type { Disposable } from "../interfaces.ts"; +import { getVelocity } from "./derivative"; + +// export interface SpringParams { +// mass: number; // = 1.0 +// damping: number; // = 10.0 +// stiffness: number; // = 100.0 +// } + +type seconds = number; + +type CSSStyleKeys = { + [Style in keyof CSSStyleDeclaration]: Style extends string + ? CSSStyleDeclaration[Style] extends string + ? Style + : never + : never; +}[keyof CSSStyleDeclaration]; + +/** + * 基于 Web Animation API 的弹簧动画工具类,效果上可能逊于实时演算的版本 + */ +export class WebAnimationSpring extends EventTarget implements Disposable { + private currentAnimation: Animation; + private targetPosition = 0; + private isStatic = true; + // private params: Partial = {}; + private currentSolver: (t: seconds) => number = () => this.targetPosition; + private getV: (t: seconds) => number = () => 0; + + constructor( + private element: HTMLElement, + private styleName: CSSStyleKeys, + private valueGenerator: (value: number) => string, + private currentPosition = 0, + ) { + super(); + this.targetPosition = currentPosition; + this.currentAnimation = element.animate( + [ + { + [styleName]: valueGenerator(currentPosition), + }, + ], + { + duration: 1000, + fill: "both", + composite: "add", + }, + ); + } + + makeStatic(): void { + this.getV = () => 0; + this.currentSolver = () => this.targetPosition; + this.currentAnimation.cancel(); + this.currentAnimation = this.element.animate( + [ + { + [this.styleName]: this.valueGenerator(this.targetPosition), + }, + { + [this.styleName]: this.valueGenerator(this.targetPosition), + }, + ], + { + duration: Number.POSITIVE_INFINITY, + id: `wa-spring-static-${this.styleName}`, + fill: "both", + easing: "cubic-bezier(0.5, 0, 0.5, 1)", + composite: "add", + }, + ); + this.currentAnimation.pause(); + } + + setTargetPosition(targetPosition: number): void { + this.targetPosition = targetPosition; + this.onStepFinished(); + } + + getCurrentPosition(): number { + if (this.isStatic || !this.currentAnimation.effect) + return this.currentPosition; + + const timing = this.currentAnimation.effect?.getComputedTiming(); + return this.currentSolver(timing.progress ?? 1); + } + + getCurrentVelocity(): number { + if (this.isStatic || !this.currentAnimation.effect) return 0; + + const timing = this.currentAnimation.effect?.getComputedTiming(); + return this.getV(timing.progress ?? 1); + } + + private onStepFinished() { + const currentPosition = this.getCurrentPosition(); + if (Math.abs(this.targetPosition - currentPosition) < 0.0001) { + this.makeStatic(); + this.dispatchEvent(new Event("finished")); + return; + } + this.currentSolver = bezier(0.5, 0, 0.5, 1); + this.getV = getVelocity(this.currentSolver); + this.currentAnimation.cancel(); + const delta = (this.targetPosition - currentPosition) * 1.05; + this.currentPosition += delta; + this.currentAnimation = this.element.animate( + [ + { + [this.styleName]: this.valueGenerator(currentPosition), + }, + { + [this.styleName]: this.valueGenerator(this.currentPosition), + }, + ], + { + duration: 250, + id: `wa-spring-dynamic-${this.styleName}`, + fill: "forwards", + easing: "cubic-bezier(0.5, 0, 0.5, 1)", + composite: "add", + }, + ); + this.currentAnimation.onfinish = () => this.onStepFinished(); + } + + stop(): void { + if (this.currentAnimation) { + this.currentAnimation.cancel(); + } + } + + dispose(): void { + this.stop(); + } +} diff --git a/amll-local/packages/core/tsconfig.json b/amll-local/packages/core/tsconfig.json new file mode 100644 index 0000000..9e5fc68 --- /dev/null +++ b/amll-local/packages/core/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src"] +} diff --git a/amll-local/packages/core/tsdown.config.ts b/amll-local/packages/core/tsdown.config.ts new file mode 100644 index 0000000..5a9769f --- /dev/null +++ b/amll-local/packages/core/tsdown.config.ts @@ -0,0 +1,34 @@ +import { readFileSync } from "node:fs"; +import { defineConfig } from "tsdown"; +import { baseConfig } from "../../tsdown.base.ts"; + +const rawQueryPlugin = { + name: "raw-query", + resolveId(id: string, importer: string | undefined) { + if (id.endsWith("?raw")) { + const rawPath = id.slice(0, -4); + const base = importer ? `file://${importer}` : `file://${process.cwd()}/`; + const resolved = new URL(rawPath, base).pathname.replace( + /^\/([A-Za-z]:)/, + "$1", + ); + return `\0raw:${resolved}`; + } + }, + load(id: string) { + if (id.startsWith("\0raw:")) { + const file = id.slice(5); + const content = readFileSync(file, "utf-8"); + return `export default ${JSON.stringify(content)}`; + } + }, +}; + +export default defineConfig({ + ...baseConfig, + entry: { "amll-core": "./src/index.ts" }, + plugins: [rawQueryPlugin], + define: { + "import.meta.env.DEV": "process.env.NODE_ENV !== 'production'", + }, +}); diff --git a/amll-local/packages/docs/README.md b/amll-local/packages/docs/README.md new file mode 100644 index 0000000..512f4b4 --- /dev/null +++ b/amll-local/packages/docs/README.md @@ -0,0 +1,5 @@ +# AMLL Docs + +AMLL 框架的开发文档页面,使用 Astro + React + MDX 编写并部署到 Github Pages。 + +[查看文档](https://steve-xmh.github.io/applemusic-like-lyrics/zh-CN) diff --git a/amll-local/packages/docs/astro.config.ts b/amll-local/packages/docs/astro.config.ts new file mode 100644 index 0000000..327c0f3 --- /dev/null +++ b/amll-local/packages/docs/astro.config.ts @@ -0,0 +1,181 @@ +import react from "@astrojs/react"; +import starlight from "@astrojs/starlight"; +import { defineConfig } from "astro/config"; +import starlightSidebarTopics from "starlight-sidebar-topics"; +import { generateTypedocDocs } from "./src/scripts/typedoc"; + +const docsSidebar = [ + { + label: "概览", + translations: { en: "Overview" }, + items: [{ slug: "guides/overview/intro" }, { slug: "guides/overview/eco" }], + }, + { + label: "歌词组件", + translations: { en: "Lyric Component" }, + items: [ + { slug: "guides/component/quickstart" }, + { slug: "guides/component/sequence" }, + { slug: "guides/component/background" }, + ], + }, + { + label: "歌词格式", + translations: { en: "Lyric Formats" }, + items: [ + { slug: "guides/lyric/quickstart" }, + { slug: "guides/lyric/formats" }, + { slug: "guides/lyric/ttml" }, + ], + }, +]; + +const referenceSidebar = [ + { + label: "Core 核心", + translations: { en: "Core" }, + collapsed: true, + items: [{ autogenerate: { directory: "reference/core", collapsed: true } }], + }, + { + label: "React 绑定", + translations: { en: "React Bindings" }, + collapsed: true, + items: [ + { autogenerate: { directory: "reference/react", collapsed: true } }, + ], + }, + { + label: "React Full 组件库", + translations: { en: "React Full Components" }, + collapsed: true, + items: [ + { autogenerate: { directory: "reference/react-full", collapsed: true } }, + ], + }, + { + label: "Vue 绑定", + translations: { en: "Vue Bindings" }, + collapsed: true, + items: [{ autogenerate: { directory: "reference/vue", collapsed: true } }], + }, + { + label: "Lyric 歌词处理", + translations: { en: "Lyric Processing" }, + collapsed: true, + items: [ + { autogenerate: { directory: "reference/lyric", collapsed: true } }, + ], + }, + { + label: "TTML 歌词处理", + translations: { en: "TTML Processing" }, + collapsed: true, + items: [{ autogenerate: { directory: "reference/ttml", collapsed: true } }], + }, +]; + +const contributeSidebar = [ + { + label: "开发指南", + translations: { en: "Development" }, + items: [ + { slug: "contribute/development/environments" }, + { slug: "contribute/development/structure" }, + ], + }, + { + label: "仓库规范", + translations: { en: "Repository Guidelines" }, + items: [ + { slug: "contribute/guidelines/pr" }, + { slug: "contribute/guidelines/publishing" }, + ], + }, +]; + +export default defineConfig({ + site: "https://amll.dev", + trailingSlash: "never", + build: { + format: "file", + }, + integrations: [ + react(), + starlight({ + favicon: "favicon.ico", + title: "AMLL Docs", + logo: { + src: "./src/assets/amll-logo.svg", + alt: "AMLL", + }, + customCss: [ + "./src/styles/consts.css", + "./src/styles/frame.css", + "./src/styles/content.css", + ], + expressiveCode: { + themes: ["github-dark", "github-light"], + useStarlightDarkModeSwitch: true, + useStarlightUiThemeColors: false, + }, + locales: { + root: { label: "简体中文", lang: "zh-CN" }, + en: { label: "English", lang: "en" }, + }, + social: [ + { + icon: "github", + label: "GitHub", + href: "https://github.com/amll-dev/applemusic-like-lyrics", + }, + ], + plugins: [ + starlightSidebarTopics([ + { + id: "docs", + label: { "zh-CN": "使用文档", en: "Guides" }, + link: "/guides", + icon: "open-book", + items: docsSidebar, + }, + { + id: "reference", + label: { "zh-CN": "API 参考", en: "API Reference" }, + link: "/reference", + icon: "information", + items: referenceSidebar, + }, + { + id: "docs", + label: { "zh-CN": "试验场", en: "Playground" }, + link: "/playground", + icon: "puzzle", + items: docsSidebar, + }, + { + id: "contribute", + label: { "zh-CN": "贡献指南", en: "Contributing" }, + link: "/contribute", + icon: "code-branch", + items: contributeSidebar, + }, + ]), + { + name: "typedoc", + hooks: { + "config:setup": async (cfg) => { + cfg.logger.info("Generating typedoc..."); + await generateTypedocDocs(cfg.logger); + cfg.logger.info("Finished typedoc generation"); + }, + }, + }, + ], + editLink: { + baseUrl: + "https://github.com/amll-dev/applemusic-like-lyrics/blob/main/packages/docs/", + }, + }), + ], +}); diff --git a/amll-local/packages/docs/package.json b/amll-local/packages/docs/package.json new file mode 100644 index 0000000..c7dac72 --- /dev/null +++ b/amll-local/packages/docs/package.json @@ -0,0 +1,36 @@ +{ + "name": "@applemusic-like-lyrics/docs", + "type": "module", + "version": "0.0.1", + "scripts": { + "dev": "astro dev", + "build": "astro build", + "build:full": "run-s build:full:projects build:full:copy-playground", + "build:full:projects": "cross-env PLAYGROUND_BASE_URL=/playground/ nx run-many --target=build --projects=docs,playground-core", + "build:full:copy-playground": "node ./scripts/copy-playground.js", + "preview": "astro preview" + }, + "imports": { + "#components/*": "./src/components/*" + }, + "dependencies": { + "@applemusic-like-lyrics/core": "workspace:^", + "@applemusic-like-lyrics/react": "workspace:^", + "@astrojs/react": "^5.0.5", + "@astrojs/starlight": "^0.39.2", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "astro": "^6.3.3", + "react": "catalog:", + "react-dom": "catalog:", + "sharp": "catalog:", + "starlight-sidebar-topics": "^0.7.1" + }, + "devDependencies": { + "cross-env": "^10.1.0", + "typedoc": "catalog:", + "typedoc-plugin-mark-react-functional-components": "^0.2.2", + "typedoc-plugin-markdown": "catalog:", + "typedoc-plugin-vue": "^1.5.1" + } +} diff --git a/amll-local/packages/docs/public/favicon.ico b/amll-local/packages/docs/public/favicon.ico new file mode 100644 index 0000000..edec1f8 Binary files /dev/null and b/amll-local/packages/docs/public/favicon.ico differ diff --git a/amll-local/packages/docs/scripts/copy-playground.js b/amll-local/packages/docs/scripts/copy-playground.js new file mode 100644 index 0000000..27c802e --- /dev/null +++ b/amll-local/packages/docs/scripts/copy-playground.js @@ -0,0 +1,5 @@ +import fs from "node:fs"; + +fs.rmSync("dist/playground", { recursive: true, force: true }); +fs.cpSync("../playground/core/dist", "dist/playground", { recursive: true }); +console.log("Playground copied to dist/playground"); diff --git a/amll-local/packages/docs/src/assets/amll-logo.svg b/amll-local/packages/docs/src/assets/amll-logo.svg new file mode 100644 index 0000000..387c527 --- /dev/null +++ b/amll-local/packages/docs/src/assets/amll-logo.svg @@ -0,0 +1,159 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/amll-local/packages/docs/src/components/AMLLPreview/index.tsx b/amll-local/packages/docs/src/components/AMLLPreview/index.tsx new file mode 100644 index 0000000..00a9aea --- /dev/null +++ b/amll-local/packages/docs/src/components/AMLLPreview/index.tsx @@ -0,0 +1,161 @@ +import type { LyricLine } from "@applemusic-like-lyrics/core"; +import "@applemusic-like-lyrics/core/style.css"; +import { LyricPlayer } from "@applemusic-like-lyrics/react"; +import { useLayoutEffect, useRef, useState } from "react"; + +const buildLyricLines = ( + lyric: string, + startTime: number, + otherParams: Partial = {}, +): LyricLine => { + let curTime = startTime; + const words = []; + for (const word of lyric.split("|")) { + const [text, duration] = word.split(","); + const endTime = curTime + Number(duration); + words.push({ + word: text, + startTime: curTime, + endTime, + obscene: false, + }); + curTime = endTime; + } + return { + startTime, + endTime: curTime + 500, + translatedLyric: "", + romanLyric: "", + isBG: false, + isDuet: false, + words, + ...otherParams, + }; +}; + +const DEMO_LYRICS: LyricLine[][] = [ + [ + buildLyricLines( + "Apple ,350|Music ,300|Like ,300|Ly,500|ri,900|cs ,250", + 2000, + { translatedLyric: "类苹果歌词" }, + ), + // A lyric component library for the web + buildLyricLines( + "A ,200|ly,100|ric ,250|com,200|po,200|nent ,200|li,100|bra,200|ry ,100|for ,100|the ,200|web ,600", + 5000, + { translatedLyric: "为 Web 而生的歌词组件库" }, + ), + // Brought to you with + buildLyricLines("Brought ,300|to ,250|you ,800|with ,600", 8000, { + translatedLyric: "为你带来", + }), + // Background Lyric Line + buildLyricLines("Background ,750|lyric ,300|line ,500", 8500, { + translatedLyric: "背景歌词行", + isBG: true, + }), + // And Duet Lyric Line + buildLyricLines("And ,300|Duet ,300|lyric ,500|line ,650", 10500, { + translatedLyric: "还有对唱歌词行", + isDuet: true, + }), + ], +]; + +export const AMLLPreview = () => { + const [lyricLines, setLyricLines] = useState([]); + const [currentTime, setCurrentTime] = useState(0); + const wRef = useRef(null); + + useLayoutEffect(() => { + let selectedDemo = DEMO_LYRICS.length - 1; + let endTime = 0; + let startTime = 0; + let canceled = false; + + const onFrame = (time: number) => { + if (canceled) return; + if (time - startTime > endTime) { + const w = wRef.current; + if (!w) { + if (canceled) return; + return; + } + if (canceled) return; + + setCurrentTime(0); + + w.animate( + { + opacity: 0, + filter: "blur(10px)", + }, + { + duration: 500, + easing: "ease-in-out", + fill: "forwards", + }, + ).onfinish = () => { + if (canceled) return; + + selectedDemo = (selectedDemo + 1) % DEMO_LYRICS.length; + setLyricLines(JSON.parse(JSON.stringify(DEMO_LYRICS[selectedDemo]))); + endTime = DEMO_LYRICS[selectedDemo].reduce( + (acc, v) => Math.max(acc, v.endTime), + 0, + ); + startTime = time; + + setTimeout(() => { + if (canceled) return; + w.animate( + { + opacity: 1, + filter: "blur(0px)", + }, + { + duration: 300, + easing: "ease-in-out", + fill: "forwards", + }, + ).onfinish = () => { + if (canceled) return; + requestAnimationFrame(onFrame); + }; + }, 600); + }; + } else { + setCurrentTime((time - startTime) | 0); + requestAnimationFrame(onFrame); + } + }; + + requestAnimationFrame(onFrame); + return () => { + canceled = true; + }; + }, []); + + return ( +
+ +
+ ); +}; diff --git a/amll-local/packages/docs/src/components/PackageInstall.astro b/amll-local/packages/docs/src/components/PackageInstall.astro new file mode 100644 index 0000000..c6db332 --- /dev/null +++ b/amll-local/packages/docs/src/components/PackageInstall.astro @@ -0,0 +1,30 @@ +--- +import { Code, TabItem, Tabs } from "@astrojs/starlight/components"; + +interface Props { + packages: string | string[]; + dev?: boolean; + syncKey?: string; +} + +const { packages, dev = false, syncKey = "pkg" } = Astro.props; +const packageList = Array.isArray(packages) + ? packages + : packages.split(/\s+/).filter(Boolean); +const packageNames = packageList.join(" "); +const devFlag = dev ? " -D" : ""; + +const commands = [ + { label: "npm", command: `npm install${devFlag} ${packageNames}` }, + { label: "pnpm", command: `pnpm add${devFlag} ${packageNames}` }, + { label: "Yarn", command: `yarn add${devFlag} ${packageNames}` }, +]; +--- + + + {commands.map(({ label, command }) => ( + + + + ))} + diff --git a/amll-local/packages/docs/src/content.config.ts b/amll-local/packages/docs/src/content.config.ts new file mode 100644 index 0000000..a3a3642 --- /dev/null +++ b/amll-local/packages/docs/src/content.config.ts @@ -0,0 +1,7 @@ +import { defineCollection } from "astro:content"; +import { docsLoader } from "@astrojs/starlight/loaders"; +import { docsSchema } from "@astrojs/starlight/schema"; + +export const collections = { + docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }), +}; diff --git a/amll-local/packages/docs/src/content/docs/contribute/development/environments.md b/amll-local/packages/docs/src/content/docs/contribute/development/environments.md new file mode 100644 index 0000000..5817019 --- /dev/null +++ b/amll-local/packages/docs/src/content/docs/contribute/development/environments.md @@ -0,0 +1,36 @@ +--- +title: 开发环境配置 +--- + +## 必要环境 + +- pnpm([官网](https://pnpm.io/)),建议版本与仓库 `package.json` 中 `packageManager` 一致(当前为 `pnpm@11.1.0`) + +本仓库默认使用 `pnpm nx ...` 执行 Nx 命令,不要求全局安装 Nx。为了本地便利开发,你也可以全局安装 Nx,效果是相同的。 + +Node.js 仅在 npm 发布相关 CI 步骤中作为运行时使用(当前发布工作流为 Node 24)。 + +### 版本自查 + +```bash +pnpm --version +nx --version # 可选 +``` + +## 首次初始化 + +在仓库根目录执行: + +```bash +pnpm install --frozen-lockfile +``` + +完成后,执行一次构建所有包:`pnpm run build:libs`,若成功构建完成说明环境无误,可以开始工作。 + +### 依赖安装慢或失败 + +优先确认 pnpm 版本与锁文件一致,再重试: + +```bash +pnpm install --frozen-lockfile +``` diff --git a/amll-local/packages/docs/src/content/docs/contribute/development/structure.mdx b/amll-local/packages/docs/src/content/docs/contribute/development/structure.mdx new file mode 100644 index 0000000..8868036 --- /dev/null +++ b/amll-local/packages/docs/src/content/docs/contribute/development/structure.mdx @@ -0,0 +1,102 @@ +--- +title: 仓库结构介绍 +--- + +import { FileTree } from "@astrojs/starlight/components"; + +## Monorepo 结构 + +仓库使用 `Nx` 管理任务编排,`pnpm workspace` 管理依赖,主要目录如下: + + + +- packages/ + - core/ 核心组件,Vanilla JS 实现 + - react/ 核心组件的 React 绑定 + - vue/ 核心组件的 Vue 绑定 + - react-full/ 可直接使用的完整播放器组件,仅 React 版本 + - ttml/ TTML 解析与处理库 + - lyric/ 歌词解析与处理库,TTML 实际依赖 ttml 包 + - docs/ Astro + Starlight 文档站 + - playground/ 用于开发和测试的示例项目 + - core/ 使用核心组件的示例 + - react/ 使用 React 绑定的示例 + - vue/ 使用 Vue 绑定的示例 + - react-full/ 使用完整播放器组件的示例 +- .github/ + - workflows/ + - scripts/ + + + +## Nx 发布分组 + +`nx.json` 中的发布配置包含两个主要 group: + +- `core-bundle` 三个包版本保持一致: + - `@applemusic-like-lyrics/core` + - `@applemusic-like-lyrics/react` + - `@applemusic-like-lyrics/vue` +- 其余包版本各自独立: + - `@applemusic-like-lyrics/lyric` + - `@applemusic-like-lyrics/react-full` + - `@applemusic-like-lyrics/ttml` + +## 依赖关系 + +你可以在终端执行: + +```sh +pnpm nx graph +``` + +来查看完整的依赖关系图。 + +在提升版本号时,如果被依赖的包发生了更新,则依赖它的包也会被自动提升版本号,即使代码没有改动。 + +## 命令行 + +### 库开发 + +Nx 提供了便捷的命令行工具,无需输入 `@applemusic-like-lyrics/` 前缀也可执行命令。 + +例如,要构建 `@applemusic-like-lyrics/react` 包,可以直接执行: + +```sh +pnpm nx build react +``` + +Nx 会自动解析依赖,因此会先构建 `core` 包,然后构建 `react` 包。 + +我们在 `package/playground` 中准备了用于测试的 web app,基于 Vite,具有 HMR 支持。我们从 `core`、`react`、`vue`、`react-full` 中添加了转发,因此要启动对应环境,只需要: + +```sh +# 启动 package/playground/core 的开发服务器 +pnpm nx dev core + +# 下面同理 +pnpm nx dev react +pnpm nx dev vue +pnpm nx dev react-full +``` + +要构建全部包,执行: + +```sh +pnpm build:libs +``` + +即可自动并行构建所有包,构建顺序由依赖关系决定。 + +### 文档开发 + +同理,文档站的别名是 `docs`,有如下命令: + +```sh +# 启动文档站开发服务器 +pnpm nx dev docs +# 构建文档站 +pnpm nx build docs +``` + +会自动处理依赖关系。 diff --git a/amll-local/packages/docs/src/content/docs/contribute/guidelines/pr.md b/amll-local/packages/docs/src/content/docs/contribute/guidelines/pr.md new file mode 100644 index 0000000..a5c8358 --- /dev/null +++ b/amll-local/packages/docs/src/content/docs/contribute/guidelines/pr.md @@ -0,0 +1,46 @@ +--- +title: PR 流程 +--- + +## 基本要求 + +- 所有改动通过 PR 合入 `main`。 +- PR 进入 Ready for Review 后会触发校验工作流:`.github/workflows/pr-release-check.yaml`。 +- 通过校验后再合并。 + +## PR 校验包含什么 + +工作流会做两类检查: + +1. `Release metadata` + - 判断本次 PR 是否需要 release plan。 + - 在需要时执行 `pnpm run release:plan:check --base=... --head=...`。 + +2. `Build libs` + - 安装 pnpm 依赖(`pnpm install --frozen-lockfile`)。 + - 执行 `pnpm run ci:build:libs`。 + +## 什么时候需要 release plan + +检查逻辑来自 `.github/scripts/check-release-requirements.mjs`: + +- 如果改动全部是文档/CI/基础设施等忽略范围,PR 必须打上 `no-release` label。 +- 否则,即改动包含“非忽略文件”,必须有 release plan(存放于 `.nx/version-plans/`)。 +- `no-release` 只能用于“全部改动都在忽略范围”的 PR。 + +## 创建 release plan(本地) + +可在仓库根目录执行: + +```bash +# 只为本次 touched 项目生成计划(默认行为) +pnpm nx release plan +``` + +然后根据提示选择各包变动幅度并输入 changelog。 + +命令会在 `.nx/version-plans/` 下生成计划文件,请将该文件一并提交。 + +## 关于合并 + +目前设置了分支保护规则,仅允许 squash merge,以保持主分支洁净。 diff --git a/amll-local/packages/docs/src/content/docs/contribute/guidelines/publishing.md b/amll-local/packages/docs/src/content/docs/contribute/guidelines/publishing.md new file mode 100644 index 0000000..e1563ed --- /dev/null +++ b/amll-local/packages/docs/src/content/docs/contribute/guidelines/publishing.md @@ -0,0 +1,38 @@ +--- +title: 发布包 +--- + +## 发布方式 + +npm 包发布通过 GitHub Actions 手动触发,工作流文件: + +- `.github/workflows/publish-libs.yaml` + +触发方式为 `workflow_dispatch`,并带 `mode` 参数: + +- `dry-run`:仅演练版本与发布流程,不真正发布。 +- `publish`:执行真实发版并推送 release commit/tag。 + +## 发布前置条件 + +- `publish` 必须从 `main` 分支触发,`dry-run` 可以从其他分支触发。 +- `.nx/version-plans/` 下必须存在 release plan 文件。 + +## 工作流执行步骤摘要 + +1. 当 `mode=publish` 时,强校验当前分支必须是 `main`。 +2. 安装依赖与发布环境(pnpm、Node 24)。 +3. 校验 trusted publishing 运行时(Node 版本等)。 +4. 执行 `pnpm install --frozen-lockfile`。 +5. 执行 `npx nx release --skip-publish --preid alpha` 创建 release commit 与 tags。 +6. 格式化 `package.json` 并 amend release commit。 +7. 当 `mode=publish` 时: + - `git push origin HEAD:main --follow-tags`。 + - 调整发布前清单(强制 npm 发布器、准备 npm manifests)。 + - 执行 `npx nx release publish --excludeTaskDependencies` 发布到 npm。 + +## 推荐发布流程 + +1. 先手动触发一次 `mode=dry-run`,确认版本变更与发布前检查正常(该模式不会推送 tags,也不会发布到 npm)。 +2. 再触发 `mode=publish` 完成正式发布。 +3. 发布后检查 npm 与 GitHub tags 是否符合预期。 diff --git a/amll-local/packages/docs/src/content/docs/contribute/index.mdx b/amll-local/packages/docs/src/content/docs/contribute/index.mdx new file mode 100644 index 0000000..a8c142b --- /dev/null +++ b/amll-local/packages/docs/src/content/docs/contribute/index.mdx @@ -0,0 +1,16 @@ +--- +title: 贡献指南 +description: 项目结构介绍、开发环境配置与仓库规范 +editUrl: false +--- + +欢迎参与 AMLL 开发。 + +本仓库是一个基于 `Nx + pnpm workspace` 的 monorepo,包含多个可发布 npm 包和一个 Astro 文档站。 + +你可以按下面顺序阅读: + +1. [开发环境配置](/contribute/development/environments):本地依赖与常用命令。 +2. [仓库结构介绍](/contribute/development/structure):各包职责和依赖关系。 +3. [PR 流程](/contribute/guidelines/pr):以 PR 向 `main` 提交更新的要求。 +4. [发布包](/contribute/guidelines/publishing):手动触发 Actions 发版流程。 diff --git a/amll-local/packages/docs/src/content/docs/en/contribute/development/environments.md b/amll-local/packages/docs/src/content/docs/en/contribute/development/environments.md new file mode 100644 index 0000000..3c486ab --- /dev/null +++ b/amll-local/packages/docs/src/content/docs/en/contribute/development/environments.md @@ -0,0 +1,36 @@ +--- +title: Development Environment Setup +--- + +## Required Environment + +- pnpm ([official site](https://pnpm.io/)); use the version specified in the repository `package.json` `packageManager` field when possible (currently `pnpm@10.15.1`) + +This repository uses `pnpm nx ...` by default for Nx commands, so global Nx installation is not required. For local convenience, you can still install Nx globally; behavior is the same. + +Node.js is only used as the runtime in npm publishing related CI steps (currently Node 24 in the publishing workflow). + +### Version Check + +```bash +pnpm --version +nx --version # optional +``` + +## First-time Initialization + +Run in the repository root: + +```bash +pnpm install --frozen-lockfile +``` + +After setup, build all libraries once with `pnpm run build:libs`. If it succeeds, your environment is ready. + +### Dependency installation is slow or fails + +First verify your pnpm version matches the lockfile expectations, then retry: + +```bash +pnpm install --frozen-lockfile +``` diff --git a/amll-local/packages/docs/src/content/docs/en/contribute/development/structure.mdx b/amll-local/packages/docs/src/content/docs/en/contribute/development/structure.mdx new file mode 100644 index 0000000..8c22872 --- /dev/null +++ b/amll-local/packages/docs/src/content/docs/en/contribute/development/structure.mdx @@ -0,0 +1,102 @@ +--- +title: Repository Structure +--- + +import { FileTree } from "@astrojs/starlight/components"; + +## Monorepo Structure + +The repository uses `Nx` for task orchestration and `pnpm workspace` for dependency management. Main directories are: + + + +- packages/ + - core/ Core components in Vanilla JS + - react/ React bindings for core + - vue/ Vue bindings for core + - react-full/ Ready-to-use full player components (React only) + - ttml/ TTML parser and processing library + - lyric/ Lyrics parser and processing library (TTML depends on the ttml package) + - docs/ Astro + Starlight docs site + - playground/ Example projects for development and testing + - core/ Example using core + - react/ Example using React bindings + - vue/ Example using Vue bindings + - react-full/ Example using full player components +- .github/ + - workflows/ + - scripts/ + + + +## Nx Release Groups + +Release config in `nx.json` includes two major groups: + +- `core-bundle`: three packages share the same version: + - `@applemusic-like-lyrics/core` + - `@applemusic-like-lyrics/react` + - `@applemusic-like-lyrics/vue` +- Other packages are versioned independently: + - `@applemusic-like-lyrics/lyric` + - `@applemusic-like-lyrics/react-full` + - `@applemusic-like-lyrics/ttml` + +## Dependency Graph + +Run: + +```sh +pnpm nx graph +``` + +to view the full dependency graph. + +When bumping versions, if a dependency package changes, dependent packages may also be bumped automatically even when their own code is unchanged. + +## CLI + +### Library Development + +Nx provides convenient aliases; you do not need to type the `@applemusic-like-lyrics/` prefix. + +For example, to build `@applemusic-like-lyrics/react`: + +```sh +pnpm nx build react +``` + +Nx resolves dependencies automatically, so it builds `core` first, then `react`. + +We provide web apps in `package/playground` for testing, based on Vite with HMR support. We added forwarding entries for `core`, `react`, `vue`, and `react-full`, so to start a matching environment: + +```sh +# Start dev server for package/playground/core +pnpm nx dev core + +# Same for others +pnpm nx dev react +pnpm nx dev vue +pnpm nx dev react-full +``` + +To build all libraries: + +```sh +pnpm build:libs +``` + +It builds all packages in parallel with dependency-aware ordering. + +### Docs Development + +Similarly, the docs site alias is `docs`, with these commands: + +```sh +# Start docs dev server +pnpm nx dev docs +# Build docs site +pnpm nx build docs +``` + +Dependencies are handled automatically. diff --git a/amll-local/packages/docs/src/content/docs/en/contribute/guidelines/pr.md b/amll-local/packages/docs/src/content/docs/en/contribute/guidelines/pr.md new file mode 100644 index 0000000..5650b47 --- /dev/null +++ b/amll-local/packages/docs/src/content/docs/en/contribute/guidelines/pr.md @@ -0,0 +1,46 @@ +--- +title: PR Process +--- + +## Basic Requirements + +- All changes are merged into `main` via PR. +- After a PR is set to Ready for Review, the validation workflow `.github/workflows/pr-release-check.yaml` runs. +- Merge only after checks pass. + +## What PR Validation Includes + +The workflow runs two groups of checks: + +1. `Release metadata` + - Determines whether this PR requires a release plan. + - Runs `pnpm run release:plan:check --base=... --head=...` when needed. + +2. `Build libs` + - Installs pnpm dependencies (`pnpm install --frozen-lockfile`). + - Runs `pnpm run ci:build:libs`. + +## When a Release Plan Is Required + +The check logic is in `.github/scripts/check-release-requirements.mjs`: + +- If all changes are in ignored scopes (docs/CI/infrastructure, etc.), the PR must have the `no-release` label. +- Otherwise, if there are non-ignored file changes, a release plan is required (stored in `.nx/version-plans/`). +- `no-release` can only be used for PRs where all changes are in ignored scopes. + +## Create a Release Plan Locally + +Run in the repository root: + +```bash +# Generate plans only for touched projects (default behavior) +pnpm nx release plan +``` + +Then choose the bump level for each package and enter changelog messages as prompted. + +The command generates plan files in `.nx/version-plans/`; commit them together with your changes. + +## About Merging + +A branch protection rule is enabled to allow only squash merges, keeping `main` clean. diff --git a/amll-local/packages/docs/src/content/docs/en/contribute/guidelines/publishing.md b/amll-local/packages/docs/src/content/docs/en/contribute/guidelines/publishing.md new file mode 100644 index 0000000..b4e3f4e --- /dev/null +++ b/amll-local/packages/docs/src/content/docs/en/contribute/guidelines/publishing.md @@ -0,0 +1,38 @@ +--- +title: Publishing Packages +--- + +## Publishing Method + +npm package publishing is triggered manually through GitHub Actions workflow: + +- `.github/workflows/publish-libs.yaml` + +The trigger is `workflow_dispatch` with a `mode` parameter: + +- `dry-run`: rehearses versioning and publishing flow without actual publishing. +- `publish`: performs real release and pushes release commit/tag. + +## Preconditions + +- `publish` must be triggered from the `main` branch. `dry-run` can be triggered from other branches. +- A release plan file must exist in `.nx/version-plans/`. + +## Workflow Steps Summary + +1. When `mode=publish`, strictly validate current branch is `main`. +2. Install dependencies and release environment (pnpm, Node 24). +3. Validate trusted publishing runtime requirements (Node version, etc.). +4. Run `pnpm install --frozen-lockfile`. +5. Run `npx nx release --skip-publish --preid alpha` to create release commit and tags. +6. Format `package.json` and amend the release commit. +7. When `mode=publish`: + - `git push origin HEAD:main --follow-tags` + - Adjust pre-publish manifest handling (force npm registries, prepare npm manifests) + - Run `npx nx release publish --excludeTaskDependencies` to publish to npm. + +## Recommended Process + +1. Trigger `mode=dry-run` first, and confirm version changes and pre-publish checks are correct (no tags pushed and no npm publish in this mode). +2. Trigger `mode=publish` to perform the formal release. +3. Verify npm packages and GitHub tags after publishing. diff --git a/amll-local/packages/docs/src/content/docs/en/contribute/index.mdx b/amll-local/packages/docs/src/content/docs/en/contribute/index.mdx new file mode 100644 index 0000000..50c7ebd --- /dev/null +++ b/amll-local/packages/docs/src/content/docs/en/contribute/index.mdx @@ -0,0 +1,16 @@ +--- +title: Contributing +description: Repository structure, development environment setup, and repository guidelines +editUrl: false +--- + +Welcome to AMLL development. + +This repository is a monorepo based on `Nx + pnpm workspace`, containing multiple publishable npm packages and an Astro documentation site. + +You can read in this order: + +1. [Development Environment Setup](/en/contribute/development/environments): local dependencies and common commands. +2. [Repository Structure](/en/contribute/development/structure): package responsibilities and dependency relationships. +3. [PR Process](/en/contribute/guidelines/pr): requirements for submitting updates to `main` through pull requests. +4. [Publishing Packages](/en/contribute/guidelines/publishing): manually triggering the Actions publishing workflow. diff --git a/amll-local/packages/docs/src/content/docs/en/guides/component/background.md b/amll-local/packages/docs/src/content/docs/en/guides/component/background.md new file mode 100644 index 0000000..dfb3eeb --- /dev/null +++ b/amll-local/packages/docs/src/content/docs/en/guides/component/background.md @@ -0,0 +1,218 @@ +--- +title: Dynamic Background +--- + +AMLL Core provides a standalone background rendering component, [`BackgroundRender`](/en/reference/core/classbackgroundrender). It renders album artwork or album video into an Apple Music style dynamic background. The lyric component still only handles the lyric view itself; audio playback, resource loading, and layer mounting should be managed by the host environment. + +This page mainly explains background integration with the vanilla Core API. If you use the React or Vue bindings, jump directly to the [bindings section](#react-and-vue-bindings). + +## Basic Structure + +The background component consists of two parts: + +- [`BackgroundRender`](/en/reference/core/classbackgroundrender): a unified wrapper that provides methods for setting album media, frame rate, render scale, pause/resume state, and more. +- Renderer: Core currently provides [`MeshGradientRenderer`](/en/reference/core/classmeshgradientrenderer) and [`PixiRenderer`](/en/reference/core/classpixirenderer). + +When creating a background, choose one of these renderers: + +```ts +// Use Mesh Gradient +import { + BackgroundRender, + MeshGradientRenderer, +} from "@applemusic-like-lyrics/core"; +const meshBackground = BackgroundRender.new(MeshGradientRenderer); + +// Use Pixi +import { BackgroundRender, PixiRenderer } from "@applemusic-like-lyrics/core"; +const pixiBackground = BackgroundRender.new(PixiRenderer); +``` + +## Layering With the Lyric Component + +The background element is a ``. Usually, place it in the same container as the lyric component and insert it before the lyric element. + +```ts +import { + BackgroundRender, + DomLyricPlayer, + MeshGradientRenderer, +} from "@applemusic-like-lyrics/core"; +import "@applemusic-like-lyrics/core/style.css"; + +const background = BackgroundRender.new(MeshGradientRenderer); +const lyricPlayer = new DomLyricPlayer(); + +function mountPlayer(host: HTMLElement) { + const backgroundElement = background.getElement(); + const lyricElement = lyricPlayer.getElement(); + + host.appendChild(backgroundElement); + host.appendChild(lyricElement); +} + +const host = document.querySelector("#player"); +if (!host) throw new Error("missing #player"); + +mountPlayer(host); +``` + +The canvas size is determined by CSS. Internally, a `ResizeObserver` adjusts the actual drawing size according to the device pixel ratio and render scale. Therefore, the host container should have an explicit width and height. + +AMLL does not define positioning or z-index styles for the background and lyric components. Define those styles in your host application. + +## Setting Album Media + +Call [`setAlbum`](/en/reference/core/classbackgroundrender#setalbum) to set the background source. It accepts an image or video URL, an `HTMLImageElement`, or an `HTMLVideoElement`. + +If you pass a string URL and the resource is a video, set the second parameter to `true`: + +```ts +// Image URL +await background.setAlbum("/album-cover.jpg"); + +// Video URL +await background.setAlbum("/album-video.webm", true); +``` + +If you already have a `File` or `Blob` object, use [`URL.createObjectURL`](https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL_static) to create an object URL and pass it to `setAlbum`. + +The background renderer draws the resource into a canvas or WebGL texture. + +## Syncing Playback State + +The background component has its own animation loop, so you do not need to manually call `update(delta)` frame by frame as you do with `DomLyricPlayer`. Just call `resume()` and `pause()` when playback starts or pauses: + +```ts +audio.addEventListener("play", () => { + lyricPlayer.resume(); + background.resume(); +}); + +audio.addEventListener("pause", () => { + lyricPlayer.pause(); + background.pause(); +}); +``` + +The background animation state is independent from the lyric animation state. You can also control only the background animation. + +```ts +function setBackgroundPlaying(playing: boolean) { + if (playing) background.resume(); + else background.pause(); +} +``` + +## Applying Render Settings + +Common settings can be applied after initialization or when the user changes options. They take effect immediately. + +```ts +function applyBackgroundSettings() { + background.setFPS(60); + background.setRenderScale(1); + background.setFlowSpeed(0.2); + background.setStaticMode(false); + background.setLowFreqVolume(1); +} +``` + +| Method | Description | +| ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | +| [`setFPS(fps)`](/en/reference/core/classbackgroundrender#setfps) | Sets the frame rate of the background animation | +| [`setRenderScale(scale)`](/en/reference/core/classbackgroundrender#setrenderscale) | Sets the render scale. Higher values are clearer and also more performance-intensive | +| [`setFlowSpeed(speed)`](/en/reference/core/classbackgroundrender#setflowspeed) | Sets the background flow speed | +| [`setStaticMode(enable)`](/en/reference/core/classbackgroundrender#setstaticmode) | When enabled, the background can stay static after the resource transition animation to save work | +| [`setLowFreqVolume(volume)`](/en/reference/core/classbackgroundrender#setlowfreqvolume) | Passes a low-frequency volume hint. Some renderers may use it to adjust dynamic effects | +| [`setHasLyric(hasLyric)`](/en/reference/core/classbackgroundrender#sethaslyric) | Tells the renderer whether the current song has lyrics. Some renderers may adjust effects | + +`setRenderScale` is usually a tradeoff between `0.5` and `1`. On mobile or low-performance devices, lower the render scale and frame rate. For a fullscreen player, you can increase the render scale. + +## Changing Renderers + +After `BackgroundRender` is created, its internal renderer cannot be replaced. If the user switches from `MeshGradientRenderer` to `PixiRenderer`, dispose the old instance and create a new one: + +```ts +type PlayerBackground = + | BackgroundRender + | BackgroundRender; + +function switchToPixiRenderer( + host: HTMLElement, + lyricPlayer: DomLyricPlayer, + currentBackground: PlayerBackground, +) { + currentBackground.dispose(); + + const nextBackground = BackgroundRender.new(PixiRenderer); + host.insertBefore(nextBackground.getElement(), lyricPlayer.getElement()); + return nextBackground; +} +``` + +## Cleanup + +When the page unloads, the player is destroyed, or you permanently switch implementations, release the background instance: + +```ts +background.dispose(); +lyricPlayer.dispose(); +``` + +`dispose()` releases internal renderer resources and removes the background canvas. If you created an `ObjectURL`, audio event listeners, or other async loading state yourself, clean those up in your host code as well. + +## React and Vue Bindings + +The background component has much less intermediate state than the lyric component, so componentized usage is simpler. + +React and Vue are similar: both set options and maintain state through props. Use the `playing` prop to specify playback state, and use the `album` prop to specify album image or video media. See the API reference for the complete prop list. + +- React prop reference: [BackgroundRenderProps](/en/reference/react/interfacebackgroundrenderprops) +- Vue prop reference: [BackgroundRender](/en/reference/vue/classbackgroundrender) + +The component automatically releases internal resources when unmounted. You still need to release listeners, object URLs, and similar resources that you create yourself. + +Here is a minimal React example: + +```tsx +import { LyricPlayer, BackgroundRender } from "@applemusic-like-lyrics/react"; + +function app() { + const albumUrl = "/album-cover.jpg"; + return ( + <> + + + + ); +} +``` + +Here is a minimal Vue example: + +```vue + + + +``` + +## Checklist + +- The host container has an explicit size. +- The background canvas is inserted before the lyric element, and the lyric layer is above the background layer. +- Image or video resources allow cross-origin reads, or are same-origin with the page. +- Video URLs are passed with `setAlbum(source, true)`. +- Playback state is synced to `resume()` / `pause()`. +- When switching renderers, call `dispose()` on the old background before creating the new one. +- On unmount, release the background, lyric component, and resources created by host code. diff --git a/amll-local/packages/docs/src/content/docs/en/guides/component/quickstart.mdx b/amll-local/packages/docs/src/content/docs/en/guides/component/quickstart.mdx new file mode 100644 index 0000000..e09083a --- /dev/null +++ b/amll-local/packages/docs/src/content/docs/en/guides/component/quickstart.mdx @@ -0,0 +1,286 @@ +--- +title: Quick Start +--- + +import { CardGrid, LinkCard } from "@astrojs/starlight/components"; +import PackageInstall from "#components/PackageInstall.astro"; + +This page quickly introduces how to integrate AMLL lyric components into your project. AMLL does not provide a CDN usage mode; you must use a bundler. + +For adjacent utility packages other than the component libraries, see the [API Reference](/en/reference). + +## Dependencies + +Install the AMLL core library: + + + +AMLL also declares several graphics and animation libraries as peer dependencies so it can reuse related dependencies that may already exist in your project. Some package managers or configurations may install peers automatically. If they are not installed, install them manually. + + + +The examples below use `@applemusic-like-lyrics/lyric` to parse TTML lyric files. If your project can already provide `LyricLine[]` directly, you can skip this package. + + + +Next, you can use the vanilla package, or use the React or Vue bindings. + + + + + + + +## Use Vanilla JS + +The AMLL core library is framework-agnostic. You can use this method whether or not your project uses a framework. + +The example below assumes: + +- The page already has `