From 92f296ddb2f72923a606b35737e271dfa63e16e1 Mon Sep 17 00:00:00 2001 From: Rekushi <170186554+RekuNote@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:56:15 +0000 Subject: [PATCH] Add files via upload --- CREDITS.md | 25 + LICENSE | 661 +++ README.md | 70 + assets/bootstrap.js | 348 ++ assets/editor.css | 2462 +++++++++ assets/flipnote-player/pause.svg | 3 + assets/fonts/ndsbios.ttf | Bin 0 -> 57204 bytes assets/fonts/paxalah.otf | Bin 0 -> 27028 bytes assets/loading.webp | Bin 0 -> 9572 bytes assets/ppm-import.js | 117 + assets/vendor/flipnote-LICENSE.txt | 21 + assets/vendor/flipnote.js | 8232 ++++++++++++++++++++++++++++ index.html | 550 ++ package.json | 12 + tests/fixtures/local-import.ppm | Bin 0 -> 2220 bytes tests/import.test.cjs | 80 + tests/release.test.cjs | 52 + tools/serve.cjs | 23 + 18 files changed, 12656 insertions(+) create mode 100644 CREDITS.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 assets/bootstrap.js create mode 100644 assets/editor.css create mode 100644 assets/flipnote-player/pause.svg create mode 100644 assets/fonts/ndsbios.ttf create mode 100644 assets/fonts/paxalah.otf create mode 100644 assets/loading.webp create mode 100644 assets/ppm-import.js create mode 100644 assets/vendor/flipnote-LICENSE.txt create mode 100644 assets/vendor/flipnote.js create mode 100644 index.html create mode 100644 package.json create mode 100644 tests/fixtures/local-import.ppm create mode 100644 tests/import.test.cjs create mode 100644 tests/release.test.cjs create mode 100644 tools/serve.cjs 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 0000000000000000000000000000000000000000..90b9a7c6adabdea77c4f88c40f1080733ea22de0 GIT binary patch literal 57204 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

ZnbLeY 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 literal 0 HcmV?d00001 diff --git a/assets/loading.webp b/assets/loading.webp new file mode 100644 index 0000000000000000000000000000000000000000..6f318cfb4101e28709b3ff2aff4a88c74f48657b GIT binary patch literal 9572 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 A_W%F@ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b604f6348d421fe68ac6377c1c0d2420ea606846 GIT binary patch literal 2220 zcmWG=3~~%&WMGhBU|?WmPyvz*46Y0*44Dii4EYR23(h|+ifSQk 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}`));