commit 92f296ddb2f72923a606b35737e271dfa63e16e1 Author: Rekushi <170186554+RekuNote@users.noreply.github.com> Date: Thu Oct 29 11:56:15 2026 +0000 Add files via upload diff --git a/CREDITS.md b/CREDITS.md new file mode 100644 index 0000000..8791535 --- /dev/null +++ b/CREDITS.md @@ -0,0 +1,25 @@ +# Credits and bundled assets + +Flipnote Editor was developed for RexiMemo by Rekushi. This standalone edition +retains its interface, drawing tools and artwork. Original editor code is +covered by the accompanying GNU AGPL version 3 licence. + +Thanks to **bizkit** and **CommandCreep** for creating sprites for the editor. + +## Dependencies + +- **flipnote.js**, James Daniel. MIT licence. The unminified browser build is + bundled in `assets/vendor/flipnote.js`; its licence is beside it. Used here + to read PPM files, not to write them. + Source: https://github.com/jaames/flipnote.js +- **bidi-js**. MIT licence, retained at + `assets/flipnote-editor/bidi-LICENSE.txt`. +- The Arabic joining algorithm follows **arabic-reshaper**. Its MIT licence is + retained at `assets/flipnote-editor/arabic-reshaper-LICENSE.txt`. + +## Artwork + +The original interface assets are included so this copy looks like the editor. + +Nintendo's names and Flipnote Studio are used to describe compatibility. This +project is independent of Nintendo. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ac4d0a3 --- /dev/null +++ b/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) + + 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 +. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..f05d42d --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# Flipnote Editor + +A browser animation editor extracted from RexiMemo. The drawing interface is +the same: a 256×192 canvas, two ink layers, eight playback speeds, drawing and +paint tools, selections, transforms, text, onion skin and sound tracks. + +This version runs on its own. It has no accounts, site navigation, online +library or server API. Files selected in the editor are read by your browser. +They are not sent anywhere. + +## Run it + +With Node.js 18 or newer: + +```sh +npm start +``` + +Open **http://localhost:8090**. No dependency installation or build step is +needed. Alternatively, serve this folder with any static web server, for example: + +```sh +python -m http.server 8090 --bind 127.0.0.1 +``` + +Use localhost or HTTPS for microphone recording. Opening `index.html` directly +from disk is not supported because the text renderer loads local JSON. +For static hosting, copy the whole folder. Asset paths also work in a subdirectory. + +## Files and audio + +- **Tab 5 → Open .PPM** reads a Flipnote Studio file from your device, including + its pages, ink colours, paper, hidden layers, sound effects and playback speed. +- **Tab 4** records music and three sound effects. Hold Shift while clicking an + audio button to import an MP3 or WAV and trim it. +- **Tab 4 → Import Image** accepts local images and clipboard pastes. +- **Tab 5 → Save .WEBM** renders the animation and its audio into a video. + +PPM writing, signing and online uploads are absent. WebM is the only animation +output. There is no editable project save format or automatic project backup; +keep the page open until you have finished. The browser asks before closing a +changed project. Tool preferences and keyboard shortcuts stay on your device. + +Open the editor's settings button for zoom, the pixel grid, the desktop Shift +button and remappable shortcuts. The pixel grid appears at 400% zoom and above. +Text uses **Shift+T** by default. + +## Source layout + +| File | Purpose | +| --- | --- | +| `index.html` | Editor controls and dialogs | +| `assets/editor.css` | Original editor styling and shared form rules | +| `assets/bootstrap.js` | Asset loading, input fixes and touch Shift control | +| `assets/flipnote-editor/editor.js` | Drawing, pages, selection, audio and video rendering | +| `assets/flipnote-editor/drawing-engine.js` | Brush and paint behaviour | +| `assets/flipnote-editor/text-tool.js` | Pixel text tool | +| `assets/ppm-import.js` | Local PPM import adapter | +| `assets/vendor/flipnote.js` | Bundled PPM decoder | +| `tools/serve.cjs` | Optional local static file server | + +Run `npm test` for the local import and release checks. Tests do not contact +RexiMemo or require an account. The original RexiMemo editor is not modified by +this standalone version. + +## Licence + +The editor code follows the source project's GNU AGPL version 3 licence. See +`LICENSE`. Bundled dependencies retain their own licences. Additional credits +are listed in `CREDITS.md`. diff --git a/assets/bootstrap.js b/assets/bootstrap.js new file mode 100644 index 0000000..a0bc994 --- /dev/null +++ b/assets/bootstrap.js @@ -0,0 +1,348 @@ + + (function(){ + var loader=document.getElementById('flipnote-editor-loader'); + var statusEl=loader&&loader.querySelector('[data-flipnote-loader-status]'); + var progressEl=loader&&loader.querySelector('.flipnote-editor-loader-progress'); + var progressFill=loader&&loader.querySelector('[data-flipnote-loader-progress]'); + var copyEl=loader&&loader.querySelector('.flipnote-editor-loader-copy'); + var titleEl=loader&&loader.querySelector('.flipnote-editor-loader-title'); + + + + + var drawingEngineScript='./assets/flipnote-editor/drawing-engine.js?v=2.47-advanced-eraser-paint-memory'; + var textToolScript='./assets/flipnote-editor/text-tool.js?v=3.26.2'; + var editorScript='./assets/flipnote-editor/editor.js?v=3.26.2-canvas'; + // The server enumerates every image in web/static/flipnote-editor on each request. + // This means newly-added button states (including manually-added tab art) join the + // preload automatically without needing another JavaScript manifest update. + + // Keep a small explicit list for image assets used by the editor but stored elsewhere. + + var imageUrls=[]; + var imageCacheBank=document.getElementById('flipnote-editor-image-cache'); + var decodedImageCache=window.__flipnoteEditorImageCache=window.__flipnoteEditorImageCache||new Map(); + document.querySelectorAll('img[src]').forEach(function(img){imageUrls.push(img.getAttribute('src'));}); + document.querySelectorAll('*').forEach(function(node){ + Array.from(node.attributes||[]).forEach(function(attr){ + if(/^data-.*src$/i.test(attr.name)&&attr.value)imageUrls.push(attr.value); + }); + }); + imageUrls=Array.from(new Set(imageUrls.concat(["./assets/flipnote-editor/base.png","./assets/flipnote-editor/checker.png","./assets/flipnote-editor/clear.png","./assets/flipnote-editor/clone-placeholder.png","./assets/flipnote-editor/closepages.png","./assets/flipnote-editor/colour.png","./assets/flipnote-editor/copy.png","./assets/flipnote-editor/copy_pressed.png","./assets/flipnote-editor/cut.png","./assets/flipnote-editor/delete-frame.png","./assets/flipnote-editor/downloadwebm.png","./assets/flipnote-editor/Draw.png","./assets/flipnote-editor/Draw_selected.png","./assets/flipnote-editor/earsHurt.png","./assets/flipnote-editor/editpattern.png","./assets/flipnote-editor/Erase.png","./assets/flipnote-editor/erase1.png","./assets/flipnote-editor/erase1_Adv.png","./assets/flipnote-editor/erase2.png","./assets/flipnote-editor/erase2_Adv.png","./assets/flipnote-editor/erase3.png","./assets/flipnote-editor/erase3_Adv.png","./assets/flipnote-editor/erase4.png","./assets/flipnote-editor/erase4_Adv.png","./assets/flipnote-editor/erase5.png","./assets/flipnote-editor/erase5_Adv.png","./assets/flipnote-editor/erase6.png","./assets/flipnote-editor/erase6_Adv.png","./assets/flipnote-editor/erase7.png","./assets/flipnote-editor/erase7_Adv.png","./assets/flipnote-editor/erase8.png","./assets/flipnote-editor/erase8_Adv.png","./assets/flipnote-editor/eraseaudio.png","./assets/flipnote-editor/erasebig.png","./assets/flipnote-editor/eraser.png","./assets/flipnote-editor/Erase_selected.png","./assets/flipnote-editor/fill.png","./assets/flipnote-editor/fill1.png","./assets/flipnote-editor/fill2.png","./assets/flipnote-editor/fill3.png","./assets/flipnote-editor/fill4.png","./assets/flipnote-editor/fill5.png","./assets/flipnote-editor/fill6.png","./assets/flipnote-editor/fill7.png","./assets/flipnote-editor/fill8.png","./assets/flipnote-editor/fill_selected.png","./assets/flipnote-editor/filmstrip-dots.png","./assets/flipnote-editor/first-frame.png","./assets/flipnote-editor/flip_x.png","./assets/flipnote-editor/flip_y.png","./assets/flipnote-editor/goodVolume.png","./assets/flipnote-editor/ImportImage.png","./assets/flipnote-editor/invert.png","./assets/flipnote-editor/last-frame.png","./assets/flipnote-editor/layer1.png","./assets/flipnote-editor/layer1_inactive.png","./assets/flipnote-editor/layer2.png","./assets/flipnote-editor/layer2_inactive.png","./assets/flipnote-editor/lightbox-placeholder.png","./assets/flipnote-editor/lightbox.png","./assets/flipnote-editor/lightbox_pressed.png","./assets/flipnote-editor/loop_disabled.png","./assets/flipnote-editor/loop_enabled.png","./assets/flipnote-editor/musicBarEmpty.png","./assets/flipnote-editor/musicImportalt.png","./assets/flipnote-editor/musicPlay.png","./assets/flipnote-editor/musicRec.png","./assets/flipnote-editor/musicStop.png","./assets/flipnote-editor/new-frame.png","./assets/flipnote-editor/next-frame.png","./assets/flipnote-editor/openpages.png","./assets/flipnote-editor/openppm.png","./assets/flipnote-editor/pageCopy.png","./assets/flipnote-editor/pageErase.png","./assets/flipnote-editor/pageInsert.png","./assets/flipnote-editor/pagePaste.png","./assets/flipnote-editor/Paint.png","./assets/flipnote-editor/paint1.png","./assets/flipnote-editor/paint1_Adv.png","./assets/flipnote-editor/paint2.png","./assets/flipnote-editor/paint2_Adv.png","./assets/flipnote-editor/paint3.png","./assets/flipnote-editor/paint3_Adv.png","./assets/flipnote-editor/paint4.png","./assets/flipnote-editor/paint4_Adv.png","./assets/flipnote-editor/paint5.png","./assets/flipnote-editor/paint5_Adv.png","./assets/flipnote-editor/paint6.png","./assets/flipnote-editor/paint6_Adv.png","./assets/flipnote-editor/paint7.png","./assets/flipnote-editor/paint7_Adv.png","./assets/flipnote-editor/paint8.png","./assets/flipnote-editor/paint8_Adv.png","./assets/flipnote-editor/Paint_selected.png","./assets/flipnote-editor/paper-placeholder.png","./assets/flipnote-editor/paperB.png","./assets/flipnote-editor/paperW.png","./assets/flipnote-editor/paste.png","./assets/flipnote-editor/pen.png","./assets/flipnote-editor/pen1.png","./assets/flipnote-editor/pen1_Adv.png","./assets/flipnote-editor/pen2.png","./assets/flipnote-editor/pen2_Adv.png","./assets/flipnote-editor/pen3.png","./assets/flipnote-editor/pen3_Adv.png","./assets/flipnote-editor/pen4.png","./assets/flipnote-editor/pen4_Adv.png","./assets/flipnote-editor/pen5.png","./assets/flipnote-editor/pen5_Adv.png","./assets/flipnote-editor/pen6.png","./assets/flipnote-editor/pen6_Adv.png","./assets/flipnote-editor/pen7.png","./assets/flipnote-editor/pen7_Adv.png","./assets/flipnote-editor/pen8.png","./assets/flipnote-editor/pen8_Adv.png","./assets/flipnote-editor/play.png","./assets/flipnote-editor/previous-frame.png","./assets/flipnote-editor/redo.png","./assets/flipnote-editor/redo_grey.png","./assets/flipnote-editor/resize_larger.png","./assets/flipnote-editor/resize_smaller.png","./assets/flipnote-editor/rotate_anticlockwise.png","./assets/flipnote-editor/rotate_clockwise.png","./assets/flipnote-editor/selbase.png","./assets/flipnote-editor/select.png","./assets/flipnote-editor/selectionCopy.png","./assets/flipnote-editor/selectionCut.png","./assets/flipnote-editor/select_pressed.png","./assets/flipnote-editor/select_y.png","./assets/flipnote-editor/shiftl.png","./assets/flipnote-editor/shiftl_active.png","./assets/flipnote-editor/soundBarEmpry.png","./assets/flipnote-editor/soundImport1.png","./assets/flipnote-editor/soundImport1alt.png","./assets/flipnote-editor/soundImport2.png","./assets/flipnote-editor/soundImport2alt.png","./assets/flipnote-editor/soundImport3.png","./assets/flipnote-editor/soundImport3alt.png","./assets/flipnote-editor/soundPlay1.png","./assets/flipnote-editor/soundPlay2.png","./assets/flipnote-editor/soundPlay3.png","./assets/flipnote-editor/soundRecord1.png","./assets/flipnote-editor/soundRecord2.png","./assets/flipnote-editor/soundRecord3.png","./assets/flipnote-editor/soundStop1.png","./assets/flipnote-editor/soundStop2.png","./assets/flipnote-editor/soundStop3.png","./assets/flipnote-editor/spacer.png","./assets/flipnote-editor/speed1.png","./assets/flipnote-editor/speed2.png","./assets/flipnote-editor/speed3.png","./assets/flipnote-editor/speed4.png","./assets/flipnote-editor/speed5.png","./assets/flipnote-editor/speed6.png","./assets/flipnote-editor/speed7.png","./assets/flipnote-editor/speed8.png","./assets/flipnote-editor/stop.png","./assets/flipnote-editor/tab-1.png","./assets/flipnote-editor/tab-2.png","./assets/flipnote-editor/tab-3.png","./assets/flipnote-editor/tab-4.png","./assets/flipnote-editor/tab-5.png","./assets/flipnote-editor/tab1.png","./assets/flipnote-editor/tab1_selected.png","./assets/flipnote-editor/tab2.png","./assets/flipnote-editor/tab2_selected.png","./assets/flipnote-editor/tab3.png","./assets/flipnote-editor/tab3_selected.png","./assets/flipnote-editor/tab4.png","./assets/flipnote-editor/tab4_selected.png","./assets/flipnote-editor/tab5.png","./assets/flipnote-editor/tab5_selected.png","./assets/flipnote-editor/transform.png","./assets/flipnote-editor/transform_active.png","./assets/flipnote-editor/undo.png","./assets/flipnote-editor/undo_grey.png","./assets/flipnote-editor/webm.png"]).filter(Boolean))); + + var fontJobs=[{css:'16px "NDS BIOS"',sample:'Preloading assets 00 / 00'}]; + var completed=0; + var total=imageUrls.length+fontJobs.length+5; + function updateProgress(){ + var safeTotal=Math.max(total,1); + var percent=Math.max(0,Math.min(100,Math.round((completed/safeTotal)*100))); + if(statusEl)statusEl.textContent='Preloading assets '+Math.min(completed,total)+' / '+total; + if(progressFill)progressFill.style.width=percent+'%'; + if(progressEl)progressEl.setAttribute('aria-valuenow',String(percent)); + } + function doneOne(){completed+=1;updateProgress();} + function preloadImage(url){ + var cached=decodedImageCache.get(url); + if(cached&&cached.promise)return cached.promise.then(function(){doneOne();}); + var image=new Image(); + image.alt=''; + image.decoding='sync'; + image.loading='eager'; + image.setAttribute('aria-hidden','true'); + if(imageCacheBank)imageCacheBank.appendChild(image); + var promise=new Promise(function(resolve,reject){ + var finished=false; + var decoding=false; + var finish=function(ok){ + if(finished)return; + finished=true; + if(!ok){reject(new Error('asset '+url));return;} + resolve(image); + }; + var decodeLoaded=function(){ + if(finished||decoding)return; + decoding=true; + if(typeof image.decode==='function'){ + image.decode().then(function(){finish(true);}).catch(function(){ + finish(image.complete&&image.naturalWidth>0); + }); + }else{ + finish(image.complete&&image.naturalWidth>0); + } + }; + image.onload=decodeLoaded; + image.onerror=function(){finish(false);}; + image.src=url; + if(image.complete&&image.naturalWidth>0)decodeLoaded(); + }); + decodedImageCache.set(url,{image:image,promise:promise}); + return promise.then(function(){doneOne();}); + } + function preloadFont(job){ + if(!document.fonts||!document.fonts.load)return Promise.reject(new Error('font loading unsupported')); + return document.fonts.load(job.css,job.sample).then(function(faces){ + if(!faces||!faces.length)throw new Error('font '+job.css); + doneOne(); + }); + } + function loadFonts(){ + return Promise.all(fontJobs.map(preloadFont)).then(function(){ + if(loader)loader.classList.add('fonts-ready'); + if(copyEl)copyEl.setAttribute('aria-hidden','false'); + }); + } + function loadScript(url,label){ + return new Promise(function(resolve,reject){ + var script=document.createElement('script'); + script.src=url; + script.onload=function(){doneOne();resolve();}; + script.onerror=function(){reject(new Error(label||'editor script'));}; + document.body.appendChild(script); + }); + } + function loadEditor(){return loadScript('./assets/vendor/flipnote.js','PPM reader').then(function(){return loadScript('./assets/ppm-import.js','PPM importer');}).then(function(){return loadScript(drawingEngineScript,'drawing engine');}).then(function(){return loadScript(textToolScript,'text tool');}).then(function(){return loadScript(editorScript,'editor');});} + function installEditorInputFixes(){ + var titleField=document.getElementById('filename'); + if(titleField&&!titleField.dataset.titleLimitFix){ + titleField.dataset.titleLimitFix='1'; + var titleLimit=80; + function titleText(){return String(titleField.textContent||'').replace(/ /g,' ');} + function selectionLength(){ + var selection=window.getSelection&&window.getSelection(); + if(!selection||!selection.rangeCount)return 0; + var range=selection.getRangeAt(0); + if(!titleField.contains(range.commonAncestorContainer))return 0; + return range.toString().length; + } + function putCaretAtEnd(){ + var selection=window.getSelection&&window.getSelection(); + if(!selection)return; + var range=document.createRange(); + range.selectNodeContents(titleField); + range.collapse(false); + selection.removeAllRanges(); + selection.addRange(range); + } + titleField.addEventListener('keydown',function(event){ + if(event.defaultPrevented||event.ctrlKey||event.metaKey||event.altKey)return; + if(event.key&&event.key.length===1&&titleText().length-selectionLength()>=titleLimit){ + event.preventDefault(); + event.stopImmediatePropagation(); + } + },true); + titleField.addEventListener('beforeinput',function(event){ + if(event.defaultPrevented||!event.inputType||event.inputType.indexOf('insert')!==0)return; + var incoming=typeof event.data==='string'?event.data:''; + if(event.inputType==='insertLineBreak'||event.inputType==='insertParagraph'){ + event.preventDefault(); + return; + } + if(titleText().length-selectionLength()+(incoming?incoming.length:1)>titleLimit){ + event.preventDefault(); + } + },true); + titleField.addEventListener('paste',function(event){ + var clipboard=event.clipboardData||window.clipboardData; + if(!clipboard)return; + var incoming=String(clipboard.getData('text')||'').replace(/[\r\n]+/g,' '); + var room=Math.max(0,titleLimit-(titleText().length-selectionLength())); + if(incoming.length<=room)return; + event.preventDefault(); + if(room>0){ + var partial=incoming.slice(0,room); + if(document.execCommand)document.execCommand('insertText',false,partial); + } + },true); + titleField.addEventListener('input',function(){ + var value=titleText(); + if(value.length<=titleLimit)return; + titleField.textContent=value.slice(0,titleLimit); + putCaretAtEnd(); + },true); + } + + var paperTool=document.getElementById('paper-tool'); + function pageUsesBlackPaper(){ + if(!paperTool)return false; + if(paperTool.getAttribute('aria-pressed')==='true')return true; + var image=paperTool.querySelector('img'); + return !!(image&&/paperB\.png(?:[?#]|$)/i.test(image.getAttribute('src')||'')); + } + function syncMonochromeInkLabel(){ + var label=pageUsesBlackPaper()?'White':'Black'; + ['flipnote-image-import-colour','flipnote-image-import-second-colour'].forEach(function(id){ + var select=document.getElementById(id); + var option=select&&select.querySelector('option[value="black"]'); + if(option)option.textContent=label; + }); + document.querySelectorAll('.flipnote-editor-layer-color[data-layer-color="black"]').forEach(function(button){ + var layer=Number(button.getAttribute('data-layer'))+1; + button.title=label; + button.setAttribute('aria-label','Layer '+layer+' '+label.toLowerCase()); + }); + } + syncMonochromeInkLabel(); + if(paperTool&&!paperTool.dataset.rexiPaperInkLabelFix){ + paperTool.dataset.rexiPaperInkLabelFix='1'; + paperTool.addEventListener('click',function(){setTimeout(syncMonochromeInkLabel,0);}); + var paperObserver=new MutationObserver(syncMonochromeInkLabel); + paperObserver.observe(paperTool,{attributes:true,attributeFilter:['aria-pressed','title'],subtree:true}); + var paperImage=paperTool.querySelector('img'); + if(paperImage)paperObserver.observe(paperImage,{attributes:true,attributeFilter:['src']}); + } + + var imageImportButton=document.getElementById('flipnote-import-image-button'); + if(imageImportButton&&!imageImportButton.dataset.rexiInkLabelFix){ + imageImportButton.dataset.rexiInkLabelFix='1'; + imageImportButton.addEventListener('click',function(){setTimeout(syncMonochromeInkLabel,0);}); + } + + var imageApply=document.getElementById('flipnote-image-import-apply'); + if(imageApply&&!imageApply.dataset.rexiImageColourFix){ + imageApply.dataset.rexiImageColourFix='1'; + imageApply.addEventListener('click',function(){ + var mode=document.getElementById('flipnote-image-import-mode'); + var first=document.getElementById('flipnote-image-import-colour'); + var second=document.getElementById('flipnote-image-import-second-colour'); + var layer=document.getElementById('flipnote-image-import-layer'); + var chosenMode=mode?mode.value:'single'; + var firstColour=first?first.value:'black'; + var secondColour=second?second.value:'red'; + var chosenLayer=Math.max(0,Math.min(1,Number(layer&&layer.value)||0)); + function applyLayerColour(index,colour){ + var button=document.querySelector('.flipnote-editor-layer-color[data-layer="'+index+'"][data-layer-color="'+colour+'"]'); + if(button)button.click(); + } + setTimeout(function(){ + if(chosenMode==='dual'){ + applyLayerColour(0,firstColour); + applyLayerColour(1,secondColour); + }else{ + applyLayerColour(chosenLayer,firstColour); + } + syncMonochromeInkLabel(); + },0); + },true); + } + } + + function installMobileShiftToggle(){ + var button=document.getElementById('flipnote-mobile-shift-toggle'); + if(!button)return; + var ua=String(navigator.userAgent||''); + var uaMobile=!!(navigator.userAgentData&&navigator.userAgentData.mobile); + var ipadLike=navigator.platform==='MacIntel'&&Number(navigator.maxTouchPoints||0)>1; + var mobileOrTablet=uaMobile||ipadLike||/(Android|iPad|iPhone|iPod|Mobile|Tablet|Silk|Kindle)/i.test(ua); + var desktopSetting=document.getElementById('flipnote-editor-desktop-shift'); + var desktopSettingKey='flipnote-editor.flipnote-editor.desktop-shift.v1'; + var desktopEnabled=false; + try{desktopEnabled=window.localStorage.getItem(desktopSettingKey)==='1';}catch(_error){} + if(desktopSetting)desktopSetting.checked=desktopEnabled; + if(!mobileOrTablet&&desktopSetting)desktopSetting.addEventListener('change',function(){ + desktopEnabled=desktopSetting.checked; + try{window.localStorage.setItem(desktopSettingKey,desktopEnabled?'1':'0');}catch(_error){} + if(!desktopEnabled)setVirtualShift(false); + button.hidden=!desktopEnabled; + }); + + var image=button.querySelector('img'); + var normalSrc=image&&image.getAttribute('data-normal-src')||'./assets/flipnote-editor/shiftl.png'; + var activeSrc=image&&image.getAttribute('data-active-src')||'./assets/flipnote-editor/shiftl_active.png'; + var active=false; + + function makeShiftEvent(type,down){ + var event; + try{ + event=new KeyboardEvent(type,{key:'Shift',code:'ShiftLeft',location:1,shiftKey:!!down,bubbles:true,cancelable:true}); + }catch(_error){ + event=document.createEvent('Event'); + event.initEvent(type,true,true); + } + try{Object.defineProperty(event,'key',{configurable:true,get:function(){return 'Shift';}});}catch(_error){} + try{Object.defineProperty(event,'code',{configurable:true,get:function(){return 'ShiftLeft';}});}catch(_error){} + try{Object.defineProperty(event,'keyCode',{configurable:true,get:function(){return 16;}});}catch(_error){} + try{Object.defineProperty(event,'which',{configurable:true,get:function(){return 16;}});}catch(_error){} + try{Object.defineProperty(event,'shiftKey',{configurable:true,get:function(){return !!down;}});}catch(_error){} + var target=document.body||document.documentElement||document; + target.dispatchEvent(event); + } + + function setVirtualShift(next){ + next=!!next; + if(active===next)return; + active=next; + button.setAttribute('aria-pressed',active?'true':'false'); + button.title=active?'Shift enabled':'Shift'; + if(image)image.src=active?activeSrc:normalSrc; + makeShiftEvent(active?'keydown':'keyup',active); + } + + function forceShiftModifier(event){ + if(!active||!event)return; + try{Object.defineProperty(event,'shiftKey',{configurable:true,get:function(){return true;}});}catch(_error){} + if(typeof event.getModifierState==='function'){ + var original=event.getModifierState.bind(event); + try{Object.defineProperty(event,'getModifierState',{configurable:true,value:function(key){return String(key||'').toLowerCase()==='shift'?true:original(key);}});}catch(_error){} + } + } + + ['pointerdown','pointerup','mousedown','mouseup','click','dblclick'].forEach(function(type){ + document.addEventListener(type,forceShiftModifier,true); + }); + + function consumesOneShotShift(target){ + if(!target||!target.closest)return false; + var slot=target.closest('[data-tool-slot]'); + if(slot){ + var slotImage=slot.querySelector('img'); + var slotSrc=slotImage&&String(slotImage.getAttribute('src')||''); + return slotSrc.toLowerCase().indexOf('_adv.png')!==-1; + } + return !!target.closest('#copy-tool,.flipnote-editor-more-tool,[data-audio-control]'); + } + + button.addEventListener('click',function(){setVirtualShift(!active);}); + document.addEventListener('click',function(event){ + if(!active||button.contains(event.target)||!consumesOneShotShift(event.target))return; + window.setTimeout(function(){setVirtualShift(false);},0); + },true); + document.addEventListener('keyup',function(event){ + if(active&&event.key==='Shift')window.setTimeout(function(){if(active)makeShiftEvent('keydown',true);},0); + }); + window.addEventListener('focus',function(){if(active)makeShiftEvent('keydown',true);}); + window.addEventListener('pagehide',function(){if(active)makeShiftEvent('keyup',false);}); + button.hidden=!(mobileOrTablet||desktopEnabled); + } + + + + function reveal(){ + updateProgress(); + document.body.classList.remove('flipnote-editor-loading'); + if(!loader)return; + loader.classList.add('is-exiting'); + var removed=false; + var removeLoader=function(){ + if(removed)return; + removed=true; + loader.remove(); + }; + loader.addEventListener('transitionend',function(event){ + if(event.target===loader&&event.propertyName==='transform')removeLoader(); + }); + window.setTimeout(removeLoader,900); + } + function fail(error){ if(loader)loader.classList.add('load-failed'); if(titleEl)titleEl.textContent='Flipnote Editor could not load'; if(statusEl)statusEl.textContent=error&&error.message?error.message:'Try refreshing the page.'; } + updateProgress(); + var imageJobs=imageUrls.map(preloadImage); + var fontPromise=loadFonts(); + + function settleDecodedImages(){ + return new Promise(function(resolve){ + requestAnimationFrame(function(){requestAnimationFrame(resolve);}); + }); + } + + Promise.all(imageJobs.concat([fontPromise])).then(settleDecodedImages).then(loadEditor).then(function(){installEditorInputFixes();installMobileShiftToggle();return settleDecodedImages();}).then(reveal).catch(fail); + })(); + \ No newline at end of file diff --git a/assets/editor.css b/assets/editor.css new file mode 100644 index 0000000..b98c8eb --- /dev/null +++ b/assets/editor.css @@ -0,0 +1,2462 @@ +@font-face{font-family:"NDS BIOS";src:url("./fonts/ndsbios.ttf") format("truetype");font-style:normal;font-weight:400;font-display:swap} +:root{ + --red:#ae301f; + --red-hover:#c13b29; + --red-dark:#7d2116; + --header-pink:#ffd0d7; + --bg:#111011; + --surface:#181718; + --surface-raised:#1e1c1d; + --surface-hover:#252223; + --line:#353032; + --line-soft:#292627; + --text:#f4f1f0; + --muted:#aca5a3; + --quiet:#797270; + --yellow:#f6c54d; + --green:#63cf82; + --blue:#65aef5; + --purple:#bb87e8; + --danger:#e06259; + --max:1320px; + color-scheme:dark; + font-family:"Trebuchet MS","Avenir Next",system-ui,sans-serif; + font-kerning:normal; + font-feature-settings:"kern" 1,"liga" 1; + text-rendering:optimizeLegibility; +} + +*{box-sizing:border-box} +html{background:var(--bg);scroll-behavior:smooth;scrollbar-gutter:stable} +body{margin:0;min-height:100vh;display:flex;flex-direction:column;background:var(--bg);color:var(--text);font-family:inherit;font-size:15px;line-height:1.45} +a{color:inherit;text-decoration:none} +a:hover:not(.button){color:inherit;text-decoration:underline;text-underline-offset:2px} +img{display:block;max-width:100%} +button,input,textarea,select{font:inherit;color:inherit} +button{cursor:pointer} +small,.muted{color:var(--muted)} +code{padding:.12rem .38rem;border:1px solid var(--line);border-radius:3px;background:#0c0b0c;color:#ffd3cd;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.88em} +.page-shell{width:100%;max-width:var(--max);margin:0 auto;padding:28px 20px 68px;flex:1 0 auto} + +.button{display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:34px;padding:6px 12px;border:1px solid var(--line);border-radius:3px;background:var(--surface-raised);color:var(--text);font-weight:750;line-height:1.1} +.button:hover{background:var(--surface-hover);border-color:#51494c} +.button.primary{background:var(--red);border-color:var(--red);color:#fff}.button.primary:hover{background:var(--red-hover);border-color:var(--red-hover)} +.button.subtle{background:var(--surface-raised)} +.button.danger{border-color:#713833;background:#351d1b;color:#ffb9b3}.button.danger:hover{background:#44231f} +.button.wide{width:100%} +.button-row{display:flex;align-items:center;gap:7px;flex-wrap:wrap} +.inline-form{display:inline-flex;margin:0} + +input,textarea,select{width:100%;border:1px solid var(--line);border-radius:3px;background:#100f10;padding:8px 9px;outline:none} +textarea{resize:vertical;min-height:90px} +input:focus,textarea:focus,select:focus{border-color:#8e4439;box-shadow:0 0 0 2px rgba(174,48,31,.14)} +label{display:grid;gap:5px;color:#d8d2d0;font-size:13px;font-weight:700} +@media(max-width:680px){ + .page-shell{padding:18px 11px 44px} +} +.flipnote-editor-page .page-shell{max-width:none;padding-top:24px} +.flipnote-editor-sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0} +.flipnote-editor-workspace{width:944px;max-width:100%;margin:0 auto;display:grid;grid-template-columns:640px 280px;gap:24px;align-items:start} +.flipnote-editor-main{width:640px;max-width:100%;min-width:0} +.flipnote-editor-stage{width:640px;max-width:100%;overflow:hidden;border-radius:8px;background:var(--red);color:#fff;box-shadow:0 7px 20px rgba(0,0,0,.14)} +.flipnote-editor-canvas-wrap{padding:11.5px 8px 0;display:flex;justify-content:center;background:var(--red)} +#flipnote-canvas{display:block;width:616px;max-width:100%;height:auto;aspect-ratio:4/3;background:#fbfbfb;border:0;image-rendering:pixelated;image-rendering:crisp-edges;cursor:crosshair;user-select:none;-webkit-user-select:none;touch-action:none} +#flipnote-canvas.eraser-cursor{cursor:cell} +.flipnote-editor-filename{min-height:44px;display:flex;align-items:center;justify-content:center;padding:8px 12px;background:var(--red);color:#fff;font-size:18px;font-weight:750;line-height:1.1;text-align:center;cursor:text} +.flipnote-editor-filename:hover{background:var(--red-hover)} +.flipnote-editor-filename.editing{outline:none;box-shadow:inset 0 -2px #fff} +.flipnote-editor-filmstrip{position:relative;width:640px;max-width:100%;height:146px;margin-top:8px;padding:27px 8px 27px;overflow:hidden;border:1px solid #2c292a;border-radius:5px;background:#090909;box-shadow:0 5px 15px rgba(0,0,0,.12)} +.flipnote-editor-filmstrip:before,.flipnote-editor-filmstrip:after{content:"";position:absolute;z-index:3;left:0;right:0;height:15px;background-color:#090909;background-image:linear-gradient(#f5f5f5 0 0);background-size:8px 8px;background-repeat:repeat-x;background-position:4px center;pointer-events:none} +.flipnote-editor-filmstrip:before{top:5px} +.flipnote-editor-filmstrip:after{bottom:5px} +.flipnote-editor-timeline{position:relative;z-index:2;display:flex;align-items:center;gap:10px;width:100%;height:92px;overflow-x:auto;overflow-y:hidden;padding:2px 1px 4px;border-top:1px solid #fff;border-bottom:1px solid #fff;scrollbar-width:thin;scrollbar-color:#777 #262324} +.flipnote-editor-timeline .timeline-thumbnail{flex:0 0 114px;width:114px!important;height:86px!important;margin:0;border:1px solid #bbb;border-radius:0;background:#fff;image-rendering:pixelated;cursor:pointer} +.flipnote-editor-timeline .timeline-thumbnail:hover{border-color:#fff} +.flipnote-editor-timeline .timeline-thumbnail.selected{outline:2px solid var(--red);outline-offset:-2px} +.flipnote-editor-version{margin-top:6px;color:var(--quiet);font-size:12px;font-weight:650;opacity:.75} +.flipnote-editor-controls{width:280px;max-width:100%;display:flex;flex-direction:column;gap:16px;position:sticky;top:86px;min-width:0} +.flipnote-editor-side-top{height:58px;display:flex;align-items:center;width:100%} +.flipnote-editor-history{display:flex;align-items:center;min-width:0;gap:0} +.flipnote-editor-history-icon{display:block;height:40px;width:auto;max-width:none;object-fit:contain;cursor:pointer;user-select:none;-webkit-user-drag:none;transition:transform .1s ease,filter .1s ease} +.flipnote-editor-history-icon#undoButton{margin-right:-2px} +.flipnote-editor-history-icon:hover{transform:translateY(-1px);filter:brightness(1.08)} +.flipnote-editor-history-icon:active{transform:translateY(0) scale(.98)} +.flipnote-editor-page-counter{width:120px;height:58px;margin-left:auto;display:grid;grid-template-rows:auto 1fr;align-items:center;padding:6px 10px 5px;border:1px solid var(--red-dark);border-radius:8px;background:var(--red);color:#fff;line-height:1;text-align:left} +.flipnote-editor-page-counter span{font-size:14px;font-weight:750} +.flipnote-editor-page-counter strong{align-self:center;text-align:center;font-size:20px;font-weight:800;white-space:nowrap} + +.flipnote-editor-control-panel{width:100%;overflow:hidden;border:2px solid var(--red);border-radius:8px;background:var(--header-pink)} +.flipnote-editor-control-title{padding:7px 10px 8px;background:var(--red);color:#fff;text-align:center;font-size:18px;font-weight:750;line-height:1.1} +.flipnote-editor-control-body{background:var(--header-pink);padding:9px 8px} +.flipnote-editor-toolbar{display:flex;align-items:center;justify-content:center;gap:10px;min-height:32px;flex-wrap:nowrap} +.flipnote-editor-icon,.flipnote-editor-export-icon,.flipnote-editor-spacer{display:block;object-fit:contain;user-select:none;-webkit-user-drag:none} +.flipnote-editor-icon,.flipnote-editor-export-icon{cursor:pointer;transition:transform .1s ease,filter .1s ease} +.flipnote-editor-icon{width:32px;height:32px;flex:0 0 32px} +.flipnote-editor-spacer{width:32px;height:32px;flex:0 0 32px;cursor:default} +.flipnote-editor-icon:hover,.flipnote-editor-export-icon:hover{transform:scale(1.08);filter:brightness(1.04)} +.flipnote-editor-icon:active,.flipnote-editor-export-icon:active{transform:scale(.98)} +.flipnote-editor-toolbar-tools{gap:10px} +.flipnote-editor-export-toolbar{gap:10px} +.flipnote-editor-export-icon{width:116px;height:auto;max-width:calc(50% - 5px)} +.theme-light .flipnote-editor-stage,.theme-classic .flipnote-editor-stage{box-shadow:0 6px 17px rgba(55,25,22,.10)} +.theme-light .flipnote-editor-filmstrip,.theme-classic .flipnote-editor-filmstrip{border-color:#3a3436} +.theme-light .flipnote-editor-version,.theme-classic .flipnote-editor-version{color:var(--muted)} +@media(max-width:1000px){ + .flipnote-editor-page .page-shell{padding-left:16px;padding-right:16px} + .flipnote-editor-workspace{width:640px;grid-template-columns:1fr;gap:18px} + .flipnote-editor-controls{position:static;width:640px;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px} + .flipnote-editor-side-top{grid-column:1/-1} + .flipnote-editor-control-panel:last-child{grid-column:1/-1} + .flipnote-editor-page-counter{width:124px} +} +@media(max-width:620px){ + .flipnote-editor-page .page-shell{padding:12px 10px 42px} + .flipnote-editor-workspace,.flipnote-editor-main,.flipnote-editor-stage,.flipnote-editor-filmstrip,.flipnote-editor-controls{width:100%} + .flipnote-editor-canvas-wrap{padding:9.5px 6px 0} + .flipnote-editor-filename{min-height:40px;font-size:16px} + .flipnote-editor-filmstrip{height:127px;padding-top:24px;padding-bottom:24px} + .flipnote-editor-timeline{height:79px;gap:8px} + .flipnote-editor-timeline .timeline-thumbnail{flex-basis:100px;width:100px!important;height:75px!important} + .flipnote-editor-controls{display:flex;gap:12px} + .flipnote-editor-side-top{height:54px} + .flipnote-editor-history-icon{height:36px} + .flipnote-editor-page-counter{height:54px;width:112px} + .flipnote-editor-page-counter strong{font-size:18px} + .flipnote-editor-control-title{font-size:16px} + .flipnote-editor-control-body{padding:8px 6px} + .flipnote-editor-toolbar{gap:7px;flex-wrap:nowrap} + .flipnote-editor-icon{width:30px;height:30px;flex-basis:30px} + .flipnote-editor-spacer{width:22px;height:30px;flex-basis:22px} + .flipnote-editor-export-icon{width:110px} +} +.flipnote-editor-layer-stack{display:flex;flex-direction:column;gap:6px} +.flipnote-editor-layer-row{position:relative;width:100%;aspect-ratio:932/177;min-height:0;overflow:visible;border:0;border-radius:0;background-position:center;background-repeat:no-repeat;background-size:100% 100%;isolation:isolate} +.flipnote-editor-layer-row[data-layer-row="0"]{background-image:url("./flipnote-editor/layer1_inactive.png")} +.flipnote-editor-layer-row[data-layer-row="0"].active{background-image:url("./flipnote-editor/layer1.png")} +.flipnote-editor-layer-row[data-layer-row="1"]{background-image:url("./flipnote-editor/layer2_inactive.png")} +.flipnote-editor-layer-row[data-layer-row="1"].active{background-image:url("./flipnote-editor/layer2.png")} +.flipnote-editor-layer-select{position:absolute;z-index:1;inset:0;width:100%;height:100%;padding:0;border:0;background:transparent;color:transparent;cursor:pointer;font:inherit;text-align:left} +.flipnote-editor-layer-select strong,.flipnote-editor-layer-select small{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0} +.flipnote-editor-layer-select:focus-visible{outline:2px solid #fff;outline-offset:-6px} +.flipnote-editor-layer-colors{position:absolute;z-index:5;top:50%;right:5%;display:flex;align-items:center;justify-content:center;gap:5px;width:102px;height:32px;transform:translateY(-50%);isolation:isolate} +.flipnote-editor-layer-colors:before{content:"";position:absolute;z-index:-1;inset:-4px;border:1px solid transparent;background:transparent;pointer-events:none} +.flipnote-editor-layer-row.color-picker-open .flipnote-editor-layer-colors:before{border-color:#7f7f7f;background:rgba(242,242,242,.94);box-shadow:0 2px 4px rgba(0,0,0,.22)} +.flipnote-editor-layer-color{display:none;flex:0 0 28px;width:28px;height:28px;padding:0;border:1px solid #5d5658;border-radius:0;box-shadow:inset 0 0 0 2px rgba(255,255,255,.88);cursor:pointer;transition:transform .1s ease,filter .1s ease,border-color .08s ease,box-shadow .08s ease} +.flipnote-editor-layer-color.selected{display:block;border-color:#fff;box-shadow:0 0 0 1px #4c4648,inset 0 0 0 2px rgba(255,255,255,.9)} +.flipnote-editor-layer-row.color-picker-open .flipnote-editor-layer-color{display:block} +.flipnote-editor-layer-color:hover,.flipnote-editor-layer-color:focus-visible{outline:1px solid #ae301f;outline-offset:1px} +.flipnote-editor-layer-row:not(.color-picker-open) .flipnote-editor-layer-color.selected:hover,.flipnote-editor-layer-row:not(.color-picker-open) .flipnote-editor-layer-color.selected:focus-visible{transform:scale(1.04);filter:brightness(1.04)} +.flipnote-editor-layer-row:not(.color-picker-open) .flipnote-editor-layer-color.selected:active{transform:scale(.98)} +.flipnote-editor-layer-row.color-picker-open .flipnote-editor-layer-color:hover,.flipnote-editor-layer-row.color-picker-open .flipnote-editor-layer-color:focus-visible{transform:translateY(-1px)} +.flipnote-editor-layer-row.color-picker-open .flipnote-editor-layer-color.selected{outline:2px solid #ae301f;outline-offset:1px} +.flipnote-editor-layer-color.ink-black{background:#000} +.flipnote-editor-layer-color.ink-red{background:#ff354d} +.flipnote-editor-layer-color.ink-blue{background:#585cff} +@media(max-width:620px){.flipnote-editor-layer-colors{right:4%;width:96px;gap:4px}.flipnote-editor-layer-color{flex-basis:26px;width:26px;height:26px}} +.flipnote-editor-layers-panel,.flipnote-editor-layers-panel .flipnote-editor-control-body{overflow:visible} +.flipnote-editor-layer-row{isolation:auto} +.flipnote-editor-layer-select{z-index:1} +.flipnote-editor-layer-colors{z-index:50;pointer-events:auto} +.flipnote-editor-layer-color{position:relative;z-index:51;pointer-events:auto} +.flipnote-editor-layer-row.color-picker-open{z-index:10} +.flipnote-editor-layer-row{cursor:pointer} +.flipnote-editor-layer-select{inset:0;width:100%;height:100%} +.flipnote-editor-layer-colors{cursor:default} +.flipnote-editor-loading{overflow:hidden} +.flipnote-editor-loading .flipnote-editor-workspace{visibility:hidden;pointer-events:none} +.flipnote-editor-loader{position:fixed;z-index:40;inset:0;display:flex;align-items:center;justify-content:center;padding:24px;background:var(--bg);color:var(--text);text-align:center;transform:translateY(0);transition:transform .62s cubic-bezier(.76,0,.24,1);will-change:transform} +.flipnote-editor-loader.is-exiting{transform:translateY(-100%);pointer-events:none} +.flipnote-editor-loader-inner{display:flex;flex-direction:column;align-items:center;justify-content:center;width:min(420px,calc(100vw - 48px));min-width:240px} +.flipnote-editor-loader-spinner{display:block;width:72px;height:72px;margin:0 0 18px;object-fit:contain;image-rendering:auto;user-select:none;-webkit-user-drag:none} +.flipnote-editor-loader-copy{visibility:hidden;opacity:0;transition:opacity .12s linear} +.flipnote-editor-loader.fonts-ready .flipnote-editor-loader-copy{visibility:visible;opacity:1} +.flipnote-editor-loader-title{font-family:"Trebuchet MS","Avenir Next",system-ui,sans-serif;font-size:24px;font-weight:800;line-height:1.15;letter-spacing:-.02em} +.flipnote-editor-loader-subtext{margin-top:9px;color:var(--muted);font-family:"NDS BIOS",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:14px;font-weight:400;line-height:1.25;letter-spacing:0} +.flipnote-editor-loader-progress{width:100%;height:10px;margin-top:18px;overflow:hidden;border:1px solid var(--line);background:var(--surface);box-shadow:inset 0 0 0 1px rgba(0,0,0,.16)} +.flipnote-editor-loader-progress-fill{width:0;height:100%;background:var(--red);transition:width .12s linear} +.flipnote-editor-loader.load-failed .flipnote-editor-loader-progress-fill{transition:none} +@media(max-width:620px){.flipnote-editor-loader-title{font-size:21px}.flipnote-editor-loader-subtext{font-size:13px}.flipnote-editor-loader-spinner{width:64px;height:64px}.flipnote-editor-loader-inner{width:min(360px,calc(100vw - 36px))}} +.flipnote-editor-tool-tabs{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:0;width:100%;overflow:visible;border:0;border-radius:0;background:transparent;box-shadow:none} +.flipnote-editor-tool-tab{display:block;min-width:0;padding:0;border:0;background:transparent;box-shadow:none;outline:0;appearance:none;-webkit-appearance:none;cursor:pointer;line-height:0;position:relative} +.flipnote-editor-tool-tab:last-child{border:0} +.flipnote-editor-tool-tab img{display:block;width:100%;height:auto;aspect-ratio:4/3;object-fit:fill;user-select:none;-webkit-user-drag:none;filter:saturate(.68) brightness(.94);transition:filter .1s ease} +.flipnote-editor-tool-tab.active:after{display:none} +.flipnote-editor-tool-tab.active img{filter:none} +.flipnote-editor-tool-tab:hover img,.flipnote-editor-tool-tab:focus-visible img{filter:saturate(1) brightness(1.04)} +.flipnote-editor-tool-tab:hover,.flipnote-editor-tool-tab:active,.flipnote-editor-tool-tab:focus,.flipnote-editor-tool-tab:focus-visible{border:0;background:transparent;box-shadow:none;outline:0} +.flipnote-editor-tool-page{display:flex;flex-direction:column;gap:16px;min-width:0} +.flipnote-editor-tool-page[hidden]{display:none!important} +.flipnote-editor-paper-tools{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));align-items:start;gap:10px;width:100%} +.flipnote-editor-paper-tool{display:block;min-width:0;padding:0;border:0;background:transparent;cursor:pointer;box-shadow:none;transition:transform .1s ease,filter .1s ease} +.flipnote-editor-paper-tool img{display:block;width:100%;height:auto;aspect-ratio:480/640;object-fit:fill;user-select:none;-webkit-user-drag:none} +.flipnote-editor-paper-tool:hover,.flipnote-editor-paper-tool:focus-visible{transform:scale(1.04);filter:brightness(1.04)} +.flipnote-editor-paper-tool:active{transform:scale(.98)} +.flipnote-editor-paper-tool:focus-visible{outline:2px solid var(--red);outline-offset:2px} +.flipnote-editor-paper-tool.is-enabled{border:0;box-shadow:none} +.flipnote-editor-paper-tool.is-placeholder{cursor:default;filter:grayscale(1) opacity(.72)} +.flipnote-editor-selection-tools{position:relative;display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:0;align-items:start;width:100%;max-width:100%;margin:0 auto;overflow:visible} +.flipnote-editor-selection-base{position:absolute;z-index:0;inset:0;display:block;width:100%;height:100%;object-fit:fill;pointer-events:none;user-select:none;-webkit-user-drag:none;transform:none!important;filter:none!important;transition:none!important} +.flipnote-editor-selection-base[hidden]{display:none!important} +.flipnote-editor-selection-tool{display:block;position:relative;z-index:1;transform-origin:center center;transition:transform .1s ease!important} +.flipnote-editor-selection-tool:hover,.flipnote-editor-selection-tool:focus-visible{z-index:2} +.flipnote-editor-selection-tool:is([aria-pressed="true"],.is-enabled,.active,.selected){z-index:2;transform:scale(1.04)} +.flipnote-editor-selection-tool:is([aria-pressed="true"],.is-enabled,.active,.selected):hover,.flipnote-editor-selection-tool:is([aria-pressed="true"],.is-enabled,.active,.selected):focus-visible,.flipnote-editor-selection-tool:is([aria-pressed="true"],.is-enabled,.active,.selected):active{transform:scale(1.04)} +.flipnote-editor-selection-tool.is-disabled,.flipnote-editor-selection-tool[aria-disabled="true"]{filter:grayscale(1) opacity(.48);cursor:default;transform:none!important;transition:transform .1s ease!important} +.flipnote-editor-selection-tool.is-disabled:hover,.flipnote-editor-selection-tool.is-disabled:focus-visible,.flipnote-editor-selection-tool.is-disabled:active,.flipnote-editor-selection-tool[aria-disabled="true"]:hover,.flipnote-editor-selection-tool[aria-disabled="true"]:focus-visible,.flipnote-editor-selection-tool[aria-disabled="true"]:active{filter:grayscale(1) opacity(.48);transform:none!important} +.flipnote-editor-selection-tool :is(.selbase,.selection-base,[class*="selbase"],[src*="selbase"]){display:none!important} +.flipnote-editor-selection-copy{margin:12px auto 0;max-width:420px;color:var(--muted);text-align:center;font-size:13px;line-height:1.35} +.flipnote-editor-tool-page-placeholder{min-height:106px;display:grid;place-items:center;border:2px dashed var(--line);border-radius:8px;background:var(--surface);color:var(--muted);font-family:"NDS BIOS",ui-monospace,monospace;font-size:14px} +.flipnote-editor-tool-page-placeholder[hidden]{display:none!important} +@media(max-width:1000px){ + .flipnote-editor-tool-tabs,.flipnote-editor-tool-page,.flipnote-editor-control-panel[data-tool-tab-shared],.flipnote-editor-export-panel{grid-column:1/-1} + .flipnote-editor-paper-tools{grid-template-columns:repeat(3,minmax(0,132px));justify-content:center;gap:16px} +} +@media(max-width:620px){ + .flipnote-editor-paper-tools{grid-template-columns:repeat(3,minmax(0,1fr));gap:8px} + .flipnote-editor-selection-tools{gap:0} + .flipnote-editor-selection-copy{font-size:12px} +} +.flipnote-editor-tool-tab[data-tab-selected-src]:not(.active) img{filter:saturate(.68) brightness(.94)} +.flipnote-editor-tool-tab[data-tab-selected-src].active img{filter:none} +.flipnote-editor-tool-tab[data-tab-selected-src].active:after{display:none} +.flipnote-editor-tool-tab[data-tab-selected-src]:hover img,.flipnote-editor-tool-tab[data-tab-selected-src]:focus-visible img{filter:saturate(1) brightness(1.04)} +.flipnote-editor-tool-tabs{border:0!important;background:transparent!important;box-shadow:none!important} +.flipnote-editor-tool-tab,.flipnote-editor-tool-tab:hover,.flipnote-editor-tool-tab:active,.flipnote-editor-tool-tab:focus,.flipnote-editor-tool-tab:focus-visible{border:0!important;outline:0!important;background:transparent!important;box-shadow:none!important} +.flipnote-editor-tool-tab img{filter:saturate(.68) brightness(.94);transition:filter .1s ease} +.flipnote-editor-tool-tab.active img{filter:saturate(1) brightness(1)} +.flipnote-editor-tool-tab:hover img,.flipnote-editor-tool-tab:focus-visible img{filter:saturate(1.08) brightness(1.06)} +.flipnote-editor-history-icon.flipnote-editor-history-clear{height:36px} +.flipnote-editor-history-icon.flipnote-editor-history-clear:hover{transform:none;filter:brightness(1.08) saturate(1.08)} +.flipnote-editor-history-icon.flipnote-editor-history-clear:active{transform:scale(.98);filter:brightness(1.04) saturate(1.04)} +@media(max-width:620px){.flipnote-editor-history-icon.flipnote-editor-history-clear{height:32.4px}} +.flipnote-editor-stage-playback{min-height:44px;display:flex;align-items:center;justify-content:center;padding:6px 12px;background:var(--red)} +.flipnote-editor-stage-toolbar{width:100%;justify-content:center;gap:10px} +.flipnote-editor-title-field{min-height:40px;display:flex;align-items:center;justify-content:center;width:100%;padding:7px 10px;border:1px solid var(--line);border-radius:4px;background:var(--surface);color:var(--text);font-size:16px;font-weight:750;line-height:1.15;text-align:center;cursor:text;overflow-wrap:anywhere} +.flipnote-editor-title-field:hover{filter:brightness(1.03)} +.flipnote-editor-title-field.editing{outline:2px solid var(--red);outline-offset:-2px;filter:none} +.flipnote-editor-title-panel .flipnote-editor-control-body{padding:8px} +@media(max-width:620px){ + .flipnote-editor-stage-playback{min-height:40px;padding:5px 8px} + .flipnote-editor-stage-toolbar{gap:7px} + .flipnote-editor-title-field{min-height:38px;font-size:15px} +} +.flipnote-editor-history-icon#undoButton,.flipnote-editor-history-icon#redoButton{height:34px} +.flipnote-editor-history-icon.flipnote-editor-history-clear{height:44px;margin-right:3px} +@media(max-width:620px){ + .flipnote-editor-history-icon#undoButton,.flipnote-editor-history-icon#redoButton{height:31px} + .flipnote-editor-history-icon.flipnote-editor-history-clear{height:40px} +} +@keyframes flipnoteSidebarGridDrift{ + from{background-position:0 0,0 0} + to{background-position:16px 16px,16px 16px} +} +.flipnote-editor-control-panel,.flipnote-editor-control-body{ + background-color:#fffbff; + background-image: + linear-gradient(to right,transparent 14px,#eff3ff 14px,#eff3ff 16px), + linear-gradient(to bottom,transparent 14px,#eff3ff 14px,#eff3ff 16px); + background-size:16px 16px; + background-position:0 0,0 0; + animation:flipnoteSidebarGridDrift 2.4s linear infinite; +} +@media (prefers-reduced-motion:reduce){ + .flipnote-editor-control-panel,.flipnote-editor-control-body,.flipnote-editor-pattern-dialog{animation:none} +} +.flipnote-editor-history{flex-direction:row} +.flipnote-editor-side-top{ + height:auto!important; + width:100%; + display:grid!important; + grid-template-columns:minmax(0,1fr) auto; + grid-template-rows:58px 44px; + grid-template-areas: + ". page" + "erase undoredo"; + align-items:center; + column-gap:8px; + row-gap:6px; +} +.flipnote-editor-side-top>.flipnote-editor-history-undo-redo{ + grid-area:undoredo; + justify-self:end; + align-self:center; + display:flex; + align-items:center; + gap:0; + margin:0!important; +} +.flipnote-editor-side-top>#eraseBigButton{ + grid-area:erase; + justify-self:start; + align-self:center; + margin:0!important; +} +.flipnote-editor-side-top>.flipnote-editor-page-counter{ + grid-area:page; + justify-self:end; + align-self:center; + margin:0!important; +} +@media(max-width:620px){ + .flipnote-editor-side-top{ + grid-template-rows:54px 40px; + column-gap:6px; + row-gap:6px; + } +} +.flipnote-editor-history-icon#undoButton{height:34px;width:54.4px;object-fit:contain} +.flipnote-editor-history-icon#redoButton{height:34px;width:18.1333px;object-fit:contain} +@media(max-width:620px){ + .flipnote-editor-history-icon#undoButton{height:31px;width:49.6px} + .flipnote-editor-history-icon#redoButton{height:31px;width:16.5333px} +} +.flipnote-editor-history-icon#undoButton.is-disabled,.flipnote-editor-history-icon#redoButton.is-disabled{cursor:default} +.flipnote-editor-history-icon#undoButton.is-disabled:hover,.flipnote-editor-history-icon#redoButton.is-disabled:hover{transform:none;filter:none} +.flipnote-editor-history-icon#undoButton.is-disabled:active,.flipnote-editor-history-icon#redoButton.is-disabled:active{transform:none;filter:none} +.flipnote-editor-history-undo-redo{isolation:isolate} +.flipnote-editor-history-icon#undoButton,.flipnote-editor-history-icon#redoButton{position:relative} +.flipnote-editor-history-icon#undoButton{left:-1px} +.flipnote-editor-history-icon#undoButton.is-disabled,.flipnote-editor-history-icon#redoButton.is-disabled{z-index:1} +.flipnote-editor-history-icon#undoButton:not(.is-disabled),.flipnote-editor-history-icon#redoButton:not(.is-disabled){z-index:3} +.flipnote-editor-history-icon#undoButton{height:44px;width:70.4px} +.flipnote-editor-history-icon#redoButton{height:44px;width:23.4667px} +.flipnote-editor-side-top{row-gap:16px} +@media(max-width:1000px){ + .flipnote-editor-side-top{row-gap:14px} +} +@media(max-width:620px){ + .flipnote-editor-history-icon#undoButton{height:40px;width:64px} + .flipnote-editor-history-icon#redoButton{height:40px;width:21.3333px} + .flipnote-editor-side-top{row-gap:12px} +} +.flipnote-editor-image-cache{position:absolute;left:-10000px;top:0;width:1px;height:1px;overflow:hidden;opacity:0;pointer-events:none;contain:strict} +.flipnote-editor-image-cache img{display:block;width:1px;height:1px;max-width:none} +.flipnote-editor-stage-speed-row{ + display:flex; + align-items:center; + justify-content:center; + gap:8px; + min-height:52px; + padding:0 12px 8px; + background:var(--red); +} +.flipnote-editor-speed-control,.flipnote-editor-loop-toggle{ + display:block; + padding:0; + border:0; + outline:0; + background:transparent; + box-shadow:none; + user-select:none; + -webkit-user-select:none; +} +.flipnote-editor-speed-control{ + width:150.4px; + height:32px; + cursor:ew-resize; + touch-action:none; +} +.flipnote-editor-loop-toggle{ + width:66px; + height:44px; + cursor:pointer; +} +.flipnote-editor-speed-control img,.flipnote-editor-loop-toggle img{ + display:block; + width:100%; + height:100%; + object-fit:fill; + user-select:none; + -webkit-user-drag:none; + pointer-events:none; + transition:filter .1s ease,transform .1s ease; +} +.flipnote-editor-speed-control:hover img,.flipnote-editor-speed-control:focus-visible img,.flipnote-editor-loop-toggle:hover img,.flipnote-editor-loop-toggle:focus-visible img{filter:brightness(1.05) saturate(1.05)} +.flipnote-editor-speed-control:active img,.flipnote-editor-loop-toggle:active img{transform:scale(.98)} +.flipnote-editor-speed-control:focus-visible,.flipnote-editor-loop-toggle:focus-visible{outline:2px solid #fff;outline-offset:2px} +@media(max-width:620px){ + .flipnote-editor-stage-speed-row{gap:7px;min-height:47px;padding:0 8px 7px} + .flipnote-editor-speed-control{width:141px;height:30px} + .flipnote-editor-loop-toggle{width:60px;height:40px} +} +.flipnote-editor-stage-playback{ + min-height:44px; + display:flex; + align-items:center; + justify-content:center; + padding:6px 12px 4px; + background:var(--red); +} +.flipnote-editor-stage-control-grid,.flipnote-editor-stage-speed-row{ + --flipnote-stage-small:48px; + --flipnote-stage-speed-width:150.4px; + --flipnote-stage-control-height:32px; + --flipnote-stage-control-gap:8px; + display:grid; + grid-template-columns:var(--flipnote-stage-small) var(--flipnote-stage-speed-width) var(--flipnote-stage-small); + column-gap:var(--flipnote-stage-control-gap); + justify-content:center; + align-items:center; +} +.flipnote-editor-stage-control-grid{width:100%} +.flipnote-editor-stage-control-grid #prev-frame{grid-column:1;justify-self:center} +.flipnote-editor-stage-control-grid #play-pause{grid-column:2;justify-self:stretch} +.flipnote-editor-stage-control-grid #next-frame{grid-column:3;justify-self:center} +.flipnote-editor-stage-play-toggle{ + display:block; + width:var(--flipnote-stage-speed-width); + height:var(--flipnote-stage-control-height); + max-width:none; + object-fit:contain; + cursor:pointer; + user-select:none; + -webkit-user-drag:none; + transition:transform .1s ease,filter .1s ease; +} +.flipnote-editor-stage-play-toggle:hover{transform:scale(1.04);filter:brightness(1.05) saturate(1.05)} +.flipnote-editor-stage-play-toggle:active{transform:scale(.98)} +.flipnote-editor-stage-speed-row{ + min-height:44px; + padding:0 12px 8px; + background:var(--red); +} +.flipnote-editor-speed-control{ + grid-column:2; + width:var(--flipnote-stage-speed-width); + height:var(--flipnote-stage-control-height); + justify-self:stretch; +} +.flipnote-editor-loop-toggle{ + grid-column:3; + width:var(--flipnote-stage-small); + height:var(--flipnote-stage-control-height); + justify-self:center; +} +.flipnote-editor-side-top{ + grid-template-areas: + "pageactions page" + "erase undoredo"; +} +.flipnote-editor-page-actions{ + grid-area:pageactions; + justify-self:start; + align-self:center; + display:flex; + align-items:center; + gap:10px; + min-width:0; +} +@media(max-width:620px){ + .flipnote-editor-stage-playback{min-height:40px;padding:5px 8px 3px} + .flipnote-editor-stage-control-grid,.flipnote-editor-stage-speed-row{ + --flipnote-stage-small:45px; + --flipnote-stage-speed-width:141px; + --flipnote-stage-control-height:30px; + --flipnote-stage-control-gap:7px; + } + .flipnote-editor-stage-speed-row{min-height:40px;padding:0 8px 7px} + .flipnote-editor-page-actions{gap:7px} +} +.flipnote-editor-speed-control:hover img,.flipnote-editor-speed-control:focus-visible img,.flipnote-editor-loop-toggle:hover img,.flipnote-editor-loop-toggle:focus-visible img{ + transform:scale(1.04); + filter:brightness(1.05) saturate(1.05); +} +.flipnote-editor-speed-control:active img,.flipnote-editor-loop-toggle:active img{transform:scale(.98)} +.flipnote-editor-history-undo-redo{ + transition:transform .1s ease; +} +.flipnote-editor-history-undo-redo:has(.flipnote-editor-history-icon:not(.is-disabled):hover){ + transform:translateY(-1px); +} +.flipnote-editor-history-undo-redo .flipnote-editor-history-icon:hover{ + transform:none; +} +.flipnote-editor-history-undo-redo .flipnote-editor-history-icon:not(.is-disabled):hover{ + filter:brightness(1.08); +} +.flipnote-editor-history-undo-redo .flipnote-editor-history-icon:not(.is-disabled):active{ + transform:scale(.98); +} +.flipnote-editor-layer-colors{ + --flipnote-picker-size:28px; + --flipnote-picker-gap:5px; + width:calc(var(--flipnote-picker-size) * 3 + var(--flipnote-picker-gap) * 2); + height:32px; + justify-content:flex-start; + gap:var(--flipnote-picker-gap); + pointer-events:none; +} +.flipnote-editor-layer-color{ + display:block; + flex:0 0 var(--flipnote-picker-size); + width:var(--flipnote-picker-size); + height:var(--flipnote-picker-size); + opacity:0; + pointer-events:none; + transform:translateX(calc((3 - var(--flipnote-picker-slot, 3)) * (var(--flipnote-picker-size) + var(--flipnote-picker-gap)))); + transition:transform .18s cubic-bezier(.2,.8,.2,1),opacity .12s ease,filter .1s ease,border-color .08s ease,box-shadow .08s ease; +} +.flipnote-editor-layer-color.selected{ + opacity:1; + pointer-events:auto; +} +.flipnote-editor-layer-row.color-picker-open .flipnote-editor-layer-color{ + opacity:1; + pointer-events:auto; + transform:translateX(0); +} +.flipnote-editor-layer-row:not(.color-picker-open) .flipnote-editor-layer-color.selected:hover,.flipnote-editor-layer-row:not(.color-picker-open) .flipnote-editor-layer-color.selected:focus-visible{ + transform:scale(1.04); + filter:brightness(1.04); +} +.flipnote-editor-layer-row:not(.color-picker-open) .flipnote-editor-layer-color.selected:active{ + transform:scale(.98); +} +.flipnote-editor-layer-row.color-picker-open .flipnote-editor-layer-color:hover,.flipnote-editor-layer-row.color-picker-open .flipnote-editor-layer-color:focus-visible{ + transform:translateY(-1px); +} +@media(max-width:620px){ + .flipnote-editor-layer-colors{ + --flipnote-picker-size:26px; + --flipnote-picker-gap:4px; + width:calc(var(--flipnote-picker-size) * 3 + var(--flipnote-picker-gap) * 2); + } +} +.flipnote-editor-history-undo-redo{ + transform:none !important; +} +.flipnote-editor-history-undo-redo:has(.flipnote-editor-history-icon:not(.is-disabled):hover){ + transform:none !important; +} +.flipnote-editor-history-undo-redo .flipnote-editor-history-icon{ + transition:transform .1s ease,filter .1s ease; +} +.flipnote-editor-history-undo-redo .flipnote-editor-history-icon:not(.is-disabled):hover{ + transform:translateY(-1px); + filter:brightness(1.08); + z-index:4; +} +.flipnote-editor-history-undo-redo .flipnote-editor-history-icon:not(.is-disabled):active{ + transform:scale(.98); +} +.flipnote-editor-loader-subtext,.flipnote-editor-tool-page-placeholder{font-kerning:auto;font-feature-settings:normal;text-rendering:auto} +#flipnote-canvas.paint-cursor,#flipnote-canvas.fill-cursor{cursor:crosshair} +.flipnote-editor-tool-picker-inline{ + display:grid; + grid-template-columns:1fr; + gap:8px; + width:100%; + align-items:start; + align-content:start; + overflow:visible; +} +.flipnote-editor-tool-family-row{ + display:grid; + grid-template-columns:repeat(2,minmax(0,1fr)); + gap:7px; + width:100%; + padding:0 3px; + box-sizing:border-box; + overflow:visible; +} +.flipnote-editor-tool-family{display:block;min-width:0;padding:0;border:0;background:transparent;box-shadow:none;outline:0;appearance:none;-webkit-appearance:none;cursor:pointer;line-height:0;transition:transform .1s ease,filter .1s ease;position:relative;z-index:1;overflow:visible} +.flipnote-editor-tool-family img{display:block;width:100%;height:auto;aspect-ratio:5/4;object-fit:fill;user-select:none;-webkit-user-drag:none} +.flipnote-editor-tool-family:hover,.flipnote-editor-tool-family:focus-visible{transform:scale(1.04);filter:brightness(1.04);z-index:20} +.flipnote-editor-tool-family:focus-visible{outline:2px solid var(--red);outline-offset:2px;border-radius:4px} +.flipnote-editor-tool-family:active{transform:scale(.98)} +.flipnote-editor-tool-lower-row{display:flex;align-items:flex-start;justify-content:flex-start;gap:8px;min-height:48px;padding:0 3px;overflow:visible} +.flipnote-editor-tool-slots{ + display:grid; + grid-template-columns:repeat(4,48px); + grid-template-rows:repeat(2,48px); + gap:0; + margin:0; + width:192px; + height:96px; + overflow:visible; + isolation:isolate; + position:relative; + z-index:20; + flex:0 0 auto; + transition:none!important; +} +.flipnote-editor-tool-slots[hidden]{display:none!important} +.flipnote-editor-tool-slots>button{display:block;min-width:0;width:48px;height:48px;aspect-ratio:1;padding:0;border:0;background:transparent;box-shadow:none;outline:0;appearance:none;-webkit-appearance:none;cursor:pointer;line-height:0;overflow:visible;position:relative;will-change:transform,opacity} +.flipnote-editor-tool-slots>button img{display:block;width:100%;height:100%;object-fit:contain;image-rendering:auto;user-select:none;-webkit-user-drag:none;transform-origin:center;transition:transform 90ms cubic-bezier(.2,.78,.28,1),filter 90ms ease;will-change:transform} +.flipnote-editor-tool-slots>button:hover,.flipnote-editor-tool-slots>button:focus-visible{filter:brightness(1.06);z-index:30} +.flipnote-editor-tool-slots>button:hover img,.flipnote-editor-tool-slots>button:focus-visible img{transform:scale(1.12)} +.flipnote-editor-tool-slots>button:focus-visible{outline:2px solid var(--red);outline-offset:0;border-radius:0;z-index:30} +.flipnote-editor-tool-slots>button:active img{transform:scale(.94)} +.flipnote-editor-tool-slots>button.is-tool-clicking img{animation:flipnote-tool-click-bounce 170ms cubic-bezier(.2,.82,.28,1)} +.flipnote-editor-tool-slots.is-opening,.flipnote-editor-tool-slots.is-opening-from-selection,.flipnote-editor-tool-slots.is-closing,.flipnote-editor-tool-slots.is-closing-to-selection{overflow:hidden} +.flipnote-editor-tool-slots>button:nth-child(1){--flipnote-tool-index:0;--flipnote-tool-reverse-index:7;--flipnote-tool-col:0;--flipnote-tool-row:0;--flipnote-snake-from-x:-100%;--flipnote-snake-from-y:0%} +.flipnote-editor-tool-slots>button:nth-child(2){--flipnote-tool-index:1;--flipnote-tool-reverse-index:6;--flipnote-tool-col:1;--flipnote-tool-row:0;--flipnote-snake-from-x:-100%;--flipnote-snake-from-y:0%} +.flipnote-editor-tool-slots>button:nth-child(3){--flipnote-tool-index:2;--flipnote-tool-reverse-index:5;--flipnote-tool-col:2;--flipnote-tool-row:0;--flipnote-snake-from-x:-100%;--flipnote-snake-from-y:0%} +.flipnote-editor-tool-slots>button:nth-child(4){--flipnote-tool-index:3;--flipnote-tool-reverse-index:4;--flipnote-tool-col:3;--flipnote-tool-row:0;--flipnote-snake-from-x:-100%;--flipnote-snake-from-y:0%} +.flipnote-editor-tool-slots>button:nth-child(5){--flipnote-tool-index:4;--flipnote-tool-reverse-index:3;--flipnote-tool-col:0;--flipnote-tool-row:1;--flipnote-snake-from-x:0%;--flipnote-snake-from-y:-100%} +.flipnote-editor-tool-slots>button:nth-child(6){--flipnote-tool-index:5;--flipnote-tool-reverse-index:2;--flipnote-tool-col:1;--flipnote-tool-row:1;--flipnote-snake-from-x:-100%;--flipnote-snake-from-y:0%} +.flipnote-editor-tool-slots>button:nth-child(7){--flipnote-tool-index:6;--flipnote-tool-reverse-index:1;--flipnote-tool-col:2;--flipnote-tool-row:1;--flipnote-snake-from-x:-100%;--flipnote-snake-from-y:0%} +.flipnote-editor-tool-slots>button:nth-child(8){--flipnote-tool-index:7;--flipnote-tool-reverse-index:0;--flipnote-tool-col:3;--flipnote-tool-row:1;--flipnote-snake-from-x:-100%;--flipnote-snake-from-y:0%} + +.flipnote-editor-tool-slots.is-opening>button{animation:flipnote-tool-snake-grow 82ms both cubic-bezier(.2,.82,.28,1);animation-delay:calc(var(--flipnote-tool-index)*24ms);pointer-events:none} +.flipnote-editor-tool-slots.is-closing>button{animation:flipnote-tool-snake-retract 82ms both cubic-bezier(.42,0,.78,.25);animation-delay:calc(var(--flipnote-tool-reverse-index)*24ms);pointer-events:none} +.flipnote-editor-tool-slots.is-closing-to-selection>button:not(.is-current-tool){animation:flipnote-tool-snake-retract 82ms both cubic-bezier(.42,0,.78,.25);animation-delay:calc(var(--flipnote-tool-reverse-index)*24ms);pointer-events:none} +.flipnote-editor-tool-slots.is-closing-to-selection>button.is-current-tool{animation:flipnote-tool-snake-selected-origin 210ms both cubic-bezier(.2,.78,.28,1);pointer-events:none;z-index:10} +.flipnote-editor-tool-slots.is-closing>button.is-current-tool,.flipnote-editor-tool-slots.is-closing-to-selection>button.is-current-tool{z-index:100!important} +.flipnote-editor-tool-slots.is-selection-only{width:48px;height:48px;overflow:visible} +.flipnote-editor-tool-slots.is-selection-only>button{opacity:0;pointer-events:none} +.flipnote-editor-tool-slots.is-selection-only>button.is-current-tool{opacity:1;pointer-events:auto;transform:translate(calc(var(--flipnote-tool-col)*-100%),calc(var(--flipnote-tool-row)*-100%));z-index:10} +.flipnote-editor-tool-slots.is-opening-from-selection>button:not(.is-current-tool){animation:flipnote-tool-snake-grow 82ms both cubic-bezier(.2,.82,.28,1);animation-delay:calc(var(--flipnote-tool-index)*24ms);pointer-events:none} +.flipnote-editor-tool-slots.is-opening-from-selection>button.is-current-tool{animation:flipnote-tool-snake-selected-home 210ms both cubic-bezier(.2,.78,.28,1);pointer-events:none;z-index:10} + +.flipnote-editor-edit-pattern{display:block;flex:0 0 auto;height:48px;width:auto;margin-left:auto;padding:0;border:0;background:transparent;box-shadow:none;appearance:none;-webkit-appearance:none;cursor:pointer;line-height:0;transition:transform .1s ease,filter .1s ease;overflow:visible} +.flipnote-editor-edit-pattern img{display:block;height:48px;width:auto;max-width:none;object-fit:contain;user-select:none;-webkit-user-drag:none} +.flipnote-editor-edit-pattern:hover,.flipnote-editor-edit-pattern:focus-visible{transform:scale(1.04);filter:brightness(1.08)} +.flipnote-editor-edit-pattern:active{transform:scale(.98)} +.flipnote-editor-edit-pattern[hidden]{display:none!important} + +.flipnote-editor-tool-slots.is-motion-locked{pointer-events:none} +.flipnote-editor-tool-slots.is-motion-locked>button,.flipnote-editor-tool-slots.is-motion-locked>button:hover,.flipnote-editor-tool-slots.is-motion-locked>button:focus-visible,.flipnote-editor-tool-slots.is-motion-locked>button:active{pointer-events:none!important;filter:none!important} +.flipnote-editor-tool-slots.is-motion-locked>button img,.flipnote-editor-tool-slots.is-motion-locked>button:hover img,.flipnote-editor-tool-slots.is-motion-locked>button:focus-visible img,.flipnote-editor-tool-slots.is-motion-locked>button:active img,.flipnote-editor-tool-slots.is-motion-locked>button.is-tool-clicking img{transform:scale(1)!important;transition:none!important;animation:none!important} + +@keyframes flipnote-tool-click-bounce{0%{transform:scale(1.12)}38%{transform:scale(.88)}72%{transform:scale(1.18)}100%{transform:scale(1)}} +@keyframes flipnote-tool-snake-grow{0%{opacity:0;transform:translate(var(--flipnote-snake-from-x),var(--flipnote-snake-from-y))}1%{opacity:1}100%{opacity:1;transform:translate(0,0)}} +@keyframes flipnote-tool-snake-retract{0%{opacity:1;transform:translate(0,0)}99%{opacity:1}100%{opacity:0;transform:translate(var(--flipnote-snake-from-x),var(--flipnote-snake-from-y))}} +@keyframes flipnote-tool-snake-selected-origin{0%{opacity:1;transform:translate(0,0)}100%{opacity:1;transform:translate(calc(var(--flipnote-tool-col)*-100%),calc(var(--flipnote-tool-row)*-100%))}} +@keyframes flipnote-tool-snake-selected-home{0%{opacity:1;transform:translate(calc(var(--flipnote-tool-col)*-100%),calc(var(--flipnote-tool-row)*-100%))}100%{opacity:1;transform:translate(0,0)}} +@media(prefers-reduced-motion:reduce){ + .flipnote-editor-tool-family{transition:none} + .flipnote-editor-tool-slots>button img{transition:none} + .flipnote-editor-tool-slots>button.is-tool-clicking img{animation:none} + .flipnote-editor-tool-slots.is-opening>button,.flipnote-editor-tool-slots.is-closing>button,.flipnote-editor-tool-slots.is-closing-to-selection>button,.flipnote-editor-tool-slots.is-opening-from-selection>button{animation-duration:.01ms;animation-delay:0ms} +} +@media(max-width:620px){ + .flipnote-editor-tool-picker-inline{gap:6px} + .flipnote-editor-tool-family-row{gap:5px;padding:0 2px} + .flipnote-editor-tool-lower-row{padding:0 2px;gap:6px} + .flipnote-editor-tool-slots{grid-template-columns:repeat(4,44px);grid-template-rows:repeat(2,44px);width:176px;height:88px} + .flipnote-editor-tool-slots>button{width:44px;height:44px} + .flipnote-editor-tool-slots.is-selection-only{width:44px;height:44px} + .flipnote-editor-edit-pattern{height:44px} + .flipnote-editor-edit-pattern img{height:44px;width:auto} +} +.flipnote-editor-layer-row{ + background-image:none!important; +} +.flipnote-editor-layer-row[data-layer-row="0"]{--flipnote-layer-art:url("./flipnote-editor/layer1_inactive.png")} +.flipnote-editor-layer-row[data-layer-row="0"].active{--flipnote-layer-art:url("./flipnote-editor/layer1.png")} +.flipnote-editor-layer-row[data-layer-row="1"]{--flipnote-layer-art:url("./flipnote-editor/layer2_inactive.png")} +.flipnote-editor-layer-row[data-layer-row="1"].active{--flipnote-layer-art:url("./flipnote-editor/layer2.png")} +.flipnote-editor-layer-row::before{ + content:""; + position:absolute; + z-index:0; + inset:0; + background-image:var(--flipnote-layer-art); + background-position:center; + background-repeat:no-repeat; + background-size:100% 100%; + pointer-events:none; + transform:scale(1); + transform-origin:center; + transition:transform .11s ease,filter .11s ease; + will-change:transform; +} +.flipnote-editor-layer-row:hover::before,.flipnote-editor-layer-row:focus-within::before{ + transform:scale(1.018); + filter:brightness(1.025); +} +.flipnote-editor-layer-row:active::before{ + transform:scale(1.008); +} +@media(prefers-reduced-motion:reduce){ + .flipnote-editor-layer-row::before{transition:none} +} +.flipnote-editor-layer-row:hover::before,.flipnote-editor-layer-row:focus-within::before{ + transform:scale(1.022); + filter:brightness(1.025); +} +.flipnote-editor-layer-row:active::before{transform:scale(1.01)} +.flipnote-editor-layer-row.color-picker-interaction::before,.flipnote-editor-layer-row.color-picker-interaction:hover::before,.flipnote-editor-layer-row.color-picker-interaction:focus-within::before,.flipnote-editor-layer-row.color-picker-interaction:active::before,.flipnote-editor-layer-row.color-picker-open::before,.flipnote-editor-layer-row.color-picker-open:hover::before,.flipnote-editor-layer-row.color-picker-open:focus-within::before,.flipnote-editor-layer-row.color-picker-open:active::before{ + transform:scale(1)!important; + filter:none!important; +} + +.flipnote-editor-stage-control-grid{ + --flipnote-transport-small-w:53px; + --flipnote-transport-small-h:36px; + --flipnote-transport-play-w:159px; + --flipnote-transport-play-h:36px; + grid-template-columns: + var(--flipnote-transport-small-w) + var(--flipnote-transport-small-w) + var(--flipnote-transport-play-w) + var(--flipnote-transport-small-w) + var(--flipnote-transport-small-w); + column-gap:8px; + overflow:visible; +} +.flipnote-editor-stage-control-grid #first-frame{grid-column:1;justify-self:center} +.flipnote-editor-stage-control-grid #prev-frame{grid-column:2;justify-self:center} +.flipnote-editor-stage-control-grid #play-pause{grid-column:3;justify-self:center} +.flipnote-editor-stage-control-grid #next-frame{grid-column:4;justify-self:center} +.flipnote-editor-stage-control-grid #last-frame{grid-column:5;justify-self:center} + +.flipnote-editor-stage-nav{ + display:block; + width:53px; + height:36px; + max-width:none; + object-fit:contain; + cursor:pointer; + user-select:none; + -webkit-user-drag:none; + transform-origin:center; + transition:transform .1s ease,filter .1s ease; +} +.flipnote-editor-stage-nav:hover,.flipnote-editor-stage-nav:focus-visible{ + transform:scale(1.038,1.056); + filter:brightness(1.05) saturate(1.05); +} +.flipnote-editor-stage-nav:active{transform:scale(.98)} + +.flipnote-editor-stage-play-toggle{ + width:159px; + height:36px; + justify-self:center; + transform-origin:center; +} +.flipnote-editor-stage-play-toggle:hover,.flipnote-editor-stage-play-toggle:focus-visible{ + transform:scale(1.013,1.056); + filter:brightness(1.05) saturate(1.05); +} +.flipnote-editor-stage-play-toggle:active{transform:scale(.98)} +.flipnote-editor-loop-toggle{ + width:53px; + height:36px; +} +.flipnote-editor-loop-toggle:hover img,.flipnote-editor-loop-toggle:focus-visible img{ + transform:scale(1.038,1.056); + filter:brightness(1.05) saturate(1.05); +} +.flipnote-editor-loop-toggle:active img{transform:scale(.98)} +.flipnote-editor-stage-speed-row{ + grid-template-columns:53px 150.4px 53px; +} + +@media(max-width:620px){ + .flipnote-editor-stage-control-grid{ + --flipnote-transport-small-w:50px; + --flipnote-transport-small-h:34px; + --flipnote-transport-play-w:150px; + --flipnote-transport-play-h:34px; + column-gap:4px; + } + .flipnote-editor-stage-nav{width:50px;height:34px} + .flipnote-editor-stage-nav:hover,.flipnote-editor-stage-nav:focus-visible{transform:scale(1.04,1.059)} + .flipnote-editor-stage-play-toggle{width:150px;height:34px} + .flipnote-editor-stage-play-toggle:hover,.flipnote-editor-stage-play-toggle:focus-visible{transform:scale(1.0134,1.059)} + .flipnote-editor-loop-toggle{width:50px;height:34px} + .flipnote-editor-loop-toggle:hover img,.flipnote-editor-loop-toggle:focus-visible img{transform:scale(1.04,1.059)} + .flipnote-editor-stage-speed-row{grid-template-columns:50px 141px 50px} +} + +@media(prefers-reduced-motion:reduce){ + .flipnote-editor-stage-nav,.flipnote-editor-stage-play-toggle,.flipnote-editor-loop-toggle img{transition:none} +} +.flipnote-editor-stage-control-grid{ + --flipnote-transport-small-w:63px; + --flipnote-transport-small-h:42px; + --flipnote-transport-play-w:197.4px; + --flipnote-transport-play-h:42px; + grid-template-columns: + var(--flipnote-transport-small-w) + var(--flipnote-transport-small-w) + var(--flipnote-transport-play-w) + var(--flipnote-transport-small-w) + var(--flipnote-transport-small-w); + column-gap:8px; +} +.flipnote-editor-stage-nav,.flipnote-editor-loop-toggle{ + width:63px; + height:42px; +} +.flipnote-editor-stage-play-toggle,.flipnote-editor-speed-control{ + width:197.4px; + height:42px; +} +.flipnote-editor-stage-speed-row{ + grid-template-columns:63px 197.4px 63px; + column-gap:8px; +} +.flipnote-editor-stage-nav:hover,.flipnote-editor-stage-nav:focus-visible{ + transform:scale(1.031746,1.047619); +} +.flipnote-editor-stage-play-toggle:hover,.flipnote-editor-stage-play-toggle:focus-visible{ + transform:scale(1.010132,1.047619); +} +.flipnote-editor-speed-control:hover img,.flipnote-editor-speed-control:focus-visible img{ + transform:scale(1.010132,1.047619); +} +.flipnote-editor-loop-toggle:hover img,.flipnote-editor-loop-toggle:focus-visible img{ + transform:scale(1.031746,1.047619); +} + +@media(max-width:620px){ + .flipnote-editor-stage-control-grid{ + --flipnote-transport-small-w:57px; + --flipnote-transport-small-h:38px; + --flipnote-transport-play-w:178.6px; + --flipnote-transport-play-h:38px; + column-gap:4px; + } + .flipnote-editor-stage-nav,.flipnote-editor-loop-toggle{width:57px;height:38px} + .flipnote-editor-stage-play-toggle,.flipnote-editor-speed-control{width:178.6px;height:38px} + .flipnote-editor-stage-speed-row{grid-template-columns:57px 178.6px 57px;column-gap:4px} + .flipnote-editor-stage-nav:hover,.flipnote-editor-stage-nav:focus-visible,.flipnote-editor-loop-toggle:hover img,.flipnote-editor-loop-toggle:focus-visible img{ + transform:scale(1.035088,1.052632); + } + .flipnote-editor-stage-play-toggle:hover,.flipnote-editor-stage-play-toggle:focus-visible,.flipnote-editor-speed-control:hover img,.flipnote-editor-speed-control:focus-visible img{ + transform:scale(1.011198,1.052632); + } +} +.flipnote-editor-edit-pattern:focus-visible{outline:2px solid #fbf318;outline-offset:2px} +.flipnote-editor-pattern-modal{ + position:fixed; + inset:0; + z-index:10050; + display:grid; + place-items:center; + padding:18px; + background:rgba(0,0,0,.58); +} +.flipnote-editor-pattern-modal[hidden]{display:none!important} +.flipnote-editor-pattern-dialog{ + width:min(430px,calc(100vw - 28px)); + max-height:calc(100vh - 36px); + overflow:auto; + border:3px solid var(--red-dark); + border-radius:10px; + background-color:#fffbff; + background-image: + linear-gradient(to right,transparent 14px,#eff3ff 14px,#eff3ff 16px), + linear-gradient(to bottom,transparent 14px,#eff3ff 14px,#eff3ff 16px); + background-size:16px 16px; + background-position:0 0,0 0; + animation:flipnoteSidebarGridDrift 2.4s linear infinite; + box-shadow:0 12px 34px rgba(0,0,0,.38); + color:var(--text); +} +.flipnote-editor-pattern-header{ + display:flex; + align-items:center; + justify-content:space-between; + gap:12px; + padding:10px 12px; + background:var(--red); + color:#fff; +} +.flipnote-editor-pattern-header strong{font-size:18px;line-height:1.1} +.flipnote-editor-pattern-close{ + width:34px; + height:34px; + flex:0 0 34px; + padding:0; + border:0; + border-radius:5px; + background:transparent; + color:#fff; + font:700 27px/30px sans-serif; + cursor:pointer; +} +.flipnote-editor-pattern-close:hover,.flipnote-editor-pattern-close:focus-visible{background:rgba(255,255,255,.16)} +.flipnote-editor-pattern-body{padding:14px} +.flipnote-editor-pattern-body p{margin:0 0 12px;text-align:center;font-size:12px;color:var(--muted)} +.flipnote-editor-pattern-grid{ + display:grid; + grid-template-columns:repeat(8,1fr); + width:min(320px,100%); + aspect-ratio:1; + margin:0 auto; + border:3px solid #211315; + background:#fff; + touch-action:none; + user-select:none; +} +.flipnote-editor-pattern-grid>button{ + min-width:0; + min-height:0; + padding:0; + border:0; + border-right:1px solid #d4c7c2; + border-bottom:1px solid #d4c7c2; + border-radius:0; + background:#fff; + cursor:crosshair; +} +.flipnote-editor-pattern-grid>button:nth-child(8n){border-right:0} +.flipnote-editor-pattern-grid>button:nth-child(n+57){border-bottom:0} +.flipnote-editor-pattern-grid>button.is-on{background:#000} +.flipnote-editor-pattern-grid>button:hover{box-shadow:inset 0 0 0 2px #ae301f} +.flipnote-editor-pattern-grid>button:focus-visible{position:relative;z-index:1;outline:3px solid #ae301f;outline-offset:-3px} +.flipnote-editor-pattern-actions-secondary,.flipnote-editor-pattern-footer{display:flex;align-items:center;justify-content:center;gap:8px;flex-wrap:wrap} +.flipnote-editor-pattern-actions-secondary{margin-top:13px} +.flipnote-editor-pattern-actions-secondary button,.flipnote-editor-pattern-footer button{ + padding:7px 11px; + border:1px solid var(--red-dark); + border-radius:6px; + background:var(--surface); + color:var(--text); + font:700 12px/1 "NDS BIOS",ui-monospace,monospace; + cursor:pointer; +} +.flipnote-editor-pattern-actions-secondary button:hover,.flipnote-editor-pattern-actions-secondary button:focus-visible,.flipnote-editor-pattern-footer button:hover,.flipnote-editor-pattern-footer button:focus-visible{filter:brightness(1.08);transform:translateY(-1px)} +.flipnote-editor-pattern-footer{justify-content:flex-end;padding:11px 14px 14px;border-top:1px solid var(--line)} +.flipnote-editor-pattern-footer .flipnote-editor-pattern-save{background:var(--red);color:#fff} +@media(max-width:620px){ + .flipnote-editor-pattern-modal{padding:10px} + .flipnote-editor-pattern-dialog{width:min(390px,calc(100vw - 20px));max-height:calc(100vh - 20px)} + .flipnote-editor-pattern-grid{width:min(288px,100%)} +} +#flipnote-canvas.flipnote-brush-preview-cursor{cursor:none!important} +#flipnote-canvas.flipnote-brush-preview-cursor.flipnote-brush-preview-crosshair{cursor:crosshair!important} +#flipnote-canvas.flipnote-text-cursor{cursor:text!important} +.flipnote-editor-text-input{position:fixed;width:1px;height:1px;min-width:0;min-height:0;padding:0;border:0;opacity:0;pointer-events:none;resize:none;font:16px "NDS BIOS"} +.flipnote-editor-tool-lower-row{position:relative} +.flipnote-editor-edit-pattern{ + position:absolute; + top:0; + right:3px; + margin:0; + opacity:0; + transform:translateY(calc(-100% - 7px)); + pointer-events:none; + transition:transform 140ms cubic-bezier(.2,.82,.28,1),opacity 95ms linear,filter .1s ease; + will-change:transform,opacity; + z-index:0; +} +.flipnote-editor-edit-pattern.is-visible{ + opacity:1; + transform:translateY(0); + pointer-events:auto; + z-index:0; +} +.flipnote-editor-edit-pattern:not(.is-visible){ + opacity:0; + transform:translateY(calc(-100% - 7px)); + pointer-events:none; +} +.flipnote-editor-edit-pattern.is-visible:hover,.flipnote-editor-edit-pattern.is-visible:focus-visible{transform:scale(1.04);filter:brightness(1.08)} +.flipnote-editor-edit-pattern.is-visible:active{transform:scale(.98)} +@media(prefers-reduced-motion:reduce){ + .flipnote-editor-edit-pattern{transition-duration:.01ms} +} +@media(max-width:620px){ + .flipnote-editor-edit-pattern{right:2px;transform:translateY(calc(-100% - 5px))} + .flipnote-editor-edit-pattern:not(.is-visible){transform:translateY(calc(-100% - 5px))} + .flipnote-editor-edit-pattern.is-visible{transform:translateY(0)} + .flipnote-editor-edit-pattern.is-visible:hover,.flipnote-editor-edit-pattern.is-visible:focus-visible{transform:scale(1.04)} +} +.flipnote-editor-workspace{--flipnote-filmstrip-duration:1s} +.flipnote-editor-filmstrip::before,.flipnote-editor-filmstrip::after{ + height:17px; + background-color:#090909; + background-image:url('./flipnote-editor/filmstrip-dots.png'); + background-size:34px 17px; + background-repeat:repeat-x; + background-position:0 0; + image-rendering:pixelated; + image-rendering:crisp-edges; + animation:flipnote-editor-filmstrip-roll var(--flipnote-filmstrip-duration,1s) linear infinite; + animation-play-state:paused; + will-change:background-position; +} +.flipnote-editor-filmstrip::before{top:4px} +.flipnote-editor-filmstrip::after{bottom:4px} +.flipnote-editor-workspace.is-playing:not(.is-changing-speed) .flipnote-editor-filmstrip::before,.flipnote-editor-workspace.is-playing:not(.is-changing-speed) .flipnote-editor-filmstrip::after{ + animation-play-state:running; +} +@keyframes flipnote-editor-filmstrip-roll{ + from{background-position:0 0} + to{background-position:34px 0} +} +.flipnote-editor-timeline{border-top:0;border-bottom:0} +.flipnote-editor-workspace.is-playing .flipnote-editor-controls{ + user-select:none; +} +.flipnote-editor-workspace.is-playing .flipnote-editor-controls>:not(.flipnote-editor-audio-page){ + pointer-events:none; +} +.flipnote-editor-workspace.is-playing .flipnote-editor-controls>:not(.flipnote-editor-side-top):not(.flipnote-editor-audio-page),.flipnote-editor-workspace.is-playing .flipnote-editor-side-top>:not(.flipnote-editor-page-counter){ + filter:grayscale(1); + opacity:.46; +} +.flipnote-editor-workspace.is-playing .flipnote-editor-audio-page{ + pointer-events:auto; + filter:none; + opacity:1; +} +.flipnote-editor-workspace.is-playing .flipnote-editor-audio-button-music{ + pointer-events:none; + filter:grayscale(1); + opacity:.46; +} +.flipnote-editor-workspace.is-playing .flipnote-editor-audio-button-sound{ + pointer-events:auto; + filter:none; + opacity:1; +} +.flipnote-editor-workspace.is-playing .flipnote-editor-page-counter{ + filter:none; + opacity:1; +} +.flipnote-editor-workspace.is-playing .flipnote-editor-stage-nav,.flipnote-editor-workspace.is-playing .flipnote-editor-speed-control,.flipnote-editor-workspace.is-playing .flipnote-editor-loop-toggle{ + filter:grayscale(1); + opacity:.42; + pointer-events:none; +} +.flipnote-editor-workspace.is-playing .flipnote-editor-stage-nav,.flipnote-editor-workspace.is-playing .flipnote-editor-speed-control img,.flipnote-editor-workspace.is-playing .flipnote-editor-loop-toggle img{ + transform:none!important; +} +.flipnote-editor-workspace.is-playing .flipnote-editor-timeline .timeline-thumbnail{ + pointer-events:none; + cursor:default; +} +.flipnote-editor-workspace.is-playing #flipnote-canvas{ + cursor:default; +} +@media(prefers-reduced-motion:reduce){ + .flipnote-editor-filmstrip::before,.flipnote-editor-filmstrip::after{animation:none!important} +} +.theme-light .flipnote-editor-filename,.theme-light .flipnote-editor-page-counter,.theme-light .flipnote-editor-control-title{ + color:#fff; +} +.theme-light .flipnote-editor-page-counter :is(span,strong),.theme-light .flipnote-editor-filename{color:#fff} +.flipnote-editor-audio-page{align-items:center;gap:0} +.flipnote-editor-audio-layout{position:relative;width:280px;max-width:100%;aspect-ratio:496/651;overflow:hidden;background:transparent;isolation:isolate;user-select:none;-webkit-user-select:none} +.flipnote-editor-audio-button{position:absolute;z-index:3;display:block;padding:0;border:0!important;border-radius:0;background:transparent!important;box-shadow:none!important;outline:0;cursor:pointer;appearance:none;-webkit-appearance:none;line-height:0} +.flipnote-editor-audio-button:hover,.flipnote-editor-audio-button:active,.flipnote-editor-audio-button:focus,.flipnote-editor-audio-button:focus-visible{border:0!important;background:transparent!important;box-shadow:none!important} +.flipnote-editor-audio-button:focus-visible{filter:drop-shadow(0 0 0 #fff) drop-shadow(0 0 2px rgba(255,255,255,.92))} +.flipnote-editor-audio-button img{display:block;width:100%;height:100%;object-fit:fill;user-select:none;-webkit-user-drag:none;image-rendering:auto} +.flipnote-editor-audio-button-music{left:11.694%;top:7.373%;width:36.895%;height:12.596%} +.flipnote-editor-audio-button-sound{left:34.073%;width:26.411%;height:10.445%} +.flipnote-editor-audio-button-sound-1{top:23.041%} +.flipnote-editor-audio-button-sound-2{top:35.945%} +.flipnote-editor-audio-button-sound-3{top:48.848%} +.flipnote-editor-audio-meter{position:absolute;z-index:1;overflow:hidden} +.flipnote-editor-audio-meter img{position:absolute;z-index:2;pointer-events:none;user-select:none;-webkit-user-drag:none} +.flipnote-editor-audio-meter-fill{position:absolute;z-index:1;display:block;background:#AE301F;transition:width .12s linear,height .12s linear} +.flipnote-editor-audio-meter-music{left:14.919%;top:20.584%;width:14.113%;height:70.046%} +.flipnote-editor-audio-meter-music .flipnote-editor-audio-meter-fill{left:8%;right:8%;bottom:1.25%;height:0} +.flipnote-editor-audio-meter-music img{left:50%;top:50%;width:640%;height:15.625%;max-width:none;max-height:none;object-fit:fill;transform:translate(-50%,-50%) rotate(90deg);transform-origin:center} +.flipnote-editor-audio-meter-sound{left:61.694%;width:27.016%;height:7.988%} +.flipnote-editor-audio-meter-sound-1{top:24.117%} +.flipnote-editor-audio-meter-sound-2{top:37.327%} +.flipnote-editor-audio-meter-sound-3{top:50.077%} +.flipnote-editor-audio-meter-sound .flipnote-editor-audio-meter-fill{left:3.125%;top:8%;bottom:8%;width:0} +.flipnote-editor-audio-meter-sound img{inset:0;width:100%;height:100%;object-fit:fill} +.flipnote-editor-audio-button.is-import-mode{cursor:copy} +.flipnote-editor-audio-button.is-recording,.flipnote-editor-audio-button.is-playing{cursor:pointer} +body.flipnote-audio-trim-open{overflow:hidden} +.flipnote-editor-audio-trim-modal{position:fixed;z-index:1300;inset:0;display:grid;place-items:center;padding:20px;background:rgba(0,0,0,.68);backdrop-filter:blur(5px);-webkit-backdrop-filter:blur(5px)} +.flipnote-editor-audio-trim-modal[hidden]{display:none!important} +.flipnote-editor-audio-trim-dialog{width:min(660px,calc(100vw - 32px));max-height:calc(100vh - 32px);overflow:hidden;border:1px solid var(--line);border-radius:6px;background:var(--surface);box-shadow:0 24px 70px rgba(0,0,0,.42)} +.flipnote-editor-audio-trim-header{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:14px 16px;border-bottom:1px solid var(--line);background:var(--surface-raised)} +.flipnote-editor-audio-trim-header>div{display:grid;gap:2px}.flipnote-editor-audio-trim-header span{color:var(--muted);font-size:9px;font-weight:850;text-transform:uppercase;letter-spacing:.07em}.flipnote-editor-audio-trim-header strong{font-size:17px} +.flipnote-editor-audio-trim-header>button{display:grid;place-items:center;width:30px;height:30px;padding:0;border:1px solid var(--line);border-radius:4px;background:var(--surface);color:var(--text);font-size:20px;line-height:1;cursor:pointer} +.flipnote-editor-audio-trim-header>button:hover{background:var(--surface-hover)} +.flipnote-editor-audio-trim-body{display:grid;gap:13px;padding:16px} +.flipnote-editor-audio-trim-file{display:flex;align-items:flex-end;justify-content:space-between;gap:16px}.flipnote-editor-audio-trim-file strong{font-size:11px;overflow-wrap:anywhere}.flipnote-editor-audio-trim-file span{flex:0 0 auto;color:var(--muted);font-size:9px;text-align:right} +.flipnote-editor-audio-waveform-wrap{overflow:hidden;border:1px solid var(--line);border-radius:4px;background:#0d0c0f}.flipnote-editor-audio-waveform-wrap canvas{display:block;width:100%;height:132px} +.flipnote-editor-audio-range-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}.flipnote-editor-audio-range-grid label{display:grid;gap:6px}.flipnote-editor-audio-range-grid label>span{display:flex;justify-content:space-between;gap:8px;color:var(--muted);font-size:9px}.flipnote-editor-audio-range-grid b{color:var(--text);font-weight:800}.flipnote-editor-audio-range-grid input[type="range"]{width:100%;accent-color:#AE301F} +.flipnote-editor-audio-selection-summary{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:9px 10px;border:1px solid var(--line-soft);border-radius:4px;background:var(--surface-raised)}.flipnote-editor-audio-selection-summary span{color:var(--muted);font-size:9px}.flipnote-editor-audio-selection-summary strong{font-size:12px} +.flipnote-editor-audio-trim-footer{display:grid;grid-template-columns:auto 1fr auto auto;gap:8px;align-items:center;padding:12px 16px;border-top:1px solid var(--line);background:var(--surface-raised)} +.theme-light .flipnote-editor-audio-waveform-wrap{background:#f3eef4} +@media(max-width:620px){ + .flipnote-editor-audio-layout{width:min(100%,280px)} + .flipnote-editor-audio-trim-modal{padding:10px}.flipnote-editor-audio-trim-dialog{width:100%;max-height:calc(100vh - 20px)} + .flipnote-editor-audio-range-grid{grid-template-columns:1fr}.flipnote-editor-audio-trim-footer{grid-template-columns:1fr 1fr}.flipnote-editor-audio-trim-footer>span{display:none}.flipnote-editor-audio-trim-footer .button{width:100%} +} +.flipnote-editor-version-row{display:flex;align-items:center;gap:7px;margin-top:6px} +.flipnote-editor-version-row .flipnote-editor-version{margin-top:0} +.flipnote-editor-info-button{display:grid;place-items:center;width:20px;height:20px;padding:0;border:1px solid var(--line);border-radius:50%;background:var(--surface);color:var(--muted);font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;font-weight:850;line-height:1;cursor:pointer;transition:transform .1s ease,filter .1s ease,background .1s ease,color .1s ease} +.flipnote-editor-info-button:hover,.flipnote-editor-info-button:focus-visible{background:var(--surface-hover);color:var(--text);filter:brightness(1.04);transform:translateY(-1px)} +.flipnote-editor-info-button:active{transform:scale(.94)} +.flipnote-editor-info-button:focus-visible{outline:2px solid var(--red);outline-offset:2px} +.flipnote-editor-audio-layout{width:min(100%,280px);max-width:none;aspect-ratio:382/542} +.flipnote-editor-audio-button{transition:transform .11s cubic-bezier(.2,.8,.2,1),filter .1s ease;transform-origin:center} +.flipnote-editor-audio-button img{transition:filter .1s ease} +.flipnote-editor-audio-button:hover:not(:disabled){transform:scale(1.04);filter:drop-shadow(0 2px 2px rgba(0,0,0,.22))} +.flipnote-editor-audio-button:hover:not(:disabled) img{filter:brightness(1.08) saturate(1.08)} +.flipnote-editor-audio-button:active:not(:disabled){transform:scale(.94);filter:drop-shadow(0 1px 1px rgba(0,0,0,.18));transition-duration:.055s} +.flipnote-editor-audio-button:active:not(:disabled) img{filter:brightness(.92) saturate(1.06)} +.flipnote-editor-audio-button:focus-visible{filter:drop-shadow(0 0 0 #fff) drop-shadow(0 0 3px rgba(255,255,255,.94))} +.flipnote-editor-audio-button-music{left:0;top:0;width:47.9058%;height:15.1292%} +.flipnote-editor-audio-button-sound{left:calc(29.0576% - 2px);width:34.2932%;height:12.5461%} +.flipnote-editor-audio-button-sound-1{top:18.8192%} +.flipnote-editor-audio-button-sound-2{top:34.3173%} +.flipnote-editor-audio-button-sound-3{top:49.8155%} +.flipnote-editor-audio-meter-music{left:4.1885%;top:15.8672%;width:18.3246%;height:84.1328%} +.flipnote-editor-audio-meter-sound{left:64.9215%;width:35.0785%;height:9.5941%} +.flipnote-editor-audio-meter-sound-1{top:20.1107%} +.flipnote-editor-audio-meter-sound-2{top:35.9779%} +.flipnote-editor-audio-meter-sound-3{top:51.2915%} + +body.flipnote-editor-info-open{overflow:hidden} +.flipnote-editor-info-modal{position:fixed;z-index:1320;inset:0;display:grid;place-items:center;padding:20px;background:rgba(0,0,0,.7);backdrop-filter:blur(5px);-webkit-backdrop-filter:blur(5px)} +.flipnote-editor-info-modal[hidden]{display:none!important} +.flipnote-editor-info-dialog{width:min(760px,calc(100vw - 32px));max-height:calc(100vh - 32px);display:flex;flex-direction:column;overflow:hidden;border:1px solid var(--line);border-radius:8px;background:var(--surface);box-shadow:0 24px 70px rgba(0,0,0,.45)} +.flipnote-editor-info-header{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:14px 16px;border-bottom:1px solid var(--line);background:var(--surface-raised)} +.flipnote-editor-info-header>div{display:grid;gap:2px} +.flipnote-editor-info-header span{color:var(--muted);font-size:9px;font-weight:850;text-transform:uppercase;letter-spacing:.07em} +.flipnote-editor-info-header strong{font-size:19px} +.flipnote-editor-info-header>button{display:grid;place-items:center;width:32px;height:32px;padding:0;border:1px solid var(--line);border-radius:5px;background:var(--surface);color:var(--text);font-size:21px;line-height:1;cursor:pointer} +.flipnote-editor-info-header>button:hover{background:var(--surface-hover)} +.flipnote-editor-info-header>button:focus-visible{outline:2px solid var(--red);outline-offset:2px} +.flipnote-editor-info-body{display:grid;gap:18px;padding:18px;overflow:auto} +.flipnote-editor-info-body section{display:grid;gap:9px} +.flipnote-editor-info-body h3{margin:0;color:var(--text);font-size:15px} +.flipnote-editor-info-body p{margin:0;color:var(--muted);font-size:12px;line-height:1.55} +.flipnote-editor-shortcut-grid{display:grid;grid-template-columns:max-content 1fr;gap:7px 11px;align-items:center} +.flipnote-editor-shortcut-grid kbd{min-width:42px;padding:4px 7px;border:1px solid var(--line);border-bottom-width:2px;border-radius:4px;background:var(--surface-raised);color:var(--text);font:750 11px/1.2 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;text-align:center;box-shadow:0 1px 0 rgba(0,0,0,.16)} +.flipnote-editor-shortcut-grid span{color:var(--muted);font-size:11px;line-height:1.45} +.flipnote-editor-tricks{margin:0;padding-left:19px;color:var(--muted);font-size:11px;line-height:1.5} +.flipnote-editor-tricks li+li{margin-top:6px} +.flipnote-editor-tricks strong{color:var(--text)} +.flipnote-editor-info-footer{display:flex;justify-content:flex-end;padding:12px 16px;border-top:1px solid var(--line);background:var(--surface-raised)} +@media(max-width:620px){ + .flipnote-editor-info-modal{padding:10px} + .flipnote-editor-info-dialog{width:100%;max-height:calc(100vh - 20px)} + .flipnote-editor-info-body{padding:14px} + .flipnote-editor-shortcut-grid{grid-template-columns:72px 1fr;gap:7px 9px} +} +.flipnote-editor-audio-button.is-live-sfx{cursor:pointer} +.flipnote-editor-workspace.is-playing .flipnote-editor-audio-button-sound.is-live-empty{filter:grayscale(1);opacity:.46;cursor:default} +.flipnote-editor-audio-button.is-live-fired{animation:flipnote-editor-live-sfx-pop .17s ease-out} +@keyframes flipnote-editor-live-sfx-pop{ + 0%{transform:scale(1)} + 38%{transform:scale(.92);filter:brightness(.9) saturate(1.12) drop-shadow(0 1px 1px rgba(0,0,0,.22))} + 100%{transform:scale(1)} +} +@media(prefers-reduced-motion:reduce){ + .flipnote-editor-audio-button.is-live-fired{animation:none} +} +.flipnote-editor-audio-auto-frames{display:grid;grid-template-columns:auto 1fr;gap:10px;align-items:start;padding:10px;border:1px solid var(--line-soft);border-radius:4px;background:var(--surface-raised);cursor:pointer} +.flipnote-editor-audio-auto-frames[hidden]{display:none!important} +.flipnote-editor-audio-auto-frames input{width:16px;height:16px;margin:1px 0 0;accent-color:#AE301F;cursor:pointer} +.flipnote-editor-audio-auto-frames-copy{display:grid;gap:3px;min-width:0} +.flipnote-editor-audio-auto-frames-copy strong{font-size:10px;line-height:1.3;color:var(--text)} +.flipnote-editor-audio-auto-frames-copy small{font-size:9px;line-height:1.45;color:var(--muted)} +.flipnote-editor-audio-auto-frames:hover{border-color:var(--line);background:var(--surface-hover)} +.flipnote-editor-audio-auto-frames:focus-within{outline:2px solid var(--red);outline-offset:2px} +.flipnote-editor-audio-erase{position:absolute;z-index:4;display:block;width:7.4%;aspect-ratio:1;padding:0;border:0!important;border-radius:0;background:transparent!important;box-shadow:none!important;outline:0;cursor:pointer;appearance:none;-webkit-appearance:none;line-height:0;transform-origin:center;transition:transform .1s ease,filter .1s ease,opacity .1s ease} +.flipnote-editor-audio-erase[hidden]{display:none!important} +.flipnote-editor-audio-erase img{display:block;width:100%;height:100%;object-fit:fill;user-select:none;-webkit-user-drag:none;image-rendering:auto;transition:filter .1s ease} +.flipnote-editor-audio-erase:hover:not(:disabled){transform:scale(1.08);filter:drop-shadow(0 2px 2px rgba(0,0,0,.24))} +.flipnote-editor-audio-erase-sound:hover:not(:disabled){transform:scale(1.04)} +.flipnote-editor-audio-erase:hover:not(:disabled) img{filter:brightness(1.07) saturate(1.06)} +.flipnote-editor-audio-erase:active:not(:disabled){transform:scale(.96);filter:drop-shadow(0 1px 1px rgba(0,0,0,.2))} +.flipnote-editor-audio-erase:focus-visible{filter:drop-shadow(0 0 0 #fff) drop-shadow(0 0 3px rgba(255,255,255,.94));outline:0} +.flipnote-editor-audio-erase:disabled{cursor:default;filter:grayscale(1);opacity:.46} +.flipnote-editor-audio-meter-music .flipnote-editor-audio-meter-fill{left:calc(8% - 1px);right:calc(8% - 1px);bottom:1.25%;max-height:97.5%} +.flipnote-editor-audio-meter-sound .flipnote-editor-audio-meter-fill{left:3.125%;top:8%;bottom:8%;max-width:93.75%} +.flipnote-editor-audio-page,.flipnote-editor-audio-layout{overflow:visible} +.flipnote-editor-audio-meter-music{left:4.1885%;top:15.8672%;width:18.3246%;height:84.1328%} +.flipnote-editor-audio-meter-sound{left:64.9215%;width:35.0785%;height:9.5941%} +.flipnote-editor-audio-meter-sound-1{top:20.1107%} +.flipnote-editor-audio-meter-sound-2{top:35.9779%} +.flipnote-editor-audio-meter-sound-3{top:51.2915%} +.flipnote-editor-audio-erase{z-index:6} +.flipnote-editor-audio-erase-music{ + left:4.1885%; + top:calc(100% + 4px); + width:18.3246%; +} +.flipnote-editor-audio-erase-sound{ + left:calc(100% + 4px); + width:13.613%; +} +.flipnote-editor-audio-erase-sound-1{top:20.1107%} +.flipnote-editor-audio-erase-sound-2{top:35.9779%} +.flipnote-editor-audio-erase-sound-3{top:51.2915%} +.flipnote-editor-import-image-button{ + left:auto; + right:0; + top:0; + width:47.9058%; + height:15.1292%; +} +.flipnote-editor-import-image-button.is-disabled{ + cursor:default; + filter:grayscale(1) opacity(.46); +} +.flipnote-editor-import-image-button.is-disabled:hover{ + transform:none; + filter:grayscale(1) opacity(.46); +} +.flipnote-editor-import-image-button.is-disabled:hover img{filter:none} + +body.flipnote-editor-image-import-open{overflow:hidden} +.flipnote-editor-image-import-modal{ + position:fixed; + z-index:1340; + inset:0; + display:grid; + place-items:center; + padding:20px; + background:rgba(0,0,0,.7); + backdrop-filter:blur(5px); + -webkit-backdrop-filter:blur(5px); +} +.flipnote-editor-image-import-modal[hidden]{display:none!important} +.flipnote-editor-image-import-dialog{ + width:min(760px,calc(100vw - 32px)); + max-height:calc(100vh - 32px); + display:flex; + flex-direction:column; + overflow:hidden; + border:1px solid var(--line); + border-radius:8px; + background:var(--surface); + box-shadow:0 24px 70px rgba(0,0,0,.45); +} +.flipnote-editor-image-import-header{ + display:flex; + align-items:center; + justify-content:space-between; + gap:16px; + padding:14px 16px; + border-bottom:1px solid var(--line); + background:var(--surface-raised); +} +.flipnote-editor-image-import-header>div{display:grid} +.flipnote-editor-image-import-header strong{font-size:18px} +.flipnote-editor-image-import-header>button{ + display:grid; + place-items:center; + width:30px; + height:30px; + padding:0; + border:1px solid var(--line); + border-radius:4px; + background:var(--surface); + color:var(--text); + font-size:20px; + line-height:1; + cursor:pointer; +} +.flipnote-editor-image-import-header>button:hover{background:var(--surface-hover)} +.flipnote-editor-image-import-header>button:focus-visible{outline:2px solid var(--red);outline-offset:2px} +.flipnote-editor-image-import-body{ + display:grid; + grid-template-columns:minmax(256px,1fr) minmax(270px,1fr); + gap:14px 16px; + padding:16px; + overflow:auto; +} +.flipnote-editor-image-import-file{ + grid-column:1/-1; + display:flex; + align-items:flex-end; + justify-content:space-between; + gap:16px; +} +.flipnote-editor-image-import-file strong{font-size:11px;overflow-wrap:anywhere} +.flipnote-editor-image-import-file span{flex:0 0 auto;color:var(--muted);font-size:9px;text-align:right} +.flipnote-editor-image-import-preview-wrap{ + display:grid; + place-items:center; + align-self:start; + overflow:hidden; + border:1px solid var(--line); + border-radius:4px; + background:#0d0c0f; + min-height:192px; +} +.flipnote-editor-image-import-preview-wrap canvas{ + display:block; + width:min(100%,512px); + aspect-ratio:4/3; + image-rendering:pixelated; + image-rendering:crisp-edges; +} +.flipnote-editor-image-import-options{display:grid;grid-template-columns:1fr 1fr;gap:10px 12px;align-content:start} +.flipnote-editor-image-import-options label{display:grid;gap:5px;min-width:0} +.flipnote-editor-image-import-options label>span{display:flex;justify-content:space-between;gap:8px;color:var(--muted);font-size:9px} +.flipnote-editor-image-import-options b{color:var(--text);font-weight:800} +.flipnote-editor-image-import-options select{ + width:100%; + min-width:0; + height:34px; + padding:0 9px; + border:1px solid var(--line); + border-radius:4px; + background:var(--surface-raised); + color:var(--text); + font:inherit; + font-size:10px; +} +.flipnote-editor-image-import-options select:focus-visible,.flipnote-editor-image-import-options input:focus-visible{outline:2px solid var(--red);outline-offset:2px} +.flipnote-editor-image-import-threshold{grid-column:1/-1} +.flipnote-editor-image-import-threshold input{width:100%;accent-color:#AE301F} +.flipnote-editor-image-import-toggles{ + grid-column:2; + display:grid; + gap:7px; + align-content:start; + margin-top:-2px; +} +.flipnote-editor-image-import-toggles label{ + display:grid; + grid-template-columns:auto 1fr; + gap:9px; + align-items:start; + padding:8px 9px; + border:1px solid var(--line-soft); + border-radius:4px; + background:var(--surface-raised); + color:var(--muted); + font-size:9px; + line-height:1.4; + cursor:pointer; +} +.flipnote-editor-image-import-toggles label:hover{border-color:var(--line);background:var(--surface-hover)} +.flipnote-editor-image-import-toggles input{width:15px;height:15px;margin:0;accent-color:#AE301F;cursor:pointer} +.flipnote-editor-image-import-footer{ + display:grid; + grid-template-columns:1fr auto auto; + gap:8px; + align-items:center; + padding:12px 16px; + border-top:1px solid var(--line); + background:var(--surface-raised); +} +.theme-light .flipnote-editor-image-import-preview-wrap{background:#f3eef4} +.flipnote-editor-audio-trim-header>div,.flipnote-editor-info-header>div{gap:0} + +@media(max-width:700px){ + .flipnote-editor-image-import-modal{padding:10px} + .flipnote-editor-image-import-dialog{width:100%;max-height:calc(100vh - 20px)} + .flipnote-editor-image-import-body{grid-template-columns:1fr;padding:14px} + .flipnote-editor-image-import-file,.flipnote-editor-image-import-toggles{grid-column:1} + .flipnote-editor-image-import-preview-wrap{min-height:0} +} +@media(max-width:460px){ + .flipnote-editor-image-import-options{grid-template-columns:1fr} + .flipnote-editor-image-import-threshold{grid-column:1} + .flipnote-editor-image-import-footer{grid-template-columns:1fr 1fr} + .flipnote-editor-image-import-footer>span{display:none} + .flipnote-editor-image-import-footer .button{width:100%} +} +.flipnote-editor-image-file-input{ + position:fixed!important; + left:-10000px!important; + top:auto!important; + width:1px!important; + height:1px!important; + overflow:hidden!important; + opacity:0!important; + pointer-events:none!important; +} +.flipnote-editor-import-image-button.is-disabled{pointer-events:none} +.flipnote-editor-image-import-step[hidden]{display:none!important} +.flipnote-editor-image-import-step{min-height:0;overflow:auto} +.flipnote-editor-image-import-step-source{ + display:grid; + gap:16px; + padding:18px; +} +.flipnote-editor-image-import-source-copy{display:grid;gap:5px;text-align:center} +.flipnote-editor-image-import-source-copy strong{font-size:16px} +.flipnote-editor-image-import-source-copy span,.flipnote-editor-image-import-crop-copy{color:var(--muted);font-size:10px;line-height:1.5} +.flipnote-editor-image-import-dropzone{ + cursor:pointer; + min-height:330px; + display:flex; + flex-direction:column; + align-items:center; + justify-content:center; + gap:10px; + padding:28px; + border:2px dashed var(--line); + border-radius:8px; + background:var(--surface-raised); + text-align:center; + transition:border-color .14s ease,background .14s ease,transform .14s ease; +} +.flipnote-editor-image-import-dropzone:hover,.flipnote-editor-image-import-dropzone:focus-visible,.flipnote-editor-image-import-dropzone.is-dragover{ + border-color:var(--red); + background:var(--surface-hover); +} +.flipnote-editor-image-import-dropzone:focus-visible{outline:2px solid var(--red);outline-offset:3px} +.flipnote-editor-image-import-dropzone.is-dragover{transform:scale(1.008)} +.flipnote-editor-image-import-drop-icon{ + width:52px;height:52px;display:grid;place-items:center; + border:1px solid var(--line);border-radius:8px; + background:var(--surface);font-size:30px;line-height:1;color:var(--muted) +} +.flipnote-editor-image-import-dropzone>strong{font-size:15px} +.flipnote-editor-image-import-dropzone>span{color:var(--muted);font-size:9px} +.flipnote-editor-image-import-or{opacity:.75;text-transform:uppercase;letter-spacing:.08em} +.flipnote-editor-image-import-upload{display:inline-flex;align-items:center;justify-content:center;cursor:pointer;user-select:none} +.flipnote-editor-image-import-upload:focus-visible{outline:2px solid var(--red);outline-offset:2px} +.flipnote-editor-image-import-paste{display:flex!important;align-items:center;gap:6px} +.flipnote-editor-image-import-paste kbd{ + display:inline-flex;align-items:center;min-height:24px;padding:2px 7px; + border:1px solid var(--line);border-bottom-width:2px;border-radius:4px; + background:var(--surface);color:var(--text);font:inherit;font-size:9px;font-weight:800 +} +.flipnote-editor-image-import-source-status{min-height:18px;color:var(--muted);font-size:9px;text-align:center} +.flipnote-editor-image-import-source-status.is-error{color:#d95b4b} + +.flipnote-editor-image-import-step-crop{padding:16px;display:grid;gap:12px} +.flipnote-editor-image-import-crop-copy{text-align:center} +.flipnote-editor-image-import-crop-stage{ + min-height:0; + display:grid; + place-items:center; + overflow:hidden; + border:1px solid var(--line); + border-radius:6px; + background:#0d0c0f; +} +.flipnote-editor-image-import-crop-stage canvas{ + display:block; + width:100%; + max-height:min(62vh,560px); + aspect-ratio:3/2; + object-fit:contain; + touch-action:none; + user-select:none; + cursor:crosshair; +} +.theme-light .flipnote-editor-image-import-crop-stage{background:#e8e3e9} + +.flipnote-editor-image-import-step-options{min-height:0} +.flipnote-editor-image-import-footer{ + grid-template-columns:auto 1fr auto auto auto; +} +.flipnote-editor-image-import-footer-left{display:flex;gap:8px;align-items:center} + +@media(max-width:700px){ + .flipnote-editor-image-import-step-source,.flipnote-editor-image-import-step-crop{padding:14px} + .flipnote-editor-image-import-dropzone{min-height:280px;padding:20px} + .flipnote-editor-image-import-crop-stage canvas{max-height:52vh} + .flipnote-editor-image-import-footer{grid-template-columns:auto 1fr auto auto} + .flipnote-editor-image-import-footer-left{grid-column:1} +} +@media(max-width:520px){ + .flipnote-editor-image-import-footer{grid-template-columns:1fr 1fr;gap:8px} + .flipnote-editor-image-import-footer-left{grid-column:1/-1;display:grid;grid-template-columns:1fr 1fr} + .flipnote-editor-image-import-footer>span{display:none} + .flipnote-editor-image-import-footer .button{width:100%} + .flipnote-editor-image-import-dropzone{min-height:250px} +} +.flipnote-editor-volume-monitor{ + position:absolute; + z-index:2; + left:auto; + right:0; + top:65.3136%; + width:58%; + aspect-ratio:92/84; + overflow:hidden; + pointer-events:none; + user-select:none; + -webkit-user-select:none; +} +.flipnote-editor-volume-monitor-base{ + position:absolute; + z-index:1; + inset:0; + display:block; + width:100%; + height:100%; + object-fit:fill; + image-rendering:pixelated; + user-select:none; + -webkit-user-drag:none; +} +.flipnote-editor-volume-monitor-history{position:absolute;z-index:2;inset:0} +.flipnote-editor-volume-sample{ + position:absolute; + width:4.34782608696%; + height:2.38095238095%; + object-fit:fill; + image-rendering:pixelated; + user-select:none; + -webkit-user-drag:none; + will-change:top; +} +.flipnote-editor-audio-trim-volume{ + display:grid; + gap:6px; + padding:9px 10px; + border:1px solid var(--line-soft); + border-radius:4px; + background:var(--surface-raised); +} +.flipnote-editor-audio-trim-volume-label{display:flex;align-items:center;justify-content:space-between;gap:12px} +.flipnote-editor-audio-trim-volume-label span{color:var(--muted);font-size:9px;font-weight:750} +.flipnote-editor-audio-trim-volume-label strong{font-size:9px;font-variant-numeric:tabular-nums} +.flipnote-editor-audio-trim-volume-meter{ + position:relative; + height:10px; + overflow:hidden; + border:1px solid var(--line); + border-radius:3px; + background:rgba(0,0,0,.28); +} +.flipnote-editor-audio-trim-volume-fill{ + position:absolute;z-index:1;left:0;top:0;bottom:0;width:0; + background:#8f9a96; + transition:width .04s linear,background .08s linear; +} +.flipnote-editor-audio-trim-volume-meter.is-danger .flipnote-editor-audio-trim-volume-fill{background:#c24a3c} +.flipnote-editor-audio-trim-volume-warning{ + position:absolute;z-index:2;top:0;bottom:0;left:78.4%;width:1px; + background:rgba(194,74,60,.7);pointer-events:none +} +.theme-light .flipnote-editor-audio-trim-volume-meter{background:#ded9da} +.flipnote-editor-export-icon.is-disabled,.flipnote-editor-export-icon.is-disabled:hover,.flipnote-editor-export-icon.is-disabled:active{ + cursor:not-allowed; + transform:none; + filter:grayscale(1) brightness(.72); + opacity:.46; +} +.flipnote-editor-video-export-dialog{width:min(560px,calc(100vw - 32px))} +.flipnote-editor-video-export-body{gap:14px} +.flipnote-editor-video-export-identity{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:12px 13px;border:1px solid var(--line);border-radius:6px;background:var(--surface-raised)} +.flipnote-editor-video-export-identity span{color:var(--muted);font-size:11px;font-weight:800} +.flipnote-editor-video-export-identity strong{font:800 12px/1.2 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:var(--text);word-break:break-all} +.flipnote-editor-video-export-progress{position:relative;height:12px;overflow:hidden;border:1px solid var(--line);border-radius:999px;background:var(--surface-raised)} +.flipnote-editor-video-export-progress>span{display:block;width:0;height:100%;background:var(--red);transition:width .18s ease} +.flipnote-editor-video-export-status{min-height:34px;padding:10px 11px;border-radius:6px;background:var(--surface-raised);color:var(--muted);font-size:11px;line-height:1.45} +.flipnote-editor-video-export-footer{gap:8px} +@media(max-width:620px){ + .flipnote-editor-video-export-dialog{width:100%} + .flipnote-editor-video-export-identity{align-items:flex-start;flex-direction:column;gap:5px} +} +.flipnote-editor-ppm-tools-body{display:grid;gap:8px} +.flipnote-editor-import-ppm-button{width:100%;min-height:34px;justify-content:center} +.flipnote-editor-lock-row{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 9px;border:1px solid var(--line);border-radius:5px;background:var(--surface);cursor:pointer} +.flipnote-editor-lock-row>span{display:grid;gap:2px;min-width:0} +.flipnote-editor-lock-row strong{font-size:12px;line-height:1.2;color:var(--text)} +.flipnote-editor-lock-row small{font-size:9px;line-height:1.25;color:var(--muted)} +.flipnote-editor-lock-row input{flex:0 0 auto;width:18px;height:18px;accent-color:var(--red);cursor:pointer} +.flipnote-editor-import-ppm-status{min-height:0;color:var(--muted);font-size:9px;line-height:1.3} +.flipnote-editor-import-ppm-status:empty{display:none} +.flipnote-editor-audio-trim-dialog{width:min(820px,calc(100vw - 32px))} +.flipnote-editor-audio-import-step[hidden]{display:none!important} +.flipnote-editor-audio-import-source-step{display:grid;gap:16px;padding:18px;min-height:0} +.flipnote-editor-audio-import-source-copy{display:grid;gap:5px;text-align:center} +.flipnote-editor-audio-import-source-copy strong{font-size:16px} +.flipnote-editor-audio-import-source-copy span{color:var(--muted);font-size:10px;line-height:1.5} +.flipnote-editor-audio-import-source-options{display:grid;grid-template-columns:1fr 1fr;gap:12px} +.flipnote-editor-audio-import-source-option{ + appearance:none;display:grid;grid-template-rows:auto auto 1fr;align-content:start;justify-items:start;gap:8px; + min-height:158px;padding:16px;border:1px solid var(--line);border-radius:6px;background:var(--surface-raised); + color:var(--text);font:inherit;text-align:left;cursor:pointer;user-select:none; + transition:transform .11s cubic-bezier(.2,.8,.2,1),border-color .11s ease,background .11s ease,box-shadow .11s ease; +} +.flipnote-editor-audio-import-source-option:hover,.flipnote-editor-audio-import-source-option:focus-visible{transform:scale(1.012);border-color:var(--red);background:var(--surface-hover);box-shadow:0 7px 22px rgba(0,0,0,.13)} +.flipnote-editor-audio-import-source-option:active{transform:scale(.99)} +.flipnote-editor-audio-import-source-option:focus-visible{outline:2px solid var(--red);outline-offset:2px} +.flipnote-editor-audio-import-source-icon{display:grid;place-items:center;width:38px;height:38px;border:1px solid var(--line);border-radius:5px;background:var(--surface);font-size:22px;font-weight:900;line-height:1} +.flipnote-editor-audio-import-source-option strong{font-size:13px;line-height:1.3} +.flipnote-editor-audio-import-source-option small{color:var(--muted);font-size:9px;line-height:1.45} +.flipnote-editor-audio-source-card{position:relative;display:block;width:100%;aspect-ratio:4/3;min-width:0;overflow:hidden;border:1px solid var(--line-soft);border-radius:5px;background:#111;cursor:pointer;isolation:isolate;transition:transform .1s ease,border-color .1s ease,box-shadow .1s ease} +.flipnote-editor-audio-source-card:hover,.flipnote-editor-audio-source-card:focus-visible{transform:scale(1.012);border-color:var(--red);box-shadow:0 7px 22px rgba(0,0,0,.2);z-index:2} +.flipnote-editor-audio-source-card:active{transform:scale(.994)} +.flipnote-editor-audio-source-card:focus-visible{outline:2px solid var(--red);outline-offset:2px} +.flipnote-editor-audio-source-thumb{position:absolute;inset:0;z-index:0;display:block;width:100%;height:100%;max-width:none;object-fit:cover;background:#111;image-rendering:auto} +.flipnote-editor-audio-source-shade{position:absolute;inset:0;z-index:1;pointer-events:none;background:linear-gradient(to bottom,rgba(0,0,0,0) 42%,rgba(0,0,0,.18) 58%,rgba(0,0,0,.88) 100%)} +.flipnote-editor-audio-source-copy{position:absolute;left:0;right:0;bottom:38px;z-index:2;display:grid;gap:2px;padding:8px 9px 5px;min-width:0;color:#fff;pointer-events:none;text-shadow:0 1px 2px rgba(0,0,0,.85)} +.flipnote-editor-audio-source-copy strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:10px} +.flipnote-editor-audio-source-copy span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:rgba(255,255,255,.82);font-size:8.5px} +.flipnote-editor-audio-source-copy code{justify-self:start;margin-top:1px;padding:1px 4px;border-radius:3px;background:rgba(0,0,0,.52);color:#fff;font-size:8px;text-shadow:none} +.flipnote-editor-audio-source-actions{position:absolute;left:7px;right:7px;bottom:7px;z-index:3;display:grid;grid-template-columns:1fr 1fr;gap:6px} +.flipnote-editor-audio-source-actions .button{min-width:0;height:27px;padding:0 7px;font-size:8.5px;line-height:1} +.flipnote-editor-audio-trim-step{min-height:0} +.flipnote-editor-audio-trim-footer{grid-template-columns:auto auto 1fr auto auto} +@media(max-width:620px){ + .flipnote-editor-audio-import-source-step{padding:14px} + .flipnote-editor-audio-import-source-options{grid-template-columns:1fr} + .flipnote-editor-audio-import-source-option{min-height:130px} + .flipnote-editor-audio-trim-footer{grid-template-columns:1fr 1fr} +} +.flipnote-editor-audio-import-source-copy[hidden],.flipnote-editor-audio-import-source-options[hidden]{display:none!important} +.flipnote-editor-audio-trim-dialog{display:flex;flex-direction:column;overflow:hidden} +.flipnote-editor-audio-import-step{flex:1 1 auto;min-height:0} +.flipnote-editor-audio-import-source-step{overflow:hidden} +.flipnote-editor-selection-page{gap:12px} +.flipnote-editor-selection-page .flipnote-editor-selection-tools{width:calc(100% - 20px);margin-left:auto;margin-right:auto} +.flipnote-editor-selection-actions{display:grid;grid-template-columns:minmax(0,1fr);gap:8px;width:calc(100% - 20px);margin:0 auto} +.flipnote-editor-selection-action img{display:block;width:100%;height:auto;aspect-ratio:5/1;object-fit:fill;image-rendering:pixelated} +.flipnote-editor-selection-action.is-enabled{transform:scale(1.02);filter:brightness(1.12)} +.flipnote-editor-selection-action:disabled{cursor:default;filter:grayscale(1) opacity(.45);transform:none} +.flipnote-editor-selection-action:disabled:hover{filter:grayscale(1) opacity(.45);transform:none} +.flipnote-editor-selection-page .flipnote-editor-selection-copy{margin-top:0;margin-bottom:0;padding:0 10px} +.flipnote-editor-more-tools{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));align-items:start;gap:8px;width:calc(100% - 4px);margin-left:auto;margin-right:auto;overflow:visible} +.flipnote-editor-more-tool{display:block;min-width:0;position:relative;z-index:1} +.flipnote-editor-more-tool img{display:block;width:100%;height:auto;aspect-ratio:3/4;object-fit:fill;user-select:none;-webkit-user-drag:none} +@media(max-width:620px){ + .flipnote-editor-selection-page{gap:10px} + .flipnote-editor-more-tools{gap:6px;width:calc(100% - 8px)} + .flipnote-editor-selection-page .flipnote-editor-selection-tools{width:calc(100% - 20px)} + .flipnote-editor-selection-actions{gap:6px} +} +.flipnote-editor-tab5-actions{ + display:grid; + grid-template-columns:repeat(2,minmax(0,1fr)); + gap:10px; + align-items:center; + width:100%; +} +.flipnote-editor-tab5-image-button,.flipnote-editor-tab5-image-button:hover,.flipnote-editor-tab5-image-button:focus,.flipnote-editor-tab5-image-button:focus-visible,.flipnote-editor-tab5-image-button:active{ + min-width:0; + width:100%; + padding:0; + border:0!important; + border-radius:0; + outline:0; + background:transparent!important; + box-shadow:none!important; + line-height:0; + appearance:none; + -webkit-appearance:none; +} +.flipnote-editor-tab5-image-button{ + cursor:pointer; + transition:transform .12s ease,filter .12s ease; +} +.flipnote-editor-tab5-image-button img{ + display:block; + width:auto; + max-width:100%; + height:auto; + margin:0 auto; + user-select:none; + -webkit-user-drag:none; + transition:transform .12s ease,filter .12s ease; +} +.flipnote-editor-tab5-image-button:not(.is-disabled):not([aria-disabled="true"]):hover,.flipnote-editor-tab5-image-button:not(.is-disabled):not([aria-disabled="true"]):focus-visible{ + transform:scale(1.08); + filter:brightness(1.06) saturate(1.06); +} +.flipnote-editor-tab5-image-button:not(.is-disabled):not([aria-disabled="true"]):active{ + transform:translateY(0) scale(.98); + filter:brightness(1.02) saturate(1.03); +} +.flipnote-editor-tab5-image-button.is-disabled,.flipnote-editor-tab5-image-button[aria-disabled="true"]{ + cursor:not-allowed; + transform:none!important; +} +.flipnote-editor-export-thumbnail-picker{display:grid;gap:6px;min-width:0} +.flipnote-editor-export-thumbnail-label{color:var(--text);font-size:10px;font-weight:800} +.flipnote-editor-export-thumbnail-strip{ + position:relative;min-width:0;height:146px;padding:27px 8px;overflow:hidden; + border:1px solid #2c292a;border-radius:5px;background:#090909; + box-shadow:0 5px 15px rgba(0,0,0,.12) +} +.flipnote-editor-export-thumbnail-strip:before,.flipnote-editor-export-thumbnail-strip:after{ + content:"";position:absolute;z-index:3;left:0;right:0;height:15px; + background-color:#090909;background-image:linear-gradient(#f5f5f5 0 0); + background-size:8px 8px;background-repeat:repeat-x;background-position:4px center; + pointer-events:none +} +.flipnote-editor-export-thumbnail-strip:before{top:5px} +.flipnote-editor-export-thumbnail-strip:after{bottom:5px} +.flipnote-editor-export-thumbnail-timeline{ + position:relative;z-index:2;display:flex;align-items:center;gap:10px; + width:100%;height:92px;overflow-x:auto;overflow-y:hidden;padding:2px 1px 4px; + border-top:1px solid #fff;border-bottom:1px solid #fff; + scrollbar-width:thin;scrollbar-color:#777 #262324 +} +.flipnote-editor-export-thumbnail{ + position:relative;flex:0 0 114px;width:114px;height:86px;padding:0; + overflow:hidden;border:1px solid #bbb;border-radius:0;background:#fff; + cursor:pointer;line-height:0;image-rendering:pixelated +} +.flipnote-editor-export-thumbnail canvas{display:block;width:100%;height:100%;image-rendering:pixelated} +.flipnote-editor-export-thumbnail:hover{border-color:#fff} +.flipnote-editor-export-thumbnail.selected{outline:2px solid var(--red);outline-offset:-2px} +@media(max-width:620px){ + .flipnote-editor-export-thumbnail-strip{height:127px;padding:24px 6px} + .flipnote-editor-export-thumbnail-timeline{height:79px;gap:8px} + .flipnote-editor-export-thumbnail{flex-basis:100px;width:100px;height:75px} +} + +@media(max-width:620px){ + .flipnote-editor-tab5-actions{gap:7px} +} +.flipnote-editor-timeline-stack{ + position:relative; + width:640px; + max-width:100%; + margin-top:8px; +} +.flipnote-editor-timeline-stack .flipnote-editor-filmstrip{ + width:100%; + margin-top:0; + border-color:#ae301f!important; + position:relative; + z-index:3; +} +.flipnote-editor-page-tools-toggle{ + position:relative; + z-index:4; + display:block; + width:68px; + height:23px; + margin:-1px auto 0; + padding:0; + border:0; + background:transparent; + line-height:0; + cursor:pointer; + transform-origin:center; + transition:transform .12s ease; +} +.flipnote-editor-page-tools-toggle:hover{transform:scale(1.08)} +.flipnote-editor-page-tools-toggle:active{transform:scale(.98)} +.flipnote-editor-page-tools-toggle img{ + display:block; + width:100%; + height:100%; + object-fit:contain; + image-rendering:auto; + pointer-events:none; +} +.flipnote-editor-page-tools-tray{ + position:relative; + z-index:1; + display:flex; + align-items:center; + justify-content:center; + gap:10px; + box-sizing:border-box; + width:min(430px,calc(100% - 24px)); + height:0; + margin:-1px auto 0; + padding:0 13px; + overflow:hidden; + border:1px solid transparent; + border-radius:5px; + background:#090909; + opacity:0; + transform:translateY(-54px); + transform-origin:top center; + pointer-events:none; + transition:height .2s ease,margin-top .2s ease,padding .2s ease,border-color .12s linear,opacity .12s linear,transform .2s ease; +} +.flipnote-editor-page-tools-tray::before,.flipnote-editor-page-tools-tray::after{ + content:""; + position:absolute; + z-index:0; + left:5px; + right:5px; + height:7px; + background-image:url('./flipnote-editor/filmstrip-dots.png'); + background-repeat:repeat-x; + background-size:auto 7px; + background-position:left center; + pointer-events:none; + opacity:.96; +} +.flipnote-editor-page-tools-tray::before{top:4px} +.flipnote-editor-page-tools-tray::after{bottom:4px} +.flipnote-editor-page-tools-tray.is-open{ + height:120px; + margin-top:-1px; + padding-top:13px; + padding-bottom:13px; + border-color:#ae301f; + opacity:1; + transform:translateY(0); + pointer-events:auto; +} +.flipnote-editor-page-tool-button{ + position:relative; + z-index:1; + flex:0 0 92px; + width:92px; + height:92px; + margin:0; + padding:0; + border:0; + background:transparent; + cursor:pointer; + line-height:0; + transform-origin:center; + transition:transform .12s ease; +} +.flipnote-editor-page-tool-button:hover{transform:scale(1.08)} +.flipnote-editor-page-tool-button:active{transform:scale(.96)} +.flipnote-editor-page-tool-button img{ + display:block; + width:100%; + height:100%; + object-fit:contain; + pointer-events:none; +} +.flipnote-editor-page-tools-tray[inert]{filter:grayscale(1);opacity:.45} + +@media(max-width:760px){ + .flipnote-editor-timeline-stack{width:100%} + .flipnote-editor-page-tools-tray{gap:4px;width:calc(100% - 10px);padding-left:4px;padding-right:4px} + .flipnote-editor-page-tools-tray.is-open{height:112px} + .flipnote-editor-page-tool-button{flex-basis:84px;width:84px;height:84px} +} +@media(max-width:767px){ + .flipnote-editor-page{ + overflow-x:hidden; + overscroll-behavior-x:none; + } + .flipnote-editor-page .page-shell{ + box-sizing:border-box; + width:100%; + max-width:100%; + padding-top:12px; + padding-right:max(8px,env(safe-area-inset-right)); + padding-bottom:max(34px,calc(18px + env(safe-area-inset-bottom))); + padding-left:max(8px,env(safe-area-inset-left)); + overflow:visible; + } + .flipnote-editor-workspace{ + display:grid; + grid-template-columns:minmax(0,1fr); + width:100%; + max-width:100%; + gap:14px; + margin:0; + } + .flipnote-editor-main,.flipnote-editor-stage,.flipnote-editor-timeline-stack,.flipnote-editor-filmstrip,.flipnote-editor-controls{ + box-sizing:border-box; + width:100%; + max-width:100%; + min-width:0; + } + .flipnote-editor-stage{border-radius:6px} + .flipnote-editor-canvas-wrap{ + box-sizing:border-box; + width:100%; + padding:6px 6px 0; + } + #flipnote-canvas{ + width:100%; + max-width:none; + height:auto; + aspect-ratio:4/3; + image-rendering:pixelated; + image-rendering:crisp-edges; + touch-action:none; + } + .flipnote-editor-stage-playback{ + min-height:0; + padding:6px 6px 4px; + } + .flipnote-editor-stage-control-grid{ + --flipnote-mobile-transport-small:clamp(33px,11vw,52px); + --flipnote-mobile-transport-play:clamp(92px,40vw,197.4px); + width:100%; + grid-template-columns: + var(--flipnote-mobile-transport-small) + var(--flipnote-mobile-transport-small) + var(--flipnote-mobile-transport-play) + var(--flipnote-mobile-transport-small) + var(--flipnote-mobile-transport-small); + column-gap:clamp(2px,1vw,5px); + justify-content:center; + } + .flipnote-editor-stage-nav,.flipnote-editor-stage-play-toggle{ + width:100%; + min-width:0; + max-width:none; + height:40px; + object-fit:contain; + } + .flipnote-editor-stage-speed-row{ + box-sizing:border-box; + width:100%; + min-height:0; + padding:0 6px 7px; + grid-template-columns:clamp(40px,13vw,53px) clamp(110px,50vw,197.4px) clamp(40px,13vw,53px); + column-gap:clamp(4px,2vw,8px); + justify-content:center; + } + .flipnote-editor-speed-control,.flipnote-editor-loop-toggle{ + width:100%; + min-width:0; + max-width:none; + height:40px; + } + .flipnote-editor-timeline-stack{margin-top:7px} + .flipnote-editor-filmstrip{ + height:118px; + padding:21px 6px; + } + .flipnote-editor-filmstrip:before,.flipnote-editor-filmstrip:after{height:12px} + .flipnote-editor-timeline{ + height:76px; + gap:7px; + padding:2px 0 3px; + overflow-x:auto; + overflow-y:hidden; + -webkit-overflow-scrolling:touch; + overscroll-behavior-x:contain; + touch-action:pan-x; + } + .flipnote-editor-timeline .timeline-thumbnail{ + flex:0 0 96px; + width:96px!important; + height:72px!important; + } + .flipnote-editor-page-tools-tray{ + display:grid; + grid-template-columns:repeat(4,minmax(0,1fr)); + align-items:center; + justify-items:center; + gap:clamp(2px,1.8vw,6px); + width:calc(100% - 8px); + padding-right:5px; + padding-left:5px; + } + .flipnote-editor-page-tools-tray.is-open{ + height:clamp(92px,31vw,118px); + padding-top:12px; + padding-bottom:12px; + } + .flipnote-editor-page-tool-button{ + box-sizing:border-box; + width:100%; + max-width:92px; + height:auto; + aspect-ratio:1; + flex:none; + } + .flipnote-editor-page-tools-toggle{ + width:68px; + height:23px; + touch-action:manipulation; + } + .flipnote-editor-controls{ + position:static; + display:flex; + flex-direction:column; + gap:10px; + } + .flipnote-editor-side-top{ + grid-template-columns:minmax(0,1fr) minmax(96px,32vw)!important; + grid-template-rows:52px 42px!important; + column-gap:6px!important; + row-gap:7px!important; + } + .flipnote-editor-page-counter{ + box-sizing:border-box; + width:100%!important; + min-width:0; + height:52px!important; + padding:5px 8px; + } + .flipnote-editor-page-counter span{font-size:12px} + .flipnote-editor-page-counter strong{font-size:17px} + .flipnote-editor-page-actions{gap:5px} + .flipnote-editor-page-actions .flipnote-editor-icon{ + width:34px; + height:34px; + flex-basis:34px; + } + .flipnote-editor-history-icon#undoButton{height:38px;width:60.8px} + .flipnote-editor-history-icon#redoButton{height:38px;width:20.2667px} + .flipnote-editor-history-icon.flipnote-editor-history-clear{height:38px} + + .flipnote-editor-tool-tabs{ + width:100%; + grid-template-columns:repeat(5,minmax(0,1fr)); + } + .flipnote-editor-tool-tab{min-width:0;touch-action:manipulation} + .flipnote-editor-tool-tab img{width:100%;height:auto} + .flipnote-editor-tool-page{gap:10px} + .flipnote-editor-control-panel{border-radius:6px} + .flipnote-editor-control-title{padding:6px 8px 7px;font-size:15px} + .flipnote-editor-control-body{padding:7px 6px} + .flipnote-editor-paper-tools,.flipnote-editor-selection-tools,.flipnote-editor-more-tools,.flipnote-editor-tab5-actions{width:100%;max-width:100%;min-width:0} + .flipnote-editor-paper-tools{gap:6px} + .flipnote-editor-more-tools{gap:6px} + .flipnote-editor-selection-page{gap:9px} + .flipnote-editor-selection-tool,.flipnote-editor-more-tool,.flipnote-editor-paper-tool{min-width:0;touch-action:manipulation} + .flipnote-editor-tool-family,.flipnote-editor-tool-slots>button,.flipnote-editor-edit-pattern{touch-action:manipulation} + .flipnote-editor-tool-picker-row{max-width:100%} + .flipnote-editor-tool-slots{max-width:calc(100vw - 36px)} + .flipnote-editor-layer-colors{ + --flipnote-picker-size:26px; + --flipnote-picker-gap:4px; + right:3%; + width:calc(var(--flipnote-picker-size) * 3 + var(--flipnote-picker-gap) * 2); + height:30px; + } + .flipnote-editor-audio-page{align-items:center} + .flipnote-editor-audio-layout{ + width:min(100%,360px); + max-width:100%; + margin:0 auto; + } + .flipnote-editor-tab5-actions{ + grid-template-columns:repeat(2,minmax(0,1fr)); + gap:7px; + } + .flipnote-editor-tab5-image-button{min-width:0;width:100%} + .flipnote-editor-tab5-image-button img{max-width:100%;height:auto} + .flipnote-editor-page input:not([type="range"]):not([type="checkbox"]):not([type="radio"]),.flipnote-editor-page select,.flipnote-editor-page textarea,.flipnote-editor-pattern-modal input:not([type="range"]),.flipnote-editor-image-import-modal input:not([type="range"]),.flipnote-editor-image-import-modal select,.flipnote-editor-audio-trim-modal input:not([type="range"]){font-size:16px} + .flipnote-editor-pattern-modal,.flipnote-editor-info-modal,.flipnote-editor-image-import-modal,.flipnote-editor-audio-trim-modal{ + box-sizing:border-box; + padding-top:max(8px,env(safe-area-inset-top)); + padding-right:max(8px,env(safe-area-inset-right)); + padding-bottom:max(8px,env(safe-area-inset-bottom)); + padding-left:max(8px,env(safe-area-inset-left)); + backdrop-filter:none; + -webkit-backdrop-filter:none; + } + .flipnote-editor-pattern-dialog,.flipnote-editor-info-dialog,.flipnote-editor-image-import-dialog,.flipnote-editor-audio-trim-dialog,.flipnote-editor-video-export-dialog{ + box-sizing:border-box; + width:100%; + max-width:100%; + max-height:calc(100dvh - 16px - env(safe-area-inset-top) - env(safe-area-inset-bottom)); + } + .flipnote-editor-info-body,.flipnote-editor-image-import-body,.flipnote-editor-image-import-step,.flipnote-editor-audio-trim-body{overscroll-behavior:contain} + .flipnote-editor-audio-trim-header,.flipnote-editor-info-header,.flipnote-editor-image-import-header{padding:11px 12px} + .flipnote-editor-audio-trim-body,.flipnote-editor-info-body,.flipnote-editor-image-import-body{padding:12px} + .flipnote-editor-audio-trim-footer,.flipnote-editor-image-import-footer{padding:10px 12px} + .flipnote-editor-image-import-dropzone{min-height:180px;padding:16px} + .flipnote-editor-image-import-crop-stage canvas{max-height:42dvh} + .flipnote-editor-audio-waveform-wrap canvas{height:104px} + .flipnote-editor-control-panel,.flipnote-editor-control-body,.flipnote-editor-pattern-dialog{animation:none} +} +@media(max-width:900px) and (hover:none) and (pointer:coarse){ + .flipnote-editor-page :is(button,[role="button"],img[id],.flipnote-editor-paper-tool,.flipnote-editor-tool-tab,.flipnote-editor-tool-family,.flipnote-editor-tool-slots>button){ + -webkit-tap-highlight-color:transparent; + -webkit-touch-callout:none; + } + .flipnote-editor-page :is(button,.flipnote-editor-paper-tool,.flipnote-editor-tool-tab,.flipnote-editor-page-tool-button,.flipnote-editor-page-tools-toggle,.flipnote-editor-icon,.flipnote-editor-history-icon,.flipnote-editor-stage-nav,.flipnote-editor-stage-play-toggle,.flipnote-editor-loop-toggle){touch-action:manipulation} + .flipnote-editor-stage-nav:hover,.flipnote-editor-stage-play-toggle:hover,.flipnote-editor-page-tool-button:hover,.flipnote-editor-page-tools-toggle:hover,.flipnote-editor-paper-tool:hover,.flipnote-editor-more-tool:hover,.flipnote-editor-tab5-image-button:hover,.flipnote-editor-history-icon:hover{ + transform:none; + filter:none; + } + .flipnote-editor-speed-control:hover img,.flipnote-editor-loop-toggle:hover img,.flipnote-editor-tool-tab:hover img{transform:none} + .flipnote-editor-page .is-touch-long-press{ + transform:scale(.96)!important; + filter:brightness(1.1)!important; + } +} + +@media(max-width:380px){ + .flipnote-editor-page .page-shell{ + padding-right:max(5px,env(safe-area-inset-right)); + padding-left:max(5px,env(safe-area-inset-left)); + } + .flipnote-editor-stage-control-grid{column-gap:2px} + .flipnote-editor-stage-nav,.flipnote-editor-stage-play-toggle{height:38px} + .flipnote-editor-stage-speed-row{column-gap:4px} + .flipnote-editor-speed-control,.flipnote-editor-loop-toggle{height:38px} + .flipnote-editor-page-tools-tray.is-open{height:100px;padding-top:10px;padding-bottom:10px} + .flipnote-editor-side-top{grid-template-columns:minmax(0,1fr) minmax(90px,30vw)!important} + .flipnote-editor-page-actions .flipnote-editor-icon{width:31px;height:31px;flex-basis:31px} +} +@media (max-width:767px){ + html{ + scrollbar-gutter:auto; + -webkit-text-size-adjust:100%; + text-size-adjust:100%; + } + body{ + min-width:0; + overflow-x:hidden; + padding:0; + } + body,button,input,textarea,select{font-synthesis:none} + img{max-width:100%} + code{overflow-wrap:anywhere;word-break:break-word} + .page-shell{ + width:100%; + min-width:0; + padding-top:18px; + padding-right:max(11px,env(safe-area-inset-right)); + padding-bottom:max(48px,calc(34px + env(safe-area-inset-bottom))); + padding-left:max(11px,env(safe-area-inset-left)); + } + .page-shell>*{min-width:0;max-width:100%} + .button{ + min-height:42px; + padding:8px 12px; + touch-action:manipulation; + } + input,textarea,select{ + min-height:42px; + font-size:16px; + } + input[type="checkbox"],input[type="radio"],input[type="range"],input[type="color"],input[type="file"]{font-size:inherit} + input[type="checkbox"],input[type="radio"]{min-height:0} + textarea{min-height:110px} + .button-row{gap:8px} + .inline-form{max-width:100%} + @media (hover:none) and (pointer:coarse){ + a,button,label{touch-action:manipulation} + } +} + +@media (max-width:420px){ + .page-shell{padding-left:max(8px,env(safe-area-inset-left));padding-right:max(8px,env(safe-area-inset-right))} +} +#flipnote-canvas,#flipnote-canvas:hover,#flipnote-canvas:focus,#flipnote-canvas:focus-visible,#flipnote-canvas:active{ + outline:0!important; + outline-offset:0!important; + box-shadow:none!important; +} +.flipnote-editor-timeline .timeline-thumbnail.is-frame-drag-source{ + visibility:hidden!important; +} +.flipnote-editor-timeline .timeline-frame-lift-placeholder{ + position:absolute; + z-index:4; + box-sizing:border-box; + border:2px dotted #ae301f; + background:transparent; + pointer-events:none; +} +.flipnote-editor-timeline .timeline-frame-drop-marker{ + position:absolute; + z-index:5; + top:3px; + bottom:5px; + width:0; + border-left:2px dotted #ae301f; + pointer-events:none; +} + +.theme-sudotheme .flipnote-editor-page button:has(img),.theme-sudotheme .flipnote-editor-page [role="slider"]:has(img),.theme-sudotheme .flipnote-editor-page :is( + .flipnote-editor-stage-nav, + .flipnote-editor-stage-play-toggle, + .flipnote-editor-icon, + .flipnote-editor-history-icon +),.theme-sudotheme .flipnote-editor-loader-spinner{ + filter:hue-rotate(80deg)!important; +} +.theme-sudotheme .flipnote-editor-timeline-stack .flipnote-editor-filmstrip{ + border-color:#315C00!important; +} +.theme-sudotheme .flipnote-editor-layer-row::before{ + filter:hue-rotate(80deg)!important; +} +.theme-sudotheme .flipnote-editor-layer-row:hover::before,.theme-sudotheme .flipnote-editor-layer-row:focus-within::before{ + filter:hue-rotate(80deg) brightness(1.025)!important; +} +.flipnote-editor-workspace{position:relative} +.flipnote-editor-space-meter{ + position:absolute; + z-index:8; + left:-66px; + top:82px; + width:52px; + display:grid; + grid-template-columns:39.5164px max-content; + grid-template-rows:257.41905px auto; + align-items:center; + justify-content:center; + column-gap:4px; + row-gap:5px; + color:var(--muted); + font-size:8px; + font-weight:850; + line-height:1; + text-align:center; + user-select:none; + pointer-events:none; +} +.flipnote-editor-space-label{ + grid-column:2; + grid-row:1; + writing-mode:vertical-rl; + transform:rotate(180deg); + letter-spacing:.035em; + white-space:nowrap; +} +.flipnote-editor-space-bar{ + grid-column:1; + grid-row:1; + position:relative; + width:39.5164px; + height:257.41905px; + overflow:hidden; +} +.flipnote-editor-space-fill{ + position:absolute; + z-index:1; + left:8%; + right:8%; + bottom:1.25%; + height:0; + background:var(--red); + transition:height .12s linear; +} +.flipnote-editor-space-bar img{ + position:absolute; + z-index:2; + left:50%; + top:50%; + width:640%; + height:15.625%; + max-width:none; + max-height:none; + object-fit:fill; + transform:translate(-50%,-50%) rotate(90deg); + transform-origin:center; + user-select:none; + -webkit-user-drag:none; +} +.flipnote-editor-space-value{grid-column:1 / -1;grid-row:2;min-width:42px;font-variant-numeric:tabular-nums;white-space:nowrap} +.flipnote-editor-space-meter.is-near-limit .flipnote-editor-space-value,.flipnote-editor-space-meter.is-full .flipnote-editor-space-value{color:var(--red);font-weight:950} +.flipnote-editor-space-meter.is-limit-hit{animation:flipnote-space-limit-pulse .24s ease-in-out 3 alternate} +@keyframes flipnote-space-limit-pulse{from{transform:scale(1)}to{transform:scale(1.065)}} +@media (prefers-reduced-motion:reduce){.flipnote-editor-space-meter.is-limit-hit{animation:none}} +@media (max-width:1000px) and (min-width:768px){ + .flipnote-editor-space-meter{left:-58px} + .flipnote-editor-space-value{display:none} +} +@media (max-width:767px){.flipnote-editor-space-meter{display:none}} +@media (max-width:767px), (max-width:1024px) and (hover:none) and (pointer:coarse){ + :root{ + --mobile-site-nav-height:58px; + --mobile-editor-tabs-height:62px; + } + + html{scroll-padding-top:62px} + body{ + padding-bottom:calc(var(--mobile-site-nav-height) + env(safe-area-inset-bottom)); + background:var(--bg); + } + .page-shell{ + padding-top:16px!important; + padding-right:max(10px,env(safe-area-inset-right))!important; + padding-bottom:28px!important; + padding-left:max(10px,env(safe-area-inset-left))!important; + } + .button{border-radius:6px} + input,textarea,select{border-radius:6px} + body.flipnote-editor-page{ + padding-bottom:calc(var(--mobile-editor-tabs-height) + env(safe-area-inset-bottom)); + } + .flipnote-editor-page .main-nav{display:none!important} + .flipnote-editor-page .page-shell{ + width:100%!important; + max-width:100%!important; + padding-top:6px!important; + padding-right:max(5px,env(safe-area-inset-right))!important; + padding-bottom:18px!important; + padding-left:max(5px,env(safe-area-inset-left))!important; + } + .flipnote-editor-workspace{ + width:min(100%,720px)!important; + max-width:720px!important; + margin:0 auto!important; + gap:8px!important; + } + .flipnote-editor-main,.flipnote-editor-stage,.flipnote-editor-controls,.flipnote-editor-timeline-stack{width:100%!important;max-width:100%!important} + .flipnote-editor-stage{ + overflow:hidden; + border-radius:9px!important; + box-shadow:0 6px 20px rgba(0,0,0,.22)!important; + } + .flipnote-editor-canvas-wrap{padding:4px 4px 0!important} + #flipnote-canvas{display:block;width:100%!important;height:auto!important;aspect-ratio:4/3} + .flipnote-editor-mobile-primary-tools{ + display:block; + width:100%; + padding:5px 6px 6px; + border-top:1px solid #3a2a2a; + border-bottom:1px solid #2a2020; + background:#151314; + } + .flipnote-editor-mobile-primary-tools .flipnote-editor-tool-picker-inline{display:grid;gap:5px;width:100%} + .flipnote-editor-mobile-primary-tools .flipnote-editor-tool-family-row{ + display:grid!important; + grid-template-columns:repeat(4,minmax(0,1fr))!important; + width:100%!important; + gap:4px!important; + padding:0!important; + } + .flipnote-editor-mobile-primary-tools .flipnote-editor-tool-family{ + width:100%!important; + min-width:0!important; + max-width:none!important; + transform:none!important; + } + .flipnote-editor-mobile-primary-tools .flipnote-editor-tool-family img{ + width:100%!important; + height:auto!important; + aspect-ratio:5/4!important; + object-fit:fill; + } + .flipnote-editor-mobile-primary-tools .flipnote-editor-tool-lower-row{ + width:100%; + min-height:0; + justify-content:center; + padding:0!important; + gap:5px!important; + } + .flipnote-editor-mobile-primary-tools .flipnote-editor-tool-slots{ + grid-template-columns:repeat(4,minmax(0,48px))!important; + grid-template-rows:repeat(2,minmax(0,48px))!important; + justify-content:center; + width:100%!important; + max-width:100%!important; + height:auto!important; + gap:2px!important; + } + .flipnote-editor-mobile-primary-tools .flipnote-editor-tool-slots>button{ + width:100%!important; + max-width:48px; + height:auto!important; + aspect-ratio:1; + } + .flipnote-editor-mobile-primary-tools .flipnote-editor-tool-slots.is-selection-only{ + width:48px!important; + height:48px!important; + } + .flipnote-editor-tool-page.flipnote-editor-mobile-tools-docked{display:none!important} + .flipnote-editor-stage-playback{padding:5px 5px 3px!important} + .flipnote-editor-stage-control-grid{ + --flipnote-mobile-transport-small:clamp(32px,10vw,48px)!important; + --flipnote-mobile-transport-play:clamp(90px,38vw,180px)!important; + column-gap:clamp(2px,1vw,5px)!important; + } + .flipnote-editor-stage-nav,.flipnote-editor-stage-play-toggle{height:36px!important} + .flipnote-editor-stage-speed-row{ + min-height:0!important; + padding:0 5px 5px!important; + grid-template-columns:clamp(38px,12vw,50px) minmax(110px,180px) clamp(38px,12vw,50px)!important; + column-gap:5px!important; + } + .flipnote-editor-speed-control,.flipnote-editor-loop-toggle{height:34px!important} + .flipnote-editor-timeline-stack{margin-top:5px!important} + .flipnote-editor-filmstrip{height:100px!important;padding:17px 5px!important;border-radius:7px} + .flipnote-editor-filmstrip:before,.flipnote-editor-filmstrip:after{height:10px!important} + .flipnote-editor-timeline{height:66px!important;gap:6px!important} + .flipnote-editor-timeline .timeline-thumbnail{flex:0 0 84px!important;width:84px!important;height:63px!important} + .flipnote-editor-page-tools-tray{width:calc(100% - 6px)!important;gap:4px!important} + .flipnote-editor-page-tools-tray.is-open{height:clamp(86px,25vw,106px)!important;padding:9px 4px!important} + .flipnote-editor-page-tool-button{max-width:80px!important} + .flipnote-editor-page-tools-toggle{width:62px!important;height:22px!important} + .flipnote-editor-version-row{display:none!important} + + .flipnote-editor-controls{gap:8px!important} + .flipnote-editor-side-top{ + width:100%; + padding:6px; + grid-template-columns:minmax(0,1fr) minmax(92px,30vw)!important; + grid-template-rows:46px 38px!important; + column-gap:5px!important; + row-gap:5px!important; + border:1px solid var(--line); + border-radius:8px; + background:var(--surface); + } + .flipnote-editor-page-counter{height:46px!important;border-radius:6px} + .flipnote-editor-page-counter span{font-size:10px!important} + .flipnote-editor-page-counter strong{font-size:15px!important} + .flipnote-editor-page-actions .flipnote-editor-icon{width:32px!important;height:32px!important;flex-basis:32px!important} + .flipnote-editor-history-icon#undoButton{height:34px!important;width:54.4px!important} + .flipnote-editor-history-icon#redoButton{height:34px!important;width:18.13px!important} + .flipnote-editor-history-icon.flipnote-editor-history-clear{height:34px!important} + .flipnote-editor-tool-tabs{ + position:fixed!important; + z-index:112; + left:0; + right:0; + bottom:0; + width:100vw!important; + height:calc(var(--mobile-editor-tabs-height) + env(safe-area-inset-bottom)); + padding:4px max(4px,env(safe-area-inset-right)) calc(4px + env(safe-area-inset-bottom)) max(4px,env(safe-area-inset-left)); + grid-template-columns:repeat(5,minmax(0,1fr))!important; + gap:3px!important; + border-top:1px solid var(--line)!important; + background:color-mix(in srgb,var(--surface-raised) 96%,var(--bg))!important; + box-shadow:0 -8px 24px rgba(0,0,0,.28)!important; + } + .flipnote-editor-tool-tab{ + height:54px; + padding:0!important; + border-radius:6px!important; + overflow:hidden; + } + .flipnote-editor-tool-tab img{ + width:100%!important; + height:54px!important; + aspect-ratio:auto!important; + object-fit:fill!important; + } + .flipnote-editor-tool-tab.active{background:color-mix(in srgb,var(--red) 10%,transparent)!important} + + .flipnote-editor-control-panel{border-radius:8px!important;overflow:visible} + .flipnote-editor-control-title{padding:7px 9px!important;font-size:14px!important} + .flipnote-editor-control-body{padding:8px!important} + .flipnote-editor-tool-page{gap:8px!important} + .flipnote-editor-paper-tools,.flipnote-editor-selection-tools,.flipnote-editor-more-tools{gap:5px!important} + .flipnote-editor-tab5-actions{gap:6px!important} + .flipnote-editor-audio-layout{width:min(100%,420px)!important} + .flipnote-editor-mobile-shift-toggle{ + left:max(8px,env(safe-area-inset-left))!important; + bottom:calc(var(--mobile-editor-tabs-height) + env(safe-area-inset-bottom) + 8px)!important; + width:clamp(82px,22vw,108px)!important; + z-index:114!important; + } + .flipnote-editor-pattern-modal,.flipnote-editor-info-modal,.flipnote-editor-image-import-modal,.flipnote-editor-audio-trim-modal{ + align-items:flex-end!important; + padding:0!important; + } + .flipnote-editor-pattern-dialog,.flipnote-editor-info-dialog,.flipnote-editor-image-import-dialog,.flipnote-editor-audio-trim-dialog,.flipnote-editor-video-export-dialog{ + width:100%!important; + max-width:100%!important; + max-height:calc(90dvh - env(safe-area-inset-top))!important; + border-radius:14px 14px 0 0!important; + padding-bottom:env(safe-area-inset-bottom); + } +} +@media(max-width:380px){ + .flipnote-editor-mobile-primary-tools{padding-inline:4px} + .flipnote-editor-mobile-primary-tools .flipnote-editor-tool-family-row{gap:2px!important} + .flipnote-editor-tool-tabs{gap:2px!important;padding-left:2px!important;padding-right:2px!important} +} + +@media (prefers-reduced-motion:reduce) and (max-width:767px), (prefers-reduced-motion:reduce) and (max-width:1024px) and (hover:none) and (pointer:coarse){ + .flipnote-editor-tool-tabs{backdrop-filter:none;-webkit-backdrop-filter:none} +} +.flipnote-editor-info-button{width:28px;height:28px;border-radius:5px;font-size:19px;font-weight:500} +.flipnote-editor-canvas-wrap{overflow:hidden;justify-content:flex-start} +.flipnote-editor-canvas-surface{position:relative;flex:none;margin-inline:auto;transform-origin:center center} +#flipnote-canvas{width:100%!important;max-width:none!important;height:auto!important} +.flipnote-editor-canvas-grid{position:absolute;inset:0;display:block;width:100%;height:100%;pointer-events:none;mix-blend-mode:difference;fill:none;stroke:rgba(255,255,255,.35);stroke-width:1} +.flipnote-editor-canvas-grid[hidden]{display:none!important} +.flipnote-editor-settings-toggle,.flipnote-editor-settings-zoom{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:9px 11px;border:1px solid var(--line);border-radius:5px;background:var(--surface-raised);color:var(--text);font-size:12px} +.flipnote-editor-settings-toggle input{width:17px;height:17px;accent-color:var(--red);cursor:pointer} +.flipnote-editor-settings-zoom input{flex:1;min-width:80px;accent-color:var(--red)} +.flipnote-editor-settings-zoom output{min-width:44px;text-align:right;font-variant-numeric:tabular-nums} +.flipnote-editor-settings-keyboard-heading{display:flex;justify-content:space-between;align-items:center;gap:12px} +.flipnote-editor-settings-keyboard-heading .button{min-height:28px;padding:4px 9px;font-size:11px} +.flipnote-editor-shortcut-settings{display:grid;grid-template-columns:1fr 1fr;gap:5px 10px} +.flipnote-editor-shortcut-setting{display:grid;grid-template-columns:minmax(0,1fr) minmax(86px,auto) auto;align-items:center;gap:7px;padding:6px 8px;border:1px solid var(--line);border-radius:4px;background:var(--surface-raised)} +.flipnote-editor-shortcut-setting>span{overflow:hidden;color:var(--text);font-size:11px;line-height:1.3} +.flipnote-editor-shortcut-capture,.flipnote-editor-shortcut-reset{min-height:27px;border:1px solid var(--line);border-radius:4px;background:var(--surface);color:var(--text);cursor:pointer} +.flipnote-editor-shortcut-capture{padding:3px 7px;font:700 11px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;white-space:nowrap} +.flipnote-editor-shortcut-capture[aria-pressed="true"]{border-color:var(--red);color:var(--red);background:var(--surface-hover)} +.flipnote-editor-shortcut-reset{padding:3px 6px;color:var(--muted);font-size:10px} +.flipnote-editor-shortcut-capture:hover,.flipnote-editor-shortcut-reset:hover{border-color:var(--red);color:var(--text)} +.flipnote-editor-shortcut-capture:focus-visible,.flipnote-editor-shortcut-reset:focus-visible{outline:2px solid var(--red);outline-offset:2px} +.flipnote-editor-info-body .flipnote-editor-shortcut-message{min-height:1.4em;color:var(--text)} +@media(max-width:650px){.flipnote-editor-shortcut-settings{grid-template-columns:1fr}} +.flipnote-editor-layer-row.layer-hidden::before{filter:grayscale(1) brightness(.65)!important} +.flipnote-editor-layer-row.layer-hidden .flipnote-editor-layer-colors{opacity:.45} +.flipnote-editor-layer-menu{position:fixed;z-index:2000;min-width:145px;padding:4px;border:1px solid #555;border-radius:4px;background:#242124;box-shadow:0 3px 12px #0008} +.flipnote-editor-layer-menu[hidden]{display:none} +.flipnote-editor-layer-menu button{display:block;width:100%;padding:9px 12px;border:0;background:transparent;color:#fff;text-align:left;cursor:pointer} +.flipnote-editor-layer-menu button:hover,.flipnote-editor-layer-menu button:focus-visible{background:#59465e} diff --git a/assets/flipnote-player/pause.svg b/assets/flipnote-player/pause.svg new file mode 100644 index 0000000..7474d79 --- /dev/null +++ b/assets/flipnote-player/pause.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/fonts/ndsbios.ttf b/assets/fonts/ndsbios.ttf new file mode 100644 index 0000000..90b9a7c Binary files /dev/null and b/assets/fonts/ndsbios.ttf differ diff --git a/assets/fonts/paxalah.otf b/assets/fonts/paxalah.otf new file mode 100644 index 0000000..03a12b8 Binary files /dev/null and b/assets/fonts/paxalah.otf differ diff --git a/assets/loading.webp b/assets/loading.webp new file mode 100644 index 0000000..6f318cf Binary files /dev/null and b/assets/loading.webp differ diff --git a/assets/ppm-import.js b/assets/ppm-import.js new file mode 100644 index 0000000..887114d --- /dev/null +++ b/assets/ppm-import.js @@ -0,0 +1,117 @@ +(function (global) { + 'use strict'; + + const WIDTH = 256; + const HEIGHT = 192; + const MAX_BYTES = 1024 * 1024; + const MAX_PAGES = 999; + const SAMPLE_RATE = 32768; + + function checkFile(buffer) { + if (!(buffer instanceof ArrayBuffer) || buffer.byteLength < 0x6C0 || buffer.byteLength > MAX_BYTES) { + throw new Error('Choose a complete Flipnote Studio PPM, no larger than 1,024 KB.'); + } + const view = new DataView(buffer); + if (view.getUint32(0, false) !== 0x50415241) throw new Error('That file is not a Flipnote Studio PPM.'); + const pages = view.getUint16(12, true) + 1; + const animationEnd = 0x6A0 + view.getUint32(4, true); + if (pages > MAX_PAGES || animationEnd < 0x6A8 || animationEnd + pages > buffer.byteLength) { + throw new Error('The PPM page data is incomplete or exceeds 999 pages.'); + } + const tableBytes = view.getUint16(0x6A0, true); + const firstFrame = 0x6A8 + tableBytes; + if (tableBytes < pages * 4 || firstFrame >= animationEnd) throw new Error('The PPM page table is invalid.'); + for (let index = 0; index < pages; index++) { + if (firstFrame + view.getUint32(0x6A8 + index * 4, true) >= animationEnd) { + throw new Error('A PPM page points outside the animation data.'); + } + } + const soundStart = Math.ceil((animationEnd + pages) / 4) * 4; + if (soundStart + 32 + view.getUint32(8, true) > buffer.byteLength) { + throw new Error('The PPM audio data is incomplete.'); + } + let audioBytes = 0; + for (let index = 0; index < 4; index++) audioBytes += view.getUint32(soundStart + index * 4, true); + if (audioBytes > view.getUint32(8, true)) throw new Error('The PPM audio track lengths are invalid.'); + return pages; + } + + function base64(bytes) { + let text = ''; + for (let offset = 0; offset < bytes.length; offset += 8192) { + text += String.fromCharCode(...bytes.subarray(offset, offset + 8192)); + } + return btoa(text); + } + + function layerData(pixels) { + const bytes = new Uint8Array(WIDTH * HEIGHT / 8); + for (let index = 0; index < pixels.length; index++) { + if (pixels[index]) bytes[index >> 3] |= 1 << (index & 7); + } + return base64(bytes); + } + + function pcmWav(pcm) { + const bytes = new Uint8Array(44 + pcm.length * 2); + const view = new DataView(bytes.buffer); + const text = (offset, value) => { for (let i = 0; i < value.length; i++) bytes[offset + i] = value.charCodeAt(i); }; + text(0, 'RIFF'); view.setUint32(4, bytes.length - 8, true); text(8, 'WAVE'); + text(12, 'fmt '); view.setUint32(16, 16, true); view.setUint16(20, 1, true); + view.setUint16(22, 1, true); view.setUint32(24, SAMPLE_RATE, true); + view.setUint32(28, SAMPLE_RATE * 2, true); view.setUint16(32, 2, true); view.setUint16(34, 16, true); + text(36, 'data'); view.setUint32(40, pcm.length * 2, true); + for (let i = 0; i < pcm.length; i++) view.setInt16(44 + i * 2, pcm[i], true); + return bytes; + } + + function decode(buffer, filename = 'Flipnote.ppm') { + checkFile(buffer); + const parser = new global.flipnote.PpmParser(buffer); + const flags = parser.decodeSoundFlags(); + const ink = index => index === 2 ? 'red' : index === 3 ? 'blue' : 'black'; + const frames = []; + for (let index = 0; index < parser.frameCount; index++) { + const layers = parser.decodeFrame(index); + const palette = parser.getFramePaletteIndices(index); + frames.push({ + layer1: layerData(layers[0]), layer2: layerData(layers[1]), + colours: [ink(palette[1]), ink(palette[2])], + paper: palette[0] === 1 ? 'black' : 'white', + sounds: {sound1: flags[index][0], sound2: flags[index][1], sound3: flags[index][2]}, + }); + } + const audio = {}; + const tracks = [['music', global.flipnote.FlipnoteAudioTrack.BGM], + ['sound1', global.flipnote.FlipnoteAudioTrack.SE1], + ['sound2', global.flipnote.FlipnoteAudioTrack.SE2], + ['sound3', global.flipnote.FlipnoteAudioTrack.SE3]]; + for (const [key, track] of tracks) { + if (!parser.hasAudioTrack(track)) continue; + const pcm = parser.getAudioTrackPcm(track, SAMPLE_RATE); + if (!pcm.length) continue; + audio[key] = {data: base64(pcmWav(pcm)), mime:'audio/wav', duration:pcm.length / SAMPLE_RATE, + reference_fps:parser.framerate, source_name:filename, source_kind:'ppm_import'}; + } + return { + project: { + title:String(filename).replace(/\.ppm$/i, '').slice(0, 32) || 'Imported Flipnote', + speed_level:parser.frameSpeed, loop:parser.meta.loop, + thumbnail_frame:Math.max(0, Math.min(frames.length - 1, parser.thumbFrameIndex)), + layer_visibility:[parser.layerVisibility[1], parser.layerVisibility[2]], frames, audio, + }, + source:{filename, original_author_name:parser.meta.current.username}, + }; + } + + async function read(file) { + if (!file || !/\.ppm$/i.test(file.name || '')) throw new Error('Choose a Flipnote Studio .PPM file.'); + if (file.size > MAX_BYTES) throw new Error('That PPM is larger than the 1,024 KB limit.'); + try { return decode(await file.arrayBuffer(), file.name); } + catch (error) { throw new Error('Could not open this PPM: ' + (error.message || 'invalid file data')); } + } + + const api = Object.freeze({read, decode}); + global.FlipnotePpmImport = api; + if (typeof module === 'object' && module.exports) module.exports = api; +})(typeof window === 'object' ? window : globalThis); diff --git a/assets/vendor/flipnote-LICENSE.txt b/assets/vendor/flipnote-LICENSE.txt new file mode 100644 index 0000000..af9111d --- /dev/null +++ b/assets/vendor/flipnote-LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 James Daniel + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/assets/vendor/flipnote.js b/assets/vendor/flipnote.js new file mode 100644 index 0000000..100f732 --- /dev/null +++ b/assets/vendor/flipnote.js @@ -0,0 +1,8232 @@ +/*!! + * flipnote.js v6.3.0 + * https://flipnote.js.org + * A JavaScript library for Flipnote Studio animation files + * 2018 - 2025 James Daniel + * Flipnote Studio is (c) Nintendo Co., Ltd. This project isn't affiliated with or endorsed by them in any way. +*/ +var flipnote = (function (exports) { + 'use strict'; + + /** + * Flipnote region + */ + exports.FlipnoteRegion = void 0; + (function (FlipnoteRegion) { + /** + * Europe and Oceania + */ + FlipnoteRegion["EUR"] = "EUR"; + /** + * Americas + */ + FlipnoteRegion["USA"] = "USA"; + /** + * Japan + */ + FlipnoteRegion["JPN"] = "JPN"; + /** + * Unidentified (possibly never used) + */ + FlipnoteRegion["UNKNOWN"] = "UNKNOWN"; + })(exports.FlipnoteRegion || (exports.FlipnoteRegion = {})); + /** + * Identifies which animation format a Flipnote uses + */ + exports.FlipnoteFormat = void 0; + (function (FlipnoteFormat) { + /** + * Animation format used by Flipnote Studio (Nintendo DSiWare) + */ + FlipnoteFormat["PPM"] = "PPM"; + /** + * Animation format used by Flipnote Studio 3D (Nintendo 3DS) + */ + FlipnoteFormat["KWZ"] = "KWZ"; + })(exports.FlipnoteFormat || (exports.FlipnoteFormat = {})); + /** + * Buffer format for a FlipnoteThumbImage + */ + exports.FlipnoteThumbImageFormat = void 0; + (function (FlipnoteThumbImageFormat) { + FlipnoteThumbImageFormat[FlipnoteThumbImageFormat["Jpeg"] = 0] = "Jpeg"; + FlipnoteThumbImageFormat[FlipnoteThumbImageFormat["Rgba"] = 1] = "Rgba"; + })(exports.FlipnoteThumbImageFormat || (exports.FlipnoteThumbImageFormat = {})); + /** + * stereoscopic eye view (left/right) for 3D effects + */ + exports.FlipnoteStereoscopicEye = void 0; + (function (FlipnoteStereoscopicEye) { + FlipnoteStereoscopicEye[FlipnoteStereoscopicEye["Left"] = 0] = "Left"; + FlipnoteStereoscopicEye[FlipnoteStereoscopicEye["Right"] = 1] = "Right"; + })(exports.FlipnoteStereoscopicEye || (exports.FlipnoteStereoscopicEye = {})); + /** + * Identifies a Flipnote audio track type + */ + exports.FlipnoteAudioTrack = void 0; + (function (FlipnoteAudioTrack) { + /** + * Background music track + */ + FlipnoteAudioTrack[FlipnoteAudioTrack["BGM"] = 0] = "BGM"; + /** + * Sound effect 1 track + */ + FlipnoteAudioTrack[FlipnoteAudioTrack["SE1"] = 1] = "SE1"; + /** + * Sound effect 2 track + */ + FlipnoteAudioTrack[FlipnoteAudioTrack["SE2"] = 2] = "SE2"; + /** + * Sound effect 3 track + */ + FlipnoteAudioTrack[FlipnoteAudioTrack["SE3"] = 3] = "SE3"; + /** + * Sound effect 4 track (only used by KWZ files) + */ + FlipnoteAudioTrack[FlipnoteAudioTrack["SE4"] = 4] = "SE4"; + })(exports.FlipnoteAudioTrack || (exports.FlipnoteAudioTrack = {})); + /** + * {@link FlipnoteAudioTrack}, but just sound effect tracks + */ + exports.FlipnoteSoundEffectTrack = void 0; + (function (FlipnoteSoundEffectTrack) { + FlipnoteSoundEffectTrack[FlipnoteSoundEffectTrack["SE1"] = 1] = "SE1"; + FlipnoteSoundEffectTrack[FlipnoteSoundEffectTrack["SE2"] = 2] = "SE2"; + FlipnoteSoundEffectTrack[FlipnoteSoundEffectTrack["SE3"] = 3] = "SE3"; + FlipnoteSoundEffectTrack[FlipnoteSoundEffectTrack["SE4"] = 4] = "SE4"; + })(exports.FlipnoteSoundEffectTrack || (exports.FlipnoteSoundEffectTrack = {})); + + /****************************************************************************** + Copyright (c) Microsoft Corporation. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH + REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY + AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, + INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM + LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR + OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR + PERFORMANCE OF THIS SOFTWARE. + ***************************************************************************** */ + /* global Reflect, Promise, SuppressedError, Symbol */ + + + function __classPrivateFieldGet(receiver, state, kind, f) { + if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter"); + if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it"); + return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver); + } + + function __classPrivateFieldSet(receiver, state, value, kind, f) { + if (kind === "m") throw new TypeError("Private method is not writable"); + if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter"); + if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it"); + return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value; + } + + typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) { + var e = new Error(message); + return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e; + }; + + /** + * @internal + */ + class ByteArray { + constructor() { + /** + * @internal + */ + this.pageSize = 2048 * 2; + /** + * Allocated size counting all pages. + * @internal + */ + this.allocSize = 0; + /** + * Number of bytes actually used. + * @internal + */ + this.realSize = 0; + /** + * @internal + */ + this.pages = []; + /** + * @internal + */ + this.numPages = 0; + /** + * Page to write to. + * @internal + */ + this.pageIdx = 0; + /** + * Position in page to write to. + * @internal + */ + this.pagePtr = 0; // + /** + * Position in file. + * @internal + */ + this.realPtr = 0; + this.newPage(); + } + /** + * @internal + */ + set pointer(ptr) { + this.setPointer(ptr); + } + /** + * @internal + */ + get pointer() { + return this.realPtr; + } + /** + * @internal + */ + newPage() { + this.pages[this.numPages] = new Uint8Array(this.pageSize); + this.numPages = this.pages.length; + this.allocSize = this.numPages * this.pageSize; + } + /** + * @internal + */ + setPointer(ptr) { + // allocate enough pages to include pointer + while (ptr >= this.allocSize) { + this.newPage(); + } + // increase real file size if the end is reached + if (ptr > this.realSize) + this.realSize = ptr; + // update ptrs + this.pageIdx = Math.floor(ptr / this.pageSize); + this.pagePtr = ptr % this.pageSize; + this.realPtr = ptr; + } + /** + * @internal + */ + writeByte(value) { + this.pages[this.pageIdx][this.pagePtr] = value; + this.setPointer(this.realPtr + 1); + } + /** + * @internal + */ + writeBytes(bytes, srcPtr, length) { + for (let l = length || bytes.length, i = srcPtr || 0; i < l; i++) + this.writeByte(bytes[i]); + } + /** + * @internal + */ + writeChars(str) { + for (let i = 0; i < str.length; i++) { + this.writeByte(str.charCodeAt(i)); + } + } + /** + * @internal + */ + writeU8(value) { + this.writeByte(value & 0xFF); + } + /** + * @internal + */ + writeU16(value) { + this.writeByte((value >>> 0) & 0xFF); + this.writeByte((value >>> 8) & 0xFF); + } + /** + * @internal + */ + writeU32(value) { + this.writeByte((value >>> 0) & 0xFF); + this.writeByte((value >>> 8) & 0xFF); + this.writeByte((value >>> 16) & 0xFF); + this.writeByte((value >>> 24) & 0xFF); + } + getBytes() { + const bytes = new Uint8Array(this.realSize); + const numPages = this.numPages; + for (let i = 0; i < numPages; i++) { + const page = this.pages[i]; + if (i === numPages - 1) // last page + bytes.set(page.slice(0, this.realSize % this.pageSize), i * this.pageSize); + else + bytes.set(page, i * this.pageSize); + } + return bytes; + } + getBuffer() { + const bytes = this.getBytes(); + return bytes.buffer; + } + } + + /** + * @internal + */ + const hexFromBytes = (bytes, reverse = false) => { + let hex = []; + for (let i = 0; i < bytes.length; i++) + hex.push(bytes[i].toString(16).padStart(2, '0')); + if (reverse) + hex.reverse(); + return hex.join('').toUpperCase(); + }; + /** + * @internal + */ + const hexToBytes = (hex, reverse = false) => { + const hexSize = hex.length; + const bytes = new Uint8Array(hexSize / 2); + for (let i = 0, j = 0; i < hexSize; i += 2) + bytes[j++] = parseInt(hex.slice(i, i + 2), 16); + if (reverse) + bytes.reverse(); + return bytes; + }; + + /** + * Wrapper around the DataView API to keep track of the offset into the data, + * also provides some utils for reading ascii strings etc. + * @internal + */ + class DataStream { + constructor(buffer) { + this.buffer = buffer; + this.data = new DataView(buffer); + this.pointer = 0; + } + /** + * Returns the data as an Uint8Array of bytes. + */ + get bytes() { + return new Uint8Array(this.buffer); + } + /** + * Returns the total number of bytes in the data. + */ + get numBytes() { + return this.data.byteLength; + } + /** + * @internal + */ + seek(offset, whence) { + switch (whence) { + case 2 /* SeekOrigin.End */: + this.pointer = this.data.byteLength + offset; + break; + case 1 /* SeekOrigin.Current */: + this.pointer += offset; + break; + case 0 /* SeekOrigin.Begin */: + default: + this.pointer = offset; + break; + } + } + /** + * @internal + */ + readUint8() { + const val = this.data.getUint8(this.pointer); + this.pointer += 1; + return val; + } + /** + * @internal + */ + writeUint8(value) { + this.data.setUint8(this.pointer, value); + this.pointer += 1; + } + /** + * @internal + */ + readInt8() { + const val = this.data.getInt8(this.pointer); + this.pointer += 1; + return val; + } + /** + * @internal + */ + writeInt8(value) { + this.data.setInt8(this.pointer, value); + this.pointer += 1; + } + /** + * @internal + */ + readUint16(littleEndian = true) { + const val = this.data.getUint16(this.pointer, littleEndian); + this.pointer += 2; + return val; + } + /** + * @internal + */ + writeUint16(value, littleEndian = true) { + this.data.setUint16(this.pointer, value, littleEndian); + this.pointer += 2; + } + /** + * @internal + */ + readInt16(littleEndian = true) { + const val = this.data.getInt16(this.pointer, littleEndian); + this.pointer += 2; + return val; + } + /** + * @internal + */ + writeInt16(value, littleEndian = true) { + this.data.setInt16(this.pointer, value, littleEndian); + this.pointer += 2; + } + /** + * @internal + */ + readUint32(littleEndian = true) { + const val = this.data.getUint32(this.pointer, littleEndian); + this.pointer += 4; + return val; + } + /** + * @internal + */ + writeUint32(value, littleEndian = true) { + this.data.setUint32(this.pointer, value, littleEndian); + this.pointer += 4; + } + /** + * @internal + */ + readInt32(littleEndian = true) { + const val = this.data.getInt32(this.pointer, littleEndian); + this.pointer += 4; + return val; + } + /** + * @internal + */ + writeInt32(value, littleEndian = true) { + this.data.setInt32(this.pointer, value, littleEndian); + this.pointer += 4; + } + /** + * @internal + */ + readBytes(count) { + const bytes = new Uint8Array(this.data.buffer, this.pointer, count); + this.pointer += bytes.byteLength; + return bytes; + } + /** + * @internal + */ + writeBytes(bytes) { + bytes.forEach((byte) => this.writeUint8(byte)); + } + /** + * @internal + */ + readHex(count, reverse = false) { + const bytes = this.readBytes(count); + return hexFromBytes(bytes, reverse); + } + /** + * @internal + */ + readChar() { + const char = this.readUint8(); + return String.fromCharCode(char); + } + /** + * @internal + */ + readWideChar() { + const char = this.readUint16(); + return String.fromCharCode(char); + } + /** + * @internal + */ + readChars(count) { + const chars = this.readBytes(count); + let str = ''; + for (let i = 0; i < chars.length; i++) { + const char = chars[i]; + if (char === 0) + break; + str += String.fromCharCode(char); + } + return str; + } + /** + * @internal + */ + writeChars(string) { + for (let i = 0; i < string.length; i++) { + const char = string.charCodeAt(i); + this.writeUint8(char); + } + } + /** + * @internal + */ + readWideChars(count) { + const chars = new Uint16Array(this.data.buffer, this.pointer, count); + let str = ''; + for (let i = 0; i < chars.length; i++) { + const char = chars[i]; + if (char == 0) + break; + str += String.fromCharCode(char); + } + this.pointer += chars.byteLength; + return str; + } + end() { + return this.pointer >= this.data.byteLength; + } + } + + /** + * Clamp a number n between l and h. + * @internal + */ + const clamp = (n, l, h) => { + if (n < l) + return l; + if (n > h) + return h; + return n; + }; + /** + * Interpolate between a and b - returns a if fac = 0, b if fac = 1, and somewhere between if 0 < fac < 1. + * @internal + */ + const lerp = (a, b, fac) => a + fac * (b - a); + + /** + * Assert condition is true. + * @internal + */ + function assert(condition, errMsg = 'Assert failed') { + if (!condition) + err(errMsg); + } + /** + * Assert that a numerical value is between upper and lower bounds. + * @internal + */ + const assertRange = (value, min, max, name = '') => assert(value >= min && value <= max, `flipnote.js error: ${name || 'value'} ${value} should be between ${min} and ${max}`); + /** + * Assert condition is true. + * @internal + */ + const err = (errMsg = 'Assert failed') => { + throw new Error('flipnote.js error: ' + errMsg); + }; + + /** + * Webpack tries to replace inline calls to require() with polyfills, + * but we don't want that, since we only use require to add extra features in NodeJs environments. + * + * Modified from: + * https://github.com/getsentry/sentry-javascript/blob/bd35d7364191ebed994fb132ff31031117c1823f/packages/utils/src/misc.ts#L9-L11 + * https://github.com/getsentry/sentry-javascript/blob/89bca28994a0eaab9bc784841872b12a1f4a875c/packages/hub/src/hub.ts#L340 + * @internal + */ + const dynamicRequire = (nodeModule, p) => { + try { + return nodeModule.require(p); + } + catch { + throw new Error(`Could not require(${p})`); + } + }; + /** + * Safely get global scope object. + * @internal + */ + const getGlobalObject = () => { + return isNode + ? global + : typeof window !== 'undefined' + ? window + : typeof self !== 'undefined' + ? self + : {}; + }; + /** + * Utils to find out information about the current code execution environment. + */ + /** + * Is the code running in a browser environment? + * @internal + */ + const isBrowser = typeof window !== 'undefined' + && typeof window.document !== 'undefined'; + /** + * Assert that the current environment should support browser APIs. + * @internal + */ + const assertBrowserEnv = () => assert(isBrowser, 'This feature is only available in browser environments'); + /** + * Is the code running in a Node environment? + * @internal + */ + const isNode = typeof process !== 'undefined' + && process.versions != null + && process.versions.node != null; + /** + * Assert that the current environment should support NodeJS APIs. + * @internal + */ + const assertNodeEnv = () => assert(isNode, 'This feature is only available in NodeJS environments'); + // TODO: Deno support? + /** + * Is the code running in a Web Worker environment? + * @internal + */ + const isWebWorker = typeof self === 'object' + && self.constructor + && self.constructor.name === 'DedicatedWorkerGlobalScope'; + + /** + * Gracefully handles a given Promise factory. + * + * Example: + * `const [ error, data ] = await until(() => asyncAction())` + * + * @internal + */ + const until = async (promise) => { + try { + const data = await promise().catch((error) => { + throw error; + }); + return [null, data]; + } + catch (error) { + return [error, null]; + } + }; + + /** + * @internal + */ + ((function () { + if (!isBrowser) { + return function () { }; + } + const a = document.createElement('a'); + return function (blob, filename) { + const url = window.URL.createObjectURL(blob); + a.href = url; + a.download = filename; + a.click(); + window.URL.revokeObjectURL(url); + }; + }))(); + + var _a$2; + /** + * Base Flipnote parser class + * + * This doesn't implement any parsing functionality itself, + * it just provides a consistent API for every format parser to implement. + * @group File Parser + */ + class BaseParser extends DataStream { + constructor() { + /** + * Static file format info + */ + super(...arguments); + /** + * Instance file format info + */ + /** + * Custom object tag + */ + this[_a$2] = 'Flipnote'; + /** + * Default formats used for {@link getTitle}. + * @group Meta + */ + this.titleFormats = { + COMMENT: 'Comment by $USERNAME', + FLIPNOTE: 'Flipnote by $USERNAME', + ICON: 'Folder icon' + }; + /** + * File audio track info, see {@link FlipnoteAudioTrackInfo}. + * @group Meta + */ + this.soundMeta = new Map(); + /** + * Animation frame global layer visibility. + * @group Image + */ + this.layerVisibility = { 1: true, 2: true, 3: true }; + /** + * (KWZ only) Indicates whether or not this file is a Flipnote Studio 3D folder icon. + * @group Meta + */ + this.isFolderIcon = false; + /** + * (KWZ only) Indicates whether or not this file is a handwritten comment from Flipnote Gallery World. + * @group Meta + */ + this.isComment = false; + /** + * (KWZ only) Indicates whether or not this Flipnote is a PPM to KWZ conversion from Flipnote Studio 3D's DSi Library service. + * @group Meta + */ + this.isDsiLibraryNote = false; + } + /** + * Get file default title - e.g. "Flipnote by Y", "Comment by X", etc. + * A format object can be passed for localization, where `$USERNAME` gets replaced by author name: + * ```js + * { + * COMMENT: 'Comment by $USERNAME', + * FLIPNOTE: 'Flipnote by $USERNAME', + * ICON: 'Folder icon' + * } + * ``` + * @group Meta + */ + getTitle(formats = this.titleFormats) { + if (this.isFolderIcon) + return formats.ICON; + const title = this.isComment ? formats.COMMENT : formats.FLIPNOTE; + return title.replace('$USERNAME', this.meta.current.username); + } + /** + * Returns the Flipnote title when casting a parser instance to a string. + * + * ```js + * const str = 'Title: ' + note; + * // str === 'Title: Flipnote by username' + * ``` + * @group Utility + */ + toString() { + return this.getTitle(); + } + /** + * Allows for frame index iteration when using the parser instance as a for..of iterator. + * + * ```js + * for (const frameIndex of note) { + * // do something with frameIndex... + * } + * ``` + * @group Utility + */ + *[(_a$2 = Symbol.toStringTag, Symbol.iterator)]() { + for (let i = 0; i < this.frameCount; i++) + yield i; + } + /** + * Get the pixels for a given frame layer, as palette indices + * + * :::tip + * Layer indices are not guaranteed to be sorted by 3D depth in KWZs, use [getFrameLayerOrder](/api/classes/ppmparser/#getframelayerorder) to get the correct sort order first. + * ::: + * + * :::tip + * If the visibility flag for this layer is turned off, the result will be empty + * ::: + * + * @group Image + */ + getLayerPixels(frameIndex, layerIndex, imageBuffer = new Uint8Array(this.imageWidth * this.imageHeight), depthStrength = 0, depthEye = exports.FlipnoteStereoscopicEye.Left) { + assertRange(frameIndex, 0, this.frameCount - 1, 'Frame index'); + assertRange(layerIndex, 0, this.numLayers - 1, 'Layer index'); + // palette + const palette = this.getFramePaletteIndices(frameIndex); + const palettePtr = layerIndex * this.numLayerColors; + // raw pixels + const layers = this.decodeFrame(frameIndex); + const layerBuffer = layers[layerIndex]; + const depth = Math.floor(this.getFrameLayerDepths(frameIndex)[layerIndex] * depthStrength); + const depthShift = ((depthEye == exports.FlipnoteStereoscopicEye.Left) ? -depth : depth); + // image dimensions and crop + const srcStride = this.srcWidth; + const dstStride = this.imageWidth; + const width = this.imageWidth; + const height = this.imageHeight; + const xOffs = this.imageOffsetX; + const yOffs = this.imageOffsetY; + // clear image buffer before writing + imageBuffer.fill(0); + // handle layer visibility by returning a blank image if the layer is invisible + if (!this.layerVisibility[layerIndex + 1]) + return imageBuffer; + // convert to palette indices and crop + for (let srcY = yOffs, dstY = 0; dstY < height; srcY++, dstY++) { + for (let srcX = xOffs, dstX = 0; dstX < width; srcX++, dstX++) { + const srcPtr = srcY * srcStride + srcX; + const dstPtr = dstY * dstStride + dstX + depthShift; + let pixel = layerBuffer[srcPtr]; + if (pixel !== 0) + imageBuffer[dstPtr] = palette[palettePtr + pixel]; + } + } + return imageBuffer; + } + /** + * Get the pixels for a given frame layer, as RGBA pixels + * + * :::tip + * Layer indices are not guaranteed to be sorted by 3D depth in KWZs, use [getFrameLayerOrder](/api/classes/ppmparser/#getframelayerorder) to get the correct sort order first. + * ::: + * + * :::tip + * If the visibility flag for this layer is turned off, the result will be empty + * ::: + * + * @group Image + */ + getLayerPixelsRgba(frameIndex, layerIndex, imageBuffer = new Uint32Array(this.imageWidth * this.imageHeight), paletteBuffer = new Uint32Array(16), depthStrength = 0, depthEye = exports.FlipnoteStereoscopicEye.Left) { + assertRange(frameIndex, 0, this.frameCount - 1, 'Frame index'); + assertRange(layerIndex, 0, this.numLayers - 1, 'Layer index'); + // palette + this.getFramePaletteUint32(frameIndex, paletteBuffer); + const palettePtr = layerIndex * this.numLayerColors; + // raw pixels + const layers = this.decodeFrame(frameIndex); + const layerBuffer = layers[layerIndex]; + // depths + const depth = Math.floor(this.getFrameLayerDepths(frameIndex)[layerIndex] * depthStrength); + const depthShift = ((depthEye == exports.FlipnoteStereoscopicEye.Left) ? -depth : depth); + // image dimensions and crop + const srcStride = this.srcWidth; + const dstStride = this.imageWidth; + const width = this.imageWidth - depth; + const height = this.imageHeight; + const xOffs = this.imageOffsetX; + const yOffs = this.imageOffsetY; + // clear image buffer before writing + imageBuffer.fill(0); + // handle layer visibility by returning a blank image if the layer is invisible + if (!this.layerVisibility[layerIndex + 1]) + return imageBuffer; + // convert to palette indices and crop + for (let srcY = yOffs, dstY = 0; dstY < height; srcY++, dstY++) { + for (let srcX = xOffs, dstX = 0; dstX < width; srcX++, dstX++) { + const srcPtr = srcY * srcStride + srcX; + const dstPtr = dstY * dstStride + dstX + depthShift; + let pixel = layerBuffer[srcPtr]; + if (pixel !== 0) + imageBuffer[dstPtr] = paletteBuffer[palettePtr + pixel]; + } + } + return imageBuffer; + } + /** + * Get the image for a given frame, as palette indices + * @group Image + */ + getFramePixels(frameIndex, imageBuffer = new Uint8Array(this.imageWidth * this.imageHeight), depthStrength = 0, depthEye = exports.FlipnoteStereoscopicEye.Left) { + // image dimensions and crop + const srcStride = this.srcWidth; + this.imageWidth; + const width = this.imageWidth; + const height = this.imageHeight; + const xOffs = this.imageOffsetX; + const yOffs = this.imageOffsetY; + // palette + const palette = this.getFramePaletteIndices(frameIndex); + // clear framebuffer with paper color + imageBuffer.fill(palette[0]); + // get layer info + decode into buffers + const layerOrder = this.getFrameLayerOrder(frameIndex); + const layerDepth = this.getFrameLayerDepths(frameIndex); + const layers = this.decodeFrame(frameIndex); + // merge layers into framebuffer + for (let i = 0; i < this.numLayers; i++) { + const layerIndex = layerOrder[i]; + const layerBuffer = layers[layerIndex]; + const palettePtr = layerIndex * this.numLayerColors; + const depth = Math.floor(layerDepth[layerIndex] * depthStrength); + const depthShift = ((depthEye == exports.FlipnoteStereoscopicEye.Left) ? -depth : depth); + // skip if layer is not visible + if (!this.layerVisibility[layerIndex + 1]) + continue; + // merge layer into rgb buffer + for (let srcY = yOffs, dstY = 0; dstY < height; srcY++, dstY++) { + for (let srcX = xOffs, dstX = 0; dstX < width; srcX++, dstX++) { + const srcPtr = srcY * srcStride + srcX; + const dstPtr = dstY * width + dstX + depthShift; + let pixel = layerBuffer[srcPtr]; + if (pixel !== 0) + imageBuffer[dstPtr] = palette[palettePtr + pixel]; + } + } + } + return imageBuffer; + } + /** + * Get the image for a given frame as an uint32 array of RGBA pixels + * @group Image + */ + getFramePixelsRgba(frameIndex, imageBuffer = new Uint32Array(this.imageWidth * this.imageHeight), paletteBuffer = new Uint32Array(16), depthStrength = 0, depthEye = exports.FlipnoteStereoscopicEye.Left) { + assertRange(frameIndex, 0, this.frameCount - 1, 'Frame index'); + // image dimensions and crop + const srcStride = this.srcWidth; + const dstStride = this.imageWidth; + const width = this.imageWidth; + const height = this.imageHeight; + const xOffs = this.imageOffsetX; + const yOffs = this.imageOffsetY; + // palette + this.getFramePaletteUint32(frameIndex, paletteBuffer); + // clear framebuffer with paper color + imageBuffer.fill(paletteBuffer[0]); + // get layer info + decode into buffers + const layerOrder = this.getFrameLayerOrder(frameIndex); + const layerDepth = this.getFrameLayerDepths(frameIndex); + const layers = this.decodeFrame(frameIndex); + // merge layers into framebuffer + for (let i = 0; i < this.numLayers; i++) { + const layerIndex = layerOrder[i]; + // skip if layer is not visible + if (!this.layerVisibility[layerIndex + 1]) + continue; + const layerBuffer = layers[layerIndex]; + const palettePtr = layerIndex * this.numLayerColors; + const depth = Math.floor(layerDepth[layerIndex] * depthStrength); + const depthShift = ((depthEye == exports.FlipnoteStereoscopicEye.Left) ? -depth : depth); + for (let srcY = yOffs, dstY = 0; srcY < height; srcY++, dstY++) { + for (let srcX = xOffs, dstX = 0; srcX < width; srcX++, dstX++) { + const srcPtr = srcY * srcStride + srcX; + const dstPtr = dstY * dstStride + dstX + depthShift; + let pixel = layerBuffer[srcPtr]; + if (pixel !== 0) + imageBuffer[dstPtr] = paletteBuffer[palettePtr + pixel]; + } + } + } + return imageBuffer; + } + /** + * Get the color palette for a given frame, as an uint32 array + * @group Image + */ + getFramePaletteUint32(frameIndex, paletteBuffer = new Uint32Array(16)) { + assertRange(frameIndex, 0, this.frameCount - 1, 'Frame index'); + const colors = this.getFramePalette(frameIndex); + paletteBuffer.fill(0); + colors.forEach(([r, g, b, a], i) => paletteBuffer[i] = (a << 24) | (b << 16) | (g << 8) | r); + return paletteBuffer; + } + /** + * Get the usage flags for a given track across every frame + * @returns an array of booleans for every frame, indicating whether the track is used on that frame + * @group Audio + */ + getSoundEffectFlagsForTrack(trackId) { + return this.getSoundEffectFlags().map(flags => flags[trackId]); + } + ; + /** + * Is a given track used on a given frame + * @group Audio + */ + isSoundEffectUsedOnFrame(trackId, frameIndex) { + assertRange(frameIndex, 0, this.frameCount - 1, 'Frame index'); + if (!this.soundEffectTracks.includes(trackId)) + return false; + return this.getFrameSoundEffectFlags(frameIndex)[trackId]; + } + /** + * Does an audio track exist in the Flipnote? + * @returns boolean + * @group Audio + */ + hasAudioTrack(trackId) { + return this.soundMeta.has(trackId) && this.soundMeta.get(trackId).length > 0; + } + } + + /** + * Match an FSID from Flipnote Studio + * e.g. 1440D700CEF78DA8 + * @internal + */ + const REGEX_PPM_FSID = /^[0159]{1}[0-9A-F]{6}0[0-9A-F]{8}$/; + /** + * @internal + * There are several known exceptions to the standard FSID format, all from Nintendo or Hatena developer/event accounts (mario, zelda 25th, etc). + * This list was compiled from data provided by the Flipnote Archive, so it can be considered comprehensive enough to match any Flipnote you may encounter. + */ + const PPM_FSID_SPECIAL_CASE = [ + '01FACA7A4367FC5F', + '03D6E959E2F9A42D', + '03F80445160587FA', + '04068426E1008915', + '092A3EC8199FD5D5', + '0B8D56BA1BD441B8', + '0E61C75C9B5AD90B', + '14E494E35A443235' + ]; + /** + * Indicates whether the input is a valid Flipnote Studio user ID + */ + const isPpmFsid = (fsid) => REGEX_PPM_FSID.test(fsid) || PPM_FSID_SPECIAL_CASE.includes(fsid); + /** + * Get the region for any valid Flipnote Studio user ID + */ + const getPpmFsidRegion = (fsid) => { + switch (fsid.charAt(0)) { + case '0': + case '1': + return exports.FlipnoteRegion.JPN; + case '5': + return exports.FlipnoteRegion.USA; + case '9': + return exports.FlipnoteRegion.EUR; + default: + return exports.FlipnoteRegion.UNKNOWN; + } + }; + + /** + * @internal + */ + const ADPCM_INDEX_TABLE_2BIT = new Int8Array([ + -1, 2, -1, 2 + ]); + /** + * @internal + */ + const ADPCM_INDEX_TABLE_4BIT = new Int8Array([ + -1, -1, -1, -1, 2, 4, 6, 8, + -1, -1, -1, -1, 2, 4, 6, 8 + ]); + /** + * @internal + */ + const ADPCM_STEP_TABLE = new Int16Array([ + 7, 8, 9, 10, 11, 12, 13, 14, 16, 17, + 19, 21, 23, 25, 28, 31, 34, 37, 41, 45, + 50, 55, 60, 66, 73, 80, 88, 97, 107, 118, + 130, 143, 157, 173, 190, 209, 230, 253, 279, 307, + 337, 371, 408, 449, 494, 544, 598, 658, 724, 796, + 876, 963, 1060, 1166, 1282, 1411, 1552, 1707, 1878, 2066, + 2272, 2499, 2749, 3024, 3327, 3660, 4026, 4428, 4871, 5358, + 5894, 6484, 7132, 7845, 8630, 9493, 10442, 11487, 12635, 13899, + 15289, 16818, 18500, 20350, 22385, 24623, 27086, 29794, 32767, 0 + ]); + /** + * @internal + */ + const pcmGetSample = (src, srcSize, srcPtr) => { + if (srcPtr < 0 || srcPtr >= srcSize) + return 0; + return src[srcPtr]; + }; + /** + * Zero-order hold (nearest neighbour) audio interpolation + * Credit to SimonTime for the original C version + * @internal + */ + const pcmResampleNearestNeighbour = (src, srcFreq, dstFreq) => { + const srcLength = src.length; + const srcDuration = srcLength / srcFreq; + const dstLength = srcDuration * dstFreq; + const dst = new Int16Array(dstLength); + const adjFreq = srcFreq / dstFreq; + for (let dstPtr = 0; dstPtr < dstLength; dstPtr++) + dst[dstPtr] = pcmGetSample(src, srcLength, Math.floor(dstPtr * adjFreq)); + return dst; + }; + /** + * Simple linear audio interpolation + * @internal + */ + const pcmResampleLinear = (src, srcFreq, dstFreq) => { + const srcLength = src.length; + const srcDuration = srcLength / srcFreq; + const dstLength = srcDuration * dstFreq; + const dst = new Int16Array(dstLength); + const adjFreq = srcFreq / dstFreq; + for (let dstPtr = 0, adj = 0, srcPtr = 0, weight = 0; dstPtr < dstLength; dstPtr++) { + adj = dstPtr * adjFreq; + srcPtr = Math.floor(adj); + weight = adj % 1; + dst[dstPtr] = lerp(pcmGetSample(src, srcLength, srcPtr), pcmGetSample(src, srcLength, srcPtr + 1), weight); + } + return dst; + }; + /** + * Get a ratio of how many audio samples hit the pcm_s16_le clipping bounds + * This can be used to detect corrupted audio + * @internal + */ + const pcmGetClippingRatio = (src) => { + const numSamples = src.length; + let numClippedSamples = 0; + for (let i = 0; i < numSamples; i++) { + const sample = src[i]; + if (sample <= -32768 || sample >= 32767) + numClippedSamples += 1; + } + return numClippedSamples / numSamples; + }; + /** + * Get the root mean square of a PCM track + * @internal + */ + const pcmGetRms = (src) => { + const numSamples = src.length; + let rms = 0; + for (let i = 0; i < numSamples; i++) { + rms += Math.pow(src[i], 2); + } + return Math.sqrt(rms / numSamples); + }; + + /** + * Number of seconds between the UNIX timestamp epoch (jan 1 1970) and the Nintendo timestamp epoch (jan 1 2000). + * @internal + */ + const NINTENDO_UNIX_EPOCH = 946684800; + /** + * Convert a Nintendo DS or 3DS timestamp integer to a JS Date object. + * @internal + */ + const dateFromNintendoTimestamp = (timestamp) => new Date((timestamp + NINTENDO_UNIX_EPOCH) * 1000); + /** + * Convert a JS Date to a Nintendo timestamp integer. + */ + const dateToNintendoTimestamp = (date) => Math.floor((date.getTime() / 1000) - NINTENDO_UNIX_EPOCH); + /** + * Get the duration (in seconds) of a number of frames running at a specified framerate. + * @internal + */ + const timeGetNoteDuration = (frameCount, framerate) => + // multiply and divide by 100 to get around floating precision issues + ((frameCount * 100) * (1 / framerate)) / 100; + + /** + * Same SubtleCrypto API is available in browser and node, but in node it isn't global + * @internal + */ + const SUBTLE_CRYPTO = (() => { + if (isBrowser || isWebWorker) { + const global = getGlobalObject(); + return (global.crypto || global.msCrypto).subtle; + } + else if (isNode) + return dynamicRequire(module, 'crypto').webcrypto.subtle; + })(); + /** + * Crypto algo used + * @internal + */ + const ALGORITHM = 'RSASSA-PKCS1-v1_5'; + /** + * @internal + */ + const rsaLoadPublicKey = async (pemKey, hashType) => { + // remove PEM header and footer + const lines = pemKey + .split('\n') + .filter(line => !line.startsWith('-----') && !line.endsWith('-----')) + .join(''); + // base64 decode + const keyPlaintext = atob(lines); + // convert to byte array + const keyBytes = new Uint8Array(keyPlaintext.length) + .map((_, i) => keyPlaintext.charCodeAt(i)); + // create crypto api key + return await SUBTLE_CRYPTO.importKey('spki', keyBytes.buffer, { + name: ALGORITHM, + hash: hashType, + }, false, ['verify']); + }; + /** + * @internal + */ + const rsaVerify = async (key, signature, data) => await SUBTLE_CRYPTO.verify(ALGORITHM, key, signature, data); + + var _PpmParser_instances, _PpmParser_layerBuffers, _PpmParser_soundFlags, _PpmParser_prevLayerBuffers, _PpmParser_lineEncodingBuffers, _PpmParser_prevDecodedFrame, _PpmParser_frameDataLength, _PpmParser_soundDataLength, _PpmParser_soundDataOffset, _PpmParser_frameOffsets, _PpmParser_decodeHeader, _PpmParser_readFilename, _PpmParser_decodeMeta, _PpmParser_decodeAnimationHeader, _PpmParser_decodeSoundHeader, _PpmParser_isKeyFrame, _PpmParser_pcmAudioMix, _a$1; + /** + * PPM framerates in frames per second, indexed by the in-app frame speed. + * Frame speed 0 is never normally used + */ + const PPM_FRAMERATES = [0.5, 0.5, 1, 2, 4, 6, 12, 20, 30]; + /** + * PPM frame color defines (red, green, blue, alpha) + */ + const PPM_PALETTE = { + WHITE: [0xff, 0xff, 0xff, 0xff], + BLACK: [0x0e, 0x0e, 0x0e, 0xff], + RED: [0xff, 0x2a, 0x2a, 0xff], + BLUE: [0x0a, 0x39, 0xff, 0xff] + }; + /** + * @internal + * PPM thumbnail color defines (in ABGR order) + */ + const PPM_THUMB_PALETTE = [ + 0xFFFFFFFF, + 0xFF525252, + 0xFFFFFFFF, + 0xFF9C9C9C, + 0xFF4448FF, + 0xFF4F51C8, + 0xFFACADFF, + 0xFF00FF00, + 0xFFFF4048, + 0xFFB84F51, + 0xFFFFABAD, + 0xFF00FF00, + 0xFFB757B6, + 0xFF00FF00, + 0xFF00FF00, + 0xFF00FF00, + ]; + /** + * @internal + */ + const PPM_FRAME_DATA_MAX_SIZE = 736800; + /** + * RSA public key used to verify that the PPM file signature is genuine. + * + * This **cannot** be used to resign Flipnotes, it can only verify that they are valid + */ + const PPM_PUBLIC_KEY = `-----BEGIN PUBLIC KEY----- +MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDCPLwTL6oSflv+gjywi/sM0TUB +90xqOvuCpjduETjPoN2FwMebxNjdKIqHUyDu4AvrQ6BDJc6gKUbZ1E27BGZoCPH4 +9zQRb+zAM6M9EjHwQ6BABr0u2TcF7xGg2uQ9MBWz9AfbVQ91NjfrNWo0f7UPmffv +1VvixmTk1BCtavZxBwIDAQAB +-----END PUBLIC KEY-----`; + /** + * Parser class for (DSiWare) Flipnote Studio's PPM animation format. Format docs: https://github.com/Flipnote-Collective/flipnote-studio-docs/wiki/PPM-format + * @group File Parser + */ + class PpmParser extends BaseParser { + static matchBuffer(buffer) { + // check the buffer's magic to identify which format it uses + const magicBytes = new Uint8Array(buffer.slice(0, 4)); + const magic = (magicBytes[0] << 24) | (magicBytes[1] << 16) | (magicBytes[2] << 8) | magicBytes[3]; + // check if magic is PARA (ppm magic) + return magic === 0x50415241; + } + /** + * Create a new PPM file parser instance + * @param arrayBuffer an ArrayBuffer containing file data + * @param settings parser settings (none currently implemented) + */ + constructor(arrayBuffer, settings = {}) { + super(arrayBuffer); + _PpmParser_instances.add(this); + /** + * File format type, reflects {@link PpmParser.format}. + * @group Meta + */ + this.format = exports.FlipnoteFormat.PPM; + /** + * Custom object tag. + * @group Utility + */ + this[_a$1] = 'Flipnote Studio PPM animation file'; + /** + * Animation frame width, reflects {@link PpmParser.width}. + * @group Image + */ + this.imageWidth = PpmParser.width; + /** + * Animation frame height, reflects {@link PpmParser.height}. + * @group Image + */ + this.imageHeight = PpmParser.height; + /** + * Animation frame aspect ratio, reflects {@link PpmParser.aspect}. + * @group Image + */ + this.aspect = PpmParser.aspect; + /** + * X offset for the top-left corner of the animation frame. + * @group Image + */ + this.imageOffsetX = 0; + /** + * Y offset for the top-left corner of the animation frame. + * @group Image + */ + this.imageOffsetY = 0; + /** + * Number of animation frame layers, reflects {@link PpmParser.numLayers}. + * @group Image + */ + this.numLayers = PpmParser.numLayers; + /** + * Number of colors per layer (aside from transparent), reflects {@link PpmParser.numLayerColors}. + * @group Image + */ + this.numLayerColors = PpmParser.numLayerColors; + /** + * Key used for Flipnote verification, in PEM format. + * @group Verification + */ + this.publicKey = PpmParser.publicKey; + /** + * @internal + * @group Image + */ + this.srcWidth = PpmParser.width; + /** + * Which audio tracks are available in this format, reflects {@link PpmParser.audioTracks}. + * @group Audio + */ + this.audioTracks = PpmParser.audioTracks; + /** + * Which sound effect tracks are available in this format, reflects {@link PpmParser.soundEffectTracks}. + * @group Audio + */ + this.soundEffectTracks = PpmParser.soundEffectTracks; + /** + * Audio track base sample rate, reflects {@link PpmParser.rawSampleRate}. + * @group Audio + */ + this.rawSampleRate = PpmParser.rawSampleRate; + /** + * Audio output sample rate, reflects {@link PpmParser.sampleRate}. + * @group Audio + */ + this.sampleRate = PpmParser.sampleRate; + /** + * Global animation frame color palette, reflects {@link PpmParser.globalPalette}. + * @group Image + */ + this.globalPalette = PpmParser.globalPalette; + _PpmParser_layerBuffers.set(this, void 0); + _PpmParser_soundFlags.set(this, void 0); + _PpmParser_prevLayerBuffers.set(this, void 0); + _PpmParser_lineEncodingBuffers.set(this, void 0); + _PpmParser_prevDecodedFrame.set(this, null); + _PpmParser_frameDataLength.set(this, void 0); + _PpmParser_soundDataLength.set(this, void 0); + _PpmParser_soundDataOffset.set(this, void 0); + _PpmParser_frameOffsets.set(this, void 0); + __classPrivateFieldGet(this, _PpmParser_instances, "m", _PpmParser_decodeHeader).call(this); + __classPrivateFieldGet(this, _PpmParser_instances, "m", _PpmParser_decodeAnimationHeader).call(this); + __classPrivateFieldGet(this, _PpmParser_instances, "m", _PpmParser_decodeSoundHeader).call(this); + // this is always true afaik, it's likely just a remnant from development + // doesn't hurt to be accurate though... + if (((this.version >> 4) & 0xf) !== 0) { + __classPrivateFieldGet(this, _PpmParser_instances, "m", _PpmParser_decodeMeta).call(this); + } + // create image buffers + __classPrivateFieldSet(this, _PpmParser_layerBuffers, [ + new Uint8Array(PpmParser.width * PpmParser.height), + new Uint8Array(PpmParser.width * PpmParser.height) + ], "f"); + __classPrivateFieldSet(this, _PpmParser_prevLayerBuffers, [ + new Uint8Array(PpmParser.width * PpmParser.height), + new Uint8Array(PpmParser.width * PpmParser.height) + ], "f"); + __classPrivateFieldSet(this, _PpmParser_lineEncodingBuffers, [ + new Uint8Array(PpmParser.height), + new Uint8Array(PpmParser.height) + ], "f"); + __classPrivateFieldSet(this, _PpmParser_prevDecodedFrame, null, "f"); + } + /** + * Decodes the thumbnail image embedded in the Flipnote. Will return a {@link FlipnoteThumbImage} containing raw RGBA data. + * + * Note: For most purposes, you should probably just decode the thumbnail frame to get a higher resolution image. + * @group Meta + */ + getThumbnailImage() { + this.seek(0xA0); + const data = this.readBytes(1536); + const pixels = new Uint32Array(64 * 48); + let ptr = 0; + for (let tileY = 0; tileY < 48; tileY += 8) { + for (let tileX = 0; tileX < 64; tileX += 8) { + for (let line = 0; line < 8; line += 1) { + for (let pixel = 0; pixel < 8; pixel += 2) { + const x = tileX + pixel; + const y = tileY + line; + pixels[y * 64 + x] = PPM_THUMB_PALETTE[data[ptr] & 0xF]; + pixels[y * 64 + x + 1] = PPM_THUMB_PALETTE[(data[ptr] << 4) & 0xF]; + } + } + } + } + return { + format: exports.FlipnoteThumbImageFormat.Rgba, + width: 64, + height: 48, + data: pixels.buffer + }; + } + /** + * Get the memory meter level for the Flipnote. + * This is a value between 0 and 1 indicating how "full" the Flipnote is, based on the size limit of Flipnote Studio. + * + * Values will never be below 0, but can be above 1 if the Flipnote is larger than the size limit - it is technically possible to exceed the size limit by one frame. + * + * @group Meta + */ + getMemoryMeterLevel() { + const level = __classPrivateFieldGet(this, _PpmParser_frameDataLength, "f") / PPM_FRAME_DATA_MAX_SIZE; + if (level < 0) + return 0; + // No upper limit; can technically be exceeded by the size of a single frame + return level; + } + /** + * Decode a frame, returning the raw pixel buffers for each layer + * @group Image + */ + decodeFrame(frameIndex) { + assertRange(frameIndex, 0, this.frameCount - 1, 'Frame index'); + // return existing layer buffers if no new frame has been decoded since the last call + if (__classPrivateFieldGet(this, _PpmParser_prevDecodedFrame, "f") === frameIndex) + return __classPrivateFieldGet(this, _PpmParser_layerBuffers, "f"); + // if necessary, decode previous frames until a keyframe is reached + if (__classPrivateFieldGet(this, _PpmParser_prevDecodedFrame, "f") !== frameIndex - 1 && (!__classPrivateFieldGet(this, _PpmParser_instances, "m", _PpmParser_isKeyFrame).call(this, frameIndex)) && frameIndex !== 0) + this.decodeFrame(frameIndex - 1); + __classPrivateFieldSet(this, _PpmParser_prevDecodedFrame, frameIndex, "f"); + // https://github.com/Flipnote-Collective/flipnote-studio-docs/wiki/PPM-format#animation-data + this.seek(__classPrivateFieldGet(this, _PpmParser_frameOffsets, "f")[frameIndex]); + const header = this.readUint8(); + const isKeyFrame = (header >> 7) & 0x1; + const isTranslated = (header >> 5) & 0x3; + // reset current layer buffers + __classPrivateFieldGet(this, _PpmParser_layerBuffers, "f")[0].fill(0); + __classPrivateFieldGet(this, _PpmParser_layerBuffers, "f")[1].fill(0); + let translateX = 0; + let translateY = 0; + if (isTranslated) { + translateX = this.readInt8(); + translateY = this.readInt8(); + } + // unpack line encodings for each layer + for (let layerIndex = 0; layerIndex < 2; layerIndex++) { + const lineEncodingBuffer = __classPrivateFieldGet(this, _PpmParser_lineEncodingBuffers, "f")[layerIndex]; + lineEncodingBuffer.fill(0); + for (let ptr = 0; ptr < lineEncodingBuffer.length;) { + let byte = this.readUint8(); + // the 4 lines in this byte are all empty (type 0) - skip + if (byte === 0) { + ptr += 4; + continue; + } + // unpack 4 line types from the current byte + lineEncodingBuffer[ptr++] = byte & 0x03; + lineEncodingBuffer[ptr++] = (byte >> 2) & 0x03; + lineEncodingBuffer[ptr++] = (byte >> 4) & 0x03; + lineEncodingBuffer[ptr++] = (byte >> 6) & 0x03; + } + } + // unpack layer bitmaps + for (let layerIndex = 0; layerIndex < 2; layerIndex++) { + const pixelBuffer = __classPrivateFieldGet(this, _PpmParser_layerBuffers, "f")[layerIndex]; + const lineEncodingBuffer = __classPrivateFieldGet(this, _PpmParser_lineEncodingBuffers, "f")[layerIndex]; + for (let y = 0; y < PpmParser.height; y++) { + let pixelBufferPtr = y * PpmParser.width; + const lineType = lineEncodingBuffer[y]; + switch (lineType) { + // line type 0 = blank line, decode nothing + case 0: + break; + // line type 1 = compressed bitmap line + case 1: + // read lineHeader as a big-endian int + var lineHeader = this.readUint32(false); + // loop through each bit in the line header + // shift line header to the left by 1 bit every iteration, + // so on the next loop cycle the next bit will be checked + // and if the line header equals 0, no more bits are set, + // the rest of the line is empty and can be skipped + for (; lineHeader !== 0; lineHeader <<= 1, pixelBufferPtr += 8) { + // if the bit is set, this 8-pix wide chunk is stored + // else we can just leave it blank and move on to the next chunk + if (lineHeader & 0x80000000) { + let chunk = this.readUint8(); + // unpack chunk bits + // the chunk if shifted right 1 bit on every loop + // if the chunk equals 0, no more bits are set, + // so the rest of the chunk is empty and can be skipped + for (let pixel = 0; chunk !== 0; pixel++, chunk >>= 1) + pixelBuffer[pixelBufferPtr + pixel] = chunk & 0x1; + } + } + break; + // line type 2 = compressed bitmap line like type 1, but all pixels are set to 1 first + case 2: + // line type 2 starts as an inverted line + pixelBuffer.fill(1, pixelBufferPtr, pixelBufferPtr + PpmParser.width); + // read lineHeader as a big-endian int + var lineHeader = this.readUint32(false); + // loop through each bit in the line header + // shift line header to the left by 1 bit every iteration, + // so on the next loop cycle the next bit will be checked + // and if the line header equals 0, no more bits are set, + // the rest of the line is empty and can be skipped + for (; lineHeader !== 0; lineHeader <<= 1, pixelBufferPtr += 8) { + // if the bit is set, this 8-pix wide chunk is stored + // else we can just leave it blank and move on to the next chunk + if (lineHeader & 0x80000000) { + let chunk = this.readUint8(); + // unpack chunk bits + for (let pixel = 0; pixel < 8; pixel++, chunk >>= 1) + pixelBuffer[pixelBufferPtr + pixel] = chunk & 0x1; + } + } + break; + // line type 3 = raw bitmap line + case 3: + for (let chunk = 0, i = 0; i < PpmParser.width; i++) { + if (i % 8 === 0) + chunk = this.readUint8(); + pixelBuffer[pixelBufferPtr++] = chunk & 0x1; + chunk >>= 1; + } + break; + } + } + } + // if the current frame is based on changes from the previous one, merge them by XORing their values + const layer1 = __classPrivateFieldGet(this, _PpmParser_layerBuffers, "f")[0]; + const layer2 = __classPrivateFieldGet(this, _PpmParser_layerBuffers, "f")[1]; + const layer1Prev = __classPrivateFieldGet(this, _PpmParser_prevLayerBuffers, "f")[0]; + const layer2Prev = __classPrivateFieldGet(this, _PpmParser_prevLayerBuffers, "f")[1]; + // fast diffing if the frame isn't translated + if (!isKeyFrame && translateX === 0 && translateY === 0) { + const size = PpmParser.height * PpmParser.width; + for (let i = 0; i < size; i++) { + layer1[i] ^= layer1Prev[i]; + layer2[i] ^= layer2Prev[i]; + } + } + // slower diffing if the frame is translated + else if (!isKeyFrame) { + const w = PpmParser.width; + const h = PpmParser.height; + const startX = Math.max(translateX, 0); + const startY = Math.max(translateY, 0); + const endX = Math.min(w + translateX, w); + const endY = Math.min(h + translateY, h); + const shift = translateY * w + translateX; + let dest, src; + // loop through each line + for (let y = startY; y < endY; y++) { + // loop through each pixel in the line + for (let x = startX; x < endX; x++) { + dest = y * w + x; + src = dest - shift; + // diff pixels with a binary XOR + layer1[dest] ^= layer1Prev[src]; + layer2[dest] ^= layer2Prev[src]; + } + } + } + // copy the current layer buffers to the previous ones + __classPrivateFieldGet(this, _PpmParser_prevLayerBuffers, "f")[0].set(__classPrivateFieldGet(this, _PpmParser_layerBuffers, "f")[0]); + __classPrivateFieldGet(this, _PpmParser_prevLayerBuffers, "f")[1].set(__classPrivateFieldGet(this, _PpmParser_layerBuffers, "f")[1]); + return __classPrivateFieldGet(this, _PpmParser_layerBuffers, "f"); + } + /** + * Get the color palette indices for a given frame. RGBA colors for these values can be indexed from {@link PpmParser.globalPalette} + + * Returns an array where: + * - index 0 is the paper color index + * - index 1 is the layer 1 color index + * - index 2 is the layer 2 color index + * @group Image + */ + getFramePaletteIndices(frameIndex) { + assertRange(frameIndex, 0, this.frameCount - 1, 'Frame index'); + this.seek(__classPrivateFieldGet(this, _PpmParser_frameOffsets, "f")[frameIndex]); + const header = this.readUint8(); + const isInverted = (header & 0x1) !== 1; + const penMap = [ + isInverted ? 0 : 1, // pen index 0 isn't used in normal cases + isInverted ? 0 : 1, + 2, + 3, + ]; + return [ + isInverted ? 1 : 0, // paper + penMap[(header >> 1) & 0x3], // layer 1 color + penMap[(header >> 3) & 0x3], // layer 2 color + ]; + } + /** + * Get the RGBA colors for a given frame + * + * Returns an array where: + * - index 0 is the paper color + * - index 1 is the layer 1 color + * - index 2 is the layer 2 color + * @group Image + */ + getFramePalette(frameIndex) { + assertRange(frameIndex, 0, this.frameCount - 1, 'Frame index'); + const indices = this.getFramePaletteIndices(frameIndex); + return indices.map(colorIndex => this.globalPalette[colorIndex]); + } + /** + * Determines if a given frame is a video key frame or not. This returns an array of booleans for each layer, since in the KWZ format, keyframe encoding is done on a per-layer basis. + * @param frameIndex + * @group Image + */ + getIsKeyFrame(frameIndex) { + const flag = __classPrivateFieldGet(this, _PpmParser_instances, "m", _PpmParser_isKeyFrame).call(this, frameIndex) === 1; + return [flag, flag]; + } + /** + * Get the 3D depths for each layer in a given frame. The PPM format doesn't actually store this information, so `0` is returned for both layers. This method is only here for consistency with KWZ. + * @param frameIndex + * @group Image + */ + getFrameLayerDepths(frameIndex) { + return [0, 0]; + } + /** + * Get the FSID for a given frame's original author. The PPM format doesn't actually store this information, so the current author FSID is returned. This method is only here for consistency with KWZ. + * @param frameIndex + * @group Meta + */ + getFrameAuthor(frameIndex) { + return this.meta.current.fsid; + } + /** + * Get the camera flags for a given frame. The PPM format doesn't actually store this information so `false` will be returned for both layers. This method is only here for consistency with KWZ. + * @group Image + * @returns Array of booleans, indicating whether each layer uses a photo or not + */ + getFrameCameraFlags(frameIndex) { + return [false, false]; + } + /** + * Get the layer draw order for a given frame + * @group Image + * @returns Array of layer indices, in the order they should be drawn + */ + getFrameLayerOrder(frameIndex) { + return [1, 0]; + } + /** + * Get the sound effect flags for every frame in the Flipnote + * @group Audio + */ + decodeSoundFlags() { + if (__classPrivateFieldGet(this, _PpmParser_soundFlags, "f") !== undefined) + return __classPrivateFieldGet(this, _PpmParser_soundFlags, "f"); + assert(0x06A0 + __classPrivateFieldGet(this, _PpmParser_frameDataLength, "f") < this.numBytes); + // https://github.com/Flipnote-Collective/flipnote-studio-docs/wiki/PPM-format#sound-effect-flags + this.seek(0x06A0 + __classPrivateFieldGet(this, _PpmParser_frameDataLength, "f")); + const numFlags = this.frameCount; + const flags = this.readBytes(numFlags); + __classPrivateFieldSet(this, _PpmParser_soundFlags, new Array(numFlags), "f"); + for (let i = 0; i < numFlags; i++) { + const byte = flags[i]; + __classPrivateFieldGet(this, _PpmParser_soundFlags, "f")[i] = [ + (byte & 0x1) !== 0, // SE1 bitflag + (byte & 0x2) !== 0, // SE2 bitflag + (byte & 0x4) !== 0, // SE3 bitflag + ]; + } + return __classPrivateFieldGet(this, _PpmParser_soundFlags, "f"); + } + /** + * Get the sound effect usage flags for every frame + * @group Audio + */ + getSoundEffectFlags() { + return this.decodeSoundFlags().map(frameFlags => ({ + [exports.FlipnoteSoundEffectTrack.SE1]: frameFlags[0], + [exports.FlipnoteSoundEffectTrack.SE2]: frameFlags[1], + [exports.FlipnoteSoundEffectTrack.SE3]: frameFlags[2], + [exports.FlipnoteSoundEffectTrack.SE4]: false + })); + } + /** + * Get the sound effect usage flags for a given frame + * @group Audio + */ + getFrameSoundEffectFlags(frameIndex) { + assertRange(frameIndex, 0, this.frameCount - 1, 'Frame index'); + this.seek(0x06A0 + __classPrivateFieldGet(this, _PpmParser_frameDataLength, "f") + frameIndex); + const byte = this.readUint8(); + return { + [exports.FlipnoteSoundEffectTrack.SE1]: (byte & 0x1) !== 0, + [exports.FlipnoteSoundEffectTrack.SE2]: (byte & 0x2) !== 0, + [exports.FlipnoteSoundEffectTrack.SE3]: (byte & 0x4) !== 0, + [exports.FlipnoteSoundEffectTrack.SE4]: false + }; + } + /** + * Get the duration of a given track in seconds + * @returns number + * @group Audio + */ + getAudioTrackDuration(trackId) { + const trackMeta = this.soundMeta.get(trackId); + if (trackMeta.length === 0) + return 0; + if (trackId === exports.FlipnoteAudioTrack.BGM) { + const bgmAdjust = (1 / this.bgmrate) / (1 / this.framerate); + const freq = this.rawSampleRate * bgmAdjust; + return ((trackMeta.length - 4) * 2) / freq; + } + return ((trackMeta.length - 4) * 2) / this.sampleRate; + } + /** + * Get the raw compressed audio data for a given track + * @returns byte array + * @group Audio + */ + getAudioTrackRaw(trackId) { + const trackMeta = this.soundMeta.get(trackId); + assert(trackMeta.ptr + trackMeta.length < this.numBytes); + this.seek(trackMeta.ptr); + return this.readBytes(trackMeta.length); + } + /** + * Get the decoded audio data for a given track, using the track's native samplerate + * @returns Signed 16-bit PCM audio + * @group Audio + */ + decodeAudioTrack(trackId) { + // note this doesn't resample + // decode a 4 bit IMA adpcm audio track + // https://github.com/Flipnote-Collective/flipnote-studio-docs/wiki/PPM-format#sound-data + const trackMeta = this.soundMeta.get(trackId); + assert(trackMeta.ptr + trackMeta.length < this.numBytes); + this.seek(trackMeta.ptr); + let srcPtr = 0; + let dstPtr = 0; + let sample = 0; + let predictor = this.readInt16(); + let stepIndex = this.readUint8(); + this.readUint8(); + const srcSize = trackMeta.length - 4; + const src = this.readBytes(srcSize); + const dst = new Int16Array(srcSize * 2); + let lowNibble = true; + while (srcPtr < srcSize) { + // switch between high and low nibble each loop iteration + // increments srcPtr after every high nibble + if (lowNibble) + sample = src[srcPtr] & 0xF; + else + sample = src[srcPtr++] >> 4; + lowNibble = !lowNibble; + const step = ADPCM_STEP_TABLE[stepIndex]; + let diff = step >> 3; + if (sample & 1) + diff += step >> 2; + if (sample & 2) + diff += step >> 1; + if (sample & 4) + diff += step; + if (sample & 8) + diff = -diff; + predictor += diff; + predictor = clamp(predictor, -32768, 32767); + stepIndex += ADPCM_INDEX_TABLE_4BIT[sample]; + stepIndex = clamp(stepIndex, 0, 88); + dst[dstPtr++] = predictor; + } + return dst; + } + /** + * Get the decoded audio data for a given track, using the specified samplerate + * @returns Signed 16-bit PCM audio + * @group Audio + */ + getAudioTrackPcm(trackId, dstFreq = this.sampleRate) { + const srcPcm = this.decodeAudioTrack(trackId); + let srcFreq = this.rawSampleRate; + if (trackId === exports.FlipnoteAudioTrack.BGM) { + const bgmAdjust = (1 / this.bgmrate) / (1 / this.framerate); + srcFreq = this.rawSampleRate * bgmAdjust; + } + if (srcFreq !== dstFreq) + return pcmResampleNearestNeighbour(srcPcm, srcFreq, dstFreq); + return srcPcm; + } + /** + * Get the full mixed audio for the Flipnote, using the specified samplerate + * @returns Signed 16-bit PCM audio + * @group Audio + */ + getAudioMasterPcm(dstFreq = this.sampleRate) { + const dstSize = Math.ceil(this.duration * dstFreq); + const master = new Int16Array(dstSize); + const hasBgm = this.hasAudioTrack(exports.FlipnoteAudioTrack.BGM); + const hasSe1 = this.hasAudioTrack(exports.FlipnoteAudioTrack.SE1); + const hasSe2 = this.hasAudioTrack(exports.FlipnoteAudioTrack.SE2); + const hasSe3 = this.hasAudioTrack(exports.FlipnoteAudioTrack.SE3); + // Mix background music + if (hasBgm) { + const bgmPcm = this.getAudioTrackPcm(exports.FlipnoteAudioTrack.BGM, dstFreq); + __classPrivateFieldGet(this, _PpmParser_instances, "m", _PpmParser_pcmAudioMix).call(this, bgmPcm, master, 0); + } + // Mix sound effects + if (hasSe1 || hasSe2 || hasSe3) { + const samplesPerFrame = dstFreq / this.framerate; + const se1Pcm = hasSe1 ? this.getAudioTrackPcm(exports.FlipnoteAudioTrack.SE1, dstFreq) : null; + const se2Pcm = hasSe2 ? this.getAudioTrackPcm(exports.FlipnoteAudioTrack.SE2, dstFreq) : null; + const se3Pcm = hasSe3 ? this.getAudioTrackPcm(exports.FlipnoteAudioTrack.SE3, dstFreq) : null; + const seFlags = this.decodeSoundFlags(); + for (let frame = 0; frame < this.frameCount; frame++) { + const seOffset = Math.ceil(frame * samplesPerFrame); + const flag = seFlags[frame]; + if (hasSe1 && flag[0]) + __classPrivateFieldGet(this, _PpmParser_instances, "m", _PpmParser_pcmAudioMix).call(this, se1Pcm, master, seOffset); + if (hasSe2 && flag[1]) + __classPrivateFieldGet(this, _PpmParser_instances, "m", _PpmParser_pcmAudioMix).call(this, se2Pcm, master, seOffset); + if (hasSe3 && flag[2]) + __classPrivateFieldGet(this, _PpmParser_instances, "m", _PpmParser_pcmAudioMix).call(this, se3Pcm, master, seOffset); + } + } + this.audioClipRatio = pcmGetClippingRatio(master); + return master; + } + /** + * Get the body of the Flipnote - the data that is digested for the signature + * @group Verification + */ + getBody() { + const bodyEnd = __classPrivateFieldGet(this, _PpmParser_soundDataOffset, "f") + __classPrivateFieldGet(this, _PpmParser_soundDataLength, "f") + 32; + return this.bytes.subarray(0, bodyEnd); + } + /** + * Get the Flipnote's signature data + * @group Verification + */ + getSignature() { + const bodyEnd = __classPrivateFieldGet(this, _PpmParser_soundDataOffset, "f") + __classPrivateFieldGet(this, _PpmParser_soundDataLength, "f") + 32; + return this.bytes.subarray(bodyEnd, bodyEnd + 128); + } + /** + * Verify whether this Flipnote's signature is valid + * @group Verification + */ + async verify() { + const key = await rsaLoadPublicKey(PPM_PUBLIC_KEY, 'SHA-1'); + return await rsaVerify(key, this.getSignature(), this.getBody()); + } + } + _PpmParser_layerBuffers = new WeakMap(), _PpmParser_soundFlags = new WeakMap(), _PpmParser_prevLayerBuffers = new WeakMap(), _PpmParser_lineEncodingBuffers = new WeakMap(), _PpmParser_prevDecodedFrame = new WeakMap(), _PpmParser_frameDataLength = new WeakMap(), _PpmParser_soundDataLength = new WeakMap(), _PpmParser_soundDataOffset = new WeakMap(), _PpmParser_frameOffsets = new WeakMap(), _PpmParser_instances = new WeakSet(), _a$1 = Symbol.toStringTag, _PpmParser_decodeHeader = function _PpmParser_decodeHeader() { + assert(16 < this.numBytes); + this.seek(4); + // decode header + // https://github.com/Flipnote-Collective/flipnote-studio-docs/wiki/PPM-format#header + __classPrivateFieldSet(this, _PpmParser_frameDataLength, this.readUint32(), "f"); + __classPrivateFieldSet(this, _PpmParser_soundDataLength, this.readUint32(), "f"); + this.frameCount = this.readUint16() + 1; + this.version = this.readUint16(); + // sound data offset = frame data offset + frame data length + sound effect flags + let soundDataOffset = 0x06A0 + __classPrivateFieldGet(this, _PpmParser_frameDataLength, "f") + this.frameCount; + if (soundDataOffset % 4 !== 0) + soundDataOffset += 4 - (soundDataOffset % 4); + assert(soundDataOffset < this.numBytes); + __classPrivateFieldSet(this, _PpmParser_soundDataOffset, soundDataOffset, "f"); + }, _PpmParser_readFilename = function _PpmParser_readFilename() { + const mac = this.readHex(3); + const random = this.readChars(13); + const edits = this.readUint16().toString().padStart(3, '0'); + return `${mac}_${random}_${edits}`; + }, _PpmParser_decodeMeta = function _PpmParser_decodeMeta() { + // https://github.com/Flipnote-Collective/flipnote-studio-docs/wiki/PPM-format#metadata + assert(0x06A8 < this.numBytes); + this.seek(0x10); + const lock = this.readUint16(); + const thumbIndex = this.readInt16(); + const rootAuthorName = this.readWideChars(11); + const parentAuthorName = this.readWideChars(11); + const currentAuthorName = this.readWideChars(11); + const parentAuthorId = this.readHex(8, true); + const currentAuthorId = this.readHex(8, true); + const parentFilename = __classPrivateFieldGet(this, _PpmParser_instances, "m", _PpmParser_readFilename).call(this); + const currentFilename = __classPrivateFieldGet(this, _PpmParser_instances, "m", _PpmParser_readFilename).call(this); + const rootAuthorId = this.readHex(8, true); + this.seek(0x9A); + const timestamp = dateFromNintendoTimestamp(this.readInt32()); + this.seek(0x06A6); + const flags = this.readUint16(); + this.thumbFrameIndex = thumbIndex; + this.layerVisibility = { + 1: (flags & 0x10) === 0, + 2: (flags & 0x20) === 0, + 3: false + }; + this.isSpinoff = (currentAuthorId !== parentAuthorId) || (currentAuthorId !== rootAuthorId); + this.meta = { + lock: lock === 1, + loop: (flags >> 1 & 0x1) === 1, + advancedTools: undefined, + isSpinoff: this.isSpinoff, + frameCount: this.frameCount, + frameSpeed: this.frameSpeed, + bgmSpeed: this.bgmSpeed, + duration: this.duration, + thumbIndex: thumbIndex, + timestamp: timestamp, + root: { + username: rootAuthorName, + fsid: rootAuthorId, + region: getPpmFsidRegion(rootAuthorId), + filename: null + }, + parent: { + username: parentAuthorName, + fsid: parentAuthorId, + region: getPpmFsidRegion(parentAuthorId), + filename: parentFilename + }, + current: { + username: currentAuthorName, + fsid: currentAuthorId, + region: getPpmFsidRegion(currentAuthorId), + filename: currentFilename + }, + }; + }, _PpmParser_decodeAnimationHeader = function _PpmParser_decodeAnimationHeader() { + // jump to the start of the animation data section + // https://github.com/Flipnote-Collective/flipnote-studio-docs/wiki/PPM-format#animation-header + this.seek(0x06A0); + const offsetTableLength = this.readUint16(); + const numOffsets = offsetTableLength / 4; + assert(numOffsets <= this.frameCount); + // skip padding + flags + this.seek(0x06A8); + // read frame offsets and build them into a table + const frameOffsets = new Uint32Array(numOffsets); + for (let n = 0; n < numOffsets; n++) { + const ptr = 0x06A8 + offsetTableLength + this.readUint32(); + assert(ptr < this.numBytes, `Frame ${n} pointer is out of bounds`); + frameOffsets[n] = ptr; + } + __classPrivateFieldSet(this, _PpmParser_frameOffsets, frameOffsets, "f"); + }, _PpmParser_decodeSoundHeader = function _PpmParser_decodeSoundHeader() { + // https://github.com/Flipnote-Collective/flipnote-studio-docs/wiki/PPM-format#sound-header + let ptr = __classPrivateFieldGet(this, _PpmParser_soundDataOffset, "f"); + this.seek(ptr); + const bgmLen = this.readUint32(); + const se1Len = this.readUint32(); + const se2Len = this.readUint32(); + const se3Len = this.readUint32(); + this.frameSpeed = 8 - this.readUint8(); + this.bgmSpeed = 8 - this.readUint8(); + assert(this.frameSpeed <= 8 && this.bgmSpeed <= 8); + ptr += 32; + this.framerate = PPM_FRAMERATES[this.frameSpeed]; + this.duration = timeGetNoteDuration(this.frameCount, this.framerate); + this.bgmrate = PPM_FRAMERATES[this.bgmSpeed]; + const soundMeta = new Map(); + soundMeta.set(exports.FlipnoteAudioTrack.BGM, { ptr: ptr, length: bgmLen }); + soundMeta.set(exports.FlipnoteAudioTrack.SE1, { ptr: ptr += bgmLen, length: se1Len }); + soundMeta.set(exports.FlipnoteAudioTrack.SE2, { ptr: ptr += se1Len, length: se2Len }); + soundMeta.set(exports.FlipnoteAudioTrack.SE3, { ptr: ptr += se2Len, length: se3Len }); + this.soundMeta = soundMeta; + }, _PpmParser_isKeyFrame = function _PpmParser_isKeyFrame(frameIndex) { + assertRange(frameIndex, 0, this.frameCount - 1, 'Frame index'); + this.seek(__classPrivateFieldGet(this, _PpmParser_frameOffsets, "f")[frameIndex]); + const header = this.readUint8(); + return (header >> 7) & 0x1; + }, _PpmParser_pcmAudioMix = function _PpmParser_pcmAudioMix(src, dst, dstOffset = 0) { + const srcSize = src.length; + const dstSize = dst.length; + for (let n = 0; n < srcSize; n++) { + if (dstOffset + n > dstSize) + break; + // half src volume + const samp = dst[dstOffset + n] + (src[n] / 2); + dst[dstOffset + n] = clamp(samp, -32768, 32767); + } + }; + /** + * Default PPM parser settings. + */ + PpmParser.defaultSettings = {}; + /** + * File format type. + */ + PpmParser.format = exports.FlipnoteFormat.PPM; + /** + * Animation frame width. + */ + PpmParser.width = 256; + /** + * Animation frame height. + */ + PpmParser.height = 192; + /** + * Animation frame aspect ratio. + */ + PpmParser.aspect = 3 / 4; + /** + * Number of animation frame layers. + */ + PpmParser.numLayers = 2; + /** + * Number of colors per layer (aside from transparent). + */ + PpmParser.numLayerColors = 1; + /** + * Audio track base sample rate. + */ + PpmParser.rawSampleRate = 8180; + /** + * Nintendo DSi audio output rate. + */ + PpmParser.sampleRate = 32768; // Maybe actually more like 32720; + /** + * Which audio tracks are available in this format. + */ + PpmParser.audioTracks = [ + exports.FlipnoteAudioTrack.BGM, + exports.FlipnoteAudioTrack.SE1, + exports.FlipnoteAudioTrack.SE2, + exports.FlipnoteAudioTrack.SE3 + ]; + /** + * Which sound effect tracks are available in this format. + */ + PpmParser.soundEffectTracks = [ + exports.FlipnoteSoundEffectTrack.SE1, + exports.FlipnoteSoundEffectTrack.SE2, + exports.FlipnoteSoundEffectTrack.SE3, + ]; + /** + * Global animation frame color palette. + */ + PpmParser.globalPalette = [ + PPM_PALETTE.WHITE, + PPM_PALETTE.BLACK, + PPM_PALETTE.RED, + PPM_PALETTE.BLUE + ]; + /** + * Public key used for Flipnote verification, in PEM format. + */ + PpmParser.publicKey = PPM_PUBLIC_KEY; + + /** + * Match an FSID from Flipnote Studio 3D + * e.g. 003f-0b7e-82a6-fe0bda + * @internal + */ + const REGEX_KWZ_FSID = /^[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{6}$/; + /** + * Match an FSID from a DSi Library note (PPM to KWZ conversion) + * e.g. 10b8-b909-5180-9b2013 + * @internal + */ + const REGEX_KWZ_DSI_LIBRARY_FSID = /^(00|10|12|14)[0-9a-f]{2}-[0-9a-f]{4}-[0-9a-f]{3}0-[0-9a-f]{4}[0159]{1}[0-9a-f]{1}$/; + /** + * KWZ equivalents of PPM_FSID_SPECIAL_CASE + * @internal + */ + const KWZ_DSI_LIBRARY_FSID_SPECIAL_CASE_SUFFIX = [ + '5f-fc67-437a-cafa01', + '2d-a4f9-e259-e9d603', + 'fa-8705-1645-04f803', + '15-8900-e126-840604', + 'd5-d59f-19c8-3e2a09', + 'b8-41d4-1bba-568d0b', + '0b-d95a-9b5c-c7610e', + '35-3244-5ae3-94e414', + ]; + /** + * Indicates whether the input is a valid Flipnote Studio 3D user ID + */ + const isKwzFsid = (fsid) => REGEX_KWZ_FSID.test(fsid); + /** + * Indicates whether the input is a valid DSi Library user ID + */ + const isKwzDsiLibraryFsid = (fsid) => { + if (REGEX_KWZ_DSI_LIBRARY_FSID.test(fsid)) + return true; + for (let suffix of KWZ_DSI_LIBRARY_FSID_SPECIAL_CASE_SUFFIX) { + if (fsid.endsWith(suffix)) + return true; + } + return false; + }; + /** + * Get the region for any valid Flipnote Studio 3D user ID. + * + * :::tip + * This may be incorrect for IDs that are not from the DSi Library. + * ::: + */ + const getKwzFsidRegion = (fsid) => { + if (isKwzDsiLibraryFsid(fsid)) { + switch (fsid.charAt(19)) { + case '0': + case '1': + return exports.FlipnoteRegion.JPN; + case '5': + return exports.FlipnoteRegion.USA; + case '9': + return exports.FlipnoteRegion.EUR; + default: + return exports.FlipnoteRegion.UNKNOWN; + } + } + return exports.FlipnoteRegion.UNKNOWN; + }; + /** + * Format a hex string with dashes, to match the format used to display Flipnote Studio IDs in the app. + */ + const kwzFsidFormat = (hex) => { + return `${hex.slice(0, 4)}-${hex.slice(4, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 18)}`.toLowerCase(); + }; + /** + * Unformat a Flipnote Studio ID string back into regular hex. + */ + const kwzFsidUnformat = (fsid) => { + return fsid.replace(/-/g, '').toUpperCase(); + }; + + var _KwzParser_instances, _KwzParser_settings, _KwzParser_sectionMap, _KwzParser_bodyEndOffset, _KwzParser_layerBuffers, _KwzParser_soundFlags, _KwzParser_prevDecodedFrame, _KwzParser_frameMetaOffsets, _KwzParser_frameDataOffsets, _KwzParser_frameLayerSizes, _KwzParser_frameDataTotalSize, _KwzParser_bitIndex, _KwzParser_bitValue, _KwzParser_buildSectionMap, _KwzParser_readBits, _KwzParser_readFsid, _KwzParser_readFilename, _KwzParser_decodeMeta, _KwzParser_decodeMetaQuick, _KwzParser_getFrameOffsets, _KwzParser_decodeSoundHeader, _a; + /** + * KWZ framerates in frames per second, indexed by the in-app frame speed + */ + const KWZ_FRAMERATES = [.2, .5, 1, 2, 4, 6, 8, 12, 20, 24, 30]; + /** + * KWZ color defines (red, green, blue, alpha) + */ + const KWZ_PALETTE = { + WHITE: [0xff, 0xff, 0xff, 0xff], + BLACK: [0x10, 0x10, 0x10, 0xff], + RED: [0xff, 0x10, 0x10, 0xff], + YELLOW: [0xff, 0xe7, 0x00, 0xff], + GREEN: [0x00, 0x86, 0x31, 0xff], + BLUE: [0x00, 0x38, 0xce, 0xff], + NONE: [0xff, 0xff, 0xff, 0x00] + }; + /** + * RSA public key used to verify that the KWZ file signature is genuine. + * + * This **cannot** be used to resign Flipnotes, it can only verify that they are valid + */ + const KWZ_PUBLIC_KEY = `-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuv+zHAXXvbbtRqxADDeJ +ArX2b9RMxj3T+qpRg3FnIE/jeU3tj7eoDzsMduY+D/UT9CSnP+QHYY/vf0n5lqX9 +s6ljoZAmyUuruyj1e5Bg+fkDEu/yPEPQjqhbyywCyYL4TEAOJveopUBx9fdQxUJ6 +J4J5oCE/Im1kFrlGW+puARiHmt3mmUyNzO8bI/Jx3cGSfoOHJG1foEaQsI5aaKqA +pBqxtzvwqMhudcZtAWSyRMBMlndvkRnVTDNTfTXLOYdHShCIgnKULCTH87uLBIP/ +nsmr4/bnQz8q2rp/HyVO+0yjR6mVr0NX5APJQ+6riJmGg3t3VOldhKP7aTHDUW+h +kQIDAQAB +-----END PUBLIC KEY-----`; + /** + * Pre computed bitmasks for readBits; done as a slight optimization + * @internal + */ + const BITMASKS = new Uint16Array(16); + for (let i = 0; i < 16; i++) { + BITMASKS[i] = (1 << i) - 1; + } + /** + * Every possible sequence of pixels for each 8-pixel line + * @internal + */ + const KWZ_LINE_TABLE = new Uint8Array(6561 * 8); + /** + * Same lines as KWZ_LINE_TABLE, but the pixels are shift-rotated to the left by one place + * @internal + */ + const KWZ_LINE_TABLE_SHIFT = new Uint8Array(6561 * 8); + /** + * @internal + */ + var offset = 0; + for (let a = 0; a < 3; a++) + for (let b = 0; b < 3; b++) + for (let c = 0; c < 3; c++) + for (let d = 0; d < 3; d++) + for (let e = 0; e < 3; e++) + for (let f = 0; f < 3; f++) + for (let g = 0; g < 3; g++) + for (let h = 0; h < 3; h++) { + KWZ_LINE_TABLE.set([b, a, d, c, f, e, h, g], offset); + KWZ_LINE_TABLE_SHIFT.set([a, d, c, f, e, h, g, b], offset); + offset += 8; + } + /** + * Commonly used lines - represents lines where all the pixels are empty, full, + * or include a pattern produced by the paint tool, etc + * @internal + */ + const KWZ_LINE_TABLE_COMMON = new Uint8Array(32 * 8); + /** + * Same lines as common line table, but shift-rotates one place to the left + * @internal + */ + const KWZ_LINE_TABLE_COMMON_SHIFT = new Uint8Array(32 * 8); + [ + 0x0000, 0x0CD0, 0x19A0, 0x02D9, 0x088B, 0x0051, 0x00F3, 0x0009, + 0x001B, 0x0001, 0x0003, 0x05B2, 0x1116, 0x00A2, 0x01E6, 0x0012, + 0x0036, 0x0002, 0x0006, 0x0B64, 0x08DC, 0x0144, 0x00FC, 0x0024, + 0x001C, 0x0004, 0x0334, 0x099C, 0x0668, 0x1338, 0x1004, 0x166C + ].forEach((value, i) => { + const lineTablePtr = value * 8; + const pixels = KWZ_LINE_TABLE.subarray(lineTablePtr, lineTablePtr + 8); + const shiftPixels = KWZ_LINE_TABLE_SHIFT.subarray(lineTablePtr, lineTablePtr + 8); + KWZ_LINE_TABLE_COMMON.set(pixels, i * 8); + KWZ_LINE_TABLE_COMMON_SHIFT.set(shiftPixels, i * 8); + }); + /** + * Parser class for Flipnote Studio 3D's KWZ animation format. KWZ format docs: https://github.com/Flipnote-Collective/flipnote-studio-3d-docs/wiki/KWZ-Format + * @group File Parser + */ + class KwzParser extends BaseParser { + static matchBuffer(buffer) { + // check the buffer's magic to identify which format it uses + const magicBytes = new Uint8Array(buffer.slice(0, 4)); + const magic = (magicBytes[0] << 24) | (magicBytes[1] << 16) | (magicBytes[2] << 8); + // check if magic is KFH (kwz magic) or KIC (fs3d folder icon) + return magic === 0x4B464800 || magic === 0x4B494300; + } + /** + * Create a new KWZ file parser instance + * @param arrayBuffer an ArrayBuffer containing file data + * @param settings parser settings + */ + constructor(arrayBuffer, settings = {}) { + super(arrayBuffer); + _KwzParser_instances.add(this); + /** + * File format type, reflects {@link KwzParser.format} + */ + this.format = exports.FlipnoteFormat.KWZ; + /** + * Custom object tag + */ + this[_a] = 'Flipnote Studio 3D KWZ animation file'; + /** + * Animation frame width, reflects {@link KwzParser.width} + */ + this.imageWidth = KwzParser.width; + /** + * Animation frame height, reflects {@link KwzParser.height} + */ + this.imageHeight = KwzParser.height; + /** + * Animation frame aspect ratio, reflects {@link KwzParser.aspect} + */ + this.aspect = KwzParser.aspect; + /** + * X offset for the top-left corner of the animation frame + */ + this.imageOffsetX = 0; + /** + * Y offset for the top-left corner of the animation frame + */ + this.imageOffsetY = 0; + /** + * Number of animation frame layers, reflects {@link KwzParser.numLayers} + */ + this.numLayers = KwzParser.numLayers; + /** + * Number of colors per layer (aside from transparent), reflects {@link KwzParser.numLayerColors} + */ + this.numLayerColors = KwzParser.numLayerColors; + /** + * key used for Flipnote verification, in PEM format + */ + this.publicKey = KwzParser.publicKey; + /** + * @internal + */ + this.srcWidth = KwzParser.width; + /** + * Which audio tracks are available in this format, reflects {@link KwzParser.audioTracks} + */ + this.audioTracks = KwzParser.audioTracks; + /** + * Which sound effect tracks are available in this format, reflects {@link KwzParser.soundEffectTracks} + */ + this.soundEffectTracks = KwzParser.soundEffectTracks; + /** + * Audio track base sample rate, reflects {@link KwzParser.rawSampleRate} + */ + this.rawSampleRate = KwzParser.rawSampleRate; + /** + * Audio output sample rate, reflects {@link KwzParser.sampleRate} + */ + this.sampleRate = KwzParser.sampleRate; + /** + * Global animation frame color palette, reflects {@link KwzParser.globalPalette} + */ + this.globalPalette = KwzParser.globalPalette; + _KwzParser_settings.set(this, void 0); + _KwzParser_sectionMap.set(this, void 0); + _KwzParser_bodyEndOffset.set(this, void 0); + _KwzParser_layerBuffers.set(this, void 0); + _KwzParser_soundFlags.set(this, void 0); // sound effect flag cache + _KwzParser_prevDecodedFrame.set(this, null); + // frameMeta: Map; + _KwzParser_frameMetaOffsets.set(this, void 0); + _KwzParser_frameDataOffsets.set(this, void 0); + _KwzParser_frameLayerSizes.set(this, void 0); + _KwzParser_frameDataTotalSize.set(this, void 0); + _KwzParser_bitIndex.set(this, 0); + _KwzParser_bitValue.set(this, 0); + __classPrivateFieldSet(this, _KwzParser_settings, { ...KwzParser.defaultSettings, ...settings }, "f"); + __classPrivateFieldSet(this, _KwzParser_layerBuffers, [ + new Uint8Array(KwzParser.width * KwzParser.height), + new Uint8Array(KwzParser.width * KwzParser.height), + new Uint8Array(KwzParser.width * KwzParser.height), + ], "f"); + // skip through the file and read all of the section headers so we can locate them + __classPrivateFieldGet(this, _KwzParser_instances, "m", _KwzParser_buildSectionMap).call(this); + // if the KIC section is present, we're dealing with a folder icon + // these are single-frame KWZs without a KFH section for metadata, or a KSN section for sound + // while the data for a full frame (320*240) is present, only the top-left 24*24 pixels are used + if (__classPrivateFieldGet(this, _KwzParser_sectionMap, "f").has('KIC')) { + this.isFolderIcon = true; + // icons still use the full 320 * 240 frame size, so we just set up our image crop to deal with that + this.imageWidth = 24; + this.imageHeight = 24; + this.frameCount = 1; + this.frameSpeed = 0; + this.framerate = KWZ_FRAMERATES[0]; + this.thumbFrameIndex = 0; + __classPrivateFieldGet(this, _KwzParser_instances, "m", _KwzParser_getFrameOffsets).call(this); + } + // if the KSN section is not present, then this is a handwritten comment from the Flipnote Gallery World online service + // these are single-frame KWZs, just with no sound + else if (!__classPrivateFieldGet(this, _KwzParser_sectionMap, "f").has('KSN')) { + this.isComment = true; + __classPrivateFieldGet(this, _KwzParser_instances, "m", _KwzParser_decodeMeta).call(this); + __classPrivateFieldGet(this, _KwzParser_instances, "m", _KwzParser_getFrameOffsets).call(this); + } + // else let's assume this is a regular note + else { + __classPrivateFieldGet(this, _KwzParser_instances, "m", _KwzParser_decodeMeta).call(this); + __classPrivateFieldGet(this, _KwzParser_instances, "m", _KwzParser_getFrameOffsets).call(this); + __classPrivateFieldGet(this, _KwzParser_instances, "m", _KwzParser_decodeSoundHeader).call(this); + } + // apply special optimizations for converted DSi library notes + if (__classPrivateFieldGet(this, _KwzParser_settings, "f").dsiLibraryNote) { + this.isDsiLibraryNote = true; + } + // automatically crop out the border around every frame + if (__classPrivateFieldGet(this, _KwzParser_settings, "f").borderCrop) { + // dsi library notes can be cropped to their original resolution + if (this.isDsiLibraryNote) { + this.imageOffsetX = 32; + this.imageOffsetY = 24; + this.imageWidth = 256; + this.imageHeight = 192; + } + // even standard notes have a bit of a border... + else if (!this.isFolderIcon) { + this.imageOffsetX = 5; + this.imageOffsetY = 5; + this.imageWidth = 310; + this.imageHeight = 230; + } + } + } + /** + * Decodes the thumbnail image embedded in the Flipnote. Will return a {@link FlipnoteThumbImage} containing JPEG data. + * + * Note: For most purposes, you should probably just decode the thumbnail frame to get a higher resolution image. + * @group Meta + */ + getThumbnailImage() { + assert(__classPrivateFieldGet(this, _KwzParser_sectionMap, "f").has('KTN'), 'KTN section missing - Note that folder icons and comments do not contain thumbnail data'); + const ktn = __classPrivateFieldGet(this, _KwzParser_sectionMap, "f").get('KTN'); + this.seek(ktn.ptr + 12); + const bytes = this.readBytes(ktn.length - 12); + return { + format: exports.FlipnoteThumbImageFormat.Jpeg, + width: 80, + height: 64, + data: bytes.buffer + }; + } + /** + * Get the memory meter level for the Flipnote. + * This is a value between 0 and 1 indicating how "full" the Flipnote is, based on the size calculation formula inside Flipnote Studio 3D. + * + * Values will never be below 0, but can be above 1 if the Flipnote is larger than the size limit - it is technically possible to exceed the size limit by one frame. + * + * @group Meta + */ + getMemoryMeterLevel() { + // NOTE: Flipnote Studio 3D seems to have a couple of different calculations for the actual memory limit + // This is based on the function at 0x002b4224, which gives the level used for the memory bar itself + // A slightly different calculation is used when deciding if a new frame can be added, unsure why! + assert(__classPrivateFieldGet(this, _KwzParser_sectionMap, "f").has('KMI') && __classPrivateFieldGet(this, _KwzParser_sectionMap, "f").has('KMC')); + const sampleRate = this.rawSampleRate; + const bytesPerSecond = sampleRate / 2; + const bgmMaxSize = bytesPerSecond * 60; + const seMaxSize = bytesPerSecond * 2; + const audioMaxSize = bgmMaxSize + seMaxSize * 4; + const totalSize = __classPrivateFieldGet(this, _KwzParser_frameDataTotalSize, "f"); + // The function at 0x0031f258 gives the max size used in this calculation + // I'm actually unsure what the (something + 39) is, but maximum possible audio size seems to fit well enough for now :^) + const maxSize = 4219447 - ((audioMaxSize + 39) & 0xfffffffc); + const level = (totalSize + 57600) / maxSize; + return level; + } + /** + * Get the color palette indices for a given frame. RGBA colors for these values can be indexed from {@link KwzParser.globalPalette} + * + * Returns an array where: + * - index 0 is the paper color index + * - index 1 is the layer A color 1 index + * - index 2 is the layer A color 2 index + * - index 3 is the layer B color 1 index + * - index 4 is the layer B color 2 index + * - index 5 is the layer C color 1 index + * - index 6 is the layer C color 2 index + * @group Image + */ + getFramePaletteIndices(frameIndex) { + assertRange(frameIndex, 0, this.frameCount - 1, 'Frame index'); + this.seek(__classPrivateFieldGet(this, _KwzParser_frameMetaOffsets, "f")[frameIndex]); + const flags = this.readUint32(); + return [ + flags & 0xF, + (flags >> 8) & 0xF, + (flags >> 12) & 0xF, + (flags >> 16) & 0xF, + (flags >> 20) & 0xF, + (flags >> 24) & 0xF, + (flags >> 28) & 0xF, + ]; + } + /** + * Get the RGBA colors for a given frame + * + * Returns an array where: + * - index 0 is the paper color + * - index 1 is the layer A color 1 + * - index 2 is the layer A color 2 + * - index 3 is the layer B color 1 + * - index 4 is the layer B color 2 + * - index 5 is the layer C color 1 + * - index 6 is the layer C color 2 + * @group Image + */ + getFramePalette(frameIndex) { + assertRange(frameIndex, 0, this.frameCount - 1, 'Frame index'); + const indices = this.getFramePaletteIndices(frameIndex); + return indices.map(colorIndex => this.globalPalette[colorIndex]); + } + getFrameDiffingFlag(frameIndex) { + assertRange(frameIndex, 0, this.frameCount - 1, 'Frame index'); + this.seek(__classPrivateFieldGet(this, _KwzParser_frameMetaOffsets, "f")[frameIndex]); + return (this.readUint32() >> 4) & 0x07; + } + /** + * Determines if a given frame is a video key frame or not. This returns an array of booleans for each layer, since keyframe encoding is done on a per-layer basis. + * @param frameIndex + * @group Image + */ + getIsKeyFrame(frameIndex) { + const flag = this.getFrameDiffingFlag(frameIndex); + return [ + (flag & 0x1) === 0, + (flag & 0x2) === 0, + (flag & 0x4) === 0, + ]; + } + /** + * Get the 3D depths for each layer in a given frame. + * @param frameIndex + * @group Image + */ + getFrameLayerDepths(frameIndex) { + assertRange(frameIndex, 0, this.frameCount - 1, 'Frame index'); + this.seek(__classPrivateFieldGet(this, _KwzParser_frameMetaOffsets, "f")[frameIndex] + 0x14); + return [ + this.readUint8(), + this.readUint8(), + this.readUint8() + ]; + } + /** + * Get the FSID for a given frame's original author. + * @param frameIndex + * @group Meta + */ + getFrameAuthor(frameIndex) { + assertRange(frameIndex, 0, this.frameCount - 1, 'Frame index'); + this.seek(__classPrivateFieldGet(this, _KwzParser_frameMetaOffsets, "f")[frameIndex] + 0xA); + return __classPrivateFieldGet(this, _KwzParser_instances, "m", _KwzParser_readFsid).call(this); + } + /** + * Get the camera flags for a given frame + * @group Image + * @returns Array of booleans, indicating whether each layer uses a photo or not + */ + getFrameCameraFlags(frameIndex) { + this.seek(__classPrivateFieldGet(this, _KwzParser_frameMetaOffsets, "f")[frameIndex] + 0x1A); + const cameraFlags = this.readUint8(); + return [ + (cameraFlags & 0x1) !== 0, + (cameraFlags & 0x2) !== 0, + (cameraFlags & 0x4) !== 0, + ]; + } + /** + * Get the layer draw order for a given frame + * @group Image + */ + getFrameLayerOrder(frameIndex) { + assertRange(frameIndex, 0, this.frameCount - 1, 'Frame index'); + const depths = this.getFrameLayerDepths(frameIndex); + return [2, 1, 0].sort((a, b) => depths[b] - depths[a]); + } + /** + * Decode a frame, returning the raw pixel buffers for each layer + * @group Image + */ + decodeFrame(frameIndex, diffingFlag = 0x7, isPrevFrame = false) { + assertRange(frameIndex, 0, this.frameCount - 1, 'Frame index'); + // return existing layer buffers if no new frame has been decoded since the last call + if (__classPrivateFieldGet(this, _KwzParser_prevDecodedFrame, "f") === frameIndex) + return __classPrivateFieldGet(this, _KwzParser_layerBuffers, "f"); + // the prevDecodedFrame check is an optimization for decoding frames in full sequence + if (__classPrivateFieldGet(this, _KwzParser_prevDecodedFrame, "f") !== frameIndex - 1 && frameIndex !== 0) { + // if this frame is being decoded as a prev frame, then we only want to decode the layers necessary + // diffingFlag is negated with ~ so if no layers are diff-based, diffingFlag is 0 + if (isPrevFrame) + diffingFlag = diffingFlag & ~this.getFrameDiffingFlag(frameIndex + 1); + // if diffing flag isn't 0, decode the previous frame before this one + if (diffingFlag !== 0) + this.decodeFrame(frameIndex - 1, diffingFlag, true); + } + let framePtr = __classPrivateFieldGet(this, _KwzParser_frameDataOffsets, "f")[frameIndex]; + const layerSizes = __classPrivateFieldGet(this, _KwzParser_frameLayerSizes, "f")[frameIndex]; + for (let layerIndex = 0; layerIndex < 3; layerIndex++) { + // dsi gallery conversions don't use the third layer, so it can be skipped if this is set + if (__classPrivateFieldGet(this, _KwzParser_settings, "f").dsiLibraryNote && layerIndex === 3) + break; + this.seek(framePtr); + let layerSize = layerSizes[layerIndex]; + framePtr += layerSize; + const pixelBuffer = __classPrivateFieldGet(this, _KwzParser_layerBuffers, "f")[layerIndex]; + // if the layer is 38 bytes then it hasn't changed at all since the previous frame, so we can skip it + if (layerSize === 38) + continue; + // if this layer doesn't need to be decoded for diffing + if (((diffingFlag >> layerIndex) & 0x1) === 0) + continue; + // reset readbits state + __classPrivateFieldSet(this, _KwzParser_bitIndex, 16, "f"); + __classPrivateFieldSet(this, _KwzParser_bitValue, 0, "f"); + // tile skip counter + let skipTileCounter = 0; + for (let tileOffsetY = 0; tileOffsetY < 240; tileOffsetY += 128) { + for (let tileOffsetX = 0; tileOffsetX < 320; tileOffsetX += 128) { + // loop small tiles + for (let subTileOffsetY = 0; subTileOffsetY < 128; subTileOffsetY += 8) { + const y = tileOffsetY + subTileOffsetY; + if (y >= 240) + break; + for (let subTileOffsetX = 0; subTileOffsetX < 128; subTileOffsetX += 8) { + const x = tileOffsetX + subTileOffsetX; + if (x >= 320) + break; + // continue to next tile loop if skipTileCounter is > 0 + if (skipTileCounter > 0) { + skipTileCounter -= 1; + continue; + } + let pixelBufferPtr = y * KwzParser.width + x; + const tileType = __classPrivateFieldGet(this, _KwzParser_instances, "m", _KwzParser_readBits).call(this, 3); + if (tileType === 0) { + const linePtr = __classPrivateFieldGet(this, _KwzParser_instances, "m", _KwzParser_readBits).call(this, 5) * 8; + const pixels = KWZ_LINE_TABLE_COMMON.subarray(linePtr, linePtr + 8); + pixelBuffer.set(pixels, pixelBufferPtr); + pixelBuffer.set(pixels, pixelBufferPtr += 320); + pixelBuffer.set(pixels, pixelBufferPtr += 320); + pixelBuffer.set(pixels, pixelBufferPtr += 320); + pixelBuffer.set(pixels, pixelBufferPtr += 320); + pixelBuffer.set(pixels, pixelBufferPtr += 320); + pixelBuffer.set(pixels, pixelBufferPtr += 320); + pixelBuffer.set(pixels, pixelBufferPtr += 320); + } + else if (tileType === 1) { + const linePtr = __classPrivateFieldGet(this, _KwzParser_instances, "m", _KwzParser_readBits).call(this, 13) * 8; + const pixels = KWZ_LINE_TABLE.subarray(linePtr, linePtr + 8); + pixelBuffer.set(pixels, pixelBufferPtr); + pixelBuffer.set(pixels, pixelBufferPtr += 320); + pixelBuffer.set(pixels, pixelBufferPtr += 320); + pixelBuffer.set(pixels, pixelBufferPtr += 320); + pixelBuffer.set(pixels, pixelBufferPtr += 320); + pixelBuffer.set(pixels, pixelBufferPtr += 320); + pixelBuffer.set(pixels, pixelBufferPtr += 320); + pixelBuffer.set(pixels, pixelBufferPtr += 320); + } + else if (tileType === 2) { + const linePtr = __classPrivateFieldGet(this, _KwzParser_instances, "m", _KwzParser_readBits).call(this, 5) * 8; + const a = KWZ_LINE_TABLE_COMMON.subarray(linePtr, linePtr + 8); + const b = KWZ_LINE_TABLE_COMMON_SHIFT.subarray(linePtr, linePtr + 8); + pixelBuffer.set(a, pixelBufferPtr); + pixelBuffer.set(b, pixelBufferPtr += 320); + pixelBuffer.set(a, pixelBufferPtr += 320); + pixelBuffer.set(b, pixelBufferPtr += 320); + pixelBuffer.set(a, pixelBufferPtr += 320); + pixelBuffer.set(b, pixelBufferPtr += 320); + pixelBuffer.set(a, pixelBufferPtr += 320); + pixelBuffer.set(b, pixelBufferPtr += 320); + } + else if (tileType === 3) { + const linePtr = __classPrivateFieldGet(this, _KwzParser_instances, "m", _KwzParser_readBits).call(this, 13) * 8; + const a = KWZ_LINE_TABLE.subarray(linePtr, linePtr + 8); + const b = KWZ_LINE_TABLE_SHIFT.subarray(linePtr, linePtr + 8); + pixelBuffer.set(a, pixelBufferPtr); + pixelBuffer.set(b, pixelBufferPtr += 320); + pixelBuffer.set(a, pixelBufferPtr += 320); + pixelBuffer.set(b, pixelBufferPtr += 320); + pixelBuffer.set(a, pixelBufferPtr += 320); + pixelBuffer.set(b, pixelBufferPtr += 320); + pixelBuffer.set(a, pixelBufferPtr += 320); + pixelBuffer.set(b, pixelBufferPtr += 320); + } + // most common tile type + else if (tileType === 4) { + const flags = __classPrivateFieldGet(this, _KwzParser_instances, "m", _KwzParser_readBits).call(this, 8); + for (let mask = 1; mask < 0xFF; mask <<= 1) { + if (flags & mask) { + const linePtr = __classPrivateFieldGet(this, _KwzParser_instances, "m", _KwzParser_readBits).call(this, 5) * 8; + const pixels = KWZ_LINE_TABLE_COMMON.subarray(linePtr, linePtr + 8); + pixelBuffer.set(pixels, pixelBufferPtr); + } + else { + const linePtr = __classPrivateFieldGet(this, _KwzParser_instances, "m", _KwzParser_readBits).call(this, 13) * 8; + const pixels = KWZ_LINE_TABLE.subarray(linePtr, linePtr + 8); + pixelBuffer.set(pixels, pixelBufferPtr); + } + pixelBufferPtr += 320; + } + } + else if (tileType === 5) { + skipTileCounter = __classPrivateFieldGet(this, _KwzParser_instances, "m", _KwzParser_readBits).call(this, 5); + continue; + } + // type 6 doesnt exist + else if (tileType === 7) { + let pattern = __classPrivateFieldGet(this, _KwzParser_instances, "m", _KwzParser_readBits).call(this, 2); + let useCommonLines = __classPrivateFieldGet(this, _KwzParser_instances, "m", _KwzParser_readBits).call(this, 1); + let a, b; + if (useCommonLines !== 0) { + const linePtrA = __classPrivateFieldGet(this, _KwzParser_instances, "m", _KwzParser_readBits).call(this, 5) * 8; + const linePtrB = __classPrivateFieldGet(this, _KwzParser_instances, "m", _KwzParser_readBits).call(this, 5) * 8; + a = KWZ_LINE_TABLE_COMMON.subarray(linePtrA, linePtrA + 8); + b = KWZ_LINE_TABLE_COMMON.subarray(linePtrB, linePtrB + 8); + pattern += 1; + } + else { + const linePtrA = __classPrivateFieldGet(this, _KwzParser_instances, "m", _KwzParser_readBits).call(this, 13) * 8; + const linePtrB = __classPrivateFieldGet(this, _KwzParser_instances, "m", _KwzParser_readBits).call(this, 13) * 8; + a = KWZ_LINE_TABLE.subarray(linePtrA, linePtrA + 8); + b = KWZ_LINE_TABLE.subarray(linePtrB, linePtrB + 8); + } + switch (pattern % 4) { + case 0: + pixelBuffer.set(a, pixelBufferPtr); + pixelBuffer.set(b, pixelBufferPtr += 320); + pixelBuffer.set(a, pixelBufferPtr += 320); + pixelBuffer.set(b, pixelBufferPtr += 320); + pixelBuffer.set(a, pixelBufferPtr += 320); + pixelBuffer.set(b, pixelBufferPtr += 320); + pixelBuffer.set(a, pixelBufferPtr += 320); + pixelBuffer.set(b, pixelBufferPtr += 320); + break; + case 1: + pixelBuffer.set(a, pixelBufferPtr); + pixelBuffer.set(a, pixelBufferPtr += 320); + pixelBuffer.set(b, pixelBufferPtr += 320); + pixelBuffer.set(a, pixelBufferPtr += 320); + pixelBuffer.set(a, pixelBufferPtr += 320); + pixelBuffer.set(b, pixelBufferPtr += 320); + pixelBuffer.set(a, pixelBufferPtr += 320); + pixelBuffer.set(a, pixelBufferPtr += 320); + break; + case 2: + pixelBuffer.set(a, pixelBufferPtr); + pixelBuffer.set(b, pixelBufferPtr += 320); + pixelBuffer.set(a, pixelBufferPtr += 320); + pixelBuffer.set(a, pixelBufferPtr += 320); + pixelBuffer.set(b, pixelBufferPtr += 320); + pixelBuffer.set(a, pixelBufferPtr += 320); + pixelBuffer.set(a, pixelBufferPtr += 320); + pixelBuffer.set(b, pixelBufferPtr += 320); + break; + case 3: + pixelBuffer.set(a, pixelBufferPtr); + pixelBuffer.set(b, pixelBufferPtr += 320); + pixelBuffer.set(b, pixelBufferPtr += 320); + pixelBuffer.set(a, pixelBufferPtr += 320); + pixelBuffer.set(b, pixelBufferPtr += 320); + pixelBuffer.set(b, pixelBufferPtr += 320); + pixelBuffer.set(a, pixelBufferPtr += 320); + pixelBuffer.set(b, pixelBufferPtr += 320); + break; + } + } + } + } + } + } + } + __classPrivateFieldSet(this, _KwzParser_prevDecodedFrame, frameIndex, "f"); + return __classPrivateFieldGet(this, _KwzParser_layerBuffers, "f"); + } + decodeFrameSoundFlags(frameIndex) { + assertRange(frameIndex, 0, this.frameCount - 1, 'Frame index'); + this.seek(__classPrivateFieldGet(this, _KwzParser_frameMetaOffsets, "f")[frameIndex] + 0x17); + const soundFlags = this.readUint8(); + return [ + (soundFlags & 0x1) !== 0, + (soundFlags & 0x2) !== 0, + (soundFlags & 0x4) !== 0, + (soundFlags & 0x8) !== 0, + ]; + } + /** + * Get the sound effect flags for every frame in the Flipnote + * @group Audio + */ + decodeSoundFlags() { + if (__classPrivateFieldGet(this, _KwzParser_soundFlags, "f") !== undefined) + return __classPrivateFieldGet(this, _KwzParser_soundFlags, "f"); + __classPrivateFieldSet(this, _KwzParser_soundFlags, new Array(this.frameCount) + .fill(false) + .map((_, i) => this.decodeFrameSoundFlags(i)), "f"); + return __classPrivateFieldGet(this, _KwzParser_soundFlags, "f"); + } + /** + * Get the sound effect usage flags for every frame + * @group Audio + */ + getSoundEffectFlags() { + return this.decodeSoundFlags().map((frameFlags) => ({ + [exports.FlipnoteSoundEffectTrack.SE1]: frameFlags[0], + [exports.FlipnoteSoundEffectTrack.SE2]: frameFlags[1], + [exports.FlipnoteSoundEffectTrack.SE3]: frameFlags[2], + [exports.FlipnoteSoundEffectTrack.SE4]: frameFlags[3], + })); + } + /** + * Get the sound effect usage for a given frame + * @param frameIndex + * @group Audio + */ + getFrameSoundEffectFlags(frameIndex) { + const frameFlags = this.decodeFrameSoundFlags(frameIndex); + return { + [exports.FlipnoteSoundEffectTrack.SE1]: frameFlags[0], + [exports.FlipnoteSoundEffectTrack.SE2]: frameFlags[1], + [exports.FlipnoteSoundEffectTrack.SE3]: frameFlags[2], + [exports.FlipnoteSoundEffectTrack.SE4]: frameFlags[3], + }; + } + /** + * Get the duration of a given track in seconds + * @returns number + * @group Audio + */ + getAudioTrackDuration(trackId) { + const trackMeta = this.soundMeta.get(trackId); + if (trackMeta.length === 0) + return 0; + const decoded = this.decodeAudioTrack(trackId); + if (trackId === exports.FlipnoteAudioTrack.BGM) { + const bgmAdjust = (1 / this.bgmrate) / (1 / this.framerate); + const freq = this.rawSampleRate * bgmAdjust; + return decoded.length / freq; + } + return decoded.length / this.sampleRate; + } + /** + * Get the raw compressed audio data for a given track + * @returns Byte array + * @group Audio + */ + getAudioTrackRaw(trackId) { + const trackMeta = this.soundMeta.get(trackId); + assert(trackMeta.ptr + trackMeta.length < this.numBytes); + return new Uint8Array(this.buffer, trackMeta.ptr, trackMeta.length); + } + decodeAdpcm(src, dst, predictor = 0, stepIndex = 40) { + const srcSize = src.length; + let dstPtr = 0; + let sample = 0; + let step = 0; + let diff = 0; + // loop through each byte in the raw adpcm data + for (let srcPtr = 0; srcPtr < srcSize; srcPtr++) { + let currByte = src[srcPtr]; + let currBit = 0; + while (currBit < 8) { + // 2 bit sample + if (stepIndex < 18 || currBit > 4) { + sample = currByte & 0x3; + step = ADPCM_STEP_TABLE[stepIndex]; + diff = step >> 3; + if (sample & 1) + diff += step; + if (sample & 2) + diff = -diff; + predictor += diff; + stepIndex += ADPCM_INDEX_TABLE_2BIT[sample]; + currByte >>= 2; + currBit += 2; + } + // 4 bit sample + else { + sample = currByte & 0xf; + step = ADPCM_STEP_TABLE[stepIndex]; + diff = step >> 3; + if (sample & 1) + diff += step >> 2; + if (sample & 2) + diff += step >> 1; + if (sample & 4) + diff += step; + if (sample & 8) + diff = -diff; + predictor += diff; + stepIndex += ADPCM_INDEX_TABLE_4BIT[sample]; + currByte >>= 4; + currBit += 4; + } + stepIndex = clamp(stepIndex, 0, 79); + // clamp as 12 bit then scale to 16 + predictor = clamp(predictor, -2048, 2047); + dst[dstPtr] = predictor * 16; + dstPtr += 1; + } + } + return dstPtr; + } + /** + * Get the decoded audio data for a given track, using the track's native samplerate + * @returns Signed 16-bit PCM audio + * @group Audio + */ + decodeAudioTrack(trackId) { + const settings = __classPrivateFieldGet(this, _KwzParser_settings, "f"); + const src = this.getAudioTrackRaw(trackId); + const dstSize = this.rawSampleRate * 60; // enough for 60 seconds, the max bgm size + const dst = new Int16Array(dstSize); + // initial decoder state + let predictor = 0; + let stepIndex = 40; + // Nintendo messed up the initial adpcm state for a bunch of the PPM conversions on DSi Library + // they are effectively random, so you can optionally provide your own state values, or let the lib make a best guess + if (this.isDsiLibraryNote) { + if (trackId === exports.FlipnoteAudioTrack.BGM) { + // passing an initial index or predictor value should disable bruteforcing + let doGuess = true; + // allow manual overrides for default predictor + if (settings.initialBgmPredictor !== null) { + predictor = settings.initialBgmPredictor; + doGuess = false; + } + // allow manual overrides for default step index + if (settings.initialBgmStepIndex !== null) { + stepIndex = settings.initialBgmStepIndex; + doGuess = false; + } + // bruteforce step index by finding the lowest track root mean square + if (doGuess && settings.guessInitialBgmState) { + let bestRms = 0xFFFFFFFF; // arbitrarily large + let bestStepIndex = 0; + for (stepIndex = 0; stepIndex <= 40; stepIndex++) { + const dstPtr = this.decodeAdpcm(src, dst, predictor, stepIndex); + const rms = pcmGetRms(dst.subarray(0, dstPtr)); // uses same underlying memory as dst + if (rms < bestRms) { + bestRms = rms; + bestStepIndex = stepIndex; + } + } + stepIndex = bestStepIndex; + } + } + else { + const trackIndex = this.soundEffectTracks.indexOf(trackId); + // allow manual overrides for default predictor + if (Array.isArray(settings.initialSePredictors) && settings.initialSePredictors[trackIndex] !== undefined) + predictor = settings.initialSePredictors[trackIndex]; + // allow manual overrides for default step index + if (Array.isArray(settings.initialSeStepIndices) && settings.initialSeStepIndices[trackIndex] !== undefined) + stepIndex = settings.initialSeStepIndices[trackIndex]; + } + } + // decode track + const dstPtr = this.decodeAdpcm(src, dst, predictor, stepIndex); + // copy part of dst with slice() so dst buffer can be garbage collected + return dst.slice(0, dstPtr); + } + /** + * Get the decoded audio data for a given track, using the specified samplerate + * @returns Signed 16-bit PCM audio + * @group Audio + */ + getAudioTrackPcm(trackId, dstFreq = this.sampleRate) { + const srcPcm = this.decodeAudioTrack(trackId); + let srcFreq = this.rawSampleRate; + if (trackId === exports.FlipnoteAudioTrack.BGM) { + const bgmAdjust = (1 / this.bgmrate) / (1 / this.framerate); + srcFreq = this.rawSampleRate * bgmAdjust; + } + if (srcFreq !== dstFreq) + return pcmResampleLinear(srcPcm, srcFreq, dstFreq); + return srcPcm; + } + pcmAudioMix(src, dst, dstOffset = 0) { + const srcSize = src.length; + const dstSize = dst.length; + for (let n = 0; n < srcSize; n++) { + if (dstOffset + n > dstSize) + break; + // half src volume + const samp = dst[dstOffset + n] + src[n]; + dst[dstOffset + n] = clamp(samp, -32768, 32767); + } + } + /** + * Get the full mixed audio for the Flipnote, using the specified samplerate + * @returns Signed 16-bit PCM audio + * @group Audio + */ + getAudioMasterPcm(dstFreq = this.sampleRate) { + const dstSize = Math.ceil(this.duration * dstFreq); + const master = new Int16Array(dstSize); + const hasBgm = this.hasAudioTrack(exports.FlipnoteAudioTrack.BGM); + const hasSe1 = this.hasAudioTrack(exports.FlipnoteAudioTrack.SE1); + const hasSe2 = this.hasAudioTrack(exports.FlipnoteAudioTrack.SE2); + const hasSe3 = this.hasAudioTrack(exports.FlipnoteAudioTrack.SE3); + const hasSe4 = this.hasAudioTrack(exports.FlipnoteAudioTrack.SE4); + // Mix background music + if (hasBgm) { + const bgmPcm = this.getAudioTrackPcm(exports.FlipnoteAudioTrack.BGM, dstFreq); + this.pcmAudioMix(bgmPcm, master, 0); + } + // Mix sound effects + if (hasSe1 || hasSe2 || hasSe3 || hasSe4) { + const samplesPerFrame = dstFreq / this.framerate; + const se1Pcm = hasSe1 ? this.getAudioTrackPcm(exports.FlipnoteAudioTrack.SE1, dstFreq) : null; + const se2Pcm = hasSe2 ? this.getAudioTrackPcm(exports.FlipnoteAudioTrack.SE2, dstFreq) : null; + const se3Pcm = hasSe3 ? this.getAudioTrackPcm(exports.FlipnoteAudioTrack.SE3, dstFreq) : null; + const se4Pcm = hasSe4 ? this.getAudioTrackPcm(exports.FlipnoteAudioTrack.SE4, dstFreq) : null; + const soundEffectFlags = this.decodeSoundFlags(); + for (let i = 0; i < this.frameCount; i++) { + const seFlags = soundEffectFlags[i]; + const seOffset = Math.ceil(i * samplesPerFrame); + if (hasSe1 && seFlags[0]) + this.pcmAudioMix(se1Pcm, master, seOffset); + if (hasSe2 && seFlags[1]) + this.pcmAudioMix(se2Pcm, master, seOffset); + if (hasSe3 && seFlags[2]) + this.pcmAudioMix(se3Pcm, master, seOffset); + if (hasSe4 && seFlags[3]) + this.pcmAudioMix(se4Pcm, master, seOffset); + } + } + this.audioClipRatio = pcmGetClippingRatio(master); + return master; + } + /** + * Get the body of the Flipnote - the data that is digested for the signature + * @group Verification + */ + getBody() { + const bodyEnd = __classPrivateFieldGet(this, _KwzParser_bodyEndOffset, "f"); + return this.bytes.subarray(0, bodyEnd); + } + /** + * Get the Flipnote's signature data + * @group Verification + */ + getSignature() { + const bodyEnd = __classPrivateFieldGet(this, _KwzParser_bodyEndOffset, "f"); + return this.bytes.subarray(bodyEnd, bodyEnd + 256); + } + /** + * Verify whether this Flipnote's signature is valid + * @group Verification + */ + async verify() { + const key = await rsaLoadPublicKey(KWZ_PUBLIC_KEY, 'SHA-256'); + return await rsaVerify(key, this.getSignature(), this.getBody()); + } + } + _KwzParser_settings = new WeakMap(), _KwzParser_sectionMap = new WeakMap(), _KwzParser_bodyEndOffset = new WeakMap(), _KwzParser_layerBuffers = new WeakMap(), _KwzParser_soundFlags = new WeakMap(), _KwzParser_prevDecodedFrame = new WeakMap(), _KwzParser_frameMetaOffsets = new WeakMap(), _KwzParser_frameDataOffsets = new WeakMap(), _KwzParser_frameLayerSizes = new WeakMap(), _KwzParser_frameDataTotalSize = new WeakMap(), _KwzParser_bitIndex = new WeakMap(), _KwzParser_bitValue = new WeakMap(), _KwzParser_instances = new WeakSet(), _a = Symbol.toStringTag, _KwzParser_buildSectionMap = function _KwzParser_buildSectionMap() { + const fileSize = this.numBytes - 256; + const sectionMap = new Map(); + let sectionCount = 0; + let ptr = 0; + // counting sections should mitigate against one of mrnbayoh's notehax exploits + while (ptr < fileSize && sectionCount < 6) { + this.seek(ptr); + const magic = this.readChars(4).substring(0, 3); + const length = this.readUint32(); + sectionMap.set(magic, { ptr, length }); + ptr += length + 8; + sectionCount += 1; + } + __classPrivateFieldSet(this, _KwzParser_bodyEndOffset, ptr, "f"); + __classPrivateFieldSet(this, _KwzParser_sectionMap, sectionMap, "f"); + assert(sectionMap.has('KMC') && sectionMap.has('KMI')); + }, _KwzParser_readBits = function _KwzParser_readBits(num) { + // assert(num < 16); + if (__classPrivateFieldGet(this, _KwzParser_bitIndex, "f") + num > 16) { + const nextBits = this.readUint16(); + __classPrivateFieldSet(this, _KwzParser_bitValue, __classPrivateFieldGet(this, _KwzParser_bitValue, "f") | nextBits << (16 - __classPrivateFieldGet(this, _KwzParser_bitIndex, "f")), "f"); + __classPrivateFieldSet(this, _KwzParser_bitIndex, __classPrivateFieldGet(this, _KwzParser_bitIndex, "f") - 16, "f"); + } + const result = __classPrivateFieldGet(this, _KwzParser_bitValue, "f") & BITMASKS[num]; + __classPrivateFieldSet(this, _KwzParser_bitValue, __classPrivateFieldGet(this, _KwzParser_bitValue, "f") >> num, "f"); + __classPrivateFieldSet(this, _KwzParser_bitIndex, __classPrivateFieldGet(this, _KwzParser_bitIndex, "f") + num, "f"); + return result; + }, _KwzParser_readFsid = function _KwzParser_readFsid() { + if (__classPrivateFieldGet(this, _KwzParser_settings, "f").dsiLibraryNote) { // format as DSi PPM FSID + const hex = this.readHex(10, true); + return hex.slice(2, 18); + } + return kwzFsidFormat(this.readHex(10)); + }, _KwzParser_readFilename = function _KwzParser_readFilename() { + const ptr = this.pointer; + const chars = this.readChars(28); + if (chars.length === 28) + return chars; + // Otherwise, this is likely a DSi Library note, + // where sometimes Nintendo's buggy PPM converter includes the original packed PPM filename + this.seek(ptr); + const mac = this.readHex(3); + const random = this.readChars(13); + const edits = this.readUint16().toString().padStart(3, '0'); + this.seek(ptr + 28); + return `${mac}_${random}_${edits}`; + }, _KwzParser_decodeMeta = function _KwzParser_decodeMeta() { + if (__classPrivateFieldGet(this, _KwzParser_settings, "f").quickMeta) + return __classPrivateFieldGet(this, _KwzParser_instances, "m", _KwzParser_decodeMetaQuick).call(this); + assert(__classPrivateFieldGet(this, _KwzParser_sectionMap, "f").has('KFH')); + this.seek(__classPrivateFieldGet(this, _KwzParser_sectionMap, "f").get('KFH').ptr + 12); + const creationTime = dateFromNintendoTimestamp(this.readUint32()); + const modifiedTime = dateFromNintendoTimestamp(this.readUint32()); + // const simonTime = + this.readUint32(); + const rootAuthorId = __classPrivateFieldGet(this, _KwzParser_instances, "m", _KwzParser_readFsid).call(this); + const parentAuthorId = __classPrivateFieldGet(this, _KwzParser_instances, "m", _KwzParser_readFsid).call(this); + const currentAuthorId = __classPrivateFieldGet(this, _KwzParser_instances, "m", _KwzParser_readFsid).call(this); + const rootAuthorName = this.readWideChars(11); + const parentAuthorName = this.readWideChars(11); + const currentAuthorName = this.readWideChars(11); + const rootFilename = __classPrivateFieldGet(this, _KwzParser_instances, "m", _KwzParser_readFilename).call(this); + const parentFilename = __classPrivateFieldGet(this, _KwzParser_instances, "m", _KwzParser_readFilename).call(this); + const currentFilename = __classPrivateFieldGet(this, _KwzParser_instances, "m", _KwzParser_readFilename).call(this); + const frameCount = this.readUint16(); + const thumbIndex = this.readUint16(); + const flags = this.readUint16(); + const frameSpeed = this.readUint8(); + const layerFlags = this.readUint8(); + this.isSpinoff = (currentAuthorId !== parentAuthorId) || (currentAuthorId !== rootAuthorId); + this.frameCount = frameCount; + this.frameSpeed = frameSpeed; + this.framerate = KWZ_FRAMERATES[frameSpeed]; + this.duration = timeGetNoteDuration(this.frameCount, this.framerate); + this.thumbFrameIndex = thumbIndex; + this.layerVisibility = { + 1: (layerFlags & 0x1) === 0, + 2: (layerFlags & 0x2) === 0, + 3: (layerFlags & 0x3) === 0, + }; + // Try to auto-detect whether the current author ID matches a converted PPM ID + // if (isKwzDsiLibraryFsid(currentAuthorId)) { + // this.isDsiLibraryNote = true; + // } + this.meta = { + lock: (flags & 0x1) !== 0, + loop: (flags & 0x2) !== 0, + advancedTools: (flags & 0x4) !== 0, + isSpinoff: this.isSpinoff, + frameCount: frameCount, + frameSpeed: frameSpeed, + duration: this.duration, + thumbIndex: thumbIndex, + timestamp: modifiedTime, + creationTimestamp: creationTime, + root: { + username: rootAuthorName, + fsid: rootAuthorId, + region: getKwzFsidRegion(rootAuthorId), + filename: rootFilename, + isDsiFilename: rootFilename.length !== 28 + }, + parent: { + username: parentAuthorName, + fsid: parentAuthorId, + region: getKwzFsidRegion(parentAuthorId), + filename: parentFilename, + isDsiFilename: parentFilename.length !== 28 + }, + current: { + username: currentAuthorName, + fsid: currentAuthorId, + region: getKwzFsidRegion(currentAuthorId), + filename: currentFilename, + isDsiFilename: currentFilename.length !== 28 + }, + }; + }, _KwzParser_decodeMetaQuick = function _KwzParser_decodeMetaQuick() { + assert(__classPrivateFieldGet(this, _KwzParser_sectionMap, "f").has('KFH')); + this.seek(__classPrivateFieldGet(this, _KwzParser_sectionMap, "f").get('KFH').ptr + 0x8 + 0xC4); + const frameCount = this.readUint16(); + const thumbFrameIndex = this.readUint16(); + this.readUint16(); + const frameSpeed = this.readUint8(); + const layerFlags = this.readUint8(); + this.frameCount = frameCount; + this.thumbFrameIndex = thumbFrameIndex; + this.frameSpeed = frameSpeed; + this.framerate = KWZ_FRAMERATES[frameSpeed]; + this.duration = timeGetNoteDuration(this.frameCount, this.framerate); + this.layerVisibility = { + 1: (layerFlags & 0x1) === 0, + 2: (layerFlags & 0x2) === 0, + 3: (layerFlags & 0x3) === 0, + }; + }, _KwzParser_getFrameOffsets = function _KwzParser_getFrameOffsets() { + assert(__classPrivateFieldGet(this, _KwzParser_sectionMap, "f").has('KMI') && __classPrivateFieldGet(this, _KwzParser_sectionMap, "f").has('KMC')); + const numFrames = this.frameCount; + const kmiSection = __classPrivateFieldGet(this, _KwzParser_sectionMap, "f").get('KMI'); + const kmcSection = __classPrivateFieldGet(this, _KwzParser_sectionMap, "f").get('KMC'); + assert(kmiSection.length / 28 >= numFrames); + const frameMetaOffsets = new Uint32Array(numFrames); + const frameDataOffsets = new Uint32Array(numFrames); + const frameLayerSizes = []; + let frameMetaPtr = kmiSection.ptr + 8; + let frameDataPtr = kmcSection.ptr + 12; + let totalSize = 0; + for (let frameIndex = 0; frameIndex < numFrames; frameIndex++) { + this.seek(frameMetaPtr + 4); + const layerASize = this.readUint16(); + const layerBSize = this.readUint16(); + const layerCSize = this.readUint16(); + frameMetaOffsets[frameIndex] = frameMetaPtr; + frameDataOffsets[frameIndex] = frameDataPtr; + frameMetaPtr += 28; + frameDataPtr += layerASize + layerBSize + layerCSize; + assert(frameMetaPtr < this.numBytes, `frame${frameIndex} meta pointer out of bounds`); + assert(frameDataPtr < this.numBytes, `frame${frameIndex} data pointer out of bounds`); + frameLayerSizes.push([layerASize, layerBSize, layerCSize]); + totalSize += layerASize + layerBSize + layerCSize; + } + __classPrivateFieldSet(this, _KwzParser_frameMetaOffsets, frameMetaOffsets, "f"); + __classPrivateFieldSet(this, _KwzParser_frameDataOffsets, frameDataOffsets, "f"); + __classPrivateFieldSet(this, _KwzParser_frameLayerSizes, frameLayerSizes, "f"); + __classPrivateFieldSet(this, _KwzParser_frameDataTotalSize, totalSize, "f"); + }, _KwzParser_decodeSoundHeader = function _KwzParser_decodeSoundHeader() { + assert(__classPrivateFieldGet(this, _KwzParser_sectionMap, "f").has('KSN')); + let ptr = __classPrivateFieldGet(this, _KwzParser_sectionMap, "f").get('KSN').ptr + 8; + this.seek(ptr); + this.bgmSpeed = this.readUint32(); + assert(this.bgmSpeed <= 10); + this.bgmrate = KWZ_FRAMERATES[this.bgmSpeed]; + const trackSizes = new Uint32Array(this.buffer, ptr + 4, 20); + const soundMeta = new Map(); + soundMeta.set(exports.FlipnoteAudioTrack.BGM, { ptr: ptr += 28, length: trackSizes[0] }); + soundMeta.set(exports.FlipnoteAudioTrack.SE1, { ptr: ptr += trackSizes[0], length: trackSizes[1] }); + soundMeta.set(exports.FlipnoteAudioTrack.SE2, { ptr: ptr += trackSizes[1], length: trackSizes[2] }); + soundMeta.set(exports.FlipnoteAudioTrack.SE3, { ptr: ptr += trackSizes[2], length: trackSizes[3] }); + soundMeta.set(exports.FlipnoteAudioTrack.SE4, { ptr: ptr += trackSizes[3], length: trackSizes[4] }); + this.soundMeta = soundMeta; + }; + /** + * Default KWZ parser settings + */ + KwzParser.defaultSettings = { + quickMeta: false, + dsiLibraryNote: false, + borderCrop: false, + guessInitialBgmState: true, + initialBgmPredictor: null, + initialBgmStepIndex: null, + initialSePredictors: null, + initialSeStepIndices: null, + }; + /** + * File format type + */ + KwzParser.format = exports.FlipnoteFormat.KWZ; + /** + * Animation frame width + */ + KwzParser.width = 320; + /** + * Animation frame height + */ + KwzParser.height = 240; + /** + * Animation frame aspect ratio + */ + KwzParser.aspect = 3 / 4; + /** + * Number of animation frame layers + */ + KwzParser.numLayers = 3; + /** + * Number of colors per layer (aside from transparent) + */ + KwzParser.numLayerColors = 2; + /** + * Audio track base sample rate + */ + KwzParser.rawSampleRate = 16364; + /** + * Audio output sample rate + */ + KwzParser.sampleRate = 32768; + /** + * Which audio tracks are available in this format + */ + KwzParser.audioTracks = [ + exports.FlipnoteAudioTrack.BGM, + exports.FlipnoteAudioTrack.SE1, + exports.FlipnoteAudioTrack.SE2, + exports.FlipnoteAudioTrack.SE3, + exports.FlipnoteAudioTrack.SE4, + ]; + /** + * Which sound effect tracks are available in this format + */ + KwzParser.soundEffectTracks = [ + exports.FlipnoteSoundEffectTrack.SE1, + exports.FlipnoteSoundEffectTrack.SE2, + exports.FlipnoteSoundEffectTrack.SE3, + exports.FlipnoteSoundEffectTrack.SE4, + ]; + /** + * Global animation frame color palette + */ + KwzParser.globalPalette = [ + KWZ_PALETTE.WHITE, + KWZ_PALETTE.BLACK, + KWZ_PALETTE.RED, + KWZ_PALETTE.YELLOW, + KWZ_PALETTE.GREEN, + KWZ_PALETTE.BLUE, + KWZ_PALETTE.NONE, + ]; + /** + * Public key used for Flipnote verification, in PEM format + */ + KwzParser.publicKey = KWZ_PUBLIC_KEY; + + /** + * Indicates whether the input is a valid Flipnote Studio or Flipnote Studio 3D user ID. + */ + const isFsid = (fsid) => isPpmFsid(fsid) || isKwzFsid(fsid); + /** + * Get the region for any valid Flipnote Studio or Flipnote Studio 3D user ID + */ + const getFsidRegion = (fsid) => { + if (isPpmFsid(fsid)) + return getPpmFsidRegion(fsid); + else if (isKwzFsid(fsid)) + return getKwzFsidRegion(fsid); + return exports.FlipnoteRegion.UNKNOWN; + }; + /** + * Convert a PPM Flipnote Studio ID to the format used by KWZ Flipnote Studio IDs (as seen in Nintendo DSi Library Flipnotes). + * Will return `null` if the conversion could not be made. + * + * :::tip + * KWZ Flipnote Studio IDs contain an extra two characters at the beginning. + * It is not possible to resolve these from a PPM Flipnote Studio ID. + * ::: + */ + const ppmFsidToKwzFsidSuffix = (fsid) => { + if (isPpmFsid(fsid)) { + const a = fsid.slice(14, 16); + const b = fsid.slice(12, 14); + const c = fsid.slice(10, 12); + const d = fsid.slice(8, 10); + const e = fsid.slice(6, 8); + const f = fsid.slice(4, 6); + const g = fsid.slice(2, 4); + const h = fsid.slice(0, 2); + return `${a}-${b}${c}-${d}${e}-${f}${g}${h}`.toLocaleLowerCase(); + } + return null; + }; + /** + * Convert a PPM Flipnote Studio ID to an array of all possible matching KWZ Flipnote Studio IDs (as seen in Nintendo DSi Library Flipnotes). + * Will return `null` if the conversion could not be made. + */ + const ppmFsidToPossibleKwzFsids = (fsid) => { + const kwzIdSuffix = ppmFsidToKwzFsidSuffix(fsid); + if (kwzIdSuffix) { + return [ + '00' + kwzIdSuffix, + '10' + kwzIdSuffix, + '12' + kwzIdSuffix, + '14' + kwzIdSuffix, + ]; + } + return null; + }; + /** + * Convert a KWZ Flipnote Studio ID (from a Nintendo DSi Library Flipnote) to the format used by PPM Flipnote Studio IDs. + * Will return `null` if the conversion could not be made. + */ + const kwzFsidToPpmFsid = (fsid) => { + if (isKwzDsiLibraryFsid(fsid)) { + const a = fsid.slice(19, 21); + const b = fsid.slice(17, 19); + const c = fsid.slice(15, 17); + const d = fsid.slice(12, 14); + const e = fsid.slice(10, 12); + const f = fsid.slice(7, 9); + const g = fsid.slice(5, 7); + const h = fsid.slice(2, 4); + return `${a}${b}${c}${d}${e}${f}${g}${h}`.toUpperCase(); + } + return null; + }; + + /** + * ## Flipnote Studio IDs + * + * Flipnote Studio and Flipnote Studio 3D both generate a "unique" ID for the user when they first launch the app. + * This ID is then saved into any Flipnotes they create, and was also used as their unique identifier when they went online. + * + * In both apps, your Flipnote Studio ID can be found in the settings menu. + * + * This module contains functions for working with those IDs. + */ + + var index$3 = /*#__PURE__*/Object.freeze({ + __proto__: null, + getFsidRegion: getFsidRegion, + getKwzFsidRegion: getKwzFsidRegion, + getPpmFsidRegion: getPpmFsidRegion, + isFsid: isFsid, + isKwzDsiLibraryFsid: isKwzDsiLibraryFsid, + isKwzFsid: isKwzFsid, + isPpmFsid: isPpmFsid, + kwzFsidFormat: kwzFsidFormat, + kwzFsidToPpmFsid: kwzFsidToPpmFsid, + kwzFsidUnformat: kwzFsidUnformat, + ppmFsidToKwzFsidSuffix: ppmFsidToKwzFsidSuffix, + ppmFsidToPossibleKwzFsids: ppmFsidToPossibleKwzFsids + }); + + const REGEX_PPM_LOCAL_FILENAME = /^[0-9A-Z]{1}[0-9A-F]{5}_[0-9A-F]{13}_[0-9]{3}$/; + const REGEX_PPM_FILENAME = /^[0-9A-F]{6}_[0-9A-F]{13}_[0-9]{3}$/; + const CHECKSUM_ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + /** + * Determines if a string matches the PPM filename format. + */ + const isPpmFilename = (filename) => REGEX_PPM_LOCAL_FILENAME.test(filename); + /** + * Does the same thing as {@link isPpmFilename}, expect it only matches "basic" filenames, without the checksum character that is added when saving a Flipnote to the filesystem. + */ + const isPpmBasicFilename = (filename) => REGEX_PPM_FILENAME.test(filename); + /** + * + */ + const ppmFilenameCalculateCheckDigit = (filename) => { + let sum = parseInt(filename.slice(0, 2), 16); + for (let i = 1; i < 16; i++) { + const char = filename.charCodeAt(i); + sum = (sum + char) & 0xff; + } + return CHECKSUM_ALPHABET[sum % 36]; + }; + /** + * + */ + const ppmFilenameDecode = (filename) => { + const macSuffix = filename.slice(0, 6); + const random1 = filename.slice(7, 12); + const random2 = filename.slice(12, 19); + const edits = parseInt(filename.slice(-3)); + return { macSuffix, random1, random2, edits }; + }; + /** + * + */ + const ppmFilenameEncode = (filename) => { + const edits = filename.edits.toString().padEnd(3, '0'); + return `${filename.macSuffix}_${filename.random1}_${filename.random2}_${edits}`; + }; + + const REGEX_KWZ_FILENAME = /^[0-5a-z]{28}$/; + const BASE32_ALPHABET = 'cwmfjordvegbalksnthpyxquiz012345'; + const base32Decode = (src) => { + const srcSize = src.length; + let srcPtr = 0; + const dst = new Uint8Array(srcSize * 5 / 8); + let dstPtr = 0; + let value = 0; + for (let i = 0; i < srcSize; i++) { + value = (value << 5) | BASE32_ALPHABET.indexOf(src[i]); + srcPtr += 5; + if (srcPtr >= 8) { + dst[dstPtr++] = (value >>> (srcPtr - 8)) & 0xFF; + srcPtr -= 8; + } + } + return dst; + }; + const base32Encode = (src) => { + const srcSize = src.byteLength; + let srcPtr = 0; + let dst = ''; + let value = 0; + for (let i = 0; i < srcSize; i++) { + value = (value << 8) | src[i]; + srcPtr += 8; + while (srcPtr >= 5) { + dst += BASE32_ALPHABET[(value >>> (srcPtr - 5)) & 31]; + srcPtr -= 5; + } + } + if (srcPtr > 0) + dst += BASE32_ALPHABET[(value << (5 - srcPtr)) & 31]; + return dst; + }; + /** + * Determines if a string matches the KWZ filename format. + */ + const isKwzFilename = (filename) => REGEX_KWZ_FILENAME.test(filename); + /** + * Decode a KWZ filename into its constituent parts. + */ + const kwzFilenameDecode = (filename) => { + const bytes = base32Decode(filename); + const data = new DataView(bytes.buffer); + const fsid = kwzFsidFormat(hexFromBytes(bytes.slice(0, 9))); + const created = dateFromNintendoTimestamp(data.getUint32(9, true)); + const edited = dateFromNintendoTimestamp(data.getUint32(13, true)); + return { fsid, created, edited }; + }; + /** + * Encode a KWZ filename from its parts; i.e. do the inverse of {@link kwzFilenameDecode}. + */ + const kwzFilenameEncode = (filename) => { + const bytes = new Uint8Array(17); + const data = new DataView(bytes.buffer); + const fsid = kwzFsidUnformat(filename.fsid); + bytes.set(hexToBytes(fsid)); + data.setUint32(9, dateToNintendoTimestamp(filename.created), true); + data.setUint32(13, dateToNintendoTimestamp(filename.edited), true); + return base32Encode(bytes); + }; + + /** + * ## Flipnote Filenames + * + * Flipnote Studio and Flipnote Studio 3D both autogenerate filenames when Flipnotes are saved. + * This module contains functions for testing and working with those Flipnote filenames. + */ + + var index$2 = /*#__PURE__*/Object.freeze({ + __proto__: null, + isKwzFilename: isKwzFilename, + isPpmBasicFilename: isPpmBasicFilename, + isPpmFilename: isPpmFilename, + kwzFilenameDecode: kwzFilenameDecode, + kwzFilenameEncode: kwzFilenameEncode, + ppmFilenameCalculateCheckDigit: ppmFilenameCalculateCheckDigit, + ppmFilenameDecode: ppmFilenameDecode, + ppmFilenameEncode: ppmFilenameEncode + }); + + const XOR_KEY = [ + 0xF7, 0x4C, 0x6A, 0x3A, 0xFB, 0x82, 0xA6, 0x37, + 0x6E, 0x11, 0x38, 0xCF, 0xA0, 0xDD, 0x85, 0xC0, + 0xC7, 0x9B, 0xC4, 0xD8, 0xDD, 0x28, 0x8A, 0x87, + 0x53, 0x20, 0xEE, 0xE0, 0x0B, 0xEB, 0x43, 0xA0, + 0xDB, 0x55, 0x0F, 0x75, 0x36, 0x37, 0xEB, 0x35, + 0x6A, 0x34, 0x7F, 0xB5, 0x0F, 0x99, 0xF7, 0xEF, + 0x43, 0x25, 0xCE, 0xA0, 0x29, 0x46, 0xD9, 0xD4, + 0x4D, 0xBB, 0x04, 0x66, 0x68, 0x08, 0xF1, 0xF8, + ]; + class BasePlaylistParser extends DataStream { + constructor(buffer) { + super(buffer); + /** + * List of filepaths in the playlist. + */ + this.entries = []; + } + addEntry(full) { + const parts = full.split('/'); + const name = parts.at(-1); + const lastDot = full.lastIndexOf('.'); + this.entries.push({ + full, + name, + base: full.slice(0, lastDot), + ext: full.slice(lastDot + 1), + folder: parts.at(-2), + parentFolder: parts.at(-3) + }); + } + } + + /** + * Parses .pls and .lst playlist files from Flipnote Studio (DSiWare). + * + * > This only supports playlists from version 2 of Flipnote Studio. + * > Since version 1 was only ever released in Japan (and for a short period of time at that) I didn't bother including support. + * + * File format documentation is at https://github.com/Flipnote-Collective/flipnote-studio-docs/wiki/.pls-and-.lst-files + */ + class PpmPlaylist extends BasePlaylistParser { + constructor(buffer) { + super(buffer); + this.format = exports.FlipnoteFormat.PPM; + // Decrypt + const bytes = this.bytes; + const size = this.numBytes; + for (let i = 0; i < size; i++) + bytes[i] = bytes[i] ^ XOR_KEY[i % 64]; + // Parse + let currPath = ''; + while (!this.end()) { + const char = this.readChar(); + if (char === '\x0A') { + this.addEntry(currPath); + currPath = ''; + continue; + } + currPath += char; + } + } + } + + /** + * Parses .lst playlist files from Flipnote Studio 3D. + * + * File format documentation is at https://github.com/Flipnote-Collective/flipnote-studio-3d-docs/wiki/lst-format + */ + class KwzPlaylist extends BasePlaylistParser { + constructor(buffer) { + super(buffer); + this.format = exports.FlipnoteFormat.KWZ; + // Decrypt + const bytes = this.bytes; + const size = this.numBytes; + for (let i = 0; i < size; i++) + bytes[i] = bytes[i] ^ XOR_KEY[i % 61]; // Yes, the KWZ playlist format doesn't use the full key length. + // Parse + let currPath = ''; + this.seek(4); + const separator = this.readWideChar(); + while (!this.end()) { + const char = this.readWideChar(); + if (char === separator) { + this.addEntry(currPath); + currPath = ''; + continue; + } + currPath += char; + } + } + } + + /** + * Loader for web url strings (Browser only) + * @group Loader + */ + const urlLoader = { + name: 'url', + matches(source) { + return typeof source === 'string'; + }, + async load(source) { + const response = await fetch(source); + assert(response.status >= 200 && response.status < 300, `Failed to load Flipnote from URL, response failed with status ${response.status}`); + return await response.arrayBuffer(); + } + }; + + /** + * Loader for File objects (browser only) + * @group Loader + */ + const fileLoader = { + name: 'file', + matches(source) { + return isBrowser + && typeof File !== 'undefined' + && typeof FileReader !== 'undefined' + && source instanceof File; + }, + async load(source) { + return source.arrayBuffer(); + } + }; + + /** + * Loader for Blob objects (browser only) + * @group Loader + */ + const blobLoader = { + name: 'blob', + matches(source) { + return isBrowser + && typeof Blob !== 'undefined' + && typeof Response !== 'undefined' + && source instanceof Blob; + }, + async load(source) { + return source.arrayBuffer(); + } + }; + + /** + * Loader for Buffer objects (Node only) + * @group Loader + */ + const nodeBufferLoader = { + name: 'node-buffer', + matches(source) { + return isNode && (source instanceof Buffer); + }, + async load(source) { + return source.buffer; + } + }; + + /** + * Loader for ArrayBuffer objects. + * @group Loader + */ + const arrayBufferLoader = { + name: 'array-buffer', + matches(source) { + return source instanceof ArrayBuffer; + }, + async load(source) { + return source; + } + }; + + const LOADER_REGISTRY = new Map(); + /** + * Resolve a source, using the current loaders list. + * Returns an ArrayBuffer containing the data loaded from the source. + */ + const load = (source) => { + for (let [name, loader] of LOADER_REGISTRY) { + if (!loader.matches(source)) + continue; + try { + return loader.load(source); + } + catch (e) { + err(`Failed to load Flipnote from source, loader "${name}" failed with error ${err}`); + } + } + err('No loader available for source type'); + }; + /** + * List all currently registered loaders, as an object. + */ + const list = () => Object.fromEntries(LOADER_REGISTRY.entries()); + /** + * Clear all currently registered loaders. + */ + const clear = () => LOADER_REGISTRY.clear(); + /** + * Register a resource loader to use when loading Flipnotes. + * A loader should take a source and return an ArrayBuffer. + */ + const register = (loader) => { + LOADER_REGISTRY.set(loader.name, loader); + }; + register(arrayBufferLoader); + register(nodeBufferLoader); + register(blobLoader); + register(fileLoader); + register(urlLoader); + + var index$1 = /*#__PURE__*/Object.freeze({ + __proto__: null, + clear: clear, + list: list, + load: load, + register: register + }); + + /** + * Parse a playlist file (.pls or .lst) from Flipnote Studio or Flipnote Studio 3D. + */ + const parse$1 = async (format, source) => { + const buffer = await load(source); + if (format === exports.FlipnoteFormat.PPM || format === 'ppm') + return new PpmPlaylist(buffer); + if (format === exports.FlipnoteFormat.KWZ || format === 'kwz') + return new KwzPlaylist(buffer); + }; + + var index = /*#__PURE__*/Object.freeze({ + __proto__: null, + KwzPlaylist: KwzPlaylist, + PpmPlaylist: PpmPlaylist, + parse: parse$1 + }); + + /** + * Load a Flipnote from a given source, returning a promise with a parser object. + * It will auto-detect the Flipnote format and return either a {@link PpmParser} or {@link KwzParser} accordingly. + * + * @param source - Source to load a Flipnote from. This will attempt to use one of the registered {@link loaders} to load the Flipnote. + * Depending on the operating environment, the default loader set supports the following sources: + * - A string representing a web URL. + * - An {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer | ArrayBuffer}. + * - A {@link https://developer.mozilla.org/en-US/docs/Web/API/File | File} object (Browser only). + * - A {@link https://nodejs.org/api/buffer.html | Buffer} object (NodeJS only). + * + * @param parserConfig - Config settings to pass to the parser, see {@link FlipnoteParserSettings}. + */ + const parse = async (source, parserConfig) => { + const buffer = await load(source); + if (PpmParser.matchBuffer(buffer)) + return new PpmParser(buffer, parserConfig); + if (KwzParser.matchBuffer(buffer)) + return new KwzParser(buffer, parserConfig); + err('Could not identify source as a valid Flipnote file'); + }; + /** + * @deprecated Use {@link parse} instead. + */ + const parseSource = (...args) => { + console.warn('parseSource() is deprecated, please use parse() instead'); + return parse(...args); + }; + + /** + * Player event types + */ + exports.PlayerEvent = void 0; + (function (PlayerEvent) { + PlayerEvent["__Any"] = "any"; + PlayerEvent["Play"] = "play"; + PlayerEvent["Pause"] = "pause"; + PlayerEvent["CanPlay"] = "canplay"; + PlayerEvent["CanPlayThrough"] = "canplaythrough"; + PlayerEvent["SeekStart"] = "seeking"; + PlayerEvent["SeekEnd"] = "seeked"; + PlayerEvent["Duration"] = "durationchange"; + PlayerEvent["Loop"] = "loop"; + PlayerEvent["Ended"] = "ended"; + PlayerEvent["VolumeChange"] = "volumechange"; + PlayerEvent["Progress"] = "progress"; + PlayerEvent["TimeUpdate"] = "timeupdate"; + PlayerEvent["FrameUpdate"] = "frameupdate"; + PlayerEvent["FrameNext"] = "framenext"; + PlayerEvent["FramePrev"] = "frameprev"; + PlayerEvent["FrameFirst"] = "framefirst"; + PlayerEvent["FrameLast"] = "framelast"; + PlayerEvent["Ready"] = "ready"; + PlayerEvent["Load"] = "load"; + PlayerEvent["LoadStart"] = "loadstart"; + PlayerEvent["LoadedData"] = "loadeddata"; + PlayerEvent["LoadedMeta"] = "loadedmetadata"; + PlayerEvent["Emptied"] = "emptied"; + PlayerEvent["Close"] = "close"; + PlayerEvent["Error"] = "error"; + PlayerEvent["Destroy"] = "destroy"; + })(exports.PlayerEvent || (exports.PlayerEvent = {})); + /** + * @internal + */ + const supportedEvents = [ + exports.PlayerEvent.Play, + exports.PlayerEvent.Pause, + exports.PlayerEvent.CanPlay, + exports.PlayerEvent.CanPlayThrough, + exports.PlayerEvent.SeekStart, + exports.PlayerEvent.SeekEnd, + exports.PlayerEvent.Duration, + exports.PlayerEvent.Loop, + exports.PlayerEvent.Ended, + exports.PlayerEvent.VolumeChange, + exports.PlayerEvent.Progress, + exports.PlayerEvent.TimeUpdate, + exports.PlayerEvent.FrameUpdate, + exports.PlayerEvent.FrameNext, + exports.PlayerEvent.FramePrev, + exports.PlayerEvent.FrameFirst, + exports.PlayerEvent.FrameLast, + exports.PlayerEvent.Ready, + exports.PlayerEvent.Load, + exports.PlayerEvent.LoadStart, + exports.PlayerEvent.LoadedData, + exports.PlayerEvent.LoadedMeta, + exports.PlayerEvent.Emptied, + exports.PlayerEvent.Close, + exports.PlayerEvent.Error, + ]; + + /** + * @internal + */ + const createTimeRanges = (ranges) => ({ + length: ranges.length, + start: (i) => ranges[i][0], + end: (i) => ranges[i][1], + }); + /** + * @internal + */ + const padNumber = (num, strLength) => num.toString().padStart(strLength, '0'); + /** + * @internal + */ + const formatTime = (seconds) => { + const m = Math.floor((seconds % 3600) / 60); + const s = Math.floor(seconds % 60); + return `${m}:${padNumber(s, 2)}`; + }; + + exports.CanvasStereoscopicMode = void 0; + (function (CanvasStereoscopicMode) { + CanvasStereoscopicMode[CanvasStereoscopicMode["None"] = 0] = "None"; + CanvasStereoscopicMode[CanvasStereoscopicMode["Dual"] = 1] = "Dual"; + // not actually supported, sorry! + CanvasStereoscopicMode[CanvasStereoscopicMode["Anaglyph"] = 2] = "Anaglyph"; + })(exports.CanvasStereoscopicMode || (exports.CanvasStereoscopicMode = {})); + /** + * @internal + */ + class CanvasInterface { + constructor(parent, width, height, options) { } + } + + /* @license twgl.js 5.5.4 Copyright (c) 2015, Gregg Tavares All Rights Reserved. + Available via the MIT license. + see: http://github.com/greggman/twgl.js for details */ + /* + * Copyright 2019 Gregg Tavares + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + */ + + + /* + * Copyright 2019 Gregg Tavares + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + */ + + /* DataType */ + const BYTE$2 = 0x1400; + const UNSIGNED_BYTE$3 = 0x1401; + const SHORT$2 = 0x1402; + const UNSIGNED_SHORT$3 = 0x1403; + const INT$3 = 0x1404; + const UNSIGNED_INT$3 = 0x1405; + const FLOAT$3 = 0x1406; + const UNSIGNED_SHORT_4_4_4_4$1 = 0x8033; + const UNSIGNED_SHORT_5_5_5_1$1 = 0x8034; + const UNSIGNED_SHORT_5_6_5$1 = 0x8363; + const HALF_FLOAT$1 = 0x140B; + const UNSIGNED_INT_2_10_10_10_REV$1 = 0x8368; + const UNSIGNED_INT_10F_11F_11F_REV$1 = 0x8C3B; + const UNSIGNED_INT_5_9_9_9_REV$1 = 0x8C3E; + const FLOAT_32_UNSIGNED_INT_24_8_REV$1 = 0x8DAD; + const UNSIGNED_INT_24_8$1 = 0x84FA; + + const glTypeToTypedArray = {}; + { + const tt = glTypeToTypedArray; + tt[BYTE$2] = Int8Array; + tt[UNSIGNED_BYTE$3] = Uint8Array; + tt[SHORT$2] = Int16Array; + tt[UNSIGNED_SHORT$3] = Uint16Array; + tt[INT$3] = Int32Array; + tt[UNSIGNED_INT$3] = Uint32Array; + tt[FLOAT$3] = Float32Array; + tt[UNSIGNED_SHORT_4_4_4_4$1] = Uint16Array; + tt[UNSIGNED_SHORT_5_5_5_1$1] = Uint16Array; + tt[UNSIGNED_SHORT_5_6_5$1] = Uint16Array; + tt[HALF_FLOAT$1] = Uint16Array; + tt[UNSIGNED_INT_2_10_10_10_REV$1] = Uint32Array; + tt[UNSIGNED_INT_10F_11F_11F_REV$1] = Uint32Array; + tt[UNSIGNED_INT_5_9_9_9_REV$1] = Uint32Array; + tt[FLOAT_32_UNSIGNED_INT_24_8_REV$1] = Uint32Array; + tt[UNSIGNED_INT_24_8$1] = Uint32Array; + } + + /** + * Get the GL type for a typedArray + * @param {ArrayBufferView} typedArray a typedArray + * @return {number} the GL type for array. For example pass in an `Int8Array` and `gl.BYTE` will + * be returned. Pass in a `Uint32Array` and `gl.UNSIGNED_INT` will be returned + * @memberOf module:twgl/typedArray + */ + function getGLTypeForTypedArray(typedArray) { + if (typedArray instanceof Int8Array) { return BYTE$2; } // eslint-disable-line + if (typedArray instanceof Uint8Array) { return UNSIGNED_BYTE$3; } // eslint-disable-line + if (typedArray instanceof Uint8ClampedArray) { return UNSIGNED_BYTE$3; } // eslint-disable-line + if (typedArray instanceof Int16Array) { return SHORT$2; } // eslint-disable-line + if (typedArray instanceof Uint16Array) { return UNSIGNED_SHORT$3; } // eslint-disable-line + if (typedArray instanceof Int32Array) { return INT$3; } // eslint-disable-line + if (typedArray instanceof Uint32Array) { return UNSIGNED_INT$3; } // eslint-disable-line + if (typedArray instanceof Float32Array) { return FLOAT$3; } // eslint-disable-line + throw new Error('unsupported typed array type'); + } + + /** + * Get the GL type for a typedArray type + * @param {ArrayBufferView} typedArrayType a typedArray constructor + * @return {number} the GL type for type. For example pass in `Int8Array` and `gl.BYTE` will + * be returned. Pass in `Uint32Array` and `gl.UNSIGNED_INT` will be returned + * @memberOf module:twgl/typedArray + */ + function getGLTypeForTypedArrayType(typedArrayType) { + if (typedArrayType === Int8Array) { return BYTE$2; } // eslint-disable-line + if (typedArrayType === Uint8Array) { return UNSIGNED_BYTE$3; } // eslint-disable-line + if (typedArrayType === Uint8ClampedArray) { return UNSIGNED_BYTE$3; } // eslint-disable-line + if (typedArrayType === Int16Array) { return SHORT$2; } // eslint-disable-line + if (typedArrayType === Uint16Array) { return UNSIGNED_SHORT$3; } // eslint-disable-line + if (typedArrayType === Int32Array) { return INT$3; } // eslint-disable-line + if (typedArrayType === Uint32Array) { return UNSIGNED_INT$3; } // eslint-disable-line + if (typedArrayType === Float32Array) { return FLOAT$3; } // eslint-disable-line + throw new Error('unsupported typed array type'); + } + + /** + * Get the typed array constructor for a given GL type + * @param {number} type the GL type. (eg: `gl.UNSIGNED_INT`) + * @return {function} the constructor for a the corresponding typed array. (eg. `Uint32Array`). + * @memberOf module:twgl/typedArray + */ + function getTypedArrayTypeForGLType(type) { + const CTOR = glTypeToTypedArray[type]; + if (!CTOR) { + throw new Error('unknown gl type'); + } + return CTOR; + } + + const isArrayBuffer$1 = typeof SharedArrayBuffer !== 'undefined' + ? function isArrayBufferOrSharedArrayBuffer(a) { + return a && a.buffer && (a.buffer instanceof ArrayBuffer || a.buffer instanceof SharedArrayBuffer); + } + : function isArrayBuffer(a) { + return a && a.buffer && a.buffer instanceof ArrayBuffer; + }; + + const isTypeWeakMaps = new Map(); + + function isType(object, type) { + if (!object || typeof object !== 'object') { + return false; + } + let weakMap = isTypeWeakMaps.get(type); + if (!weakMap) { + weakMap = new WeakMap(); + isTypeWeakMaps.set(type, weakMap); + } + let isOfType = weakMap.get(object); + if (isOfType === undefined) { + const s = Object.prototype.toString.call(object); + isOfType = s.substring(8, s.length - 1) === type; + weakMap.set(object, isOfType); + } + return isOfType; + } + + function isBuffer(gl, t) { + return typeof WebGLBuffer !== 'undefined' && isType(t, 'WebGLBuffer'); + } + + function isTexture(gl, t) { + return typeof WebGLTexture !== 'undefined' && isType(t, 'WebGLTexture'); + } + + /* + * Copyright 2019 Gregg Tavares + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + */ + + const STATIC_DRAW = 0x88e4; + const ARRAY_BUFFER$1 = 0x8892; + const ELEMENT_ARRAY_BUFFER$2 = 0x8893; + const BUFFER_SIZE = 0x8764; + + const BYTE$1 = 0x1400; + const UNSIGNED_BYTE$2 = 0x1401; + const SHORT$1 = 0x1402; + const UNSIGNED_SHORT$2 = 0x1403; + const INT$2 = 0x1404; + const UNSIGNED_INT$2 = 0x1405; + const FLOAT$2 = 0x1406; + const defaults$2 = { + attribPrefix: "", + }; + + function setBufferFromTypedArray(gl, type, buffer, array, drawType) { + gl.bindBuffer(type, buffer); + gl.bufferData(type, array, drawType || STATIC_DRAW); + } + + /** + * Given typed array creates a WebGLBuffer and copies the typed array + * into it. + * + * @param {WebGLRenderingContext} gl A WebGLRenderingContext + * @param {ArrayBuffer|SharedArrayBuffer|ArrayBufferView|WebGLBuffer} typedArray the typed array. Note: If a WebGLBuffer is passed in it will just be returned. No action will be taken + * @param {number} [type] the GL bind type for the buffer. Default = `gl.ARRAY_BUFFER`. + * @param {number} [drawType] the GL draw type for the buffer. Default = 'gl.STATIC_DRAW`. + * @return {WebGLBuffer} the created WebGLBuffer + * @memberOf module:twgl/attributes + */ + function createBufferFromTypedArray(gl, typedArray, type, drawType) { + if (isBuffer(gl, typedArray)) { + return typedArray; + } + type = type || ARRAY_BUFFER$1; + const buffer = gl.createBuffer(); + setBufferFromTypedArray(gl, type, buffer, typedArray, drawType); + return buffer; + } + + function isIndices(name) { + return name === "indices"; + } + + // This is really just a guess. Though I can't really imagine using + // anything else? Maybe for some compression? + function getNormalizationForTypedArrayType(typedArrayType) { + if (typedArrayType === Int8Array) { return true; } // eslint-disable-line + if (typedArrayType === Uint8Array) { return true; } // eslint-disable-line + return false; + } + + function getArray$1(array) { + return array.length ? array : array.data; + } + + const texcoordRE = /coord|texture/i; + const colorRE = /color|colour/i; + + function guessNumComponentsFromName(name, length) { + let numComponents; + if (texcoordRE.test(name)) { + numComponents = 2; + } else if (colorRE.test(name)) { + numComponents = 4; + } else { + numComponents = 3; // position, normals, indices ... + } + + if (length % numComponents > 0) { + throw new Error(`Can not guess numComponents for attribute '${name}'. Tried ${numComponents} but ${length} values is not evenly divisible by ${numComponents}. You should specify it.`); + } + + return numComponents; + } + + function getNumComponents$1(array, arrayName, numValues) { + return array.numComponents || array.size || guessNumComponentsFromName(arrayName, numValues || getArray$1(array).length); + } + + function makeTypedArray(array, name) { + if (isArrayBuffer$1(array)) { + return array; + } + + if (isArrayBuffer$1(array.data)) { + return array.data; + } + + if (Array.isArray(array)) { + array = { + data: array, + }; + } + + let Type = array.type ? typedArrayTypeFromGLTypeOrTypedArrayCtor(array.type) : undefined; + if (!Type) { + if (isIndices(name)) { + Type = Uint16Array; + } else { + Type = Float32Array; + } + } + return new Type(array.data); + } + + function glTypeFromGLTypeOrTypedArrayType(glTypeOrTypedArrayCtor) { + return typeof glTypeOrTypedArrayCtor === 'number' + ? glTypeOrTypedArrayCtor + : glTypeOrTypedArrayCtor ? getGLTypeForTypedArrayType(glTypeOrTypedArrayCtor) : FLOAT$2; + } + + function typedArrayTypeFromGLTypeOrTypedArrayCtor(glTypeOrTypedArrayCtor) { + return typeof glTypeOrTypedArrayCtor === 'number' + ? getTypedArrayTypeForGLType(glTypeOrTypedArrayCtor) + : glTypeOrTypedArrayCtor || Float32Array; + } + + function attribBufferFromBuffer(gl, array/*, arrayName */) { + return { + buffer: array.buffer, + numValues: 2 * 3 * 4, // safely divided by 2, 3, 4 + type: glTypeFromGLTypeOrTypedArrayType(array.type), + arrayType: typedArrayTypeFromGLTypeOrTypedArrayCtor(array.type), + }; + } + + function attribBufferFromSize(gl, array/*, arrayName*/) { + const numValues = array.data || array; + const arrayType = typedArrayTypeFromGLTypeOrTypedArrayCtor(array.type); + const numBytes = numValues * arrayType.BYTES_PER_ELEMENT; + const buffer = gl.createBuffer(); + gl.bindBuffer(ARRAY_BUFFER$1, buffer); + gl.bufferData(ARRAY_BUFFER$1, numBytes, array.drawType || STATIC_DRAW); + return { + buffer, + numValues, + type: getGLTypeForTypedArrayType(arrayType), + arrayType, + }; + } + + function attribBufferFromArrayLike(gl, array, arrayName) { + const typedArray = makeTypedArray(array, arrayName); + return { + arrayType: typedArray.constructor, + buffer: createBufferFromTypedArray(gl, typedArray, undefined, array.drawType), + type: getGLTypeForTypedArray(typedArray), + numValues: 0, + }; + } + + /** + * The info for an attribute. This is effectively just the arguments to `gl.vertexAttribPointer` plus the WebGLBuffer + * for the attribute. + * + * @typedef {Object} AttribInfo + * @property {number[]|ArrayBufferView} [value] a constant value for the attribute. Note: if this is set the attribute will be + * disabled and set to this constant value and all other values will be ignored. + * @property {number} [numComponents] the number of components for this attribute. + * @property {number} [size] synonym for `numComponents`. + * @property {number} [type] the type of the attribute (eg. `gl.FLOAT`, `gl.UNSIGNED_BYTE`, etc...) Default = `gl.FLOAT` + * @property {boolean} [normalize] whether or not to normalize the data. Default = false + * @property {number} [offset] offset into buffer in bytes. Default = 0 + * @property {number} [stride] the stride in bytes per element. Default = 0 + * @property {number} [divisor] the divisor in instances. Default = 0. + * Requires WebGL2 or the ANGLE_instanced_arrays extension. + * and, if you're using WebGL1 you must have called {@link module:twgl.addExtensionsToContext} + * @property {WebGLBuffer} buffer the buffer that contains the data for this attribute + * @property {number} [drawType] the draw type passed to gl.bufferData. Default = gl.STATIC_DRAW + * @memberOf module:twgl + */ + + /** + * @typedef {(Int8ArrayConstructor|Uint8ArrayConstructor|Int16ArrayConstructor|Uint16ArrayConstructor|Int32ArrayConstructor|Uint32ArrayConstructor|Float32ArrayConstructor)} TypedArrayConstructor + */ + + /** + * Use this type of array spec when TWGL can't guess the type or number of components of an array + * @typedef {Object} FullArraySpec + * @property {number[]|ArrayBufferView} [value] a constant value for the attribute. Note: if this is set the attribute will be + * disabled and set to this constant value and all other values will be ignored. + * @property {(number|number[]|ArrayBufferView)} [data] The data of the array. A number alone becomes the number of elements of type. + * @property {number} [numComponents] number of components for `vertexAttribPointer`. Default is based on the name of the array. + * If `coord` is in the name assumes `numComponents = 2`. + * If `color` is in the name assumes `numComponents = 4`. + * otherwise assumes `numComponents = 3` + * @property {number|TypedArrayConstructor} [type] type. This is used if `data` is a JavaScript array, or `buffer` is passed in, or `data` is a number. + * It can either be the constructor for a typedarray. (eg. `Uint8Array`) OR a WebGL type, (eg `gl.UNSIGNED_BYTE`). + * For example if you want colors in a `Uint8Array` you might have a `FullArraySpec` like `{ type: gl.UNSIGNED_BYTE, data: [255,0,255,255, ...], }`. + * @property {number} [size] synonym for `numComponents`. + * @property {boolean} [normalize] normalize for `vertexAttribPointer`. Default is true if type is `Int8Array` or `Uint8Array` otherwise false. + * @property {number} [stride] stride for `vertexAttribPointer`. Default = 0 + * @property {number} [offset] offset for `vertexAttribPointer`. Default = 0 + * @property {number} [divisor] divisor for `vertexAttribDivisor`. Default = 0. + * Requires WebGL2 or the ANGLE_instanced_arrays extension. + * and, if you using WebGL1 you must have called {@link module:twgl.addExtensionsToContext} + * @property {string} [attrib] name of attribute this array maps to. Defaults to same name as array prefixed by the default attribPrefix. + * @property {string} [name] synonym for `attrib`. + * @property {string} [attribName] synonym for `attrib`. + * @property {WebGLBuffer} [buffer] Buffer to use for this attribute. This lets you use your own buffer + * but you will need to supply `numComponents` and `type`. You can effectively pass an `AttribInfo` + * to provide this. Example: + * + * const bufferInfo1 = twgl.createBufferInfoFromArrays(gl, { + * position: [1, 2, 3, ... ], + * }); + * const bufferInfo2 = twgl.createBufferInfoFromArrays(gl, { + * position: bufferInfo1.attribs.position, // use the same buffer from bufferInfo1 + * }); + * + * @property {number} [drawType] the draw type passed to gl.bufferData. Default = gl.STATIC_DRAW + * @memberOf module:twgl + */ + + /** + * An individual array in {@link module:twgl.Arrays} + * + * When passed to {@link module:twgl.createBufferInfoFromArrays} if an ArraySpec is `number[]` or `ArrayBufferView` + * the types will be guessed based on the name. `indices` will be `Uint16Array`, everything else will + * be `Float32Array`. If an ArraySpec is a number it's the number of floats for an empty (zeroed) buffer. + * + * @typedef {(number|number[]|ArrayBufferView|module:twgl.FullArraySpec)} ArraySpec + * @memberOf module:twgl + */ + + /** + * This is a JavaScript object of arrays by name. The names should match your shader's attributes. If your + * attributes have a common prefix you can specify it by calling {@link module:twgl.setAttributePrefix}. + * + * Bare JavaScript Arrays + * + * var arrays = { + * position: [-1, 1, 0], + * normal: [0, 1, 0], + * ... + * } + * + * Bare TypedArrays + * + * var arrays = { + * position: new Float32Array([-1, 1, 0]), + * color: new Uint8Array([255, 128, 64, 255]), + * ... + * } + * + * * Will guess at `numComponents` if not specified based on name. + * + * If `coord` is in the name assumes `numComponents = 2` + * + * If `color` is in the name assumes `numComponents = 4` + * + * otherwise assumes `numComponents = 3` + * + * Objects with various fields. See {@link module:twgl.FullArraySpec}. + * + * var arrays = { + * position: { numComponents: 3, data: [0, 0, 0, 10, 0, 0, 0, 10, 0, 10, 10, 0], }, + * texcoord: { numComponents: 2, data: [0, 0, 0, 1, 1, 0, 1, 1], }, + * normal: { numComponents: 3, data: [0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1], }, + * indices: { numComponents: 3, data: [0, 1, 2, 1, 2, 3], }, + * }; + * + * @typedef {Object.} Arrays + * @memberOf module:twgl + */ + + + /** + * Creates a set of attribute data and WebGLBuffers from set of arrays + * + * Given + * + * var arrays = { + * position: { numComponents: 3, data: [0, 0, 0, 10, 0, 0, 0, 10, 0, 10, 10, 0], }, + * texcoord: { numComponents: 2, data: [0, 0, 0, 1, 1, 0, 1, 1], }, + * normal: { numComponents: 3, data: [0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1], }, + * color: { numComponents: 4, data: [255, 255, 255, 255, 255, 0, 0, 255, 0, 0, 255, 255], type: Uint8Array, }, + * indices: { numComponents: 3, data: [0, 1, 2, 1, 2, 3], }, + * }; + * + * returns something like + * + * var attribs = { + * position: { numComponents: 3, type: gl.FLOAT, normalize: false, buffer: WebGLBuffer, }, + * texcoord: { numComponents: 2, type: gl.FLOAT, normalize: false, buffer: WebGLBuffer, }, + * normal: { numComponents: 3, type: gl.FLOAT, normalize: false, buffer: WebGLBuffer, }, + * color: { numComponents: 4, type: gl.UNSIGNED_BYTE, normalize: true, buffer: WebGLBuffer, }, + * }; + * + * notes: + * + * * Arrays can take various forms + * + * Bare JavaScript Arrays + * + * var arrays = { + * position: [-1, 1, 0], + * normal: [0, 1, 0], + * ... + * } + * + * Bare TypedArrays + * + * var arrays = { + * position: new Float32Array([-1, 1, 0]), + * color: new Uint8Array([255, 128, 64, 255]), + * ... + * } + * + * * Will guess at `numComponents` if not specified based on name. + * + * If `coord` is in the name assumes `numComponents = 2` + * + * If `color` is in the name assumes `numComponents = 4` + * + * otherwise assumes `numComponents = 3` + * + * @param {WebGLRenderingContext} gl The webgl rendering context. + * @param {module:twgl.Arrays} arrays The arrays + * @param {module:twgl.BufferInfo} [srcBufferInfo] a BufferInfo to copy from + * This lets you share buffers. Any arrays you supply will override + * the buffers from srcBufferInfo. + * @return {Object.} the attribs + * @memberOf module:twgl/attributes + */ + function createAttribsFromArrays(gl, arrays) { + const attribs = {}; + Object.keys(arrays).forEach(function(arrayName) { + if (!isIndices(arrayName)) { + const array = arrays[arrayName]; + const attribName = array.attrib || array.name || array.attribName || (defaults$2.attribPrefix + arrayName); + if (array.value) { + if (!Array.isArray(array.value) && !isArrayBuffer$1(array.value)) { + throw new Error('array.value is not array or typedarray'); + } + attribs[attribName] = { + value: array.value, + }; + } else { + let fn; + if (array.buffer && array.buffer instanceof WebGLBuffer) { + fn = attribBufferFromBuffer; + } else if (typeof array === "number" || typeof array.data === "number") { + fn = attribBufferFromSize; + } else { + fn = attribBufferFromArrayLike; + } + const {buffer, type, numValues, arrayType} = fn(gl, array, arrayName); + const normalization = array.normalize !== undefined ? array.normalize : getNormalizationForTypedArrayType(arrayType); + const numComponents = getNumComponents$1(array, arrayName, numValues); + attribs[attribName] = { + buffer: buffer, + numComponents: numComponents, + type: type, + normalize: normalization, + stride: array.stride || 0, + offset: array.offset || 0, + divisor: array.divisor === undefined ? undefined : array.divisor, + drawType: array.drawType, + }; + } + } + }); + gl.bindBuffer(ARRAY_BUFFER$1, null); + return attribs; + } + + function getBytesPerValueForGLType(gl, type) { + if (type === BYTE$1) return 1; // eslint-disable-line + if (type === UNSIGNED_BYTE$2) return 1; // eslint-disable-line + if (type === SHORT$1) return 2; // eslint-disable-line + if (type === UNSIGNED_SHORT$2) return 2; // eslint-disable-line + if (type === INT$2) return 4; // eslint-disable-line + if (type === UNSIGNED_INT$2) return 4; // eslint-disable-line + if (type === FLOAT$2) return 4; // eslint-disable-line + return 0; + } + + // Tries to get the number of elements from a set of arrays. + const positionKeys = ['position', 'positions', 'a_position']; + + function getNumElementsFromAttributes(gl, attribs) { + let key; + let ii; + for (ii = 0; ii < positionKeys.length; ++ii) { + key = positionKeys[ii]; + if (key in attribs) { + break; + } + key = defaults$2.attribPrefix + key; + if (key in attribs) { + break; + } + } + if (ii === positionKeys.length) { + key = Object.keys(attribs)[0]; + } + const attrib = attribs[key]; + if (!attrib.buffer) { + return 1; // There's no buffer + } + gl.bindBuffer(ARRAY_BUFFER$1, attrib.buffer); + const numBytes = gl.getBufferParameter(ARRAY_BUFFER$1, BUFFER_SIZE); + gl.bindBuffer(ARRAY_BUFFER$1, null); + + const bytesPerValue = getBytesPerValueForGLType(gl, attrib.type); + const totalElements = numBytes / bytesPerValue; + const numComponents = attrib.numComponents || attrib.size; + // TODO: check stride + const numElements = totalElements / numComponents; + if (numElements % 1 !== 0) { + throw new Error(`numComponents ${numComponents} not correct for length ${length}`); + } + return numElements; + } + + /** + * @typedef {Object} BufferInfo + * @property {number} numElements The number of elements to pass to `gl.drawArrays` or `gl.drawElements`. + * @property {number} [elementType] The type of indices `UNSIGNED_BYTE`, `UNSIGNED_SHORT` etc.. + * @property {WebGLBuffer} [indices] The indices `ELEMENT_ARRAY_BUFFER` if any indices exist. + * @property {Object.} [attribs] The attribs appropriate to call `setAttributes` + * @memberOf module:twgl + */ + + /** + * Creates a BufferInfo from an object of arrays. + * + * This can be passed to {@link module:twgl.setBuffersAndAttributes} and to + * {@link module:twgl:drawBufferInfo}. + * + * Given an object like + * + * var arrays = { + * position: { numComponents: 3, data: [0, 0, 0, 10, 0, 0, 0, 10, 0, 10, 10, 0], }, + * texcoord: { numComponents: 2, data: [0, 0, 0, 1, 1, 0, 1, 1], }, + * normal: { numComponents: 3, data: [0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1], }, + * indices: { numComponents: 3, data: [0, 1, 2, 1, 2, 3], }, + * }; + * + * Creates an BufferInfo like this + * + * bufferInfo = { + * numElements: 4, // or whatever the number of elements is + * indices: WebGLBuffer, // this property will not exist if there are no indices + * attribs: { + * position: { buffer: WebGLBuffer, numComponents: 3, }, + * normal: { buffer: WebGLBuffer, numComponents: 3, }, + * texcoord: { buffer: WebGLBuffer, numComponents: 2, }, + * }, + * }; + * + * The properties of arrays can be JavaScript arrays in which case the number of components + * will be guessed. + * + * var arrays = { + * position: [0, 0, 0, 10, 0, 0, 0, 10, 0, 10, 10, 0], + * texcoord: [0, 0, 0, 1, 1, 0, 1, 1], + * normal: [0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1], + * indices: [0, 1, 2, 1, 2, 3], + * }; + * + * They can also be TypedArrays + * + * var arrays = { + * position: new Float32Array([0, 0, 0, 10, 0, 0, 0, 10, 0, 10, 10, 0]), + * texcoord: new Float32Array([0, 0, 0, 1, 1, 0, 1, 1]), + * normal: new Float32Array([0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1]), + * indices: new Uint16Array([0, 1, 2, 1, 2, 3]), + * }; + * + * Or AugmentedTypedArrays + * + * var positions = createAugmentedTypedArray(3, 4); + * var texcoords = createAugmentedTypedArray(2, 4); + * var normals = createAugmentedTypedArray(3, 4); + * var indices = createAugmentedTypedArray(3, 2, Uint16Array); + * + * positions.push([0, 0, 0, 10, 0, 0, 0, 10, 0, 10, 10, 0]); + * texcoords.push([0, 0, 0, 1, 1, 0, 1, 1]); + * normals.push([0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1]); + * indices.push([0, 1, 2, 1, 2, 3]); + * + * var arrays = { + * position: positions, + * texcoord: texcoords, + * normal: normals, + * indices: indices, + * }; + * + * For the last example it is equivalent to + * + * var bufferInfo = { + * attribs: { + * position: { numComponents: 3, buffer: gl.createBuffer(), }, + * texcoord: { numComponents: 2, buffer: gl.createBuffer(), }, + * normal: { numComponents: 3, buffer: gl.createBuffer(), }, + * }, + * indices: gl.createBuffer(), + * numElements: 6, + * }; + * + * gl.bindBuffer(gl.ARRAY_BUFFER, bufferInfo.attribs.position.buffer); + * gl.bufferData(gl.ARRAY_BUFFER, arrays.position, gl.STATIC_DRAW); + * gl.bindBuffer(gl.ARRAY_BUFFER, bufferInfo.attribs.texcoord.buffer); + * gl.bufferData(gl.ARRAY_BUFFER, arrays.texcoord, gl.STATIC_DRAW); + * gl.bindBuffer(gl.ARRAY_BUFFER, bufferInfo.attribs.normal.buffer); + * gl.bufferData(gl.ARRAY_BUFFER, arrays.normal, gl.STATIC_DRAW); + * gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, bufferInfo.indices); + * gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, arrays.indices, gl.STATIC_DRAW); + * + * @param {WebGLRenderingContext} gl A WebGLRenderingContext + * @param {module:twgl.Arrays} arrays Your data + * @param {module:twgl.BufferInfo} [srcBufferInfo] An existing + * buffer info to start from. WebGLBuffers etc specified + * in the srcBufferInfo will be used in a new BufferInfo + * with any arrays specified overriding the ones in + * srcBufferInfo. + * @return {module:twgl.BufferInfo} A BufferInfo + * @memberOf module:twgl/attributes + */ + function createBufferInfoFromArrays(gl, arrays, srcBufferInfo) { + const newAttribs = createAttribsFromArrays(gl, arrays); + const bufferInfo = Object.assign({}, srcBufferInfo ? srcBufferInfo : {}); + bufferInfo.attribs = Object.assign({}, srcBufferInfo ? srcBufferInfo.attribs : {}, newAttribs); + const indices = arrays.indices; + if (indices) { + const newIndices = makeTypedArray(indices, "indices"); + bufferInfo.indices = createBufferFromTypedArray(gl, newIndices, ELEMENT_ARRAY_BUFFER$2); + bufferInfo.numElements = newIndices.length; + bufferInfo.elementType = getGLTypeForTypedArray(newIndices); + } else if (!bufferInfo.numElements) { + bufferInfo.numElements = getNumElementsFromAttributes(gl, bufferInfo.attribs); + } + + return bufferInfo; + } + + /* + * Copyright 2019 Gregg Tavares + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + */ + + /** + * Gets the gl version as a number + * @param {WebGLRenderingContext} gl A WebGLRenderingContext + * @return {number} version of gl + * @private + */ + //function getVersionAsNumber(gl) { + // return parseFloat(gl.getParameter(gl.VERSION).substr(6)); + //} + + /** + * Check if context is WebGL 2.0 + * @param {WebGLRenderingContext} gl A WebGLRenderingContext + * @return {bool} true if it's WebGL 2.0 + * @memberOf module:twgl + */ + function isWebGL2(gl) { + // This is the correct check but it's slow + // return gl.getParameter(gl.VERSION).indexOf("WebGL 2.0") === 0; + // This might also be the correct check but I'm assuming it's slow-ish + // return gl instanceof WebGL2RenderingContext; + return !!gl.texStorage2D; + } + + const TEXTURE0 = 0x84c0; + + const ARRAY_BUFFER = 0x8892; + const ELEMENT_ARRAY_BUFFER$1 = 0x8893; + + const ACTIVE_UNIFORMS = 0x8b86; + const ACTIVE_ATTRIBUTES = 0x8b89; + const TRANSFORM_FEEDBACK_VARYINGS = 0x8c83; + const ACTIVE_UNIFORM_BLOCKS = 0x8a36; + const UNIFORM_BLOCK_REFERENCED_BY_VERTEX_SHADER = 0x8a44; + const UNIFORM_BLOCK_REFERENCED_BY_FRAGMENT_SHADER = 0x8a46; + const UNIFORM_BLOCK_DATA_SIZE = 0x8a40; + const UNIFORM_BLOCK_ACTIVE_UNIFORM_INDICES = 0x8a43; + + const FLOAT = 0x1406; + const FLOAT_VEC2 = 0x8B50; + const FLOAT_VEC3 = 0x8B51; + const FLOAT_VEC4 = 0x8B52; + const INT = 0x1404; + const INT_VEC2 = 0x8B53; + const INT_VEC3 = 0x8B54; + const INT_VEC4 = 0x8B55; + const BOOL = 0x8B56; + const BOOL_VEC2 = 0x8B57; + const BOOL_VEC3 = 0x8B58; + const BOOL_VEC4 = 0x8B59; + const FLOAT_MAT2 = 0x8B5A; + const FLOAT_MAT3 = 0x8B5B; + const FLOAT_MAT4 = 0x8B5C; + const SAMPLER_2D = 0x8B5E; + const SAMPLER_CUBE = 0x8B60; + const SAMPLER_3D = 0x8B5F; + const SAMPLER_2D_SHADOW = 0x8B62; + const FLOAT_MAT2x3 = 0x8B65; + const FLOAT_MAT2x4 = 0x8B66; + const FLOAT_MAT3x2 = 0x8B67; + const FLOAT_MAT3x4 = 0x8B68; + const FLOAT_MAT4x2 = 0x8B69; + const FLOAT_MAT4x3 = 0x8B6A; + const SAMPLER_2D_ARRAY = 0x8DC1; + const SAMPLER_2D_ARRAY_SHADOW = 0x8DC4; + const SAMPLER_CUBE_SHADOW = 0x8DC5; + const UNSIGNED_INT = 0x1405; + const UNSIGNED_INT_VEC2 = 0x8DC6; + const UNSIGNED_INT_VEC3 = 0x8DC7; + const UNSIGNED_INT_VEC4 = 0x8DC8; + const INT_SAMPLER_2D = 0x8DCA; + const INT_SAMPLER_3D = 0x8DCB; + const INT_SAMPLER_CUBE = 0x8DCC; + const INT_SAMPLER_2D_ARRAY = 0x8DCF; + const UNSIGNED_INT_SAMPLER_2D = 0x8DD2; + const UNSIGNED_INT_SAMPLER_3D = 0x8DD3; + const UNSIGNED_INT_SAMPLER_CUBE = 0x8DD4; + const UNSIGNED_INT_SAMPLER_2D_ARRAY = 0x8DD7; + + const TEXTURE_2D$1 = 0x0DE1; + const TEXTURE_CUBE_MAP = 0x8513; + const TEXTURE_3D = 0x806F; + const TEXTURE_2D_ARRAY = 0x8C1A; + + const typeMap = {}; + + /** + * Returns the corresponding bind point for a given sampler type + * @private + */ + function getBindPointForSamplerType(gl, type) { + return typeMap[type].bindPoint; + } + + // This kind of sucks! If you could compose functions as in `var fn = gl[name];` + // this code could be a lot smaller but that is sadly really slow (T_T) + + function floatSetter(gl, location) { + return function(v) { + gl.uniform1f(location, v); + }; + } + + function floatArraySetter(gl, location) { + return function(v) { + gl.uniform1fv(location, v); + }; + } + + function floatVec2Setter(gl, location) { + return function(v) { + gl.uniform2fv(location, v); + }; + } + + function floatVec3Setter(gl, location) { + return function(v) { + gl.uniform3fv(location, v); + }; + } + + function floatVec4Setter(gl, location) { + return function(v) { + gl.uniform4fv(location, v); + }; + } + + function intSetter(gl, location) { + return function(v) { + gl.uniform1i(location, v); + }; + } + + function intArraySetter(gl, location) { + return function(v) { + gl.uniform1iv(location, v); + }; + } + + function intVec2Setter(gl, location) { + return function(v) { + gl.uniform2iv(location, v); + }; + } + + function intVec3Setter(gl, location) { + return function(v) { + gl.uniform3iv(location, v); + }; + } + + function intVec4Setter(gl, location) { + return function(v) { + gl.uniform4iv(location, v); + }; + } + + function uintSetter(gl, location) { + return function(v) { + gl.uniform1ui(location, v); + }; + } + + function uintArraySetter(gl, location) { + return function(v) { + gl.uniform1uiv(location, v); + }; + } + + function uintVec2Setter(gl, location) { + return function(v) { + gl.uniform2uiv(location, v); + }; + } + + function uintVec3Setter(gl, location) { + return function(v) { + gl.uniform3uiv(location, v); + }; + } + + function uintVec4Setter(gl, location) { + return function(v) { + gl.uniform4uiv(location, v); + }; + } + + function floatMat2Setter(gl, location) { + return function(v) { + gl.uniformMatrix2fv(location, false, v); + }; + } + + function floatMat3Setter(gl, location) { + return function(v) { + gl.uniformMatrix3fv(location, false, v); + }; + } + + function floatMat4Setter(gl, location) { + return function(v) { + gl.uniformMatrix4fv(location, false, v); + }; + } + + function floatMat23Setter(gl, location) { + return function(v) { + gl.uniformMatrix2x3fv(location, false, v); + }; + } + + function floatMat32Setter(gl, location) { + return function(v) { + gl.uniformMatrix3x2fv(location, false, v); + }; + } + + function floatMat24Setter(gl, location) { + return function(v) { + gl.uniformMatrix2x4fv(location, false, v); + }; + } + + function floatMat42Setter(gl, location) { + return function(v) { + gl.uniformMatrix4x2fv(location, false, v); + }; + } + + function floatMat34Setter(gl, location) { + return function(v) { + gl.uniformMatrix3x4fv(location, false, v); + }; + } + + function floatMat43Setter(gl, location) { + return function(v) { + gl.uniformMatrix4x3fv(location, false, v); + }; + } + + function samplerSetter(gl, type, unit, location) { + const bindPoint = getBindPointForSamplerType(gl, type); + return isWebGL2(gl) ? function(textureOrPair) { + let texture; + let sampler; + if (!textureOrPair || isTexture(gl, textureOrPair)) { + texture = textureOrPair; + sampler = null; + } else { + texture = textureOrPair.texture; + sampler = textureOrPair.sampler; + } + gl.uniform1i(location, unit); + gl.activeTexture(TEXTURE0 + unit); + gl.bindTexture(bindPoint, texture); + gl.bindSampler(unit, sampler); + } : function(texture) { + gl.uniform1i(location, unit); + gl.activeTexture(TEXTURE0 + unit); + gl.bindTexture(bindPoint, texture); + }; + } + + function samplerArraySetter(gl, type, unit, location, size) { + const bindPoint = getBindPointForSamplerType(gl, type); + const units = new Int32Array(size); + for (let ii = 0; ii < size; ++ii) { + units[ii] = unit + ii; + } + + return isWebGL2(gl) ? function(textures) { + gl.uniform1iv(location, units); + textures.forEach(function(textureOrPair, index) { + gl.activeTexture(TEXTURE0 + units[index]); + let texture; + let sampler; + if (!textureOrPair || isTexture(gl, textureOrPair)) { + texture = textureOrPair; + sampler = null; + } else { + texture = textureOrPair.texture; + sampler = textureOrPair.sampler; + } + gl.bindSampler(unit, sampler); + gl.bindTexture(bindPoint, texture); + }); + } : function(textures) { + gl.uniform1iv(location, units); + textures.forEach(function(texture, index) { + gl.activeTexture(TEXTURE0 + units[index]); + gl.bindTexture(bindPoint, texture); + }); + }; + } + + typeMap[FLOAT] = { Type: Float32Array, size: 4, setter: floatSetter, arraySetter: floatArraySetter, }; + typeMap[FLOAT_VEC2] = { Type: Float32Array, size: 8, setter: floatVec2Setter, cols: 2, }; + typeMap[FLOAT_VEC3] = { Type: Float32Array, size: 12, setter: floatVec3Setter, cols: 3, }; + typeMap[FLOAT_VEC4] = { Type: Float32Array, size: 16, setter: floatVec4Setter, cols: 4, }; + typeMap[INT] = { Type: Int32Array, size: 4, setter: intSetter, arraySetter: intArraySetter, }; + typeMap[INT_VEC2] = { Type: Int32Array, size: 8, setter: intVec2Setter, cols: 2, }; + typeMap[INT_VEC3] = { Type: Int32Array, size: 12, setter: intVec3Setter, cols: 3, }; + typeMap[INT_VEC4] = { Type: Int32Array, size: 16, setter: intVec4Setter, cols: 4, }; + typeMap[UNSIGNED_INT] = { Type: Uint32Array, size: 4, setter: uintSetter, arraySetter: uintArraySetter, }; + typeMap[UNSIGNED_INT_VEC2] = { Type: Uint32Array, size: 8, setter: uintVec2Setter, cols: 2, }; + typeMap[UNSIGNED_INT_VEC3] = { Type: Uint32Array, size: 12, setter: uintVec3Setter, cols: 3, }; + typeMap[UNSIGNED_INT_VEC4] = { Type: Uint32Array, size: 16, setter: uintVec4Setter, cols: 4, }; + typeMap[BOOL] = { Type: Uint32Array, size: 4, setter: intSetter, arraySetter: intArraySetter, }; + typeMap[BOOL_VEC2] = { Type: Uint32Array, size: 8, setter: intVec2Setter, cols: 2, }; + typeMap[BOOL_VEC3] = { Type: Uint32Array, size: 12, setter: intVec3Setter, cols: 3, }; + typeMap[BOOL_VEC4] = { Type: Uint32Array, size: 16, setter: intVec4Setter, cols: 4, }; + typeMap[FLOAT_MAT2] = { Type: Float32Array, size: 32, setter: floatMat2Setter, rows: 2, cols: 2, }; + typeMap[FLOAT_MAT3] = { Type: Float32Array, size: 48, setter: floatMat3Setter, rows: 3, cols: 3, }; + typeMap[FLOAT_MAT4] = { Type: Float32Array, size: 64, setter: floatMat4Setter, rows: 4, cols: 4, }; + typeMap[FLOAT_MAT2x3] = { Type: Float32Array, size: 32, setter: floatMat23Setter, rows: 2, cols: 3, }; + typeMap[FLOAT_MAT2x4] = { Type: Float32Array, size: 32, setter: floatMat24Setter, rows: 2, cols: 4, }; + typeMap[FLOAT_MAT3x2] = { Type: Float32Array, size: 48, setter: floatMat32Setter, rows: 3, cols: 2, }; + typeMap[FLOAT_MAT3x4] = { Type: Float32Array, size: 48, setter: floatMat34Setter, rows: 3, cols: 4, }; + typeMap[FLOAT_MAT4x2] = { Type: Float32Array, size: 64, setter: floatMat42Setter, rows: 4, cols: 2, }; + typeMap[FLOAT_MAT4x3] = { Type: Float32Array, size: 64, setter: floatMat43Setter, rows: 4, cols: 3, }; + typeMap[SAMPLER_2D] = { Type: null, size: 0, setter: samplerSetter, arraySetter: samplerArraySetter, bindPoint: TEXTURE_2D$1, }; + typeMap[SAMPLER_CUBE] = { Type: null, size: 0, setter: samplerSetter, arraySetter: samplerArraySetter, bindPoint: TEXTURE_CUBE_MAP, }; + typeMap[SAMPLER_3D] = { Type: null, size: 0, setter: samplerSetter, arraySetter: samplerArraySetter, bindPoint: TEXTURE_3D, }; + typeMap[SAMPLER_2D_SHADOW] = { Type: null, size: 0, setter: samplerSetter, arraySetter: samplerArraySetter, bindPoint: TEXTURE_2D$1, }; + typeMap[SAMPLER_2D_ARRAY] = { Type: null, size: 0, setter: samplerSetter, arraySetter: samplerArraySetter, bindPoint: TEXTURE_2D_ARRAY, }; + typeMap[SAMPLER_2D_ARRAY_SHADOW] = { Type: null, size: 0, setter: samplerSetter, arraySetter: samplerArraySetter, bindPoint: TEXTURE_2D_ARRAY, }; + typeMap[SAMPLER_CUBE_SHADOW] = { Type: null, size: 0, setter: samplerSetter, arraySetter: samplerArraySetter, bindPoint: TEXTURE_CUBE_MAP, }; + typeMap[INT_SAMPLER_2D] = { Type: null, size: 0, setter: samplerSetter, arraySetter: samplerArraySetter, bindPoint: TEXTURE_2D$1, }; + typeMap[INT_SAMPLER_3D] = { Type: null, size: 0, setter: samplerSetter, arraySetter: samplerArraySetter, bindPoint: TEXTURE_3D, }; + typeMap[INT_SAMPLER_CUBE] = { Type: null, size: 0, setter: samplerSetter, arraySetter: samplerArraySetter, bindPoint: TEXTURE_CUBE_MAP, }; + typeMap[INT_SAMPLER_2D_ARRAY] = { Type: null, size: 0, setter: samplerSetter, arraySetter: samplerArraySetter, bindPoint: TEXTURE_2D_ARRAY, }; + typeMap[UNSIGNED_INT_SAMPLER_2D] = { Type: null, size: 0, setter: samplerSetter, arraySetter: samplerArraySetter, bindPoint: TEXTURE_2D$1, }; + typeMap[UNSIGNED_INT_SAMPLER_3D] = { Type: null, size: 0, setter: samplerSetter, arraySetter: samplerArraySetter, bindPoint: TEXTURE_3D, }; + typeMap[UNSIGNED_INT_SAMPLER_CUBE] = { Type: null, size: 0, setter: samplerSetter, arraySetter: samplerArraySetter, bindPoint: TEXTURE_CUBE_MAP, }; + typeMap[UNSIGNED_INT_SAMPLER_2D_ARRAY] = { Type: null, size: 0, setter: samplerSetter, arraySetter: samplerArraySetter, bindPoint: TEXTURE_2D_ARRAY, }; + + function floatAttribSetter(gl, index) { + return function(b) { + if (b.value) { + gl.disableVertexAttribArray(index); + switch (b.value.length) { + case 4: + gl.vertexAttrib4fv(index, b.value); + break; + case 3: + gl.vertexAttrib3fv(index, b.value); + break; + case 2: + gl.vertexAttrib2fv(index, b.value); + break; + case 1: + gl.vertexAttrib1fv(index, b.value); + break; + default: + throw new Error('the length of a float constant value must be between 1 and 4!'); + } + } else { + gl.bindBuffer(ARRAY_BUFFER, b.buffer); + gl.enableVertexAttribArray(index); + gl.vertexAttribPointer( + index, b.numComponents || b.size, b.type || FLOAT, b.normalize || false, b.stride || 0, b.offset || 0); + if (gl.vertexAttribDivisor) { + gl.vertexAttribDivisor(index, b.divisor || 0); + } + } + }; + } + + function intAttribSetter(gl, index) { + return function(b) { + if (b.value) { + gl.disableVertexAttribArray(index); + if (b.value.length === 4) { + gl.vertexAttrib4iv(index, b.value); + } else { + throw new Error('The length of an integer constant value must be 4!'); + } + } else { + gl.bindBuffer(ARRAY_BUFFER, b.buffer); + gl.enableVertexAttribArray(index); + gl.vertexAttribIPointer( + index, b.numComponents || b.size, b.type || INT, b.stride || 0, b.offset || 0); + if (gl.vertexAttribDivisor) { + gl.vertexAttribDivisor(index, b.divisor || 0); + } + } + }; + } + + function uintAttribSetter(gl, index) { + return function(b) { + if (b.value) { + gl.disableVertexAttribArray(index); + if (b.value.length === 4) { + gl.vertexAttrib4uiv(index, b.value); + } else { + throw new Error('The length of an unsigned integer constant value must be 4!'); + } + } else { + gl.bindBuffer(ARRAY_BUFFER, b.buffer); + gl.enableVertexAttribArray(index); + gl.vertexAttribIPointer( + index, b.numComponents || b.size, b.type || UNSIGNED_INT, b.stride || 0, b.offset || 0); + if (gl.vertexAttribDivisor) { + gl.vertexAttribDivisor(index, b.divisor || 0); + } + } + }; + } + + function matAttribSetter(gl, index, typeInfo) { + const defaultSize = typeInfo.size; + const count = typeInfo.count; + + return function(b) { + gl.bindBuffer(ARRAY_BUFFER, b.buffer); + const numComponents = b.size || b.numComponents || defaultSize; + const size = numComponents / count; + const type = b.type || FLOAT; + const typeInfo = typeMap[type]; + const stride = typeInfo.size * numComponents; + const normalize = b.normalize || false; + const offset = b.offset || 0; + const rowOffset = stride / count; + for (let i = 0; i < count; ++i) { + gl.enableVertexAttribArray(index + i); + gl.vertexAttribPointer( + index + i, size, type, normalize, stride, offset + rowOffset * i); + if (gl.vertexAttribDivisor) { + gl.vertexAttribDivisor(index + i, b.divisor || 0); + } + } + }; + } + + + + const attrTypeMap = {}; + attrTypeMap[FLOAT] = { size: 4, setter: floatAttribSetter, }; + attrTypeMap[FLOAT_VEC2] = { size: 8, setter: floatAttribSetter, }; + attrTypeMap[FLOAT_VEC3] = { size: 12, setter: floatAttribSetter, }; + attrTypeMap[FLOAT_VEC4] = { size: 16, setter: floatAttribSetter, }; + attrTypeMap[INT] = { size: 4, setter: intAttribSetter, }; + attrTypeMap[INT_VEC2] = { size: 8, setter: intAttribSetter, }; + attrTypeMap[INT_VEC3] = { size: 12, setter: intAttribSetter, }; + attrTypeMap[INT_VEC4] = { size: 16, setter: intAttribSetter, }; + attrTypeMap[UNSIGNED_INT] = { size: 4, setter: uintAttribSetter, }; + attrTypeMap[UNSIGNED_INT_VEC2] = { size: 8, setter: uintAttribSetter, }; + attrTypeMap[UNSIGNED_INT_VEC3] = { size: 12, setter: uintAttribSetter, }; + attrTypeMap[UNSIGNED_INT_VEC4] = { size: 16, setter: uintAttribSetter, }; + attrTypeMap[BOOL] = { size: 4, setter: intAttribSetter, }; + attrTypeMap[BOOL_VEC2] = { size: 8, setter: intAttribSetter, }; + attrTypeMap[BOOL_VEC3] = { size: 12, setter: intAttribSetter, }; + attrTypeMap[BOOL_VEC4] = { size: 16, setter: intAttribSetter, }; + attrTypeMap[FLOAT_MAT2] = { size: 4, setter: matAttribSetter, count: 2, }; + attrTypeMap[FLOAT_MAT3] = { size: 9, setter: matAttribSetter, count: 3, }; + attrTypeMap[FLOAT_MAT4] = { size: 16, setter: matAttribSetter, count: 4, }; + + /** + * Returns true if attribute/uniform is a reserved/built in + * + * It makes no sense to me why GL returns these because it's + * illegal to call `gl.getUniformLocation` and `gl.getAttribLocation` + * with names that start with `gl_` (and `webgl_` in WebGL) + * + * I can only assume they are there because they might count + * when computing the number of uniforms/attributes used when you want to + * know if you are near the limit. That doesn't really make sense + * to me but the fact that these get returned are in the spec. + * + * @param {WebGLActiveInfo} info As returned from `gl.getActiveUniform` or + * `gl.getActiveAttrib`. + * @return {bool} true if it's reserved + * @private + */ + function isBuiltIn(info) { + const name = info.name; + return name.startsWith("gl_") || name.startsWith("webgl_"); + } + + const tokenRE = /(\.|\[|]|\w+)/g; + const isDigit = s => s >= '0' && s <= '9'; + function addSetterToUniformTree(fullPath, setter, node, uniformSetters) { + const tokens = fullPath.split(tokenRE).filter(s => s !== ''); + let tokenNdx = 0; + let path = ''; + + for (;;) { + const token = tokens[tokenNdx++]; // has to be name or number + path += token; + const isArrayIndex = isDigit(token[0]); + const accessor = isArrayIndex + ? parseInt(token) + : token; + if (isArrayIndex) { + path += tokens[tokenNdx++]; // skip ']' + } + const isLastToken = tokenNdx === tokens.length; + if (isLastToken) { + node[accessor] = setter; + break; + } else { + const token = tokens[tokenNdx++]; // has to be . or [ + const isArray = token === '['; + const child = node[accessor] || (isArray ? [] : {}); + node[accessor] = child; + node = child; + uniformSetters[path] = uniformSetters[path] || function(node) { + return function(value) { + setUniformTree(node, value); + }; + }(child); + path += token; + } + } + } + + /** + * Creates setter functions for all uniforms of a shader + * program. + * + * @see {@link module:twgl.setUniforms} + * + * @param {WebGLRenderingContext} gl The WebGLRenderingContext to use. + * @param {WebGLProgram} program the program to create setters for. + * @returns {Object.} an object with a setter by name for each uniform + * @memberOf module:twgl/programs + */ + function createUniformSetters(gl, program) { + let textureUnit = 0; + + /** + * Creates a setter for a uniform of the given program with it's + * location embedded in the setter. + * @param {WebGLProgram} program + * @param {WebGLUniformInfo} uniformInfo + * @returns {function} the created setter. + */ + function createUniformSetter(program, uniformInfo, location) { + const isArray = uniformInfo.name.endsWith("[0]"); + const type = uniformInfo.type; + const typeInfo = typeMap[type]; + if (!typeInfo) { + throw new Error(`unknown type: 0x${type.toString(16)}`); // we should never get here. + } + let setter; + if (typeInfo.bindPoint) { + // it's a sampler + const unit = textureUnit; + textureUnit += uniformInfo.size; + if (isArray) { + setter = typeInfo.arraySetter(gl, type, unit, location, uniformInfo.size); + } else { + setter = typeInfo.setter(gl, type, unit, location, uniformInfo.size); + } + } else { + if (typeInfo.arraySetter && isArray) { + setter = typeInfo.arraySetter(gl, location); + } else { + setter = typeInfo.setter(gl, location); + } + } + setter.location = location; + return setter; + } + + const uniformSetters = {}; + const uniformTree = {}; + const numUniforms = gl.getProgramParameter(program, ACTIVE_UNIFORMS); + + for (let ii = 0; ii < numUniforms; ++ii) { + const uniformInfo = gl.getActiveUniform(program, ii); + if (isBuiltIn(uniformInfo)) { + continue; + } + let name = uniformInfo.name; + // remove the array suffix. + if (name.endsWith("[0]")) { + name = name.substr(0, name.length - 3); + } + const location = gl.getUniformLocation(program, uniformInfo.name); + // the uniform will have no location if it's in a uniform block + if (location) { + const setter = createUniformSetter(program, uniformInfo, location); + uniformSetters[name] = setter; + addSetterToUniformTree(name, setter, uniformTree, uniformSetters); + } + } + + return uniformSetters; + } + + /** + * @typedef {Object} TransformFeedbackInfo + * @property {number} index index of transform feedback + * @property {number} type GL type + * @property {number} size 1 - 4 + * @memberOf module:twgl + */ + + /** + * Create TransformFeedbackInfo for passing to bindTransformFeedbackInfo. + * @param {WebGLRenderingContext} gl The WebGLRenderingContext to use. + * @param {WebGLProgram} program an existing WebGLProgram. + * @return {Object} + * @memberOf module:twgl + */ + function createTransformFeedbackInfo(gl, program) { + const info = {}; + const numVaryings = gl.getProgramParameter(program, TRANSFORM_FEEDBACK_VARYINGS); + for (let ii = 0; ii < numVaryings; ++ii) { + const varying = gl.getTransformFeedbackVarying(program, ii); + info[varying.name] = { + index: ii, + type: varying.type, + size: varying.size, + }; + } + return info; + } + + /** + * @typedef {Object} UniformData + * @property {string} name The name of the uniform + * @property {number} type The WebGL type enum for this uniform + * @property {number} size The number of elements for this uniform + * @property {number} blockNdx The block index this uniform appears in + * @property {number} offset The byte offset in the block for this uniform's value + * @memberOf module:twgl + */ + + /** + * The specification for one UniformBlockObject + * + * @typedef {Object} BlockSpec + * @property {number} index The index of the block. + * @property {number} size The size in bytes needed for the block + * @property {number[]} uniformIndices The indices of the uniforms used by the block. These indices + * correspond to entries in a UniformData array in the {@link module:twgl.UniformBlockSpec}. + * @property {bool} usedByVertexShader Self explanatory + * @property {bool} usedByFragmentShader Self explanatory + * @property {bool} used Self explanatory + * @memberOf module:twgl + */ + + /** + * A `UniformBlockSpec` represents the data needed to create and bind + * UniformBlockObjects for a given program + * + * @typedef {Object} UniformBlockSpec + * @property {Object.} blockSpecs The BlockSpec for each block by block name + * @property {UniformData[]} uniformData An array of data for each uniform by uniform index. + * @memberOf module:twgl + */ + + /** + * Creates a UniformBlockSpec for the given program. + * + * A UniformBlockSpec represents the data needed to create and bind + * UniformBlockObjects + * + * @param {WebGL2RenderingContext} gl A WebGL2 Rendering Context + * @param {WebGLProgram} program A WebGLProgram for a successfully linked program + * @return {module:twgl.UniformBlockSpec} The created UniformBlockSpec + * @memberOf module:twgl/programs + */ + function createUniformBlockSpecFromProgram(gl, program) { + const numUniforms = gl.getProgramParameter(program, ACTIVE_UNIFORMS); + const uniformData = []; + const uniformIndices = []; + + for (let ii = 0; ii < numUniforms; ++ii) { + uniformIndices.push(ii); + uniformData.push({}); + const uniformInfo = gl.getActiveUniform(program, ii); + uniformData[ii].name = uniformInfo.name; + } + + [ + [ "UNIFORM_TYPE", "type" ], + [ "UNIFORM_SIZE", "size" ], // num elements + [ "UNIFORM_BLOCK_INDEX", "blockNdx" ], + [ "UNIFORM_OFFSET", "offset", ], + ].forEach(function(pair) { + const pname = pair[0]; + const key = pair[1]; + gl.getActiveUniforms(program, uniformIndices, gl[pname]).forEach(function(value, ndx) { + uniformData[ndx][key] = value; + }); + }); + + const blockSpecs = {}; + + const numUniformBlocks = gl.getProgramParameter(program, ACTIVE_UNIFORM_BLOCKS); + for (let ii = 0; ii < numUniformBlocks; ++ii) { + const name = gl.getActiveUniformBlockName(program, ii); + const blockSpec = { + index: gl.getUniformBlockIndex(program, name), + usedByVertexShader: gl.getActiveUniformBlockParameter(program, ii, UNIFORM_BLOCK_REFERENCED_BY_VERTEX_SHADER), + usedByFragmentShader: gl.getActiveUniformBlockParameter(program, ii, UNIFORM_BLOCK_REFERENCED_BY_FRAGMENT_SHADER), + size: gl.getActiveUniformBlockParameter(program, ii, UNIFORM_BLOCK_DATA_SIZE), + uniformIndices: gl.getActiveUniformBlockParameter(program, ii, UNIFORM_BLOCK_ACTIVE_UNIFORM_INDICES), + }; + blockSpec.used = blockSpec.usedByVertexShader || blockSpec.usedByFragmentShader; + blockSpecs[name] = blockSpec; + } + + return { + blockSpecs: blockSpecs, + uniformData: uniformData, + }; + } + + function setUniformTree(tree, values) { + for (const name in values) { + const prop = tree[name]; + if (typeof prop === 'function') { + prop(values[name]); + } else { + setUniformTree(tree[name], values[name]); + } + } + } + + /** + * Set uniforms and binds related textures. + * + * example: + * + * const programInfo = createProgramInfo( + * gl, ["some-vs", "some-fs"]); + * + * const tex1 = gl.createTexture(); + * const tex2 = gl.createTexture(); + * + * ... assume we setup the textures with data ... + * + * const uniforms = { + * u_someSampler: tex1, + * u_someOtherSampler: tex2, + * u_someColor: [1,0,0,1], + * u_somePosition: [0,1,1], + * u_someMatrix: [ + * 1,0,0,0, + * 0,1,0,0, + * 0,0,1,0, + * 0,0,0,0, + * ], + * }; + * + * gl.useProgram(programInfo.program); + * + * This will automatically bind the textures AND set the + * uniforms. + * + * twgl.setUniforms(programInfo, uniforms); + * + * For the example above it is equivalent to + * + * let texUnit = 0; + * gl.activeTexture(gl.TEXTURE0 + texUnit); + * gl.bindTexture(gl.TEXTURE_2D, tex1); + * gl.uniform1i(u_someSamplerLocation, texUnit++); + * gl.activeTexture(gl.TEXTURE0 + texUnit); + * gl.bindTexture(gl.TEXTURE_2D, tex2); + * gl.uniform1i(u_someSamplerLocation, texUnit++); + * gl.uniform4fv(u_someColorLocation, [1, 0, 0, 1]); + * gl.uniform3fv(u_somePositionLocation, [0, 1, 1]); + * gl.uniformMatrix4fv(u_someMatrix, false, [ + * 1,0,0,0, + * 0,1,0,0, + * 0,0,1,0, + * 0,0,0,0, + * ]); + * + * Note it is perfectly reasonable to call `setUniforms` multiple times. For example + * + * const uniforms = { + * u_someSampler: tex1, + * u_someOtherSampler: tex2, + * }; + * + * const moreUniforms { + * u_someColor: [1,0,0,1], + * u_somePosition: [0,1,1], + * u_someMatrix: [ + * 1,0,0,0, + * 0,1,0,0, + * 0,0,1,0, + * 0,0,0,0, + * ], + * }; + * + * twgl.setUniforms(programInfo, uniforms); + * twgl.setUniforms(programInfo, moreUniforms); + * + * You can also add WebGLSamplers to uniform samplers as in + * + * const uniforms = { + * u_someSampler: { + * texture: someWebGLTexture, + * sampler: someWebGLSampler, + * }, + * }; + * + * In which case both the sampler and texture will be bound to the + * same unit. + * + * @param {(module:twgl.ProgramInfo|Object.)} setters a `ProgramInfo` as returned from `createProgramInfo` or the setters returned from + * `createUniformSetters`. + * @param {Object.} values an object with values for the + * uniforms. + * You can pass multiple objects by putting them in an array or by calling with more arguments.For example + * + * const sharedUniforms = { + * u_fogNear: 10, + * u_projection: ... + * ... + * }; + * + * const localUniforms = { + * u_world: ... + * u_diffuseColor: ... + * }; + * + * twgl.setUniforms(programInfo, sharedUniforms, localUniforms); + * + * // is the same as + * + * twgl.setUniforms(programInfo, [sharedUniforms, localUniforms]); + * + * // is the same as + * + * twgl.setUniforms(programInfo, sharedUniforms); + * twgl.setUniforms(programInfo, localUniforms}; + * + * You can also fill out structure and array values either via + * shortcut. Example + * + * // -- in shader -- + * struct Light { + * float intensity; + * vec4 color; + * float nearFar[2]; + * }; + * uniform Light lights[2]; + * + * // in JavaScript + * + * twgl.setUniforms(programInfo, { + * lights: [ + * { intensity: 5.0, color: [1, 0, 0, 1], nearFar[0.1, 10] }, + * { intensity: 2.0, color: [0, 0, 1, 1], nearFar[0.2, 15] }, + * ], + * }); + * + * or the more traditional way + * + * twgl.setUniforms(programInfo, { + * "lights[0].intensity": 5.0, + * "lights[0].color": [1, 0, 0, 1], + * "lights[0].nearFar": [0.1, 10], + * "lights[1].intensity": 2.0, + * "lights[1].color": [0, 0, 1, 1], + * "lights[1].nearFar": [0.2, 15], + * }); + * + * You can also specify partial paths + * + * twgl.setUniforms(programInfo, { + * 'lights[1]': { intensity: 5.0, color: [1, 0, 0, 1], nearFar[0.2, 15] }, + * }); + * + * But you can not specify leaf array indices + * + * twgl.setUniforms(programInfo, { + * 'lights[1].nearFar[1]': 15, // BAD! nearFar is a leaf + * 'lights[1].nearFar': [0.2, 15], // GOOD + * }); + * + * @memberOf module:twgl/programs + */ + function setUniforms(setters, ...args) { // eslint-disable-line + const actualSetters = setters.uniformSetters || setters; + const numArgs = args.length; + for (let aNdx = 0; aNdx < numArgs; ++aNdx) { + const values = args[aNdx]; + if (Array.isArray(values)) { + const numValues = values.length; + for (let ii = 0; ii < numValues; ++ii) { + setUniforms(actualSetters, values[ii]); + } + } else { + for (const name in values) { + const setter = actualSetters[name]; + if (setter) { + setter(values[name]); + } + } + } + } + } + + /** + * Creates setter functions for all attributes of a shader + * program. You can pass this to {@link module:twgl.setBuffersAndAttributes} to set all your buffers and attributes. + * + * @see {@link module:twgl.setAttributes} for example + * @param {WebGLRenderingContext} gl The WebGLRenderingContext to use. + * @param {WebGLProgram} program the program to create setters for. + * @return {Object.} an object with a setter for each attribute by name. + * @memberOf module:twgl/programs + */ + function createAttributeSetters(gl, program) { + const attribSetters = { + }; + + const numAttribs = gl.getProgramParameter(program, ACTIVE_ATTRIBUTES); + for (let ii = 0; ii < numAttribs; ++ii) { + const attribInfo = gl.getActiveAttrib(program, ii); + if (isBuiltIn(attribInfo)) { + continue; + } + const index = gl.getAttribLocation(program, attribInfo.name); + const typeInfo = attrTypeMap[attribInfo.type]; + const setter = typeInfo.setter(gl, index, typeInfo); + setter.location = index; + attribSetters[attribInfo.name] = setter; + } + + return attribSetters; + } + + /** + * Sets attributes and binds buffers (deprecated... use {@link module:twgl.setBuffersAndAttributes}) + * + * Example: + * + * const program = createProgramFromScripts( + * gl, ["some-vs", "some-fs"); + * + * const attribSetters = createAttributeSetters(program); + * + * const positionBuffer = gl.createBuffer(); + * const texcoordBuffer = gl.createBuffer(); + * + * const attribs = { + * a_position: {buffer: positionBuffer, numComponents: 3}, + * a_texcoord: {buffer: texcoordBuffer, numComponents: 2}, + * }; + * + * gl.useProgram(program); + * + * This will automatically bind the buffers AND set the + * attributes. + * + * setAttributes(attribSetters, attribs); + * + * Properties of attribs. For each attrib you can add + * properties: + * + * * type: the type of data in the buffer. Default = gl.FLOAT + * * normalize: whether or not to normalize the data. Default = false + * * stride: the stride. Default = 0 + * * offset: offset into the buffer. Default = 0 + * * divisor: the divisor for instances. Default = undefined + * + * For example if you had 3 value float positions, 2 value + * float texcoord and 4 value uint8 colors you'd setup your + * attribs like this + * + * const attribs = { + * a_position: {buffer: positionBuffer, numComponents: 3}, + * a_texcoord: {buffer: texcoordBuffer, numComponents: 2}, + * a_color: { + * buffer: colorBuffer, + * numComponents: 4, + * type: gl.UNSIGNED_BYTE, + * normalize: true, + * }, + * }; + * + * @param {Object.} setters Attribute setters as returned from createAttributeSetters + * @param {Object.} buffers AttribInfos mapped by attribute name. + * @memberOf module:twgl/programs + * @deprecated use {@link module:twgl.setBuffersAndAttributes} + * @private + */ + function setAttributes(setters, buffers) { + for (const name in buffers) { + const setter = setters[name]; + if (setter) { + setter(buffers[name]); + } + } + } + + /** + * Sets attributes and buffers including the `ELEMENT_ARRAY_BUFFER` if appropriate + * + * Example: + * + * const programInfo = createProgramInfo( + * gl, ["some-vs", "some-fs"); + * + * const arrays = { + * position: { numComponents: 3, data: [0, 0, 0, 10, 0, 0, 0, 10, 0, 10, 10, 0], }, + * texcoord: { numComponents: 2, data: [0, 0, 0, 1, 1, 0, 1, 1], }, + * }; + * + * const bufferInfo = createBufferInfoFromArrays(gl, arrays); + * + * gl.useProgram(programInfo.program); + * + * This will automatically bind the buffers AND set the + * attributes. + * + * setBuffersAndAttributes(gl, programInfo, bufferInfo); + * + * For the example above it is equivalent to + * + * gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); + * gl.enableVertexAttribArray(a_positionLocation); + * gl.vertexAttribPointer(a_positionLocation, 3, gl.FLOAT, false, 0, 0); + * gl.bindBuffer(gl.ARRAY_BUFFER, texcoordBuffer); + * gl.enableVertexAttribArray(a_texcoordLocation); + * gl.vertexAttribPointer(a_texcoordLocation, 4, gl.FLOAT, false, 0, 0); + * + * @param {WebGLRenderingContext} gl A WebGLRenderingContext. + * @param {(module:twgl.ProgramInfo|Object.)} setters A `ProgramInfo` as returned from {@link module:twgl.createProgramInfo} or Attribute setters as returned from {@link module:twgl.createAttributeSetters} + * @param {(module:twgl.BufferInfo|module:twgl.VertexArrayInfo)} buffers a `BufferInfo` as returned from {@link module:twgl.createBufferInfoFromArrays}. + * or a `VertexArrayInfo` as returned from {@link module:twgl.createVertexArrayInfo} + * @memberOf module:twgl/programs + */ + function setBuffersAndAttributes(gl, programInfo, buffers) { + if (buffers.vertexArrayObject) { + gl.bindVertexArray(buffers.vertexArrayObject); + } else { + setAttributes(programInfo.attribSetters || programInfo, buffers.attribs); + if (buffers.indices) { + gl.bindBuffer(ELEMENT_ARRAY_BUFFER$1, buffers.indices); + } + } + } + + /** + * @typedef {Object} ProgramInfo + * @property {WebGLProgram} program A shader program + * @property {Object} uniformSetters object of setters as returned from createUniformSetters, + * @property {Object} attribSetters object of setters as returned from createAttribSetters, + * @property {module:twgl.UniformBlockSpec} [uniformBlockSpec] a uniform block spec for making UniformBlockInfos with createUniformBlockInfo etc.. + * @property {Object} [transformFeedbackInfo] info for transform feedbacks + * @memberOf module:twgl + */ + + /** + * Creates a ProgramInfo from an existing program. + * + * A ProgramInfo contains + * + * programInfo = { + * program: WebGLProgram, + * uniformSetters: object of setters as returned from createUniformSetters, + * attribSetters: object of setters as returned from createAttribSetters, + * } + * + * @param {WebGLRenderingContext} gl The WebGLRenderingContext + * to use. + * @param {WebGLProgram} program an existing WebGLProgram. + * @return {module:twgl.ProgramInfo} The created ProgramInfo. + * @memberOf module:twgl/programs + */ + function createProgramInfoFromProgram(gl, program) { + const uniformSetters = createUniformSetters(gl, program); + const attribSetters = createAttributeSetters(gl, program); + const programInfo = { + program, + uniformSetters, + attribSetters, + }; + + if (isWebGL2(gl)) { + programInfo.uniformBlockSpec = createUniformBlockSpecFromProgram(gl, program); + programInfo.transformFeedbackInfo = createTransformFeedbackInfo(gl, program); + } + + return programInfo; + } + + var vertShaderLayer = "#define GLSLIFY 1\nattribute vec4 position;attribute vec2 texcoord;varying vec2 v_uv;uniform bool u_flipY;uniform vec2 u_textureSize;uniform int u_3d_eye;uniform float u_3d_depth;uniform float u_3d_strength;void main(){vec4 pos=position;float depthDirection=u_3d_eye==0 ?-1.0 : 1.0;float depthShift=floor(u_3d_depth*u_3d_strength)/(u_textureSize.x/2.0)*depthDirection;pos.x+=depthShift;pos.y*=u_flipY ?-1.0 : 1.0;v_uv=texcoord;gl_Position=pos;}"; // eslint-disable-line + + var fragShaderLayer = "precision highp float;\n#define GLSLIFY 1\nvarying vec2 v_uv;uniform sampler2D u_tex;uniform int u_3d_mode;void main(){vec4 col=texture2D(u_tex,v_uv);if(col.a==0.0){discard;}gl_FragColor=col;}"; // eslint-disable-line + + var vertShaderUpscale = "#define GLSLIFY 1\nattribute vec4 position;attribute vec2 texcoord;varying vec2 v_texel;varying vec2 v_uv;varying float v_scale;uniform bool u_flipY;uniform vec2 u_textureSize;uniform vec2 u_screenSize;void main(){v_uv=texcoord;v_scale=floor(u_screenSize.y/u_textureSize.y+0.01);gl_Position=position;if(u_flipY){gl_Position.y*=-1.;}}"; // eslint-disable-line + + var fragShaderUpscale = "precision highp float;\n#define GLSLIFY 1\nvarying vec2 v_uv;uniform sampler2D u_tex;varying float v_scale;uniform vec2 u_textureSize;uniform vec2 u_screenSize;void main(){vec2 v_texel=v_uv*u_textureSize;vec2 texel_floored=floor(v_texel);vec2 s=fract(v_texel);float region_range=0.5-0.5/v_scale;vec2 center_dist=s-0.5;vec2 f=(center_dist-clamp(center_dist,-region_range,region_range))*v_scale+0.5;vec2 mod_texel=texel_floored+f;vec2 coord=mod_texel.xy/u_textureSize.xy;gl_FragColor=texture2D(u_tex,coord);}"; // eslint-disable-line + + var _WebglCanvas_instances, _WebglCanvas_options, _WebglCanvas_layerProgram, _WebglCanvas_upscaleProgram, _WebglCanvas_quadBuffer, _WebglCanvas_paletteBuffer, _WebglCanvas_layerTexture, _WebglCanvas_layerTexturePixelBuffer, _WebglCanvas_layerTexturePixels, _WebglCanvas_frameTexture, _WebglCanvas_frameBuffer, _WebglCanvas_textureTypes, _WebglCanvas_textureSizes, _WebglCanvas_frameBufferTextures, _WebglCanvas_applyFirefoxFix, _WebglCanvas_refs, _WebglCanvas_isCtxLost, _WebglCanvas_init, _WebglCanvas_drawLayers, _WebglCanvas_upscale, _WebglCanvas_createProgram, _WebglCanvas_createShader, _WebglCanvas_createScreenQuad, _WebglCanvas_setBuffersAndAttribs, _WebglCanvas_createTexture, _WebglCanvas_resizeTexture, _WebglCanvas_createFramebuffer, _WebglCanvas_useFramebuffer, _WebglCanvas_resizeFramebuffer, _WebglCanvas_checkContextLoss, _WebglCanvas_handleContextLoss, _WebglCanvas_handleContextRestored; + /** + * Flipnote renderer for the {@link https://developer.mozilla.org/en-US/docs/Web/API/WebGL_API WebGL} API. + * + * Only available in browser contexts. + */ + class WebglCanvas { + static isSupported() { + if (!isBrowser) + return false; + let testCanvas = document.createElement('canvas'); + let testCtx = testCanvas.getContext('2d'); + const supported = testCtx !== null; + testCanvas = null; + testCtx = null; + return supported; + } + /** + * Creates a new WebGlCanvas instance + * @param el - Canvas HTML element to use as a rendering surface + * @param width - Canvas width in CSS pixels + * @param height - Canvas height in CSS pixels + * + * The ratio between `width` and `height` should be 3:4 for best results + */ + constructor(parent, width = 640, height = 480, options = {}) { + _WebglCanvas_instances.add(this); + /** + * + */ + this.supportedStereoscopeModes = [ + exports.CanvasStereoscopicMode.None, + exports.CanvasStereoscopicMode.Dual, + // CanvasStereoscopicMode.Anaglyph, // couldn't get this working, despite spending lots of time on it :/ + ]; + /** + * + */ + this.stereoscopeMode = exports.CanvasStereoscopicMode.None; + /** + * + */ + this.stereoscopeStrength = 0; + _WebglCanvas_options.set(this, void 0); + _WebglCanvas_layerProgram.set(this, void 0); // for drawing renderbuffer w/ filtering + _WebglCanvas_upscaleProgram.set(this, void 0); // for drawing renderbuffer w/ filtering + _WebglCanvas_quadBuffer.set(this, void 0); + _WebglCanvas_paletteBuffer.set(this, new Uint32Array(16)); + _WebglCanvas_layerTexture.set(this, void 0); + _WebglCanvas_layerTexturePixelBuffer.set(this, void 0); + _WebglCanvas_layerTexturePixels.set(this, void 0); // will be same memory as layerTexturePixelBuffer, just uint8 for webgl texture + _WebglCanvas_frameTexture.set(this, void 0); + _WebglCanvas_frameBuffer.set(this, void 0); + _WebglCanvas_textureTypes.set(this, new Map()); + _WebglCanvas_textureSizes.set(this, new Map()); + _WebglCanvas_frameBufferTextures.set(this, new Map()); + _WebglCanvas_applyFirefoxFix.set(this, false); + _WebglCanvas_refs.set(this, { + programs: [], + shaders: [], + textures: [], + buffers: [], + frameBuffers: [] + }); + _WebglCanvas_isCtxLost.set(this, false); + _WebglCanvas_handleContextLoss.set(this, (e) => { + this.destroy(); + if (e) + e.preventDefault(); + if (!__classPrivateFieldGet(this, _WebglCanvas_isCtxLost, "f")) + __classPrivateFieldGet(this, _WebglCanvas_options, "f").onlost(); + __classPrivateFieldSet(this, _WebglCanvas_isCtxLost, true, "f"); + }); + _WebglCanvas_handleContextRestored.set(this, (e) => { + __classPrivateFieldSet(this, _WebglCanvas_isCtxLost, false, "f"); + __classPrivateFieldGet(this, _WebglCanvas_instances, "m", _WebglCanvas_init).call(this); + __classPrivateFieldGet(this, _WebglCanvas_options, "f").onrestored(); + }); + assertBrowserEnv(); + __classPrivateFieldSet(this, _WebglCanvas_options, { ...WebglCanvas.defaultOptions, ...options }, "f"); + this.width = width; + this.height = height; + this.canvas = document.createElement('canvas'); + this.canvas.addEventListener('webglcontextlost', __classPrivateFieldGet(this, _WebglCanvas_handleContextLoss, "f"), false); + this.canvas.addEventListener('webglcontextrestored', __classPrivateFieldGet(this, _WebglCanvas_handleContextRestored, "f"), false); + this.canvas.className = 'FlipnoteCanvas FlipnoteCanvas--webgl'; + this.gl = this.canvas.getContext('webgl', { + antialias: false, + alpha: true + }); + if (parent) + parent.appendChild(this.canvas); + __classPrivateFieldGet(this, _WebglCanvas_instances, "m", _WebglCanvas_init).call(this); + } + /** + * Resize the canvas surface + * @param width - New canvas width, in CSS pixels + * @param height - New canvas height, in CSS pixels + * + * The ratio between `width` and `height` should be 3:4 for best results + */ + setCanvasSize(width, height) { + const dpi = __classPrivateFieldGet(this, _WebglCanvas_options, "f").useDpi ? (window.devicePixelRatio || 1) : 1; + const internalWidth = width * dpi; + const internalHeight = height * dpi; + this.width = width; + this.height = height; + this.canvas.width = internalWidth; + this.canvas.height = internalHeight; + this.dstWidth = internalWidth; + this.dstHeight = internalHeight; + this.canvas.style.width = `${width}px`; + this.canvas.style.height = `${height}px`; + __classPrivateFieldGet(this, _WebglCanvas_instances, "m", _WebglCanvas_checkContextLoss).call(this); + } + /** + * Sets the note to use for this player + */ + setNote(note) { + if (__classPrivateFieldGet(this, _WebglCanvas_instances, "m", _WebglCanvas_checkContextLoss).call(this)) + return; + const width = note.imageWidth; + const height = note.imageHeight; + this.note = note; + this.srcWidth = width; + this.srcHeight = height; + __classPrivateFieldGet(this, _WebglCanvas_instances, "m", _WebglCanvas_resizeFramebuffer).call(this, __classPrivateFieldGet(this, _WebglCanvas_frameBuffer, "f"), width, height); + __classPrivateFieldGet(this, _WebglCanvas_instances, "m", _WebglCanvas_resizeTexture).call(this, __classPrivateFieldGet(this, _WebglCanvas_layerTexture, "f"), width, height); + __classPrivateFieldSet(this, _WebglCanvas_layerTexturePixelBuffer, new Uint32Array(width * height), "f"); + __classPrivateFieldSet(this, _WebglCanvas_layerTexturePixels, new Uint8Array(__classPrivateFieldGet(this, _WebglCanvas_layerTexturePixelBuffer, "f").buffer), "f"); // same memory buffer as rgbaData + this.frameIndex = undefined; + // set canvas alt text + this.canvas.title = note.getTitle(); + } + /** + * Clear the canvas + * @param color optional RGBA color to use as a background color + */ + clear(color) { + if (__classPrivateFieldGet(this, _WebglCanvas_instances, "m", _WebglCanvas_checkContextLoss).call(this)) + return; + const gl = this.gl; + const paperColor = color ?? this.note.getFramePalette(this.frameIndex)[0]; + const [r, g, b, a] = paperColor; + gl.clearColor(r / 255, g / 255, b / 255, a / 255); + gl.clear(gl.COLOR_BUFFER_BIT); + } + /** + * Draw a frame from the currently loaded Flipnote + * @param frameIndex + */ + drawFrame(frameIndex) { + if (__classPrivateFieldGet(this, _WebglCanvas_instances, "m", _WebglCanvas_checkContextLoss).call(this)) + return; + const gl = this.gl; + const mode = this.stereoscopeMode; + const strength = this.stereoscopeStrength; + this.frameIndex = frameIndex; + if (mode === exports.CanvasStereoscopicMode.None) { + __classPrivateFieldGet(this, _WebglCanvas_instances, "m", _WebglCanvas_drawLayers).call(this, frameIndex); + __classPrivateFieldGet(this, _WebglCanvas_instances, "m", _WebglCanvas_useFramebuffer).call(this, null); + __classPrivateFieldGet(this, _WebglCanvas_instances, "m", _WebglCanvas_upscale).call(this, gl.drawingBufferWidth, gl.drawingBufferHeight); + } + else if (mode === exports.CanvasStereoscopicMode.Dual) { + __classPrivateFieldGet(this, _WebglCanvas_instances, "m", _WebglCanvas_drawLayers).call(this, frameIndex, strength, exports.FlipnoteStereoscopicEye.Left); + __classPrivateFieldGet(this, _WebglCanvas_instances, "m", _WebglCanvas_useFramebuffer).call(this, null, 0, 0, gl.drawingBufferWidth / 2, gl.drawingBufferHeight); + __classPrivateFieldGet(this, _WebglCanvas_instances, "m", _WebglCanvas_upscale).call(this, gl.drawingBufferWidth / 2, gl.drawingBufferHeight); + __classPrivateFieldGet(this, _WebglCanvas_instances, "m", _WebglCanvas_drawLayers).call(this, frameIndex, strength, exports.FlipnoteStereoscopicEye.Right); + __classPrivateFieldGet(this, _WebglCanvas_instances, "m", _WebglCanvas_useFramebuffer).call(this, null, gl.drawingBufferWidth / 2, 0, gl.drawingBufferWidth / 2, gl.drawingBufferHeight); + __classPrivateFieldGet(this, _WebglCanvas_instances, "m", _WebglCanvas_upscale).call(this, gl.drawingBufferWidth / 2, gl.drawingBufferHeight); + } + } + requestStereoScopeMode(mode) { + if (this.supportedStereoscopeModes.includes(mode)) + this.stereoscopeMode = mode; + else + this.stereoscopeMode = exports.CanvasStereoscopicMode.None; + this.forceUpdate(); + } + forceUpdate() { + if (this.frameIndex !== undefined) + this.drawFrame(this.frameIndex); + } + /** + * Returns true if the webGL context has returned an error + */ + isErrorState() { + const gl = this.gl; + return gl === null || gl.getError() !== gl.NO_ERROR; + } + /** + * Get the contents of the canvas as a data URL. + * + * @param type image mime type (`image/jpeg`, `image/png`, etc) + * @param quality image quality where supported, between 0 and 1 + */ + getDataUrl(type, quality) { + return this.canvas.toDataURL(type, quality); + } + /** + * Get the contents of the canvas as a blob. + * + * @param type image mime type (`image/jpeg`, `image/png`, etc) + * @param quality image quality where supported, between 0 and 1 + */ + async getBlob(type, quality) { + return new Promise((resolve, reject) => this.canvas.toBlob(resolve, type, quality)); + } + /** + * Frees any resources used by this canvas instance + */ + destroy() { + const refs = __classPrivateFieldGet(this, _WebglCanvas_refs, "f"); + const gl = this.gl; + const canvas = this.canvas; + refs.shaders.forEach((shader) => { + gl.deleteShader(shader); + }); + refs.shaders = []; + refs.textures.forEach((texture) => { + gl.deleteTexture(texture); + }); + refs.textures = []; + refs.buffers.forEach((buffer) => { + gl.deleteBuffer(buffer); + }); + refs.buffers = []; + refs.frameBuffers.forEach((fb) => { + gl.deleteFramebuffer(fb); + }); + refs.frameBuffers = []; + refs.programs.forEach((program) => { + gl.deleteProgram(program); + }); + refs.programs = []; + __classPrivateFieldSet(this, _WebglCanvas_paletteBuffer, null, "f"); + __classPrivateFieldSet(this, _WebglCanvas_layerTexturePixelBuffer, null, "f"); + __classPrivateFieldSet(this, _WebglCanvas_layerTexturePixels, null, "f"); + __classPrivateFieldGet(this, _WebglCanvas_textureTypes, "f").clear(); + __classPrivateFieldGet(this, _WebglCanvas_textureSizes, "f").clear(); + __classPrivateFieldGet(this, _WebglCanvas_frameBufferTextures, "f").clear(); + if (canvas && canvas.parentElement) { + // shrink the canvas to reduce memory usage until it is garbage collected + canvas.width = 1; + canvas.height = 1; + // remove canvas from dom + canvas.parentNode.removeChild(canvas); + } + } + } + _WebglCanvas_options = new WeakMap(), _WebglCanvas_layerProgram = new WeakMap(), _WebglCanvas_upscaleProgram = new WeakMap(), _WebglCanvas_quadBuffer = new WeakMap(), _WebglCanvas_paletteBuffer = new WeakMap(), _WebglCanvas_layerTexture = new WeakMap(), _WebglCanvas_layerTexturePixelBuffer = new WeakMap(), _WebglCanvas_layerTexturePixels = new WeakMap(), _WebglCanvas_frameTexture = new WeakMap(), _WebglCanvas_frameBuffer = new WeakMap(), _WebglCanvas_textureTypes = new WeakMap(), _WebglCanvas_textureSizes = new WeakMap(), _WebglCanvas_frameBufferTextures = new WeakMap(), _WebglCanvas_applyFirefoxFix = new WeakMap(), _WebglCanvas_refs = new WeakMap(), _WebglCanvas_isCtxLost = new WeakMap(), _WebglCanvas_handleContextLoss = new WeakMap(), _WebglCanvas_handleContextRestored = new WeakMap(), _WebglCanvas_instances = new WeakSet(), _WebglCanvas_init = function _WebglCanvas_init() { + this.setCanvasSize(this.width, this.height); + const gl = this.gl; + if (__classPrivateFieldGet(this, _WebglCanvas_instances, "m", _WebglCanvas_checkContextLoss).call(this)) + return; + __classPrivateFieldSet(this, _WebglCanvas_layerProgram, __classPrivateFieldGet(this, _WebglCanvas_instances, "m", _WebglCanvas_createProgram).call(this, vertShaderLayer, fragShaderLayer), "f"); + __classPrivateFieldSet(this, _WebglCanvas_upscaleProgram, __classPrivateFieldGet(this, _WebglCanvas_instances, "m", _WebglCanvas_createProgram).call(this, vertShaderUpscale, fragShaderUpscale), "f"); + __classPrivateFieldSet(this, _WebglCanvas_quadBuffer, __classPrivateFieldGet(this, _WebglCanvas_instances, "m", _WebglCanvas_createScreenQuad).call(this, -1, -1, 2, 2, 1, 1), "f"); + __classPrivateFieldGet(this, _WebglCanvas_instances, "m", _WebglCanvas_setBuffersAndAttribs).call(this, __classPrivateFieldGet(this, _WebglCanvas_layerProgram, "f"), __classPrivateFieldGet(this, _WebglCanvas_quadBuffer, "f")); + __classPrivateFieldSet(this, _WebglCanvas_layerTexture, __classPrivateFieldGet(this, _WebglCanvas_instances, "m", _WebglCanvas_createTexture).call(this, gl.RGBA, gl.LINEAR, gl.CLAMP_TO_EDGE), "f"); + __classPrivateFieldSet(this, _WebglCanvas_frameTexture, __classPrivateFieldGet(this, _WebglCanvas_instances, "m", _WebglCanvas_createTexture).call(this, gl.RGBA, gl.LINEAR, gl.CLAMP_TO_EDGE), "f"); + __classPrivateFieldSet(this, _WebglCanvas_frameBuffer, __classPrivateFieldGet(this, _WebglCanvas_instances, "m", _WebglCanvas_createFramebuffer).call(this, __classPrivateFieldGet(this, _WebglCanvas_frameTexture, "f")), "f"); + const debugInfo = gl.getExtension('WEBGL_debug_renderer_info'); + const renderer = gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL); + const userAgent = navigator.userAgent; + const isMacFirefox = userAgent.includes('Firefox') && userAgent.includes('Mac'); + __classPrivateFieldSet(this, _WebglCanvas_applyFirefoxFix, isMacFirefox && renderer.includes('Apple M'), "f"); + }, _WebglCanvas_drawLayers = function _WebglCanvas_drawLayers(frameIndex, depthStrength = 0, depthEye = exports.FlipnoteStereoscopicEye.Left, shouldClear = true) { + const gl = this.gl; + const note = this.note; + const srcWidth = this.srcWidth; + const srcHeight = this.srcHeight; + const numLayers = note.numLayers; + const layerOrder = note.getFrameLayerOrder(frameIndex); + const layerDepths = note.getFrameLayerDepths(frameIndex); + __classPrivateFieldGet(this, _WebglCanvas_instances, "m", _WebglCanvas_useFramebuffer).call(this, __classPrivateFieldGet(this, _WebglCanvas_frameBuffer, "f")); + if (shouldClear) + this.clear(); + gl.useProgram(__classPrivateFieldGet(this, _WebglCanvas_layerProgram, "f").program); + for (let i = 0; i < numLayers; i++) { + const layerIndex = layerOrder[i]; + note.getLayerPixelsRgba(frameIndex, layerIndex, __classPrivateFieldGet(this, _WebglCanvas_layerTexturePixelBuffer, "f"), __classPrivateFieldGet(this, _WebglCanvas_paletteBuffer, "f")); + setUniforms(__classPrivateFieldGet(this, _WebglCanvas_layerProgram, "f"), { + u_flipY: true, + u_tex: __classPrivateFieldGet(this, _WebglCanvas_layerTexture, "f"), + u_textureSize: [srcWidth, srcHeight], + u_3d_mode: this.stereoscopeMode, + u_3d_eye: depthEye, + u_3d_depth: layerDepths[layerIndex], + u_3d_strength: depthStrength, + }); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, srcWidth, srcHeight, 0, gl.RGBA, gl.UNSIGNED_BYTE, __classPrivateFieldGet(this, _WebglCanvas_layerTexturePixels, "f")); + gl.drawElements(gl.TRIANGLES, __classPrivateFieldGet(this, _WebglCanvas_quadBuffer, "f").numElements, __classPrivateFieldGet(this, _WebglCanvas_quadBuffer, "f").elementType, 0); + } + }, _WebglCanvas_upscale = function _WebglCanvas_upscale(width, height) { + if (__classPrivateFieldGet(this, _WebglCanvas_instances, "m", _WebglCanvas_checkContextLoss).call(this)) + return; + const gl = this.gl; + gl.useProgram(__classPrivateFieldGet(this, _WebglCanvas_upscaleProgram, "f").program); + setUniforms(__classPrivateFieldGet(this, _WebglCanvas_upscaleProgram, "f"), { + // u_flipY: true, + u_tex: __classPrivateFieldGet(this, _WebglCanvas_frameTexture, "f"), + u_textureSize: [this.srcWidth, this.srcHeight], + u_screenSize: [width, height], + }); + gl.drawElements(gl.TRIANGLES, __classPrivateFieldGet(this, _WebglCanvas_quadBuffer, "f").numElements, __classPrivateFieldGet(this, _WebglCanvas_quadBuffer, "f").elementType, 0); + }, _WebglCanvas_createProgram = function _WebglCanvas_createProgram(vertexShaderSource, fragmentShaderSource) { + if (__classPrivateFieldGet(this, _WebglCanvas_instances, "m", _WebglCanvas_checkContextLoss).call(this)) + return; + const gl = this.gl; + const vert = __classPrivateFieldGet(this, _WebglCanvas_instances, "m", _WebglCanvas_createShader).call(this, gl.VERTEX_SHADER, vertexShaderSource); + const frag = __classPrivateFieldGet(this, _WebglCanvas_instances, "m", _WebglCanvas_createShader).call(this, gl.FRAGMENT_SHADER, fragmentShaderSource); + const program = gl.createProgram(); + // set up shaders + gl.attachShader(program, vert); + gl.attachShader(program, frag); + // link program + gl.linkProgram(program); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + const log = gl.getProgramInfoLog(program); + gl.deleteProgram(program); + throw new Error(log); + } + const programInfo = createProgramInfoFromProgram(gl, program); + __classPrivateFieldGet(this, _WebglCanvas_refs, "f").programs.push(program); + return programInfo; + }, _WebglCanvas_createShader = function _WebglCanvas_createShader(type, source) { + if (__classPrivateFieldGet(this, _WebglCanvas_instances, "m", _WebglCanvas_checkContextLoss).call(this)) + return; + const gl = this.gl; + const shader = gl.createShader(type); + gl.shaderSource(shader, source); + gl.compileShader(shader); + // test if shader compilation was successful + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + const log = gl.getShaderInfoLog(shader); + gl.deleteShader(shader); + throw new Error(log); + } + __classPrivateFieldGet(this, _WebglCanvas_refs, "f").shaders.push(shader); + return shader; + }, _WebglCanvas_createScreenQuad = function _WebglCanvas_createScreenQuad(x0, y0, width, height, xSubdivs, ySubdivs) { + if (__classPrivateFieldGet(this, _WebglCanvas_instances, "m", _WebglCanvas_checkContextLoss).call(this)) + return; + const numVerts = (xSubdivs + 1) * (ySubdivs + 1); + const numVertsAcross = xSubdivs + 1; + const positions = new Float32Array(numVerts * 2); + const texCoords = new Float32Array(numVerts * 2); + let positionPtr = 0; + let texCoordPtr = 0; + for (let y = 0; y <= ySubdivs; y++) { + for (let x = 0; x <= xSubdivs; x++) { + const u = x / xSubdivs; + const v = y / ySubdivs; + positions[positionPtr++] = x0 + width * u; + positions[positionPtr++] = y0 + height * v; + texCoords[texCoordPtr++] = u; + texCoords[texCoordPtr++] = v; + } + } + const indices = new Uint16Array(xSubdivs * ySubdivs * 2 * 3); + let indicesPtr = 0; + for (let y = 0; y < ySubdivs; y++) { + for (let x = 0; x < xSubdivs; x++) { + // triangle 1 + indices[indicesPtr++] = (y + 0) * numVertsAcross + x; + indices[indicesPtr++] = (y + 1) * numVertsAcross + x; + indices[indicesPtr++] = (y + 0) * numVertsAcross + x + 1; + // triangle 2 + indices[indicesPtr++] = (y + 0) * numVertsAcross + x + 1; + indices[indicesPtr++] = (y + 1) * numVertsAcross + x; + indices[indicesPtr++] = (y + 1) * numVertsAcross + x + 1; + } + } + const bufferInfo = createBufferInfoFromArrays(this.gl, { + position: { + numComponents: 2, + data: positions + }, + texcoord: { + numComponents: 2, + data: texCoords + }, + indices: indices + }); + // collect references to buffer objects + for (let name in bufferInfo.attribs) + __classPrivateFieldGet(this, _WebglCanvas_refs, "f").buffers.push(bufferInfo.attribs[name].buffer); + return bufferInfo; + }, _WebglCanvas_setBuffersAndAttribs = function _WebglCanvas_setBuffersAndAttribs(program, buffer) { + if (__classPrivateFieldGet(this, _WebglCanvas_instances, "m", _WebglCanvas_checkContextLoss).call(this)) + return; + setBuffersAndAttributes(this.gl, program.attribSetters, buffer); + }, _WebglCanvas_createTexture = function _WebglCanvas_createTexture(type, minMag, wrap, width = 1, height = 1) { + if (__classPrivateFieldGet(this, _WebglCanvas_instances, "m", _WebglCanvas_checkContextLoss).call(this)) + return; + const gl = this.gl; + const tex = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, tex); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, wrap); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, wrap); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, minMag); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, minMag); + gl.texImage2D(gl.TEXTURE_2D, 0, type, width, height, 0, type, gl.UNSIGNED_BYTE, null); + __classPrivateFieldGet(this, _WebglCanvas_refs, "f").textures.push(tex); + __classPrivateFieldGet(this, _WebglCanvas_textureTypes, "f").set(tex, type); + __classPrivateFieldGet(this, _WebglCanvas_textureSizes, "f").set(tex, { width, height }); + return tex; + }, _WebglCanvas_resizeTexture = function _WebglCanvas_resizeTexture(texture, width, height) { + if (__classPrivateFieldGet(this, _WebglCanvas_instances, "m", _WebglCanvas_checkContextLoss).call(this)) + return; + const gl = this.gl; + const textureType = __classPrivateFieldGet(this, _WebglCanvas_textureTypes, "f").get(texture); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texImage2D(gl.TEXTURE_2D, 0, textureType, width, height, 0, textureType, gl.UNSIGNED_BYTE, null); + __classPrivateFieldGet(this, _WebglCanvas_textureSizes, "f").set(texture, { width, height }); + }, _WebglCanvas_createFramebuffer = function _WebglCanvas_createFramebuffer(texture) { + if (__classPrivateFieldGet(this, _WebglCanvas_instances, "m", _WebglCanvas_checkContextLoss).call(this)) + return; + const gl = this.gl; + const fb = gl.createFramebuffer(); + gl.bindFramebuffer(gl.FRAMEBUFFER, fb); + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0); + __classPrivateFieldGet(this, _WebglCanvas_refs, "f").frameBuffers.push(fb); + __classPrivateFieldGet(this, _WebglCanvas_frameBufferTextures, "f").set(fb, texture); + return fb; + }, _WebglCanvas_useFramebuffer = function _WebglCanvas_useFramebuffer(fb, viewX, viewY, viewWidth, viewHeight) { + if (__classPrivateFieldGet(this, _WebglCanvas_instances, "m", _WebglCanvas_checkContextLoss).call(this)) + return; + const gl = this.gl; + if (fb === null) { + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + /** + * Firefox on Apple Silicon Macs seems to have some kind of viewport sizing bug that I can't track down. + * Details here: https://github.com/jaames/flipnote.js/issues/30#issuecomment-2134602056 + * Not sure what's causing it, but this hack fixes it for now. + * Need to test whether only specific versions of Firefox are affected, if it's only an Apple Silicon thing, etc, etc... + */ + if (__classPrivateFieldGet(this, _WebglCanvas_applyFirefoxFix, "f")) { + const srcWidth = this.srcWidth; + const srcHeight = this.srcHeight; + const sx = gl.drawingBufferWidth / srcWidth; + const sy = gl.drawingBufferHeight / srcHeight; + const adj = srcWidth === 256 ? 1 : 0; // ??????? why + viewWidth = gl.drawingBufferWidth * (sx - adj); + viewHeight = gl.drawingBufferHeight * (sy - adj); + viewX = -(viewWidth - srcWidth * sx); + viewY = -(viewHeight - srcHeight * sy); + } + gl.viewport(viewX ?? 0, viewY ?? 0, viewWidth ?? gl.drawingBufferWidth, viewHeight ?? gl.drawingBufferHeight); + } + else { + const tex = __classPrivateFieldGet(this, _WebglCanvas_frameBufferTextures, "f").get(fb); + const { width, height } = __classPrivateFieldGet(this, _WebglCanvas_textureSizes, "f").get(tex); + gl.bindFramebuffer(gl.FRAMEBUFFER, fb); + gl.viewport(viewX ?? 0, viewY ?? 0, viewWidth ?? width, viewHeight ?? height); + } + }, _WebglCanvas_resizeFramebuffer = function _WebglCanvas_resizeFramebuffer(fb, width, height) { + if (__classPrivateFieldGet(this, _WebglCanvas_instances, "m", _WebglCanvas_checkContextLoss).call(this)) + return; + const texture = __classPrivateFieldGet(this, _WebglCanvas_frameBufferTextures, "f").get(fb); + __classPrivateFieldGet(this, _WebglCanvas_instances, "m", _WebglCanvas_resizeTexture).call(this, texture, width, height); + }, _WebglCanvas_checkContextLoss = function _WebglCanvas_checkContextLoss() { + const isLost = __classPrivateFieldGet(this, _WebglCanvas_isCtxLost, "f") || this.isErrorState(); + if (isLost) + __classPrivateFieldGet(this, _WebglCanvas_handleContextLoss, "f").call(this); + return isLost; + }; + WebglCanvas.defaultOptions = { + onlost: () => { }, + onrestored: () => { }, + useDpi: true + }; + + var _Html5Canvas_options, _Html5Canvas_srcCanvas, _Html5Canvas_srcCtx, _Html5Canvas_frameImage, _Html5Canvas_paletteBuffer, _Html5Canvas_frameBuffer; + /** + * Flipnote renderer for the [HTML5 2D canvas API](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D) + */ + class Html5Canvas { + static isSupported() { + if (!isBrowser) + return false; + let testCanvas = document.createElement('canvas'); + let testCtx = testCanvas.getContext('2d'); + const supported = testCtx !== null; + testCanvas = null; + testCtx = null; + return supported; + } + constructor(parent, width, height, options = {}) { + /** + * + */ + this.supportedStereoscopeModes = [ + exports.CanvasStereoscopicMode.None + ]; + /** + * + */ + this.stereoscopeMode = exports.CanvasStereoscopicMode.None; + /** + * + */ + this.stereoscopeStrength = 0; + _Html5Canvas_options.set(this, void 0); + _Html5Canvas_srcCanvas.set(this, void 0); + _Html5Canvas_srcCtx.set(this, void 0); + _Html5Canvas_frameImage.set(this, void 0); + _Html5Canvas_paletteBuffer.set(this, new Uint32Array(16)); + _Html5Canvas_frameBuffer.set(this, void 0); + assertBrowserEnv(); + __classPrivateFieldSet(this, _Html5Canvas_options, { ...Html5Canvas.defaultOptions, ...options }, "f"); + this.width = width; + this.height = height; + this.canvas = document.createElement('canvas'); + this.canvas.className = 'FlipnoteCanvas FlipnoteCanvas--html5'; + this.ctx = this.canvas.getContext('2d'); + __classPrivateFieldSet(this, _Html5Canvas_srcCanvas, document.createElement('canvas'), "f"); + __classPrivateFieldSet(this, _Html5Canvas_srcCtx, __classPrivateFieldGet(this, _Html5Canvas_srcCanvas, "f").getContext('2d'), "f"); + assert(this.ctx !== null && __classPrivateFieldGet(this, _Html5Canvas_srcCtx, "f") !== null, 'Could not create HTML5 canvas'); + if (parent) + parent.appendChild(this.canvas); + this.setCanvasSize(width, height); + } + /** + * Resize the canvas surface + * @param width - New canvas width, in CSS pixels + * @param height - New canvas height, in CSS pixels + * + * The ratio between `width` and `height` should be 3:4 for best results + */ + setCanvasSize(width, height) { + const canvas = this.canvas; + const useDpi = __classPrivateFieldGet(this, _Html5Canvas_options, "f").useDpi; + const dpi = useDpi ? (window.devicePixelRatio || 1) : 1; + const internalWidth = width * dpi; + const internalHeight = height * dpi; + this.width = width; + this.height = height; + this.dstWidth = internalWidth; + this.dstHeight = internalHeight; + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + canvas.width = internalWidth; + canvas.height = internalHeight; + } + /** + */ + setNote(note) { + const width = note.imageWidth; + const height = note.imageHeight; + this.note = note; + this.srcWidth = width; + this.srcHeight = height; + __classPrivateFieldGet(this, _Html5Canvas_srcCanvas, "f").width = width; + __classPrivateFieldGet(this, _Html5Canvas_srcCanvas, "f").height = height; + // create image data to fit note size + __classPrivateFieldSet(this, _Html5Canvas_frameImage, __classPrivateFieldGet(this, _Html5Canvas_srcCtx, "f").createImageData(width, height), "f"); + // uint32 view of the img buffer memory + __classPrivateFieldSet(this, _Html5Canvas_frameBuffer, new Uint32Array(__classPrivateFieldGet(this, _Html5Canvas_frameImage, "f").data.buffer), "f"); + this.frameIndex = undefined; + // set canvas alt text + this.canvas.title = note.getTitle(); + } + /** + * Clear the canvas + * @param color optional RGBA color to use as a background color + */ + clear(color) { + // clear framebuffer + __classPrivateFieldGet(this, _Html5Canvas_frameBuffer, "f").fill(0); + // clear canvas + this.ctx.clearRect(0, 0, this.dstWidth, this.dstHeight); + // fill canvas with paper color + if (color) { + const [r, g, b, a] = color; + this.ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${a})`; + this.ctx.fillRect(0, 0, this.dstWidth, this.dstHeight); + } + } + drawFrame(frameIndex) { + // clear whatever's already been drawn + this.clear(); + // optionally enable image smoothing + if (!__classPrivateFieldGet(this, _Html5Canvas_options, "f").useSmoothing) + this.ctx.imageSmoothingEnabled = false; + // get frame pixels as RGBA buffer + this.note.getFramePixelsRgba(frameIndex, __classPrivateFieldGet(this, _Html5Canvas_frameBuffer, "f"), __classPrivateFieldGet(this, _Html5Canvas_paletteBuffer, "f")); + // put framebuffer pixels into the src canvas + __classPrivateFieldGet(this, _Html5Canvas_srcCtx, "f").putImageData(__classPrivateFieldGet(this, _Html5Canvas_frameImage, "f"), 0, 0); + // composite src canvas to dst (so image scaling can be handled) + this.ctx.drawImage(__classPrivateFieldGet(this, _Html5Canvas_srcCanvas, "f"), 0, 0, this.srcWidth, this.srcHeight, 0, 0, this.dstWidth, this.dstHeight); + this.frameIndex = frameIndex; + } + requestStereoScopeMode(mode) { + if (this.supportedStereoscopeModes.includes(mode)) + this.stereoscopeMode = mode; + else + this.stereoscopeMode = exports.CanvasStereoscopicMode.None; + this.forceUpdate(); + } + forceUpdate() { + if (this.frameIndex !== undefined) + this.drawFrame(this.frameIndex); + } + getDataUrl(type, quality) { + return this.canvas.toDataURL(type, quality); + } + async getBlob(type, quality) { + return new Promise((resolve, reject) => this.canvas.toBlob(resolve, type, quality)); + } + destroy() { + __classPrivateFieldSet(this, _Html5Canvas_frameImage, null, "f"); + __classPrivateFieldSet(this, _Html5Canvas_paletteBuffer, null, "f"); + __classPrivateFieldSet(this, _Html5Canvas_frameBuffer, null, "f"); + this.canvas.parentNode.removeChild(this.canvas); + this.canvas.width = 1; + this.canvas.height = 1; + this.canvas = null; + __classPrivateFieldGet(this, _Html5Canvas_srcCanvas, "f").width = 1; + __classPrivateFieldGet(this, _Html5Canvas_srcCanvas, "f").height = 1; + __classPrivateFieldSet(this, _Html5Canvas_srcCanvas, null, "f"); + } + } + _Html5Canvas_options = new WeakMap(), _Html5Canvas_srcCanvas = new WeakMap(), _Html5Canvas_srcCtx = new WeakMap(), _Html5Canvas_frameImage = new WeakMap(), _Html5Canvas_paletteBuffer = new WeakMap(), _Html5Canvas_frameBuffer = new WeakMap(); + Html5Canvas.defaultOptions = { + useDpi: true, + useSmoothing: true, + }; + + var _UniversalCanvas_instances, _UniversalCanvas_rendererStack, _UniversalCanvas_rendererStackIdx, _UniversalCanvas_parent, _UniversalCanvas_options, _UniversalCanvas_setSubRenderer; + class UniversalCanvas { + constructor(parent, width = 640, height = 480, options = {}) { + _UniversalCanvas_instances.add(this); + /** + * + */ + this.isReady = false; + /** + * + */ + this.isHtml5 = false; + /** + * + */ + this.supportedStereoscopeModes = []; + /** + * + */ + this.stereoscopeMode = exports.CanvasStereoscopicMode.None; + /** + * + */ + this.stereoscopeStrength = 1; + _UniversalCanvas_rendererStack.set(this, [ + WebglCanvas, + Html5Canvas + ]); + _UniversalCanvas_rendererStackIdx.set(this, 0); + _UniversalCanvas_parent.set(this, void 0); + _UniversalCanvas_options.set(this, {}); + this.width = width; + this.height = height; + __classPrivateFieldSet(this, _UniversalCanvas_parent, parent, "f"); + __classPrivateFieldSet(this, _UniversalCanvas_options, options, "f"); + __classPrivateFieldGet(this, _UniversalCanvas_instances, "m", _UniversalCanvas_setSubRenderer).call(this, __classPrivateFieldGet(this, _UniversalCanvas_rendererStack, "f")[0]); + } + fallbackIfPossible() { + if (__classPrivateFieldGet(this, _UniversalCanvas_rendererStackIdx, "f") >= __classPrivateFieldGet(this, _UniversalCanvas_rendererStack, "f").length) + throw new Error('No renderer to fall back to'); + __classPrivateFieldSet(this, _UniversalCanvas_rendererStackIdx, __classPrivateFieldGet(this, _UniversalCanvas_rendererStackIdx, "f") + 1, "f"); + __classPrivateFieldGet(this, _UniversalCanvas_instances, "m", _UniversalCanvas_setSubRenderer).call(this, __classPrivateFieldGet(this, _UniversalCanvas_rendererStack, "f")[__classPrivateFieldGet(this, _UniversalCanvas_rendererStackIdx, "f")]); + } + // for backwards compat + switchToHtml5() { + __classPrivateFieldGet(this, _UniversalCanvas_instances, "m", _UniversalCanvas_setSubRenderer).call(this, Html5Canvas); + } + setCanvasSize(width, height) { + const renderer = this.renderer; + renderer.setCanvasSize(width, height); + this.width = width; + this.width = height; + this.dstWidth = renderer.dstWidth; + this.dstHeight = renderer.dstHeight; + } + setNote(note) { + this.note = note; + this.renderer.setNote(note); + this.frameIndex = undefined; + this.srcWidth = this.renderer.srcWidth; + this.srcHeight = this.renderer.srcHeight; + } + clear(color) { + this.renderer.clear(color); + } + drawFrame(frameIndex) { + this.renderer.drawFrame(frameIndex); + this.frameIndex = frameIndex; + } + forceUpdate() { + this.renderer.forceUpdate(); + } + requestStereoScopeMode(mode) { + this.renderer.requestStereoScopeMode(mode); + this.stereoscopeMode = this.renderer.stereoscopeMode; + } + getDataUrl(type, quality) { + return this.renderer.getDataUrl(); + } + async getBlob(type, quality) { + return this.renderer.getBlob(); + } + destroy() { + this.renderer.destroy(); + this.note = null; + } + } + _UniversalCanvas_rendererStack = new WeakMap(), _UniversalCanvas_rendererStackIdx = new WeakMap(), _UniversalCanvas_parent = new WeakMap(), _UniversalCanvas_options = new WeakMap(), _UniversalCanvas_instances = new WeakSet(), _UniversalCanvas_setSubRenderer = function _UniversalCanvas_setSubRenderer(Canvas) { + let immediateLoss = false; + const renderer = new Canvas(__classPrivateFieldGet(this, _UniversalCanvas_parent, "f"), this.width, this.height, { + ...__classPrivateFieldGet(this, _UniversalCanvas_options, "f"), + onlost: () => { + immediateLoss = true; + this.fallbackIfPossible(); + } + }); + // if onlost was called immediately, we succeed to the fallback + if (immediateLoss) + return; + if (this.note) { + renderer.setNote(this.note); + renderer.frameIndex = this.renderer?.frameIndex; + renderer.forceUpdate(); + } + if (this.renderer) + this.renderer.destroy(); + this.isHtml5 = renderer instanceof Html5Canvas; + this.isReady = true; + this.renderer = renderer; + __classPrivateFieldSet(this, _UniversalCanvas_rendererStackIdx, __classPrivateFieldGet(this, _UniversalCanvas_rendererStack, "f").indexOf(Canvas), "f"); + this.supportedStereoscopeModes = renderer.supportedStereoscopeModes; + renderer.stereoscopeStrength = this.stereoscopeStrength; + this.requestStereoScopeMode(this.stereoscopeMode); + }; + + var _WebAudioPlayer_instances, _WebAudioPlayer_volume, _WebAudioPlayer_loop, _WebAudioPlayer_startTime, _WebAudioPlayer_ctxStartTime, _WebAudioPlayer_nodeRefs, _WebAudioPlayer_buffer, _WebAudioPlayer_gainNode, _WebAudioPlayer_source, _WebAudioPlayer_getCtx, _WebAudioPlayer_connectEqNodesTo, _WebAudioPlayer_initNodes; + /** + * @internal + */ + const _AudioContext = (() => { + if (isBrowser) + return (window.AudioContext || window.webkitAudioContext); + return null; + })(); + /** + * Audio player built around the {@link https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API | Web Audio API}. + * + * Capable of playing PCM streams, with volume adjustment and an optional equalizer. Only available in browser contexts. + */ + class WebAudioPlayer { + constructor() { + _WebAudioPlayer_instances.add(this); + /** + * Whether the audio is being run through an equalizer or not. + */ + this.useEq = false; + /** + * Whether to connect the output to an audio analyser (see {@link analyser}). + */ + this.useAnalyser = false; + /** + * Default equalizer settings. Credit to {@link https://www.sudomemo.net/ | Sudomemo} for these. + */ + this.eqSettings = [ + [31.25, 4.1], + [62.5, 1.2], + [125, 0], + [250, -4.1], + [500, -2.3], + [1000, 0.5], + [2000, 6.5], + [8000, 5.1], + [16000, 5.1] + ]; + _WebAudioPlayer_volume.set(this, 1); + _WebAudioPlayer_loop.set(this, false); + _WebAudioPlayer_startTime.set(this, 0); + _WebAudioPlayer_ctxStartTime.set(this, 0); + _WebAudioPlayer_nodeRefs.set(this, []); + _WebAudioPlayer_buffer.set(this, void 0); + _WebAudioPlayer_gainNode.set(this, void 0); + _WebAudioPlayer_source.set(this, void 0); + assertBrowserEnv(); + } + /** + * The audio output volume. Range is 0 to 1. + */ + set volume(value) { + this.setVolume(value); + } + get volume() { + return __classPrivateFieldGet(this, _WebAudioPlayer_volume, "f"); + } + /** + * Whether the audio should loop after it has ended + */ + set loop(value) { + __classPrivateFieldSet(this, _WebAudioPlayer_loop, value, "f"); + if (__classPrivateFieldGet(this, _WebAudioPlayer_source, "f")) + __classPrivateFieldGet(this, _WebAudioPlayer_source, "f").loop = value; + } + get loop() { + return __classPrivateFieldGet(this, _WebAudioPlayer_loop, "f"); + } + /** + * Set the audio buffer to play + * @param inputBuffer + * @param sampleRate - For best results, this should be a multiple of 16364 + */ + setBuffer(inputBuffer, sampleRate) { + const ctx = __classPrivateFieldGet(this, _WebAudioPlayer_instances, "m", _WebAudioPlayer_getCtx).call(this); + const numSamples = inputBuffer.length; + const audioBuffer = ctx.createBuffer(1, numSamples, sampleRate); + const channelData = audioBuffer.getChannelData(0); + if (inputBuffer instanceof Float32Array) + channelData.set(inputBuffer, 0); + else if (inputBuffer instanceof Int16Array) { + for (let i = 0; i < numSamples; i++) { + channelData[i] = inputBuffer[i] / 32768; + } + } + __classPrivateFieldSet(this, _WebAudioPlayer_buffer, audioBuffer, "f"); + this.sampleRate = sampleRate; + } + setAnalyserEnabled(on) { + this.useAnalyser = on; + __classPrivateFieldGet(this, _WebAudioPlayer_instances, "m", _WebAudioPlayer_initNodes).call(this); + } + /** + * Sets the audio volume level + * @param value - range is 0 to 1 + */ + setVolume(value) { + __classPrivateFieldSet(this, _WebAudioPlayer_volume, value, "f"); + if (__classPrivateFieldGet(this, _WebAudioPlayer_gainNode, "f")) { + // human perception of loudness is logarithmic, rather than linear + // https://www.dr-lex.be/info-stuff/volumecontrols.html + __classPrivateFieldGet(this, _WebAudioPlayer_gainNode, "f").gain.value = Math.pow(value, 2); + } + } + /** + * Begin playback from a specific point + * + * Note that the WebAudioPlayer doesn't keep track of audio playback itself. We rely on the {@link Player} API for that. + * @param currentTime initial playback position, in seconds + */ + playFrom(currentTime) { + __classPrivateFieldGet(this, _WebAudioPlayer_instances, "m", _WebAudioPlayer_initNodes).call(this); + __classPrivateFieldSet(this, _WebAudioPlayer_startTime, currentTime, "f"); + __classPrivateFieldSet(this, _WebAudioPlayer_ctxStartTime, this.ctx.currentTime, "f"); + __classPrivateFieldGet(this, _WebAudioPlayer_source, "f").loop = __classPrivateFieldGet(this, _WebAudioPlayer_loop, "f"); + __classPrivateFieldGet(this, _WebAudioPlayer_source, "f").start(0, currentTime); + } + /** + * Stops the audio playback + */ + stop() { + if (__classPrivateFieldGet(this, _WebAudioPlayer_source, "f")) + __classPrivateFieldGet(this, _WebAudioPlayer_source, "f").stop(0); + } + /** + * Get the current playback time, in seconds + */ + getCurrentTime() { + return __classPrivateFieldGet(this, _WebAudioPlayer_startTime, "f") + (this.ctx.currentTime - __classPrivateFieldGet(this, _WebAudioPlayer_ctxStartTime, "f")); + } + /** + * Frees any resources used by this canvas instance + */ + async destroy() { + this.stop(); + const ctx = __classPrivateFieldGet(this, _WebAudioPlayer_instances, "m", _WebAudioPlayer_getCtx).call(this); + __classPrivateFieldGet(this, _WebAudioPlayer_nodeRefs, "f").forEach(node => node.disconnect()); + __classPrivateFieldSet(this, _WebAudioPlayer_nodeRefs, [], "f"); + this.analyser = undefined; + if (ctx.state !== 'closed' && typeof ctx.close === 'function') + await ctx.close(); + __classPrivateFieldSet(this, _WebAudioPlayer_buffer, null, "f"); + } + } + _WebAudioPlayer_volume = new WeakMap(), _WebAudioPlayer_loop = new WeakMap(), _WebAudioPlayer_startTime = new WeakMap(), _WebAudioPlayer_ctxStartTime = new WeakMap(), _WebAudioPlayer_nodeRefs = new WeakMap(), _WebAudioPlayer_buffer = new WeakMap(), _WebAudioPlayer_gainNode = new WeakMap(), _WebAudioPlayer_source = new WeakMap(), _WebAudioPlayer_instances = new WeakSet(), _WebAudioPlayer_getCtx = function _WebAudioPlayer_getCtx() { + if (!this.ctx) + this.ctx = new _AudioContext(); + return this.ctx; + }, _WebAudioPlayer_connectEqNodesTo = function _WebAudioPlayer_connectEqNodesTo(inNode) { + const ctx = __classPrivateFieldGet(this, _WebAudioPlayer_instances, "m", _WebAudioPlayer_getCtx).call(this); + const eqSettings = this.eqSettings; + let lastNode = inNode; + eqSettings.forEach(([frequency, gain], index) => { + const node = ctx.createBiquadFilter(); + __classPrivateFieldGet(this, _WebAudioPlayer_nodeRefs, "f").push(node); + node.frequency.value = frequency; + node.gain.value = gain; + if (index === 0) + node.type = 'lowshelf'; + else if (index === eqSettings.length - 1) + node.type = 'highshelf'; + else + node.type = 'peaking'; + lastNode.connect(node); + lastNode = node; + }); + return lastNode; + }, _WebAudioPlayer_initNodes = function _WebAudioPlayer_initNodes() { + const ctx = __classPrivateFieldGet(this, _WebAudioPlayer_instances, "m", _WebAudioPlayer_getCtx).call(this); + __classPrivateFieldSet(this, _WebAudioPlayer_nodeRefs, [], "f"); + const source = ctx.createBufferSource(); + __classPrivateFieldGet(this, _WebAudioPlayer_nodeRefs, "f").push(source); + source.buffer = __classPrivateFieldGet(this, _WebAudioPlayer_buffer, "f"); + const gainNode = ctx.createGain(); + __classPrivateFieldGet(this, _WebAudioPlayer_nodeRefs, "f").push(gainNode); + if (this.useEq) { + const eq = __classPrivateFieldGet(this, _WebAudioPlayer_instances, "m", _WebAudioPlayer_connectEqNodesTo).call(this, source); + eq.connect(gainNode); + } + else { + source.connect(gainNode); + } + if (this.useAnalyser) { + const analyserNode = ctx.createAnalyser(); + __classPrivateFieldGet(this, _WebAudioPlayer_nodeRefs, "f").push(analyserNode); + this.analyser = analyserNode; + gainNode.connect(analyserNode); + analyserNode.connect(ctx.destination); + } + else { + this.analyser = undefined; + gainNode.connect(ctx.destination); + } + __classPrivateFieldSet(this, _WebAudioPlayer_source, source, "f"); + __classPrivateFieldSet(this, _WebAudioPlayer_gainNode, gainNode, "f"); + this.setVolume(__classPrivateFieldGet(this, _WebAudioPlayer_volume, "f")); + }; + + var _Player_instances, _Player_playAudio, _Player_stopAudio; + /** + * Flipnote Player API (exported as `flipnote.Player`) - provides a {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement | MediaElement}-like interface for loading Flipnotes and playing them. + * This is intended for cases where you want to implement your own player UI, if you just want a pre-built player with some nice UI controls, check out the [Web Components](/web-components/) page instead! + * + * ### Create a new player + * + * You'll need an element in your page's HTML to act as a wrapper for the player: + * + * ```html + *
+ * ``` + * + * Then you can create a new `Player` instance by passing a CSS selector that matches the wrapper element, plus the desired width and height. + * + * ```js + * const player = new flipnote.Player('#player-wrapper', 320, 240); + * ``` + * + * ### Load a Flipnote + * + * Load a Flipnote from a valid {@link FlipnoteSource}: + * + * ```js + * player.load('./path/to/flipnote.ppm'); + * ``` + * + * ### Listen to events + * + * Use the {@link on} method to register event listeners: + * + * ```js + * player.on('play', function() { + * // do something when the Flipnote starts playing... + * }); + * ``` + */ + class Player { + /** + * Create a new Player instance + * + * @param parent - Element to mount the rendering canvas to + * @param width - Canvas width (pixels) + * @param height - Canvas height (pixels) + * + * The ratio between `width` and `height` should be 3:4 for best results + */ + constructor(parent, width, height, parserSettings = {}) { + _Player_instances.add(this); + /** + * Animation duration, in seconds + */ + this.duration = 0; + /** + * Automatically begin playback after a Flipnote is loaded + */ + this.autoplay = false; + /** + * Array of events supported by this player + */ + this.supportedEvents = supportedEvents; + /** + * @internal + */ + this._src = null; + /** + * @internal + */ + this._loop = false; + /** + * @internal + */ + this._volume = 1; + /** + * @internal + */ + this._muted = false; + /** + * @internal + */ + this._frame = null; + /** + * @internal + */ + this._hasEnded = false; + /** + * @internal + */ + this.isNoteLoaded = false; + /** + * @internal + */ + this.events = new Map(); + /** + * @internal + */ + this.playbackStartTime = 0; + /** + * @internal + */ + this.playbackTime = 0; + /** + * @internal + */ + this.playbackLoopId = null; + /** + * @internal + */ + this.showThumbnail = true; + /** + * @internal + */ + this.hasPlaybackStarted = false; + /** + * @internal + */ + this.isPlaying = false; + /** + * @internal + */ + this.wasPlaying = false; + /** + * @internal + */ + this.isSeeking = false; + /** + * Playback animation loop + * @internal + * @group Playback Control + */ + this.playbackLoop = (timestamp) => { + if (!this.isPlaying) + return; + const now = timestamp / 1000; + const duration = this.duration; + const currAudioTime = this.audio.getCurrentTime(); + let currPlaybackTime = now - this.playbackStartTime; + // try to keep playback time in sync with the audio if there's any slipping + if (Math.abs((currPlaybackTime % duration) - (currAudioTime % duration)) > 0.01) + currPlaybackTime = currAudioTime; + // handle playback end, if reached + if (currPlaybackTime >= duration) { + if (this.loop) { + this.playbackStartTime = now; + this.emit(exports.PlayerEvent.Loop); + } + else { + this.pause(); + this._hasEnded = true; + this.emit(exports.PlayerEvent.Ended); + return; + } + } + this.setCurrentTime(currPlaybackTime % duration); + this.playbackLoopId = requestAnimationFrame(this.playbackLoop); + }; + assertBrowserEnv(); + // if parent is a string, use it to select an Element, else assume it's an Element + const mountPoint = ('string' == typeof parent) ? document.querySelector(parent) : parent; + this.parserSettings = parserSettings; + this.renderer = new UniversalCanvas(mountPoint, width, height, { + onlost: () => this.emit(exports.PlayerEvent.Error), + onrestored: () => this.reload() + }); + this.audio = new WebAudioPlayer(); + this.el = mountPoint; + } + /** + * The currently loaded Flipnote source, if there is one + * @group Lifecycle + * @deprecated + */ + get src() { + return this._src; + } + set src(source) { + throw new Error('Setting a Player source has been deprecated, please use the load() method instead'); + } + /** + * Indicates whether playback is currently paused + * @group Playback Control + */ + get paused() { + return !this.isPlaying; + } + set paused(isPaused) { + if (isPaused) + this.pause(); + else + this.play(); + } + /** + * Current animation frame index. + * @group Playback Control + */ + get currentFrame() { + return this._frame; + } + set currentFrame(frameIndex) { + this.setCurrentFrame(frameIndex); + } + /** + * Current animation playback position, in seconds. + * @group Playback Control + */ + get currentTime() { + return this.isNoteLoaded ? this.playbackTime : null; + } + set currentTime(value) { + this.setCurrentTime(value); + } + /** + * Current animation playback progress, as a percentage out of 100. + * @group Playback Control + */ + get progress() { + return this.isNoteLoaded ? (this.playbackTime / this.duration) * 100 : null; + } + set progress(value) { + this.setProgress(value); + } + /** + * Audio volume, range `0` to `1`. + * @group Audio Control + */ + get volume() { + return this.getVolume(); + } + set volume(value) { + this.setVolume(value); + } + /** + * Audio mute state. + * @group Audio Control + */ + get muted() { + return this.getMuted(); + } + set muted(value) { + this.setMuted(value); + } + /** + * Indicates whether playback should loop once the end is reached + * @group Playback Control + */ + get loop() { + return this.getLoop(); + } + set loop(value) { + this.setLoop(value); + } + /** + * Animation frame rate, measured in frames per second. + * @group Playback Control + */ + get framerate() { + return this.note.framerate; + } + /** + * Animation frame count. + * @group Frame Control + */ + get frameCount() { + return this.note.frameCount; + } + /** + * Animation frame speed. + * @group Frame Control + */ + get frameSpeed() { + return this.note.frameSpeed; + } + /** + * Implementation of the `HTMLMediaElement` {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/buffered | buffered } property. + * @group HTMLVideoElement compatibility + */ + get buffered() { + return createTimeRanges([[0, this.duration]]); + } + /** + * Implementation of the `HTMLMediaElement` {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/seekable | seekable} property + * @group HTMLVideoElement compatibility + */ + get seekable() { + return createTimeRanges([[0, this.duration]]); + } + /** + * Implementation of the `HTMLMediaElement` {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/currentSrc | currentSrc} property + * @group HTMLVideoElement compatibility + */ + get currentSrc() { + return this._src; + } + /** + * Implementation of the `HTMLVideoElement` {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLVideoElement/videoWidth | videoWidth} property + * @group HTMLVideoElement compatibility + */ + get videoWidth() { + return this.isNoteLoaded ? this.note.imageWidth : 0; + } + /** + * Implementation of the `HTMLVideoElement` {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLVideoElement/videoHeight | videoHeight} property + * @group HTMLVideoElement compatibility + */ + get videoHeight() { + return this.isNoteLoaded ? this.note.imageHeight : 0; + } + /** + * Open a Flipnote from a source + * @group Lifecycle + */ + async load(source) { + // close currently open note first + if (this.isNoteLoaded) + this.closeNote(); + // keep track of source + this._src = source; + // if no source specified, just reset everything + if (!source) + return this.openNote(this.note); + // otherwise do a normal load + this.emit(exports.PlayerEvent.LoadStart); + const [err, note] = await until(() => parse(source, this.parserSettings)); + if (err) { + this.emit(exports.PlayerEvent.Error, err); + throw new Error(`Error loading Flipnote: ${err.message}`); + } + this.openNote(note); + } + /** + * Reload the current Flipnote. + * @group Lifecycle + */ + async reload() { + if (this.note) + return await this.load(this.note.buffer); + } + /** + * Reload the current Flipnote, applying new parser settings. + * @group Lifecycle + */ + async updateSettings(settings) { + this.parserSettings = settings; + return await this.reload(); + } + /** + * Close the currently loaded Flipnote + * @group Lifecycle + */ + closeNote() { + this.pause(); + this.note = null; + this.isNoteLoaded = false; + this.meta = null; + this._src = null; + this._frame = 0; + // this.playbackFrame = null; + this.playbackTime = 0; + this.duration = 0; + this.loop = false; + this.isPlaying = false; + this.wasPlaying = false; + this.hasPlaybackStarted = false; + this.showThumbnail = true; + this.renderer.clear(); + } + /** + * Open a Flipnote into the player + * @group Lifecycle + */ + openNote(note) { + if (this.isNoteLoaded) + this.closeNote(); + this.note = note; + this.meta = note.meta; + this.emit(exports.PlayerEvent.LoadedMeta); + this.noteFormat = note.format; + this.duration = note.duration; + this.playbackTime = 0; + this._frame = 0; + this.isNoteLoaded = true; + this.isPlaying = false; + this.wasPlaying = false; + this.hasPlaybackStarted = false; + this.layerVisibility = note.layerVisibility; + this.showThumbnail = true; + this.audio.setBuffer(note.getAudioMasterPcm(), note.sampleRate); + this.emit(exports.PlayerEvent.CanPlay); + this.emit(exports.PlayerEvent.CanPlayThrough); + this.setLoop(note.meta.loop); + this.renderer.setNote(note); + this.drawFrame(note.thumbFrameIndex); + this.emit(exports.PlayerEvent.LoadedData); + this.emit(exports.PlayerEvent.Load); + this.emit(exports.PlayerEvent.Ready); + if (this.autoplay) + this.play(); + } + /** + * Set the current playback time + * @group Playback Control + */ + setCurrentTime(value) { + this.assertNoteLoaded(); + const i = Math.floor(value / (1 / this.framerate)); + this.setCurrentFrame(i); + this.playbackTime = value; + this.emit(exports.PlayerEvent.Progress, this.progress); + } + /** + * Get the current playback time + * @group Playback Control + */ + getCurrentTime() { + return this.currentTime; + } + /** + * Get the current time as a counter string, like `"0:00 / 1:00"` + * @group Playback Control + */ + getTimeCounter() { + const currentTime = formatTime(Math.max(this.currentTime, 0)); + const duration = formatTime(this.duration); + return `${currentTime} / ${duration}`; + } + /** + * Get the current frame index as a counter string, like `"001 / 999"` + * @group Playback Control + */ + getFrameCounter() { + const frame = padNumber(this.currentFrame + 1, 3); + const total = padNumber(this.frameCount, 3); + return `${frame} / ${total}`; + } + /** + * Set the current playback progress as a percentage (`0` to `100`) + * @group Playback Control + */ + setProgress(value) { + this.assertNoteLoaded(); + assertRange(value, 0, 100, 'progress'); + this.currentTime = this.duration * (value / 100); + } + /** + * Get the current playback progress as a percentage (0 to 100) + * @group Playback Control + */ + getProgress() { + return this.progress; + } + /** + * Begin animation playback starting at the current position + * @group Playback Control + */ + async play() { + this.assertNoteLoaded(); + if (this.isPlaying) + return; + // if the flipnote hasn't looped and is at the end, rewind it to 0 + if (this._hasEnded) { + this.playbackTime = 0; + this._hasEnded = false; + } + const now = performance.now(); + this.playbackStartTime = (now / 1000) - this.playbackTime; + this.isPlaying = true; + this.hasPlaybackStarted = true; + __classPrivateFieldGet(this, _Player_instances, "m", _Player_playAudio).call(this); + this.playbackLoop(now); + this.emit(exports.PlayerEvent.Play); + } + /** + * Pause animation playback at the current position + * @group Playback Control + */ + pause() { + if (!this.isPlaying) + return; + this.isPlaying = false; + if (this.playbackLoopId !== null) + cancelAnimationFrame(this.playbackLoopId); + __classPrivateFieldGet(this, _Player_instances, "m", _Player_stopAudio).call(this); + this.emit(exports.PlayerEvent.Pause); + } + /** + * Resumes animation playback if paused, otherwise pauses + * @group Playback Control + */ + togglePlay() { + if (!this.isPlaying) + this.play(); + else + this.pause(); + } + /** + * Determines if playback is currently paused + * @group Playback Control + */ + getPaused() { + return !this.isPlaying; + } + /** + * Get the duration of the Flipnote in seconds + * @group Playback Control + */ + getDuration() { + return this.duration; + } + /** + * Determines if playback is looped + * @group Playback Control + */ + getLoop() { + return this._loop; + } + /** + * Set the playback loop + * @group Playback Control + */ + setLoop(loop) { + this._loop = loop; + this.audio.loop = loop; + } + /** + * Switch the playback loop between on and off + * @group Playback Control + */ + toggleLoop() { + this.setLoop(!this._loop); + } + /** + * Jump to a given animation frame + * @group Frame Control + */ + setCurrentFrame(newFrameValue) { + this.assertNoteLoaded(); + const newFrameIndex = Math.max(0, Math.min(Math.floor(newFrameValue), this.frameCount - 1)); + if (newFrameIndex === this.currentFrame && !this.showThumbnail) + return; + this._frame = newFrameIndex; + this.drawFrame(newFrameIndex); + this.showThumbnail = false; + if (!this.isPlaying) { + this.playbackTime = newFrameIndex * (1 / this.framerate); + this.emit(exports.PlayerEvent.SeekEnd); + } + this.emit(exports.PlayerEvent.FrameUpdate, this.currentFrame); + this.emit(exports.PlayerEvent.Progress, this.progress); + this.emit(exports.PlayerEvent.TimeUpdate, this.currentFrame); + } + /** + * Jump to the next animation frame + * If the animation loops, and is currently on its last frame, it will wrap to the first frame + * @group Frame Control + */ + nextFrame() { + if ((this.loop) && (this.currentFrame === this.frameCount - 1)) + this.currentFrame = 0; + else + this.currentFrame += 1; + this.emit(exports.PlayerEvent.FrameNext); + } + /** + * Jump to the next animation frame + * If the animation loops, and is currently on its first frame, it will wrap to the last frame + * @group Frame Control + */ + prevFrame() { + if ((this.loop) && (this.currentFrame === 0)) + this.currentFrame = this.frameCount - 1; + else + this.currentFrame -= 1; + this.emit(exports.PlayerEvent.FramePrev); + } + /** + * Jump to the last animation frame + * @group Frame Control + */ + lastFrame() { + this.currentFrame = this.frameCount - 1; + this.emit(exports.PlayerEvent.FrameLast); + } + /** + * Jump to the first animation frame + * @group Frame Control + */ + firstFrame() { + this.currentFrame = 0; + this.emit(exports.PlayerEvent.FrameFirst); + } + /** + * Jump to the thumbnail frame + * @group Frame Control + */ + thumbnailFrame() { + this.currentFrame = this.note.thumbFrameIndex; + } + /** + * Begins a seek operation + * @group Playback Control + */ + startSeek() { + if (!this.isSeeking) { + this.emit(exports.PlayerEvent.SeekStart); + this.wasPlaying = this.isPlaying; + this.pause(); + this.isSeeking = true; + } + } + /** + * Seek the playback progress to a different position + * @param position - animation playback position, range `0` to `1` + * @group Playback Control + */ + seek(position) { + if (this.isSeeking) + this.progress = position * 100; + } + /** + * Ends a seek operation + * @group Playback Control + */ + endSeek() { + if (this.isSeeking && this.wasPlaying === true) + this.play(); + this.wasPlaying = false; + this.isSeeking = false; + } + /** + * Draws the specified animation frame to the canvas. Note that this doesn't update the playback time or anything, it simply decodes a given frame and displays it. + * @param frameIndex + * @group Display Control + */ + drawFrame(frameIndex) { + this.renderer.drawFrame(frameIndex); + } + /** + * Forces the current animation frame to be redrawn + * @group Display Control + */ + forceUpdate() { + this.renderer.forceUpdate(); + } + /** + * Resize the playback canvas to a new size + * @param width - new canvas width (pixels) + * @param height - new canvas height (pixels) + * + * The ratio between `width` and `height` should be 3:4 for best results + * + * @group Display Control + */ + resize(width, height) { + if (height !== width * .75) + console.warn(`Canvas width to height ratio should be 3:4 for best results (got ${width}x${height})`); + this.renderer.setCanvasSize(width, height); + this.forceUpdate(); + } + /** + * Sets whether an animation layer should be visible throughout the entire animation + * @param layer - layer index, starting at 1 + * @param value - `true` for visible, `false` for invisible + * + * @group Display Control + */ + setLayerVisibility(layer, value) { + this.note.layerVisibility[layer] = value; + this.layerVisibility[layer] = value; + this.forceUpdate(); + } + /** + * Returns the visibility state for a given layer + * @param layer - layer index, starting at 1 + * + * @group Display Control + */ + getLayerVisibility(layer) { + return this.layerVisibility[layer]; + } + /** + * Toggles whether an animation layer should be visible throughout the entire animation + * + * @group Display Control + */ + toggleLayerVisibility(layerIndex) { + this.setLayerVisibility(layerIndex, !this.layerVisibility[layerIndex]); + } + /** + * Toggle audio Sudomemo equalizer filter. + * @group Audio Control + */ + toggleAudioEq() { + this.setAudioEq(!this.audio.useEq); + } + /** + * Turn audio Sudomemo equalizer filter on or off. + * @group Audio Control + */ + setAudioEq(state) { + if (this.isPlaying) { + this.wasPlaying = true; + __classPrivateFieldGet(this, _Player_instances, "m", _Player_stopAudio).call(this); + } + this.audio.useEq = state; + if (this.wasPlaying) { + this.wasPlaying = false; + __classPrivateFieldGet(this, _Player_instances, "m", _Player_playAudio).call(this); + } + } + /** + * Turn the audio off + * @group Audio Control + */ + mute() { + this.setMuted(true); + } + /** + * Turn the audio on + * @group Audio Control + */ + unmute() { + this.setMuted(false); + } + /** + * Turn the audio on or off + * @group Audio Control + */ + setMuted(isMute) { + if (isMute) + this.audio.volume = 0; + else + this.audio.volume = this._volume; + this._muted = isMute; + this.emit(exports.PlayerEvent.VolumeChange, this.audio.volume); + } + /** + * Get the audio mute state + * @group Audio Control + */ + getMuted() { + return this.volume === 0 ? true : this._muted; + } + /** + * Switch the audio between muted and unmuted + * @group Audio Control + */ + toggleMuted() { + this.setMuted(!this._muted); + } + /** + * Set the audio volume + * @group Audio Control + */ + setVolume(volume) { + assertRange(volume, 0, 1, 'volume'); + this._volume = volume; + this.audio.volume = volume; + this.emit(exports.PlayerEvent.VolumeChange, this.audio.volume); + } + /** + * Get the current audio volume + * @group Audio Control + */ + getVolume() { + return this._muted ? 0 : this._volume; + } + /** + * Implementation of the `HTMLVideoElement` {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLVideoElement/seekToNextFrame | seekToNextFrame} method + * @group HTMLVideoElement compatibility + */ + seekToNextFrame() { + this.nextFrame(); + } + /** + * Implementation of the `HTMLMediaElement` {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/fastSeek | fastSeek} method + * @group HTMLVideoElement compatibility + */ + fastSeek(time) { + this.currentTime = time; + } + /** + * Implementation of the `HTMLVideoElement` {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLVideoElement/getVideoPlaybackQuality | getVideoPlaybackQuality } method + * @group HTMLVideoElement compatibility + */ + canPlayType(mediaType) { + switch (mediaType) { + case 'application/x-ppm': + case 'application/x-kwz': + case 'video/x-ppm': + case 'video/x-kwz': + // lauren is planning on registering these officially + case 'video/vnd.nintendo.ugomemo.ppm': + case 'video/vnd.nintendo.ugomemo.kwz': + return 'probably'; + case 'application/octet-stream': + return 'maybe'; + default: + return ''; + } + } + /** + * Implementation of the `HTMLVideoElement` [https://developer.mozilla.org/en-US/docs/Web/API/HTMLVideoElement/getVideoPlaybackQuality](getVideoPlaybackQuality) method + * @group HTMLVideoElement compatibility + */ + getVideoPlaybackQuality() { + const quality = { + creationTime: 0, + droppedVideoFrames: 0, + // @ts-ignore + corruptedVideoFrames: 0, + totalVideoFrames: this.frameCount + }; + return quality; + } + /** + * Implementation of the `HTMLVideoElement` [https://developer.mozilla.org/en-US/docs/Web/API/HTMLVideoElement/requestPictureInPicture](requestPictureInPicture) method. Not currently working, only a stub. + * @group HTMLVideoElement compatibility + */ + requestPictureInPicture() { + throw new Error('Not implemented'); + } + /** + * Implementation of the `HTMLVideoElement` [https://developer.mozilla.org/en-US/docs/Web/API/HTMLVideoElement/captureStream](captureStream) method. Not currently working, only a stub. + * @group HTMLVideoElement compatibility + */ + captureStream() { + throw new Error('Not implemented'); + } + /** + * Add an event callback + * @group Event API + */ + on(eventType, listener) { + const events = this.events; + const eventList = Array.isArray(eventType) ? eventType : [eventType]; + eventList.forEach(eventType => { + if (!events.has(eventType)) + events.set(eventType, [listener]); + else + events.get(eventType).push(listener); + }); + } + /** + * Remove an event callback + * @group Event API + */ + off(eventType, callback) { + const events = this.events; + const eventList = Array.isArray(eventType) ? eventType : [eventType]; + eventList.forEach(eventType => { + if (!events.has(eventType)) + return; + const callbackList = events.get(eventType); + events.set(eventType, callbackList.splice(callbackList.indexOf(callback), 1)); + }); + } + /** + * Emit an event - mostly used internally + * @group Event API + */ + emit(eventType, ...args) { + const events = this.events; + if (eventType !== exports.PlayerEvent.__Any && events.has(eventType)) { + const callbackList = events.get(eventType); + callbackList.forEach(callback => callback.apply(null, args)); + // call onwhatever() function for this event, if one has been added + const listenerName = `on${eventType}`; + const thisAsAny = this; + if (typeof thisAsAny[listenerName] === 'function') + thisAsAny[listenerName].apply(null, args); + } + // "any" event listeners fire for all events, and receive eventType as their first param + if (events.has(exports.PlayerEvent.__Any)) { + const callbackList = events.get(exports.PlayerEvent.__Any); + callbackList.forEach(callback => callback.apply(null, [eventType, ...args])); + } + } + /** + * Remove all registered event callbacks + * @group Event API + */ + clearEvents() { + this.events.clear(); + } + /** + * Destroy a Player instance + * @group Lifecycle + */ + async destroy() { + this.clearEvents(); + this.emit(exports.PlayerEvent.Destroy); + this.closeNote(); + await this.renderer.destroy(); + await this.audio.destroy(); + } + /** + * @internal + */ + assertNoteLoaded() { + assert(this.isNoteLoaded, 'No Flipnote is currently loaded in this player'); + } + } + _Player_instances = new WeakSet(), _Player_playAudio = function _Player_playAudio() { + this.audio.playFrom(this.currentTime); + }, _Player_stopAudio = function _Player_stopAudio() { + this.audio.stop(); + }; + + /** + * This is a bit of a hack to get a player component class to wrap a Player instance, + * while also inheriting all of the Player API's methods and properties. + * + * The resulting PlayerMixinClass will get a Player instance on this.player, + * and all of the Player API methods and properties applied as wrappers. + * + * e.g. + * - PlayerMixinClass.play() will have the same behavior as Player.play(), but will call this.player.play() internally. + * - PlayerMixinClass.paused will have getters and setters to match it to this.player.paused. + * @internal + */ + function PlayerMixin(Target) { + class PlayerMixinClass extends Target { + // Mixin needs to re-define all the normal player properties, but most should be made readonly anyway... + get renderer() { + return this.player.renderer; + } + get note() { + return this.player.note; + } + get noteFormat() { + return this.player.noteFormat; + } + get meta() { + return this.player.meta; + } + get duration() { + return this.player.duration; + } + get layerVisibility() { + return this.player.layerVisibility; + } + get autoplay() { + return this.player.autoplay; + } + set autoplay(value) { + this.player.autoplay = value; + } + } + // add all Player API methods and getter/setter props to target + for (let key of Reflect.ownKeys(Player.prototype)) { + let desc = Object.getOwnPropertyDescriptor(Player.prototype, key); + // don't override stuff that already exists, and ignore JS prototype junk + if (key in Target.prototype || key === 'constructor' || key === 'name' || key === 'prototype') + continue; + // ignore private props + if (typeof key === "string" && key.startsWith("#")) + continue; + // override methods to call e.g. `this.player.methodName()` when `methodName()` is called + if (desc.value && typeof desc.value === 'function') { + Object.defineProperty(PlayerMixinClass.prototype, key, { + ...desc, + value(...args) { + return this.player[key](...args); + } + }); + } + // override getters and setters so that e.g. `property` will always reflect `this.player.property` + else if (desc.get || desc.set) { + Object.defineProperty(PlayerMixinClass.prototype, key, { + ...desc, + set(value) { + this.player[key] = value; + }, + get() { + return this.player[key]; + } + }); + } + } + return PlayerMixinClass; + } + + class EncoderBase { + constructor() { + this.dataUrl = null; + } + /** + * Returns the file data as a NodeJS {@link https://nodejs.org/api/buffer.html | Buffer}. + * + * > Note: This method does not work outside of NodeJS environments. + */ + getBuffer() { + assertNodeEnv(); + return Buffer.from(this.getArrayBuffer()); + } + /** + * Returns the file data as a {@link https://developer.mozilla.org/en-US/docs/Web/API/Blob | Blob}. + */ + getBlob() { + assertBrowserEnv(); + return new Blob([this.getArrayBuffer()], { + type: this.mimeType + }); + } + /** + * Returns the file data as an {@link https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL | Object URL}. + * + * > Note: This method does not work outside of browser environments. + */ + getUrl() { + assertBrowserEnv(); + if (this.dataUrl) + return this.dataUrl; + return window.URL.createObjectURL(this.getBlob()); + } + /** + * Revokes this file's {@link https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL | Object URL} if one has been created, use this when the url created with {@link getUrl} is no longer needed, to preserve memory. + * + * > Note: This method does not work outside of browser environments. + */ + revokeUrl() { + assertBrowserEnv(); + if (this.dataUrl) + window.URL.revokeObjectURL(this.dataUrl); + } + } + + /* + LZWEncoder.js + + Authors + Kevin Weiner (original Java version - kweiner@fmsware.com) + Thibault Imbert (AS3 version - bytearray.org) + Johan Nordberg (JS version - code@johan-nordberg.com) + James Daniel (ES6/TS version) + + Acknowledgements + GIFCOMPR.C - GIF Image compression routines + Lempel-Ziv compression based on 'compress'. GIF modifications by + David Rowley (mgardi@watdcsu.waterloo.edu) + GIF Image compression - modified 'compress' + Based on: compress.c - File compression ala IEEE Computer, June 1984. + By Authors: Spencer W. Thomas (decvax!harpo!utah-cs!utah-gr!thomas) + Jim McKie (decvax!mcvax!jim) + Steve Davies (decvax!vax135!petsd!peora!srd) + Ken Turkowski (decvax!decwrl!turtlevax!ken) + James A. Woods (decvax!ihnp4!ames!jaw) + Joe Orost (decvax!vax135!petsd!joe) + */ + /** + * @internal + */ + const EOF = -1; + /** + * @internal + */ + const BITS = 12; + /** + * @internal + */ + const HSIZE = 5003; // 80% occupancy + /** + * @internal + */ + const masks = [ + 0x0000, 0x0001, 0x0003, 0x0007, 0x000F, 0x001F, + 0x003F, 0x007F, 0x00FF, 0x01FF, 0x03FF, 0x07FF, + 0x0FFF, 0x1FFF, 0x3FFF, 0x7FFF, 0xFFFF + ]; + /** + * @internal + */ + class LzwCompressor { + constructor(width, height, colorDepth) { + this.accum = new Uint8Array(256); + this.htab = new Int32Array(HSIZE); + this.codetab = new Int32Array(HSIZE); + this.cur_accum = 0; + this.cur_bits = 0; + this.curPixel = 0; + this.free_ent = 0; // first unused entry + // block compression parameters -- after all codes are used up, + // and compression rate changes, start over. + this.clear_flg = false; + // Algorithm: use open addressing double hashing (no chaining) on the + // prefix code / next character combination. We do a variant of Knuth's + // algorithm D (vol. 3, sec. 6.4) along with G. Knott's relatively-prime + // secondary probe. Here, the modular division first probe is gives way + // to a faster exclusive-or manipulation. Also do block compression with + // an adaptive reset, whereby the code table is cleared when the compression + // ratio decreases, but after the table fills. The variable-length output + // codes are re-sized at this point, and a special CLEAR code is generated + // for the decompressor. Late addition: construct the table according to + // file size for noticeable speed improvement on small files. Please direct + // questions about this implementation to ames!jaw. + this.g_init_bits = undefined; + this.ClearCode = undefined; + this.EOFCode = undefined; + this.width = width; + this.height = height; + this.colorDepth = colorDepth; + this.reset(); + } + reset() { + this.initCodeSize = Math.max(2, this.colorDepth); + this.accum.fill(0); + this.htab.fill(0); + this.codetab.fill(0); + this.cur_accum = 0; + this.cur_bits = 0; + this.curPixel = 0; + this.free_ent = 0; // first unused entry + this.maxcode; + // block compression parameters -- after all codes are used up, + // and compression rate changes, start over. + this.clear_flg = false; + // Algorithm: use open addressing double hashing (no chaining) on the + // prefix code / next character combination. We do a variant of Knuth's + // algorithm D (vol. 3, sec. 6.4) along with G. Knott's relatively-prime + // secondary probe. Here, the modular division first probe is gives way + // to a faster exclusive-or manipulation. Also do block compression with + // an adaptive reset, whereby the code table is cleared when the compression + // ratio decreases, but after the table fills. The variable-length output + // codes are re-sized at this point, and a special CLEAR code is generated + // for the decompressor. Late addition: construct the table according to + // file size for noticeable speed improvement on small files. Please direct + // questions about this implementation to ames!jaw. + this.g_init_bits = undefined; + this.ClearCode = undefined; + this.EOFCode = undefined; + } + // Add a character to the end of the current packet, and if it is 254 + // characters, flush the packet to disk. + char_out(c, outs) { + this.accum[this.a_count++] = c; + if (this.a_count >= 254) + this.flush_char(outs); + } + // Clear out the hash table + // table clear for block compress + cl_block(outs) { + this.cl_hash(HSIZE); + this.free_ent = this.ClearCode + 2; + this.clear_flg = true; + this.output(this.ClearCode, outs); + } + // Reset code table + cl_hash(hsize) { + for (var i = 0; i < hsize; ++i) + this.htab[i] = -1; + } + compress(init_bits, outs) { + var fcode, c, i, ent, disp, hsize_reg, hshift; + // Set up the globals: this.g_init_bits - initial number of bits + this.g_init_bits = init_bits; + // Set up the necessary values + this.clear_flg = false; + this.n_bits = this.g_init_bits; + this.maxcode = this.get_maxcode(this.n_bits); + this.ClearCode = 1 << (init_bits - 1); + this.EOFCode = this.ClearCode + 1; + this.free_ent = this.ClearCode + 2; + this.a_count = 0; // clear packet + ent = this.nextPixel(); + hshift = 0; + for (fcode = HSIZE; fcode < 65536; fcode *= 2) + ++hshift; + hshift = 8 - hshift; // set hash code range bound + hsize_reg = HSIZE; + this.cl_hash(hsize_reg); // clear hash table + this.output(this.ClearCode, outs); + outer_loop: while ((c = this.nextPixel()) != EOF) { + fcode = (c << BITS) + ent; + i = (c << hshift) ^ ent; // xor hashing + if (this.htab[i] === fcode) { + ent = this.codetab[i]; + continue; + } + else if (this.htab[i] >= 0) { // non-empty slot + disp = hsize_reg - i; // secondary hash (after G. Knott) + if (i === 0) { + disp = 1; + } + do { + if ((i -= disp) < 0) { + i += hsize_reg; + } + if (this.htab[i] === fcode) { + ent = this.codetab[i]; + continue outer_loop; + } + } while (this.htab[i] >= 0); + } + this.output(ent, outs); + ent = c; + if (this.free_ent < 1 << BITS) { + this.codetab[i] = this.free_ent++; // code -> hasthis.htable + this.htab[i] = fcode; + } + else { + this.cl_block(outs); + } + } + // Put out the final code. + this.output(ent, outs); + this.output(this.EOFCode, outs); + } + encode(pixels, outs) { + this.pixels = pixels; + outs.writeByte(this.initCodeSize); // write 'initial code size' byte + this.remaining = this.width * this.height; // reset navigation variables + this.curPixel = 0; + this.compress(this.initCodeSize + 1, outs); // compress and write the pixel data + outs.writeByte(0); // write block terminator + } + // Flush the packet to disk, and reset the this.accumulator + flush_char(outs) { + if (this.a_count > 0) { + outs.writeByte(this.a_count); + outs.writeBytes(this.accum, 0, this.a_count); + this.a_count = 0; + } + } + get_maxcode(n_bits) { + return (1 << n_bits) - 1; + } + // Return the next pixel from the image + nextPixel() { + if (this.remaining === 0) + return EOF; + --this.remaining; + var pix = this.pixels[this.curPixel++]; + return pix & 0xff; + } + output(code, outs) { + this.cur_accum &= masks[this.cur_bits]; + if (this.cur_bits > 0) + this.cur_accum |= (code << this.cur_bits); + else + this.cur_accum = code; + this.cur_bits += this.n_bits; + while (this.cur_bits >= 8) { + this.char_out((this.cur_accum & 0xff), outs); + this.cur_accum >>= 8; + this.cur_bits -= 8; + } + // If the next entry is going to be too big for the code size, + // then increase it, if possible. + if (this.free_ent > this.maxcode || this.clear_flg) { + if (this.clear_flg) { + this.maxcode = this.get_maxcode(this.n_bits = this.g_init_bits); + this.clear_flg = false; + } + else { + ++this.n_bits; + if (this.n_bits == BITS) + this.maxcode = 1 << BITS; + else + this.maxcode = this.get_maxcode(this.n_bits); + } + } + if (code == this.EOFCode) { + // At EOF, write the rest of the buffer. + while (this.cur_bits > 0) { + this.char_out((this.cur_accum & 0xff), outs); + this.cur_accum >>= 8; + this.cur_bits -= 8; + } + this.flush_char(outs); + } + } + } + + var _GifImage_instances, _GifImage_data, _GifImage_compressor, _GifImage_writeFirstFrame, _GifImage_writeAdditionalFrame, _GifImage_writeHeader, _GifImage_writeGraphicControlExt, _GifImage_writeLogicalScreenDescriptor, _GifImage_writeNetscapeExt, _GifImage_writeColorTable, _GifImage_writeImageDescriptor, _GifImage_colorTableSize, _GifImage_writePixels; + /** + * GIF image encoder, supports static single-frame GIF export as well as animated GIF. + * + * @group File Encoder + */ + class GifImage extends EncoderBase { + /** + * Create a new GIF image object. + * + * @param width Image width + * @param height Image height + * @param settings Whether the gif should loop, the delay between frames, etc. See {@link GifEncoderSettings} + */ + constructor(width, height, settings = {}) { + super(); + _GifImage_instances.add(this); + this.mimeType = 'gif/image'; + /** + * Number of current GIF frames. + */ + this.frameCount = 0; + _GifImage_data.set(this, void 0); + _GifImage_compressor.set(this, void 0); + this.width = width; + this.height = height; + __classPrivateFieldSet(this, _GifImage_data, new ByteArray(), "f"); + this.settings = { ...GifImage.defaultSettings, ...settings }; + __classPrivateFieldSet(this, _GifImage_compressor, new LzwCompressor(width, height, settings.colorDepth), "f"); + } + /** + * Create an animated GIF image from a Flipnote. + * + * This will encode the entire animation, so depending on the number of frames it could take a while to return. + * @param flipnote {@link Flipnote} object ({@link PpmParser} or {@link KwzParser} instance) + * @param settings Whether the gif should loop, the delay between frames, etc. See {@link GifEncoderSettings} + */ + static fromFlipnote(flipnote, settings = {}) { + const gif = new GifImage(flipnote.imageWidth, flipnote.imageHeight, { + delay: 100 / flipnote.framerate, + repeat: flipnote.meta?.loop ? -1 : 0, + ...settings + }); + gif.palette = flipnote.globalPalette; + for (let frameIndex = 0; frameIndex < flipnote.frameCount; frameIndex++) + gif.writeFrame(flipnote.getFramePixels(frameIndex)); + gif.finish(); + return gif; + } + /** + * Create an GIF image from a single Flipnote frame. + * @param flipnote + * @param frameIndex animation frame index to encode + * @param settings whether the gif should loop, the delay between frames, etc. See {@link GifEncoderSettings} + */ + static fromFlipnoteFrame(flipnote, frameIndex, settings = {}) { + const gif = new GifImage(flipnote.imageWidth, flipnote.imageHeight, { + delay: 0, + repeat: 0, + ...settings, + }); + gif.palette = flipnote.globalPalette; + gif.writeFrame(flipnote.getFramePixels(frameIndex)); + gif.finish(); + return gif; + } + /** + * Add a frame to the GIF image. + * @param pixels Raw pixels to encode, must be an uncompressed 8bit array of palette indices with a size matching image width * image height + */ + writeFrame(pixels) { + if (this.frameCount === 0) + __classPrivateFieldGet(this, _GifImage_instances, "m", _GifImage_writeFirstFrame).call(this, pixels); + else + __classPrivateFieldGet(this, _GifImage_instances, "m", _GifImage_writeAdditionalFrame).call(this, pixels); + this.frameCount += 1; + } + /** + * Call once all frames have been written to finish the GIF image. + */ + finish() { + __classPrivateFieldGet(this, _GifImage_data, "f").writeByte(0x3B); + } + /** + * Returns the GIF image data as an {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer | ArrayBuffer}. + */ + getArrayBuffer() { + return __classPrivateFieldGet(this, _GifImage_data, "f").getBuffer(); + } + /** + * Returns the GIF image data as an {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/Image | Image} object. + * + * Note: This method does not work outside of browser environments + */ + getImage() { + assertBrowserEnv(); + const img = new Image(this.width, this.height); + img.src = this.getUrl(); + return img; + } + } + _GifImage_data = new WeakMap(), _GifImage_compressor = new WeakMap(), _GifImage_instances = new WeakSet(), _GifImage_writeFirstFrame = function _GifImage_writeFirstFrame(pixels) { + __classPrivateFieldGet(this, _GifImage_instances, "m", _GifImage_writeHeader).call(this); + __classPrivateFieldGet(this, _GifImage_instances, "m", _GifImage_writeLogicalScreenDescriptor).call(this); + __classPrivateFieldGet(this, _GifImage_instances, "m", _GifImage_writeColorTable).call(this); + __classPrivateFieldGet(this, _GifImage_instances, "m", _GifImage_writeNetscapeExt).call(this); + __classPrivateFieldGet(this, _GifImage_instances, "m", _GifImage_writeGraphicControlExt).call(this); + __classPrivateFieldGet(this, _GifImage_instances, "m", _GifImage_writeImageDescriptor).call(this); + __classPrivateFieldGet(this, _GifImage_instances, "m", _GifImage_writePixels).call(this, pixels); + }, _GifImage_writeAdditionalFrame = function _GifImage_writeAdditionalFrame(pixels) { + __classPrivateFieldGet(this, _GifImage_instances, "m", _GifImage_writeGraphicControlExt).call(this); + __classPrivateFieldGet(this, _GifImage_instances, "m", _GifImage_writeImageDescriptor).call(this); + __classPrivateFieldGet(this, _GifImage_instances, "m", _GifImage_writePixels).call(this, pixels); + }, _GifImage_writeHeader = function _GifImage_writeHeader() { + __classPrivateFieldGet(this, _GifImage_data, "f").writeChars('GIF89a'); + }, _GifImage_writeGraphicControlExt = function _GifImage_writeGraphicControlExt() { + __classPrivateFieldGet(this, _GifImage_data, "f").writeByte(0x21); // extension introducer + __classPrivateFieldGet(this, _GifImage_data, "f").writeByte(0xf9); // GCE label + __classPrivateFieldGet(this, _GifImage_data, "f").writeByte(4); // data block size + // packed fields + __classPrivateFieldGet(this, _GifImage_data, "f").writeByte(0); + __classPrivateFieldGet(this, _GifImage_data, "f").writeU16(this.settings.delay); // delay x 1/100 sec + __classPrivateFieldGet(this, _GifImage_data, "f").writeByte(0); // transparent color index + __classPrivateFieldGet(this, _GifImage_data, "f").writeByte(0); // block terminator + }, _GifImage_writeLogicalScreenDescriptor = function _GifImage_writeLogicalScreenDescriptor() { + const palette = this.palette; + const colorDepth = this.settings.colorDepth; + const globalColorTableFlag = 1; + const sortFlag = 0; + const globalColorTableSize = __classPrivateFieldGet(this, _GifImage_instances, "m", _GifImage_colorTableSize).call(this, palette.length) - 1; + const fields = (globalColorTableFlag << 7) | + ((colorDepth - 1) << 4) | + (sortFlag << 3) | + globalColorTableSize; + const backgroundColorIndex = 0; + const pixelAspectRatio = 0; + __classPrivateFieldGet(this, _GifImage_data, "f").writeU16(this.width); + __classPrivateFieldGet(this, _GifImage_data, "f").writeU16(this.height); + __classPrivateFieldGet(this, _GifImage_data, "f").writeBytes([fields, backgroundColorIndex, pixelAspectRatio]); + }, _GifImage_writeNetscapeExt = function _GifImage_writeNetscapeExt() { + __classPrivateFieldGet(this, _GifImage_data, "f").writeByte(0x21); // extension introducer + __classPrivateFieldGet(this, _GifImage_data, "f").writeByte(0xff); // app extension label + __classPrivateFieldGet(this, _GifImage_data, "f").writeByte(11); // block size + __classPrivateFieldGet(this, _GifImage_data, "f").writeChars('NETSCAPE2.0'); // app id + auth code + __classPrivateFieldGet(this, _GifImage_data, "f").writeByte(3); // sub-block size + __classPrivateFieldGet(this, _GifImage_data, "f").writeByte(1); // loop sub-block id + __classPrivateFieldGet(this, _GifImage_data, "f").writeU16(this.settings.repeat); // loop count (extra iterations, 0=repeat forever) + __classPrivateFieldGet(this, _GifImage_data, "f").writeByte(0); // block terminator + }, _GifImage_writeColorTable = function _GifImage_writeColorTable() { + const palette = this.palette; + const colorTableLength = 1 << __classPrivateFieldGet(this, _GifImage_instances, "m", _GifImage_colorTableSize).call(this, palette.length); + for (let i = 0; i < colorTableLength; i++) { + let color = [0, 0, 0]; + if (i < palette.length) { + color = palette[i]; + } + __classPrivateFieldGet(this, _GifImage_data, "f").writeByte(color[0]); + __classPrivateFieldGet(this, _GifImage_data, "f").writeByte(color[1]); + __classPrivateFieldGet(this, _GifImage_data, "f").writeByte(color[2]); + } + }, _GifImage_writeImageDescriptor = function _GifImage_writeImageDescriptor() { + __classPrivateFieldGet(this, _GifImage_data, "f").writeByte(0x2c); // image separator + __classPrivateFieldGet(this, _GifImage_data, "f").writeU16(0); // x position + __classPrivateFieldGet(this, _GifImage_data, "f").writeU16(0); // y position + __classPrivateFieldGet(this, _GifImage_data, "f").writeU16(this.width); // image size + __classPrivateFieldGet(this, _GifImage_data, "f").writeU16(this.height); + __classPrivateFieldGet(this, _GifImage_data, "f").writeByte(0); // global palette + }, _GifImage_colorTableSize = function _GifImage_colorTableSize(length) { + return Math.max(Math.ceil(Math.log2(length)), 1); + }, _GifImage_writePixels = function _GifImage_writePixels(pixels) { + __classPrivateFieldGet(this, _GifImage_compressor, "f").colorDepth = this.settings.colorDepth; + __classPrivateFieldGet(this, _GifImage_compressor, "f").reset(); + __classPrivateFieldGet(this, _GifImage_compressor, "f").encode(pixels, __classPrivateFieldGet(this, _GifImage_data, "f")); + }; + /** + * Default GIF encoder settings + */ + GifImage.defaultSettings = { + delay: 100, + repeat: -1, + colorDepth: 8 + }; + + var _WavAudio_header, _WavAudio_pcmData; + /** + * Wav audio object. Used to create a {@link https://en.wikipedia.org/wiki/WAV | WAV} file from a PCM audio stream or a {@link Flipnote} object. + * + * Currently only supports PCM s16_le audio encoding. + * + * @group File Encoder + */ + class WavAudio extends EncoderBase { + /** + * Create a new WAV audio object + * @param sampleRate audio samplerate + * @param channels number of audio channels + * @param bitsPerSample number of bits per sample + */ + constructor(sampleRate, channels = 1, bitsPerSample = 16) { + super(); + _WavAudio_header.set(this, void 0); + _WavAudio_pcmData.set(this, void 0); + this.sampleRate = sampleRate; + this.channels = channels; + this.bitsPerSample = bitsPerSample; + // Write WAV file header + // Reference: http://www.topherlee.com/software/pcm-tut-wavformat.html + const headerBuffer = new ArrayBuffer(44); + const header = new DataStream(headerBuffer); + // 'RIFF' indent + header.writeChars('RIFF'); + // filesize (set later) + header.writeUint32(0); + // 'WAVE' indent + header.writeChars('WAVE'); + // 'fmt ' section header + header.writeChars('fmt '); + // fmt section length + header.writeUint32(16); + // specify audio format is pcm (type 1) + header.writeUint16(1); + // number of audio channels + header.writeUint16(this.channels); + // audio sample rate + header.writeUint32(this.sampleRate); + // byterate = (sampleRate * bitsPerSample * channelCount) / 8 + header.writeUint32((this.sampleRate * this.bitsPerSample * this.channels) / 8); + // blockalign = (bitsPerSample * channels) / 8 + header.writeUint16((this.bitsPerSample * this.channels) / 8); + // bits per sample + header.writeUint16(this.bitsPerSample); + // 'data' section header + header.writeChars('data'); + // data section length (set later) + header.writeUint32(0); + __classPrivateFieldSet(this, _WavAudio_header, header, "f"); + __classPrivateFieldSet(this, _WavAudio_pcmData, null, "f"); + } + /** + * Create a WAV audio file from a Flipnote's master audio track + * @param flipnote + * @param trackId + */ + static fromFlipnote(note, sampleRate = null) { + sampleRate ?? (sampleRate = note.sampleRate); + const wav = new WavAudio(sampleRate, 1, 16); + const pcm = note.getAudioMasterPcm(sampleRate); + wav.writeSamples(pcm); + return wav; + } + /** + * Create a WAV audio file from a given Flipnote audio track + * @param flipnote + * @param trackId + */ + static fromFlipnoteTrack(flipnote, trackId, sampleRate = null) { + sampleRate ?? (sampleRate = flipnote.sampleRate); + const wav = new WavAudio(sampleRate, 1, 16); + const pcm = flipnote.getAudioTrackPcm(trackId, sampleRate); + wav.writeSamples(pcm); + return wav; + } + /** + * Add PCM audio frames to the WAV + * @param pcmData signed int16 PCM audio samples + */ + writeSamples(pcmData) { + let header = __classPrivateFieldGet(this, _WavAudio_header, "f"); + // fill in filesize + header.seek(4); + header.writeUint32(header.numBytes + pcmData.byteLength); + // fill in data section length + header.seek(40); + header.writeUint32(pcmData.byteLength); + __classPrivateFieldSet(this, _WavAudio_pcmData, pcmData, "f"); + } + /** + * Returns the WAV audio data as an {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer | ArrayBuffer} + */ + getArrayBuffer() { + const headerBytes = __classPrivateFieldGet(this, _WavAudio_header, "f").bytes; + const pcmBytes = new Uint8Array(__classPrivateFieldGet(this, _WavAudio_pcmData, "f").buffer); + const resultBytes = new Uint8Array(__classPrivateFieldGet(this, _WavAudio_header, "f").numBytes + __classPrivateFieldGet(this, _WavAudio_pcmData, "f").byteLength); + resultBytes.set(headerBytes); + resultBytes.set(pcmBytes, headerBytes.byteLength); + return resultBytes.buffer; + } + } + _WavAudio_header = new WeakMap(), _WavAudio_pcmData = new WeakMap(); + + // Entrypoint for web and node + /** + * flipnote.js library version (exported as `flipnote.version`). + * You can find the latest version on the project's [NPM](https://www.npmjs.com/package/flipnote.js) page. + */ + const version = "6.3.0"; // replaced by @rollup/plugin-replace; + + exports.CanvasInterface = CanvasInterface; + exports.GifImage = GifImage; + exports.Html5Canvas = Html5Canvas; + exports.KwzParser = KwzParser; + exports.Player = Player; + exports.PlayerMixin = PlayerMixin; + exports.PpmParser = PpmParser; + exports.UniversalCanvas = UniversalCanvas; + exports.WavAudio = WavAudio; + exports.WebAudioPlayer = WebAudioPlayer; + exports.WebglCanvas = WebglCanvas; + exports.filename = index$2; + exports.id = index$3; + exports.loaders = index$1; + exports.parse = parse; + exports.parseSource = parseSource; + exports.playlist = index; + exports.supportedEvents = supportedEvents; + exports.version = version; + + return exports; + +})({}); diff --git a/index.html b/index.html new file mode 100644 index 0000000..671a48d --- /dev/null +++ b/index.html @@ -0,0 +1,550 @@ + +Flipnote Editor
+
+ + +
+
+
+
+
+ + + + +
+

Flipnote Editor

+
+ + + + +
+
+
+
+
+
+ First page + Previous page + Play + Next page + Last page +
+
+
+
+ Speed 6 +
+ +
+
+ +
+
+
+
+ + +
+
+
flipnote-editor v3.26.2
+ +
+
+ + +
+ + + + + + + + + + + + + + +
diff --git a/package.json b/package.json new file mode 100644 index 0000000..3b5cb42 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "flipnote-editor", + "version": "3.26.2", + "private": true, + "description": "A standalone browser animation editor with two ink layers and local media import.", + "license": "AGPL-3.0-only", + "scripts": { + "start": "node tools/serve.cjs", + "test": "node --test tests/*.test.cjs" + }, + "engines": {"node": ">=18"} +} diff --git a/tests/fixtures/local-import.ppm b/tests/fixtures/local-import.ppm new file mode 100644 index 0000000..b604f63 Binary files /dev/null and b/tests/fixtures/local-import.ppm differ diff --git a/tests/import.test.cjs b/tests/import.test.cjs new file mode 100644 index 0000000..468b25e --- /dev/null +++ b/tests/import.test.cjs @@ -0,0 +1,80 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const root = path.resolve(__dirname,'..'); +global.flipnote=vm.compileFunction(fs.readFileSync(path.join(root,'assets/vendor/flipnote.js'),'utf8')+'\nreturn flipnote;',['module'])(module); +const importer = require('../assets/ppm-import.js'); +const fixture = fs.readFileSync(path.join(__dirname,'fixtures/local-import.ppm')); +const buffer = bytes => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); +const bit = (encoded,index) => (Buffer.from(encoded,'base64')[index >> 3] >> (index & 7)) & 1; + +test('local import keeps both layers, per-page colours, paper and visibility',()=>{ + const result=importer.decode(buffer(fixture),'Local.ppm'); + assert.equal(result.project.frames.length,2); + assert.equal(result.project.title,'Local'); + assert.deepEqual(result.project.layer_visibility,[true,false]); + assert.deepEqual(result.project.frames.map(frame=>frame.colours),[['black','red'],['blue','black']]); + assert.deepEqual(result.project.frames.map(frame=>frame.paper),['white','black']); + assert.equal(bit(result.project.frames[0].layer1,0),1); + assert.equal(bit(result.project.frames[0].layer2,191*256+255),1); + assert.equal(bit(result.project.frames[1].layer1,7*256+7),1); + assert.equal(bit(result.project.frames[1].layer1,0),0); + assert.equal(bit(result.project.frames[1].layer2,10*256+10),1); + assert.equal(result.project.loop,true); +}); + +test('audio and page sound triggers stay with an imported animation',()=>{ + const {project}=importer.decode(buffer(fixture)); + assert.equal(project.speed_level,6); + assert.deepEqual(project.frames[0].sounds,{sound1:true,sound2:false,sound3:false}); + assert.deepEqual(Object.keys(project.audio),['music','sound1']); + for(const track of Object.values(project.audio)){ + const wav=Buffer.from(track.data,'base64'); + assert.equal(wav.toString('ascii',0,4),'RIFF'); + assert.equal(wav.readUInt32LE(24),32768); + assert.equal(track.reference_fps,12); + assert.equal(track.duration,(wav.length-44)/2/32768); + } +}); + +test('music pitch follows the stored Flipnote and music speeds exactly once',()=>{ + const normal=importer.decode(buffer(fixture)); + const fast=Buffer.from(fixture); + const animationEnd=0x6A0+fast.readUInt32LE(4); + const soundStart=Math.ceil((animationEnd+2)/4)*4; + fast[soundStart+16]=1; + const faster=importer.decode(buffer(fast)); + assert.equal(faster.project.speed_level,7); + assert.equal(faster.project.audio.music.reference_fps,20); + assert.ok(faster.project.audio.music.duration{ + for(const mutate of [ + bytes=>bytes.fill(0,0,4), + bytes=>bytes.writeUInt32LE(0xFFFFFFFF,4), + bytes=>bytes.writeUInt16LE(1000,12), + bytes=>bytes.writeUInt16LE(0,0x6A0), + bytes=>bytes.writeUInt32LE(0xFFFFFFFF,0x6A8), + bytes=>bytes.writeUInt32LE(0xFFFFFFFF,8), + ]){ + const bad=Buffer.from(fixture);mutate(bad); + assert.throws(()=>importer.decode(buffer(bad))); + } + assert.throws(()=>importer.decode(buffer(fixture.subarray(0,100)))); + assert.throws(()=>importer.decode(new ArrayBuffer(1024*1024+1))); +}); + +test('opening a device file uses its bytes without making a network request',async()=>{ + const oldFetch=global.fetch; + global.fetch=()=>{throw new Error('Unexpected network request');}; + try{ + const result=await importer.read({name:'Device.ppm',size:fixture.length,arrayBuffer:async()=>buffer(fixture)}); + assert.equal(result.project.title,'Device'); + await assert.rejects(importer.read({name:'not-a-note.txt',size:fixture.length})); + await assert.rejects(importer.read({name:'large.ppm',size:1024*1024+1})); + }finally{global.fetch=oldFetch;} +}); diff --git a/tests/release.test.cjs b/tests/release.test.cjs new file mode 100644 index 0000000..5034100 --- /dev/null +++ b/tests/release.test.cjs @@ -0,0 +1,52 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const root = path.resolve(__dirname,'..'); +const walk = directory => fs.readdirSync(directory,{withFileTypes:true}).flatMap(entry=> + entry.isDirectory()?walk(path.join(directory,entry.name)):[path.join(directory,entry.name)]); + +test('the release contains source files and assets, with no server credentials or nested bundles',()=>{ + for(const file of walk(root)){ + assert.doesNotMatch(path.relative(root,file),/\.(?:key|pem|sqlite3|zip|xpi|pyc)$/i); + if(/\.(?:html|js|css|json|md|txt)$/i.test(file)){ + assert.doesNotMatch(fs.readFileSync(file,'utf8'),/-----BEGIN (?:RSA )?PRIVATE KEY-----/); + } + } +}); + +test('all shipped JavaScript is valid and the editor has no server fetch calls',()=>{ + for(const file of walk(path.join(root,'assets')).filter(file=>file.endsWith('.js'))){ + const source=fs.readFileSync(file,'utf8'); + new vm.Script(source,{filename:file}); + if(!file.includes(path.join('assets','vendor'))&&!file.endsWith('arabic-text.js')){ + assert.doesNotMatch(source,/\bfetch\s*\(|XMLHttpRequest|sendBeacon|WebSocket/); + } + } +}); + +test('the page keeps local import and video output without site navigation',()=>{ + const html=fs.readFileSync(path.join(root,'index.html'),'utf8'); + assert.match(html,/id="flipnote-open-ppm-button"/); + assert.match(html,/id="flipnote-audio-file-input"/); + assert.match(html,/id="export-btn"/); + assert.doesNotMatch(html,/class="site-(?:header|footer)"|{ + const files=walk(root).filter(file=>/\.(?:html|css|js)$/.test(file)&&!file.includes(path.sep+'vendor'+path.sep)); + for(const file of files){ + const text=fs.readFileSync(file,'utf8'); + const references=[]; + for(const match of text.matchAll(/(?:src|href)=["'](\.\/[^"']+)["']/g))references.push(match[1]); + for(const match of text.matchAll(/url\(["']?(\.\/[^)'"\s]+)["']?\)/g))references.push(match[1]); + for(const match of text.matchAll(/["'](\.\/assets\/[^"'$`]+)["']/g))references.push(match[1]); + for(const reference of references){ + const clean=reference.split(/[?#]/)[0]; + const base=file.endsWith('.css')?path.dirname(file):root; + assert.ok(fs.existsSync(path.resolve(base,clean)),path.relative(root,file)+': missing '+clean); + } + } +}); diff --git a/tools/serve.cjs b/tools/serve.cjs new file mode 100644 index 0000000..1b69d7a --- /dev/null +++ b/tools/serve.cjs @@ -0,0 +1,23 @@ +const http = require('node:http'); +const fs = require('node:fs'); +const path = require('node:path'); +const root = path.resolve(__dirname, '..'); +const port = Number(process.env.PORT || 8090); +if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error('Choose a port between 1024 and 65535.'); +const types = {'.html':'text/html; charset=utf-8','.js':'text/javascript; charset=utf-8','.css':'text/css; charset=utf-8', + '.json':'application/json; charset=utf-8','.png':'image/png','.webp':'image/webp','.svg':'image/svg+xml', + '.ttf':'font/ttf','.otf':'font/otf','.woff2':'font/woff2','.wav':'audio/wav'}; +http.createServer((req,res) => { + try { + if (!['GET','HEAD'].includes(req.method)) {res.writeHead(405,{'Allow':'GET, HEAD'}).end();return;} + const pathname = decodeURIComponent(new URL(req.url,'http://localhost').pathname); + const relative = pathname.replace(/^\/+/, '') || 'index.html'; + const target = path.resolve(root,relative); + if (path.relative(root,target).startsWith('..') || path.isAbsolute(path.relative(root,target)) || relative.split(/[\\/]/).some(part=>part.startsWith('.'))) {res.writeHead(404).end();return;} + const stat = fs.statSync(target); + if (!stat.isFile()) {res.writeHead(404).end();return;} + res.writeHead(200,{'Content-Type':types[path.extname(target)]||'application/octet-stream', + 'Content-Length':stat.size,'X-Content-Type-Options':'nosniff','Cache-Control':'no-cache'}); + if (req.method==='HEAD')res.end();else fs.createReadStream(target).pipe(res); + } catch {res.writeHead(404).end();} +}).listen(port,'127.0.0.1',()=>console.log(`Flipnote Editor: http://localhost:${port}`));