diff options
| author | navewindre <boneyaard@gmail.com> | 2025-04-05 02:59:37 +0200 |
|---|---|---|
| committer | navewindre <boneyaard@gmail.com> | 2025-04-05 02:59:37 +0200 |
| commit | b24463f3d045783b8f4e72926054d53b908e150f (patch) | |
| tree | 036f976e217128b9e4acf3854f72908c27dec17b /config | |
| parent | 398e41be4daf339bd55862520c528a7d93b83fb6 (diff) | |
a
Diffstat (limited to 'config')
36 files changed, 5076 insertions, 0 deletions
diff --git a/config/mpv/scripts/subs2srs b/config/mpv/scripts/subs2srs deleted file mode 160000 -Subproject aceb8542a221a905e05ec369e4e29b100a3d46e diff --git a/config/mpv/scripts/subs2srsa/.gitignore b/config/mpv/scripts/subs2srsa/.gitignore new file mode 100644 index 0000000..850b67c --- /dev/null +++ b/config/mpv/scripts/subs2srsa/.gitignore @@ -0,0 +1,5 @@ +.idea +.vscode +/.github/RELEASE/*.zip +/.github/RELEASE/*.html +*TODO* diff --git a/config/mpv/scripts/subs2srsa/LICENSE b/config/mpv/scripts/subs2srsa/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/config/mpv/scripts/subs2srsa/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + <one line to give the program's name and a brief idea of what it does.> + Copyright (C) <year> <name of author> + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see <https://www.gnu.org/licenses/>. + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + <program> Copyright (C) <year> <name of author> + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +<https://www.gnu.org/licenses/>. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +<https://www.gnu.org/licenses/why-not-lgpl.html>. diff --git a/config/mpv/scripts/subs2srsa/Makefile b/config/mpv/scripts/subs2srsa/Makefile new file mode 100644 index 0000000..fc346ce --- /dev/null +++ b/config/mpv/scripts/subs2srsa/Makefile @@ -0,0 +1,36 @@ +PROJECT := mpvacious +PREFIX ?= /etc/mpv/ +BRANCH ?= remotes/origin/master +VERSION := $(shell git describe --tags $(BRANCH)) +RELEASE_DIR := .github/RELEASE +ZIP := $(RELEASE_DIR)/$(PROJECT)_$(VERSION).zip +DOCS := $(RELEASE_DIR)/README_$(VERSION).html +MD2HTML = md2html --github --full-html + +.PHONY: all clean docs install uninstall + +all: $(ZIP) +docs: $(DOCS) + +$(ZIP): + git archive \ + --prefix=$(PROJECT)_$(VERSION)/ \ + --format=zip \ + -o $@ \ + $(BRANCH) \ + +$(DOCS): + git show "$(BRANCH):README.md" | $(MD2HTML) -o $@ + +install: + find . -type f -iname '*.lua' | while read -r file; do \ + install -Dm644 "$$file" "$(PREFIX)/scripts/$(PROJECT)/$$file"; \ + done + install -Dm644 $(RELEASE_DIR)/subs2srs.conf "$(PREFIX)/script-opts/subs2srs.conf" + +uninstall: + rm -rf -- "$(PREFIX)/scripts/$(PROJECT)" + rm -- "$(PREFIX)/script-opts/subs2srs.conf" + +clean: + rm -- $(ZIP) $(DOCS) diff --git a/config/mpv/scripts/subs2srsa/README.md b/config/mpv/scripts/subs2srsa/README.md new file mode 100644 index 0000000..deefe3c --- /dev/null +++ b/config/mpv/scripts/subs2srsa/README.md @@ -0,0 +1,478 @@ +<p align="center"> +<img src="https://user-images.githubusercontent.com/69171671/117440218-4ae26800-af23-11eb-87b4-1d9026fc953f.png"/> +</p> + +# mpvacious + +[](https://aur.archlinux.org/packages/mpv-mpvacious/) +[](https://tatsumoto-ren.github.io/blog/join-our-community.html) + +[](https://tatsumoto.neocities.org/blog/donating-to-tatsumoto.html) + +mpvacious is your semi-automatic subs2srs for mpv. +It supports multiple workflows and allows you to quickly create Anki cards +while watching your favorite TV show. +**[Video demonstration](https://redirect.invidious.io/watch?v=vU85ramvyo4)**. + +## Requirements + +<table> +<tr> + <th><a href="https://www.gnu.org/gnu/about-gnu.html">GNU/Linux</a></th> + <th><a href="https://www.gnu.org/proprietary/malware-microsoft.en.html">Windows 10+</a></th> + <th><a href="https://www.gnu.org/proprietary/malware-apple.en.html">macOS</a></th> + <th>Comments</th> +</tr> +<tr> + <td><a href="https://wiki.archlinux.org/index.php/Mpv">mpv</a></td> + <td><a href="https://sourceforge.net/projects/mpv-player-windows/files">mpv</a></td> + <td><a href="https://mpv.io/installation/">mpv</a></td> + <td>v0.32.0 or newer.</td> +</tr> +<tr> + <td><a href="https://wiki.archlinux.org/index.php/Anki">Anki</a></td> + <td colspan="2" align="center"><a href="https://apps.ankiweb.net/">Anki</a></td> + <td></td> +</tr> +<tr> + <td colspan="3" align="center"><a href="https://ankiweb.net/shared/info/2055492159">AnkiConnect</a></td> + <td>Install from AnkiWeb.</td> +</tr> +<tr> + <td><a href="https://www.archlinux.org/packages/core/x86_64/curl/">curl</a></td> + <td colspan="2" align="center"><a href="https://curl.haxx.se/">curl</a></td> + <td>Installed by default on all platforms except Windows 7.</td> +</tr> +<tr> + <td><a href="https://www.archlinux.org/packages/extra/x86_64/xclip/">xclip</a> or <a href="https://archlinux.org/packages/extra/x86_64/wl-clipboard">wl-copy</a></td> + <td></td> + <td>pbcopy</td> + <td>To copy subtitle text to clipboard.</td> +</tr> +</table> + +Install all dependencies at once (on [Arch-based](https://www.parabola.nu/) +[distros](https://www.gnu.org/distros/free-distros.en.html)): + +``` +$ sudo pacman -Syu mpv anki curl xclip --needed +``` + +## Prerequisites + +* A guide on how to set up Anki can be found [on our site](https://tatsumoto.neocities.org/blog/setting-up-anki.html). +* If you're on a [Windows](https://www.fsf.org/windows) or a [Windows-like](https://reactos.org/) machine, + a mpv build by `shinchiro` is recommended. +* **macOS** users are advised to use [homebrew](https://brew.sh/) or manually add `mpv` to `PATH`. +* Note that it is not recommended to use FlatPak or similar containers. + If you still want to, [read this](howto/flatpak.md). +* Make sure that your build of mpv supports encoding of audio and images. + This shell command can be used to test it. + + ``` + $ mpv 'test_video.mkv' --loop-file=no --frames=1 -o='test_image.jpg' + ``` + + If the command fails, find a compatible build on the [mpv website](https://mpv.io/installation/) + or instead install FFmpeg and [enable FFmpeg support](#configuration) in `mpvacious`'s config file. +* Most problems with adding audio or images to Anki cards can be fixed + by installing FFmpeg and enabling it settings. + +## Installation + +There are multiple ways you can install `mpvacious`. +I recommend installing with `git` so that you can easily update on demand. + +`mpvacious` is a user-script for mpv, +so it has to be installed in the directory `mpv` reads its user-scripts from. + +| OS | Location | +|-----------|--------------------------------------------------| +| GNU/Linux | `~/.config/mpv/scripts/` | +| Windows | `C:/Users/Username/AppData/Roaming/mpv/scripts/` | + +Windows is not recommended, +but we acknowledge that some people haven't switched to GNU/Linux yet. + +### Using git + +Clone the repo to the `scripts` directory. + +``` +mkdir -p ~/.config/mpv/scripts/ +git clone 'https://github.com/Ajatt-Tools/mpvacious.git' ~/.config/mpv/scripts/subs2srs +``` + +To update, run the following command. + +``` +cd ~/.config/mpv/scripts/subs2srs && git pull +``` + +### From the AUR + +`mpvacious` can be installed with the [mpv-mpvacious](https://aur.archlinux.org/packages/mpv-mpvacious/) package. + +### Manually + +This way is not recommended because it's easy to make a mistake during the process +and end up with a broken install. + +Download +[the repository](https://github.com/Ajatt-Tools/mpvacious/archive/refs/heads/master.zip) +or +[the latest release](https://github.com/Ajatt-Tools/mpvacious/releases) +and extract the folder containing +[subs2srs.lua](https://raw.githubusercontent.com/Ajatt-Tools/mpvacious/master/subs2srs.lua) +to your [mpv scripts](https://github.com/mpv-player/mpv/wiki/User-Scripts) directory. + +<details> + +<summary>Expected directory tree</summary> + +``` +~/.config/mpv/scripts +|-- other script 1 +|-- other script 2 +|-- subs2srs +| |-- main.lua +| |-- subs2srs.lua +| `-- other files +`-- other script 3 +``` + +</details> + +<details> + +<summary>A note for mpv v0.32 and older</summary> + +Older versions of `mpv` don't know how to handle user-scripts in subdirectories. +You need to tell mpv where to look for `mpvacious`. + +Open or create `~/.config/mpv/scripts/modules.lua` and add these lines: +``` +local mpv_scripts_dir_path = os.getenv("HOME") .. "/.config/mpv/scripts/" +package.path = package.path .. ';' .. os.getenv("HOME") .. '/.config/mpv/scripts/subs2srs/?.lua' +function load(relative_path) dofile(mpv_scripts_dir_path .. relative_path) end +load("subs2srs/subs2srs.lua") +``` + +</details> + +**Note:** in [Celluloid](https://www.archlinux.org/packages/community/x86_64/celluloid/) +user scripts are installed in `/.config/celluloid/scripts/`. +When following the instructions above, replace `.config/mpv` with `.config/celluloid` +and optionally `subs2srs` with the name of the folder mpvacious is cloned into. + +## Configuration + +The config file should be created by the user, if needed. + +| OS | Config location | +|--------------------|-------------------------------------------------------------------| +| GNU/Linux | `~/.config/mpv/script-opts/subs2srs.conf` | +| Windows | `C:/Users/Username/AppData/Roaming/mpv/script-opts/subs2srs.conf` | +| Windows (portable) | `mpv.exeフォルダ/portable_config/script-opts/subs2srs.conf` | + +If a parameter is not specified +in the config file, the default value will be used. +mpv doesn't tolerate spaces before and after `=`. + +<p align="center"> + <a href="https://github.com/Ajatt-Tools/mpvacious/blob/master/.github/RELEASE/subs2srs.conf">Example configuration file</a> +</p> + +If the first field is empty, it will be set contain the string `[empty]`. +Otherwise, Anki won't allow mpvacious to add new notes. +This won't happen if the sentence field is first in the note type settings. + +**Tip**: Try [our official note type](https://ankiweb.net/shared/info/1557722832) +if you don't want to configure note fields yourself. +Alternatively, we have a collection of user-created note types, which you can browse +[here](https://github.com/Ajatt-Tools/AnkiNoteTypes). + +If you are having problems playing media files on older mobile devices, +set `audio_format` to `mp3` and/or `snapshot_format` to `jpg`. +Otherwise, I recommend sticking with `opus` for audio, +and `avif` or `webp` for images, +as they greatly reduce the size of the generated files. + +If you still use AnkiMobile (the [proprietary](https://www.gnu.org/proprietary/) Anki app), +set `opus_container` to `m4a` or `webm`. I'll allow iOS to play Opus files, while still maintaining +compatibility with non-Apple devices. For really old iOS devices, set `opus_container` to +[`caf`](https://en.wikipedia.org/wiki/Core_Audio_Format). CAF plays only on Anki Desktop, +AnkiWeb in Safari and AnkiMobile, and is really not recommended. (Please note that +[Lockdown Mode](https://support.apple.com/en-us/105120) completely disables Opus and AVIF support, +though you may try to add an exception for AnkiMobile.) + +If no matter what mpvacious fails to create audio clips and/or snapshots, +change `use_ffmpeg` to `yes`. +By using ffmpeg instead of the encoder built in mpv you can work around most encoder issues. +You need to have ffmpeg installed for this to work. + +### Key bindings + +The user may change some global key bindings, though this step is not necessary. +See [Usage](#usage) for the explanation of what they do. + +| OS | Config location | +|-----------|----------------------------------------------------| +| GNU/Linux | `~/.config/mpv/input.conf` | +| Windows | `C:/Users/Username/AppData/Roaming/mpv/input.conf` | + +Default bindings: + +``` +a script-binding mpvacious-menu-open + +Ctrl+g script-binding mpvacious-animated-snapshot-toggle + +Ctrl+n script-binding mpvacious-export-note + +Ctrl+m script-binding mpvacious-update-last-note +Ctrl+M script-binding mpvacious-overwrite-last-note + +g script-binding mpvacious-quick-card-menu-open +Alt+g script-binding mpvacious-quick-card-sel-menu-open + +Ctrl+c script-binding mpvacious-copy-primary-sub-to-clipboard +Ctrl+C script-binding mpvacious-copy-secondary-sub-to-clipboard +Ctrl+t script-binding mpvacious-autocopy-toggle + +H script-binding mpvacious-sub-seek-back +L script-binding mpvacious-sub-seek-forward + +Alt+h script-binding mpvacious-sub-seek-back-pause +Alt+l script-binding mpvacious-sub-seek-forward-pause + +Ctrl+h script-binding mpvacious-sub-rewind +Ctrl+H script-binding mpvacious-sub-replay +Ctrl+L script-binding mpvacious-sub-play-up-to-next + +Ctrl+v script-binding mpvacious-secondary-sid-toggle +Ctrl+k script-binding mpvacious-secondary-sid-prev +Ctrl+j script-binding mpvacious-secondary-sid-next +``` + +**Note:** A capital letter means that you need to press Shift in order to activate the corresponding binding. +For example, <kbd>Ctrl+M</kbd> actually means <kbd>Ctrl+Shift+m</kbd>. +mpv accepts both variants in `input.conf`. + +## Usage + +* [Create a card](howto/create_card.md) +* [Quick card creation](howto/create_quick_card.md) +* [Open the "Add" dialog](howto/add_dialog.md) +* [Usage with Rikaitan](howto/yomichan.md) +* [Usage with GoldenDict](howto/goldendict.md) + +### Global bindings + +**Menu:** + +* <kbd>a</kbd> - Open `advanced menu`. + +**Enable\Disable animation:** + +* <kbd>Ctrl+g</kbd> - If animation is enabled, animated snapshots will be generated instead of static images. + Animated snapshot are like GIFs (just in a different format) + and will capture the video from the start to the end times selected. + +**Make a card:** + +* <kbd>Ctrl+n</kbd> - Export a card with the currently visible subtitle line on the front. +Use this when your subs are well-timed, +and the target sentence doesn't span multiple subs. + +**Quick card creation:** + +* <kbd>g</kbd> - Quick card creation menu. +* <kbd>Alt+g</kbd> - Quick card creation, card selection menu. + +**Update the last card:** + +* <kbd>Ctrl+m</kbd> - Append to the media fields of the newly added Anki card. +* <kbd>Ctrl+Shift+m</kbd> - Overwrite media fields of the newly added Anki card. + +**Clipboard:** + +* <kbd>Ctrl+c</kbd> - Copy current subtitle string to the system clipboard. +* <kbd>Ctrl+t</kbd> - Toggle automatic copying of subtitles to the clipboard. + +**Seeking:** + +* <kbd>Shift+h</kbd> and <kbd>Shift+l</kbd> - Seek to the previous or the next subtitle. +* <kbd>Alt+h</kbd> and <kbd>Alt+l</kbd> - Seek to the previous, or the next subtitle, and pause. +* <kbd>Ctrl+h</kbd> - Seek to the start of the currently visible subtitle. Use it if you missed something. +* <kbd>Ctrl+Shift+h</kbd> - Replay current subtitle line, and pause. +* <kbd>Ctrl+Shift+l</kbd> - Play until the end of the next subtitle, and pause. Useful for beginners who need + to look up words in each and every dialogue line. + +**Secondary subtitles:** + +* <kbd>Ctrl+v</kbd> - Toggle visibility. +* <kbd>Ctrl+k</kbd> - Switch to the previous subtitle if it's not already selected. +* <kbd>Ctrl+j</kbd> - Switch to the next subtitle if it's not already selected. + +### Menu options + +Advanced menu has the following options: + +* <kbd>f</kbd> - Increment number of cards to update. Only affects note updating, including quick card creation. The number of cards to update is reset to 1 upon updating a note. +* <kbd>shift+f</kbd> - Decrement number of cards to update. + +* <kbd>c</kbd> - Interactive subtitle selection. + The range of the currently displayed subtitle line is selected. The selection then grows both ways based on the following displayed lines. + It does nothing if there are no subs on screen. + +* <kbd>shift+s</kbd> - Set the start time to the current sub. The selection then grows forward based on the following displayed lines. + The default selection spans across the range of the currently displayed subtitle line. +* <kbd>shift+e</kbd> - Set the end time to the current sub. The selection then grows backward based on the following displayed lines. + The default selection spans across the range of the currently displayed subtitle line. + +Then seek with <kbd>Shift+h</kbd> and <kbd>Shift+l</kbd> to the previous/next line that you want to add. +Press <kbd>n</kbd> to make the card. + +* <kbd>r</kbd> - Forget all previously saved timings and associated dialogs. + +* <kbd>z</kbd> and <kbd>Shift+z</kbd> - Adjust subtitle delay. + +If above fails, you have to manually set timings. +* <kbd>s</kbd> - Set the start time. The selection then grows forward based on the following displayed lines. +The default selection spans across the selected start point and the end of the subtitle line. +* <kbd>e</kbd> - Set the end time. The selection then grows backward based on the following displayed lines. +The default selection spans across the selected end point and the start of the subtitle line. + +Then, as earlier, press <kbd>n</kbd> to make the card. + +**Tip**: change playback speed by pressing <kbd>[</kbd> and <kbd>]</kbd> +to precisely mark start and end of the phrase. + +### My subtitles are not in sync + +If subs are badly timed, first, you could try to re-time them. +Read [Retiming subtitles](https://tatsumoto.neocities.org/blog/retiming-subtitles). +Or shift timings using key bindings provided by mpv (usually <kbd>z</kbd> and <kbd>Shift+z</kbd>). + +### Example sentence card + +With the addon you can make cards like this in just a few seconds. + + + +### Audio cards + +It is possible to make a card with just audio, and a picture +when subtitles for the show you are watching aren't available, for example. +mpv by default allows you to do a `1` second exact seek by pressing <kbd>Shift+LEFT</kbd> and <kbd>Shift+RIGHT</kbd>. +Open the mpvacious menu by pressing <kbd>a</kbd>, seek to the position you need, and set the timings. +Then press <kbd>g</kbd> to invoke the `Add Cards` dialog. +Here's a [video demonstration](https://redirect.invidious.io/watch?v=BXhyckdHPGE). + +If the show is hard-subbed, you can use +[transformers-ocr](https://tatsumoto.neocities.org/blog/mining-from-manga.html) +to recognize and add text to the card. + +### Secondary subtitles + +If you want to add a translation to your cards, and you have the subtitles in that language, +you can add them as secondary subtitles if you run `mpv` with `--secondary-sid=<sid>` parameter, +`sid` being the track identifier for the subtitle. + +You also need to specify `secondary_field` in the [config file](#Configuration) +if it is different from the default. + +If you want to load secondary subtitles **automatically**, don't modify the run parameters +and instead set the desired languages in the config file (`secondary_sub_lang` option). + +Secondary subtitles will be visible when hovering over the top part of the `mpv` window. + +https://user-images.githubusercontent.com/69171671/188492261-909ba3e8-b82c-493f-88cf-0ec953dfcfe1.mp4 + +By pressing <kbd>Ctrl</kbd>+<kbd>v</kbd> you can control secondary sid visibility without using the mouse. + +### Other tools + +If you don't like the default Rikaitan Search tool, try: + +* Clipboard Inserter browser add-on +([chrome](https://chrome.google.com/webstore/detail/clipboard-inserter/deahejllghicakhplliloeheabddjajm)) +([firefox](https://addons.mozilla.org/ja/firefox/addon/clipboard-inserter/)) +* A html page ([1](https://pastebin.com/zDY6s3NK)) ([2](https://pastebin.com/hZ4sawL4)) +to paste the contents of your clipboard to + +You can use any html page as long as it has \<body\>\</body\> in it. + +### Additional mpv key bindings + +I recommend adding these lines to your [input.conf](#key-bindings) for smoother experience. +``` +# vim-like seeking +l seek 5 +h seek -5 +j seek -60 +k seek 60 + +# Cycle between subtitle files +K cycle sub +J cycle sub down + +# Add/subtract 50 ms delay from subs +Z add sub-delay +0.05 +z add sub-delay -0.05 + +# Adjust timing to previous/next subtitle +X sub-step 1 +x sub-step -1 +``` + +## Profiles + +Mpvacious supports config profiles. +To make use of them, create a new config file called `subs2srs_profiles.conf` +in the same folder as your [subs2srs.conf](#Configuration). +Inside the file, define available profile names (without `.conf`) and the name of the active profile: + +``` +profiles=subs2srs,english,german +active=subs2srs +``` + +In the example above, I have three profiles. +The first one is the default, +the second one is for learning English, +the third one is for learning German. + +Then in the same folder create config files for each of the defined profiles. +For example, below is the contents of my `english.conf` file: + +``` +deck_name=English sentence mining +model_name=General +sentence_field=Question +audio_field=Audio +image_field=Extra +``` + +You don't have to redefine all settings in the new profile. +Specify only the ones you want to be different from the default. + +To cycle profiles, open the advanced menu by pressing <kbd>a</kbd> and then press <kbd>p</kbd>. +At any time you can see what profile is active in the menu's status bar. + +## Hacking + +If you want to modify this script +or make an entirely new one from scratch, +these links may help. + +* https://mpv.io/manual/master/#lua-scripting +* https://github.com/mpv-player/mpv/blob/master/player/lua/defaults.lua +* https://github.com/SenneH/mpv2anki +* https://github.com/kelciour/mpv-scripts/blob/master/subs2srs.lua +* https://pastebin.com/M2gBksHT +* https://pastebin.com/NBudhMUk +* https://pastebin.com/W5YV1A9q +* https://github.com/ayuryshev/subs2srs +* https://github.com/erjiang/subs2srs diff --git a/config/mpv/scripts/subs2srsa/ankiconnect.lua b/config/mpv/scripts/subs2srsa/ankiconnect.lua new file mode 100644 index 0000000..f9c87d8 --- /dev/null +++ b/config/mpv/scripts/subs2srsa/ankiconnect.lua @@ -0,0 +1,241 @@ +--[[ +Copyright: Ren Tatsumoto and contributors +License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/gpl.html + +AnkiConnect requests +]] + +local utils = require('mp.utils') +local msg = require('mp.msg') +local h = require('helpers') +local self = {} + +self.execute = function(request, completion_fn) + -- utils.format_json returns a string + -- On error, request_json will contain "null", not nil. + local request_json, error = utils.format_json(request) + + if error ~= nil or request_json == "null" then + return completion_fn and completion_fn() + else + return self.platform.curl_request(self.config.ankiconnect_url, request_json, completion_fn) + end +end + +self.parse_result = function(curl_output) + -- there are two values that we actually care about: result and error + -- but we need to crawl inside to get them. + + if curl_output == nil then + return nil, "Failed to format json or no args passed" + end + + if curl_output.status ~= 0 then + return nil, "Ankiconnect isn't running" + end + + local stdout_json = utils.parse_json(curl_output.stdout) + + if stdout_json == nil then + return nil, "Fatal error from Ankiconnect" + end + + if stdout_json.error ~= nil then + return nil, tostring(stdout_json.error) + end + + return stdout_json.result, nil +end + +self.get_media_dir_path = function() + -- Ask AnkiConnect where to store media files. + -- If AnkiConnect isn't running, returns nil. + + local ret = self.execute({ + action = "getMediaDirPath", + version = 6, + }) + local dir_path, error = self.parse_result(ret) + if not error then + return dir_path + else + msg.error(string.format("Couldn't retrieve path to collection.media folder: %s", error)) + return nil + end +end + +self.create_deck = function(deck_name) + local args = { + action = "changeDeck", + version = 6, + params = { + cards = {}, + deck = deck_name + } + } + local result_notify = function(_, result, _) + local _, error = self.parse_result(result) + if not error then + msg.info(string.format("Deck %s: check completed.", deck_name)) + else + msg.warn(string.format("Deck %s: check failed. Reason: %s.", deck_name, error)) + end + end + self.execute(args, result_notify) +end + +self.add_note = function(note_fields, tag, gui) + local action = gui and 'guiAddCards' or 'addNote' + local args = { + action = action, + version = 6, + params = { + note = { + deckName = self.config.deck_name, + modelName = self.config.model_name, + fields = note_fields, + options = { + allowDuplicate = self.config.allow_duplicates, + duplicateScope = "deck", + }, + tags = h.is_empty(tag) and {} or { tag, }, + } + } + } + local result_notify = function(_, result, _) + local note_id, error = self.parse_result(result) + if not error then + h.notify(string.format("Note added. ID = %s.", note_id)) + else + h.notify(string.format("Error: %s.", error), "error", 2) + end + end + self.execute(args, result_notify) +end + +self.get_last_note_ids = function(n_cards) + local ret = self.execute { + action = "findNotes", + version = 6, + params = { + query = "added:1" -- find all notes added today + } + } + + local note_ids, _ = self.parse_result(ret) + + if not h.is_empty(note_ids) then + return h.get_last_n_added_notes(note_ids, n_cards) + else + return {} + end +end + +self.get_note_fields = function(note_id) + local ret = self.execute { + action = "notesInfo", + version = 6, + params = { + notes = { note_id } + } + } + + local result, error = self.parse_result(ret) + + if error == nil then + result = result[1].fields + for key, value in pairs(result) do + result[key] = value.value + end + return result + else + return nil + end +end + +self.get_first_field = function(model_name) + local ret = self.execute { + action = "findModelsByName", + version = 6, + params = { + modelNames = { model_name } + } + } + + local result, error = self.parse_result(ret) + + if error == nil then + for _, field in pairs(result[1].flds) do + if field.ord == 0 then + return field.name + end + end + else + msg.error(string.format("Couldn't retrieve the first field's name of note type %s: %s", model_name, error)) + return nil + end +end + +self.gui_browse = function(query) + if not self.config.disable_gui_browse then + self.execute { + action = 'guiBrowse', + version = 6, + params = { + query = query + } + } + end +end + +self.add_tag = function(note_id, tag) + if not h.is_empty(tag) then + self.execute { + action = 'addTags', + version = 6, + params = { + notes = { note_id }, + tags = tag + } + } + end +end + +self.append_media = function(note_id, fields, create_media_fn, tag) + -- AnkiConnect will fail to update the note if it's selected in the Anki Browser. + -- https://github.com/FooSoft/anki-connect/issues/82 + -- Switch focus from the current note to avoid it. + self.gui_browse("nid:1") -- impossible nid + + local args = { + action = "updateNoteFields", + version = 6, + params = { + note = { + id = note_id, + fields = fields, + } + } + } + + local on_finish = function(_, result, _) + local _, error = self.parse_result(result) + if not error then + create_media_fn() + self.add_tag(note_id, tag) + self.gui_browse(string.format("nid:%s", note_id)) -- select the updated note in the card browser + h.notify(string.format("Note #%s updated.", note_id)) + else + h.notify(string.format("Error: %s.", error), "error", 2) + end + end + + self.execute(args, on_finish) +end + +self.init = function(config, platform) + self.config = config + self.platform = platform +end + +return self diff --git a/config/mpv/scripts/subs2srsa/cfg_mgr.lua b/config/mpv/scripts/subs2srsa/cfg_mgr.lua new file mode 100644 index 0000000..076c0d9 --- /dev/null +++ b/config/mpv/scripts/subs2srsa/cfg_mgr.lua @@ -0,0 +1,240 @@ +--[[ +Copyright: Ren Tatsumoto and contributors +License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/gpl.html + +Config management, validation, loading. +]] + +local mp = require('mp') +local mpopt = require('mp.options') +local msg = require('mp.msg') +local h = require('helpers') +local utils = require('mp.utils') + +local min_side_px = 42 +local max_side_px = 640 +local default_height_px = 200 + +-- This constant should be used in place of width and/or height in the config file. +-- It tells the encoder to preserve aspect ratio when downscaling snapshots. +-- The user almost always wants to set either width or height to this value. +-- Note: If set to -1, encoding will fail with the "height/width not divisible by 2" error. +local preserve_aspect_ratio = -2 + +local self = { + config = nil, + profiles = nil, + initial_config = {} +} + +local default_profile_filename = 'subs2srs' +local profiles_filename = 'subs2srs_profiles' + +local function set_file_extension_for_opus() + -- Default to OGG, then change if an extension is supported. + -- https://en.wikipedia.org/wiki/Core_Audio_Format + self.config.audio_extension = '.ogg' + for _, extension in ipairs({ 'opus', 'm4a', 'webm', 'caf' }) do + if extension == self.config.opus_container then + self.config.audio_extension = '.' .. self.config.opus_container + break + end + end +end + +local function set_audio_format() + if self.config.audio_format == 'opus' then + -- https://opus-codec.org/ + self.config.audio_codec = 'libopus' + set_file_extension_for_opus() + else + self.config.audio_codec = 'libmp3lame' + self.config.audio_extension = '.mp3' + end +end + +local function set_video_format() + if self.config.snapshot_format == 'avif' then + self.config.snapshot_extension = '.avif' + self.config.snapshot_codec = 'libaom-av1' + elseif self.config.snapshot_format == 'webp' then + self.config.snapshot_extension = '.webp' + self.config.snapshot_codec = 'libwebp' + else + self.config.snapshot_extension = '.jpg' + self.config.snapshot_codec = 'mjpeg' + end + + -- Animated webp images can only have .webp extension. + -- The user has no choice on this. Same logic for avif. + if self.config.animated_snapshot_format == 'avif' then + self.config.animated_snapshot_extension = '.avif' + self.config.animated_snapshot_codec = 'libaom-av1' + else + self.config.animated_snapshot_extension = '.webp' + self.config.animated_snapshot_codec = 'libwebp' + end +end + +local function ensure_in_range(dimension) + self.config[dimension] = self.config[dimension] < min_side_px and preserve_aspect_ratio or self.config[dimension] + self.config[dimension] = self.config[dimension] > max_side_px and max_side_px or self.config[dimension] +end + +local function conditionally_set_defaults(width, height, quality) + if self.config[width] < 1 and self.config[height] < 1 then + self.config[width] = preserve_aspect_ratio + self.config[height] = default_height_px + end + if self.config[quality] < 0 or self.config[quality] > 100 then + self.config[quality] = 15 + end +end + +local function check_image_settings() + ensure_in_range('snapshot_width') + ensure_in_range('snapshot_height') + conditionally_set_defaults('snapshot_width', 'snapshot_height', 'snapshot_quality') +end + +local function ensure_correct_fps() + if self.config.animated_snapshot_fps == nil or self.config.animated_snapshot_fps <= 0 or self.config.animated_snapshot_fps > 30 then + self.config.animated_snapshot_fps = 10 + end +end + +local function check_animated_snapshot_settings() + ensure_in_range('animated_snapshot_width') + ensure_in_range('animated_snapshot_height') + conditionally_set_defaults('animated_snapshot_width', 'animated_snapshot_height', 'animated_snapshot_quality') + ensure_correct_fps() +end + +local function validate_config() + set_audio_format() + set_video_format() + check_image_settings() + check_animated_snapshot_settings() +end + +local function remember_initial_config() + if h.is_empty(self.initial_config) then + for key, value in pairs(self.config) do + self.initial_config[key] = value + end + else + msg.fatal("Ignoring. Initial config has been read already.") + end +end + +local function restore_initial_config() + for key, value in pairs(self.initial_config) do + self.config[key] = value + end +end + +local function read_profile_list() + mpopt.read_options(self.profiles, profiles_filename) + msg.info("Read profile list. Defined profiles: " .. self.profiles.profiles) +end + +local function read_profile(profile_name) + mpopt.read_options(self.config, profile_name) + msg.info("Read config file: " .. profile_name) +end + +local function read_default_config() + read_profile(default_profile_filename) +end + +local function reload_from_disk() + --- Loads default config file (subs2srs.conf), then overwrites it with current profile. + if not h.is_empty(self.config) and not h.is_empty(self.profiles) then + restore_initial_config() + read_default_config() + if self.profiles.active ~= default_profile_filename then + read_profile(self.profiles.active) + end + validate_config() + else + msg.fatal("Attempt to load config when init hasn't been done.") + end +end + +local function next_profile() + local first, next, new + for profile in string.gmatch(self.profiles.profiles, '[^,]+') do + if not first then + first = profile + end + if profile == self.profiles.active then + next = true + elseif next then + next = false + new = profile + end + end + if next == true or not new then + new = first + end + self.profiles.active = new + reload_from_disk() +end + +local function create_config_file() + local name = default_profile_filename + -- ~/.config/mpv/scripts/ and the mpvacious dir + local parent, child = utils.split_path(mp.get_script_directory()) + -- ~/.config/mpv/ and "scripts" + parent, child = utils.split_path(parent:gsub("/$", "")) + -- ~/.config/mpv/script-opts/subs2srs.conf + local config_filepath = utils.join_path(utils.join_path(parent, "script-opts"), string.format('%s.conf', name)) + local example_config_filepath = utils.join_path(mp.get_script_directory(), ".github/RELEASE/subs2srs.conf") + + local file_info = utils.file_info(config_filepath) + if file_info and file_info.is_file then + print("config already exists") + return + end + + local handle = io.open(example_config_filepath, 'r') + if handle == nil then + return + end + + local content = handle:read("*a") + handle:close() + + handle = io.open(config_filepath, 'w') + if handle == nil then + h.notify(string.format("Couldn't open %s.", config_filepath), "error", 4) + return + end + + handle:write(string.format("# Written by %s on %s.\n", name, os.date())) + handle:write(content) + handle:close() + h.notify("Settings saved.", "info", 2) +end + +local function init(config_table, profiles_table) + create_config_file() + self.config, self.profiles = config_table, profiles_table + -- 'subs2srs' is the main profile, it is always loaded. 'active profile' overrides it afterwards. + -- initial state is saved to another table to maintain consistency when cycling through incomplete profiles. + read_profile_list() + read_default_config() + remember_initial_config() + if self.profiles.active ~= default_profile_filename then + read_profile(self.profiles.active) + end + validate_config() +end + +return { + reload_from_disk = reload_from_disk, + init = init, + next_profile = next_profile, + default_height_px = default_height_px, + preserve_aspect_ratio = preserve_aspect_ratio, +} diff --git a/config/mpv/scripts/subs2srsa/encoder/codec_support.lua b/config/mpv/scripts/subs2srsa/encoder/codec_support.lua new file mode 100644 index 0000000..26cd91a --- /dev/null +++ b/config/mpv/scripts/subs2srsa/encoder/codec_support.lua @@ -0,0 +1,40 @@ +--[[ +Copyright: Ajatt-Tools and contributors +License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/gpl.html + +Check what codecs are supported by mpv. +If a desired codec is not supported, set the "use_ffmpeg" config option to "yes". +]] + +local mp = require('mp') +local h = require('helpers') + +local ovc_help = h.subprocess { 'mpv', '--ovc=help' } +local oac_help = h.subprocess { 'mpv', '--oac=help' } + +local function is_audio_supported(codec) + return oac_help.status == 0 and oac_help.stdout:find('--oac=' .. codec, 1, true) ~= nil +end + +local function is_image_supported(codec) + return ovc_help.status == 0 and ovc_help.stdout:find('--ovc=' .. codec, 1, true) ~= nil +end + +local inspection_result = { + snapshot = { + ['libaom-av1'] = is_image_supported('libaom-av1'), + libwebp = is_image_supported('libwebp'), + mjpeg = is_image_supported('mjpeg'), + }, + audio = { + libmp3lame = is_audio_supported('libmp3lame'), + libopus = is_audio_supported('libopus'), + }, +} +for type, codecs in pairs(inspection_result) do + for codec, supported in pairs(codecs) do + mp.msg.info(string.format("mpv supports %s codec %s: %s", type, codec, tostring(supported))) + end +end + +return inspection_result diff --git a/config/mpv/scripts/subs2srsa/encoder/encoder.lua b/config/mpv/scripts/subs2srsa/encoder/encoder.lua new file mode 100644 index 0000000..b05c3db --- /dev/null +++ b/config/mpv/scripts/subs2srsa/encoder/encoder.lua @@ -0,0 +1,729 @@ +--[[ +Copyright: Ren Tatsumoto and contributors +License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/gpl.html + +Encoder creates audio clips and snapshots, both animated and static. +]] + +local mp = require('mp') +local utils = require('mp.utils') +local h = require('helpers') +local filename_factory = require('utils.filename_factory') +local msg = require('mp.msg') + +--Contains the state of the module +local self = { + snapshot = {}, + audio = {}, + config = nil, + store_fn = nil, + platform = nil, + encoder = nil, + output_dir_path = nil, +} + +------------------------------------------------------------ +-- utility functions + +local function pad_timings(padding, start_time, end_time) + local video_duration = mp.get_property_number('duration') + start_time = start_time - padding + end_time = end_time + padding + + if start_time < 0 then + start_time = 0 + end + + if end_time > video_duration then + end_time = video_duration + end + + return start_time, end_time +end + +local function alt_path_dirs() + return { + '/opt/homebrew/bin', + '/usr/local/bin', + utils.join_path(os.getenv("HOME") or "~", '.local/bin'), + } +end + +local function find_exec(name) + local path, info + for _, alt_dir in pairs(alt_path_dirs()) do + path = utils.join_path(alt_dir, name) + info = utils.file_info(path) + if info and info.is_file then + return path + end + end + return name +end + +local function toms(timestamp) + --- Trim timestamp down to milliseconds. + return string.format("%.3f", timestamp) +end + +local function fit_quality_percentage_to_range(quality, worst_val, best_val) + local scaled = worst_val + (best_val - worst_val) * quality / 100 + -- Round to the nearest integer that's better in quality. + if worst_val > best_val then + return math.floor(scaled) + end + return math.ceil(scaled) +end + +local function quality_to_crf_avif(quality_value) + -- Quality is from 0 to 100. For avif images CRF is from 0 to 63 and reversed. + local worst_avif_crf = 63 + local best_avif_crf = 0 + return fit_quality_percentage_to_range(quality_value, worst_avif_crf, best_avif_crf) +end + +local function quality_to_jpeg_qscale(quality_value) + local worst_jpeg_quality = 31 + local best_jpeg_quality = 2 + return fit_quality_percentage_to_range(quality_value, worst_jpeg_quality, best_jpeg_quality) +end + +------------------------------------------------------------ +-- ffmpeg encoder + +local ffmpeg = {} + +ffmpeg.exec = find_exec("ffmpeg") + +ffmpeg.prepend = function(...) + return { + ffmpeg.exec, "-hide_banner", "-nostdin", "-y", "-loglevel", "quiet", "-sn", + ..., + } +end + +local function make_scale_filter(algorithm, width, height) + -- algorithm is either "sinc" or "lanczos" + -- Static image scaling uses "sinc", which is the best downscaling algorithm: https://stackoverflow.com/a/6171860 + -- Animated images use Lanczos, which is faster. + return string.format( + "scale='min(%d,iw)':'min(%d,ih)':flags=%s+accurate_rnd", + width, height, algorithm + ) +end + +local function static_scale_filter() + return make_scale_filter('sinc', self.config.snapshot_width, self.config.snapshot_height) +end + +local function animated_scale_filter() + return make_scale_filter( + 'lanczos', + self.config.animated_snapshot_width, + self.config.animated_snapshot_height + ) +end + +ffmpeg.make_static_snapshot_args = function(source_path, output_path, timestamp) + local encoder_args + if self.config.snapshot_format == 'avif' then + encoder_args = { + '-c:v', 'libaom-av1', + -- cpu-used < 6 can take a lot of time to encode. + '-cpu-used', '6', + -- Avif quality can be controlled with crf. + '-crf', tostring(quality_to_crf_avif(self.config.snapshot_quality)), + '-still-picture', '1', + } + elseif self.config.snapshot_format == 'webp' then + encoder_args = { + '-c:v', 'libwebp', + '-compression_level', '6', + '-quality', tostring(self.config.snapshot_quality), + } + else + encoder_args = { + '-c:v', 'mjpeg', + '-q:v', tostring(quality_to_jpeg_qscale(self.config.snapshot_quality)), + } + end + + local args = ffmpeg.prepend( + '-an', + '-ss', toms(timestamp), + '-i', source_path, + '-map_metadata', '-1', + '-vf', static_scale_filter(), + '-frames:v', '1', + h.unpack(encoder_args) + ) + table.insert(args, output_path) + return args +end + +ffmpeg.make_animated_snapshot_args = function(source_path, output_path, start_timestamp, end_timestamp) + local encoder_args + if self.config.animated_snapshot_format == 'avif' then + encoder_args = { + '-c:v', 'libaom-av1', + -- cpu-used < 6 can take a lot of time to encode. + '-cpu-used', '6', + -- Avif quality can be controlled with crf. + '-crf', tostring(quality_to_crf_avif(self.config.animated_snapshot_quality)), + } + else + -- Documentation: https://www.ffmpeg.org/ffmpeg-all.html#libwebp + encoder_args = { + '-c:v', 'libwebp', + '-compression_level', '6', + '-quality', tostring(self.config.animated_snapshot_quality), + } + end + + local args = ffmpeg.prepend( + '-an', + '-ss', toms(start_timestamp), + '-to', toms(end_timestamp), + '-i', source_path, + '-map_metadata', '-1', + '-loop', '0', + '-vf', string.format( + 'fps=%d,%s', + self.config.animated_snapshot_fps, + animated_scale_filter() + ), + h.unpack(encoder_args) + ) + table.insert(args, output_path) + return args +end + +local function make_loudnorm_targets() + return string.format( + 'loudnorm=I=%s:LRA=%s:TP=%s:dual_mono=true', + self.config.loudnorm_target, + self.config.loudnorm_range, + self.config.loudnorm_peak + ) +end + +local function parse_loudnorm(loudnorm_targets, json_extractor, loudnorm_consumer) + local function warn() + msg.warn('Failed to measure loudnorm stats, falling back on dynamic loudnorm.') + end + + return function(success, result) + local json + if success and result.status == 0 then + json = json_extractor(result.stdout, result.stderr) + end + + if json == nil then + warn() + loudnorm_consumer(loudnorm_targets) + return + end + + local loudnorm_args = { loudnorm_targets } + local function add_arg(name, val) + -- loudnorm sometimes fails to gather stats for extremely short inputs. + -- Simply omit the stat to fall back on dynamic loudnorm. + if val ~= '-inf' and val ~= 'inf' then + table.insert(loudnorm_args, string.format('%s=%s', name, val)) + else + warn() + end + end + + local stats = utils.parse_json(json) + add_arg('measured_I', stats.input_i) + add_arg('measured_LRA', stats.input_lra) + add_arg('measured_TP', stats.input_tp) + add_arg('measured_thresh', stats.input_thresh) + add_arg('offset', stats.target_offset) + + loudnorm_consumer(table.concat(loudnorm_args, ':')) + end +end + +local function add_filter(filters, filter) + if #filters == 0 then + filters = filter + else + filters = string.format('%s,%s', filters, filter) + end +end + +local function separate_filters(filters, new_args, args) + -- Would've strongly preferred + -- if args[i] == '-af' or args[i] == '-filter:a' then + -- i = i + 1 + -- add_filter(args[i]) + -- but https://lua.org/manual/5.4/manual.html#3.3.5 says that + -- "You should not change the value of the control variable during the loop." + local expect_filter = false + for i = 1, #args do + if args[i] == '-af' or args[i] == '-filter:a' then + expect_filter = true + else + if expect_filter then + add_filter(filters, args[i]) + else + table.insert(new_args, args[i]) + end + expect_filter = false + end + end +end + +ffmpeg.append_user_audio_args = function(args) + local new_args = {} + local filters = '' + + separate_filters(filters, new_args, args) + if self.config.tie_volumes then + add_filter(filters, string.format("volume=%.1f", mp.get_property_native('volume') / 100.0)) + end + + local user_args = {} + for arg in string.gmatch(self.config.ffmpeg_audio_args, "%S+") do + table.insert(user_args, arg) + end + separate_filters(filters, new_args, user_args) + + if #filters > 0 then + table.insert(new_args, '-af') + table.insert(new_args, filters) + end + return new_args +end + +ffmpeg.make_audio_args = function( + source_path, output_path, start_timestamp, end_timestamp, args_consumer +) + local audio_track = h.get_active_track('audio') + local audio_track_id = audio_track['ff-index'] + + if audio_track and audio_track.external == true then + source_path = audio_track['external-filename'] + audio_track_id = 'a' + end + + local function make_ffargs(...) + return ffmpeg.append_user_audio_args( + ffmpeg.prepend( + '-vn', + '-ss', toms(start_timestamp), + '-to', toms(end_timestamp), + '-i', source_path, + '-map_metadata', '-1', + '-map_chapters', '-1', + '-map', string.format("0:%s", tostring(audio_track_id)), + '-ac', '1', + ... + ) + ) + end + + local function make_encoding_args(loudnorm_args) + local encoder_args + if self.config.audio_format == 'opus' then + encoder_args = { + '-c:a', 'libopus', + '-application', 'voip', + '-apply_phase_inv', '0', -- Improves mono audio. + } + if self.config.opus_container == 'm4a' then + table.insert(encoder_args, '-f') + table.insert(encoder_args, 'mp4') + end + else + -- https://wiki.hydrogenaud.io/index.php?title=LAME#Recommended_encoder_settings: + -- "For very low bitrates, up to 100kbps, ABR is most often the best solution." + encoder_args = { + '-c:a', 'libmp3lame', + '-compression_level', '0', + '-abr', '1', + } + end + + encoder_args = { '-b:a', tostring(self.config.audio_bitrate), h.unpack(encoder_args) } + if loudnorm_args then + table.insert(encoder_args, '-af') + table.insert(encoder_args, loudnorm_args) + end + local args = make_ffargs(h.unpack(encoder_args)) + table.insert(args, output_path) + args_consumer(args) + end + + if not self.config.loudnorm then + make_encoding_args(nil) + return + end + + local loudnorm_targets = make_loudnorm_targets() + local args = make_ffargs( + '-loglevel', 'info', + '-af', loudnorm_targets .. ':print_format=json' + ) + table.insert(args, '-f') + table.insert(args, 'null') + table.insert(args, '-') + h.subprocess( + args, + parse_loudnorm( + loudnorm_targets, + function(stdout, stderr) + local start, stop, json = string.find(stderr, '%[Parsed_loudnorm_0.-({.-})') + return json + end, + make_encoding_args + ) + ) +end + +------------------------------------------------------------ +-- mpv encoder + +local mpv = { } + +mpv.exec = find_exec("mpv") + +mpv.prepend_common_args = function(source_path, ...) + return { + mpv.exec, + source_path, + '--no-config', + '--loop-file=no', + '--keep-open=no', + '--no-sub', + '--no-ocopy-metadata', + ..., + } +end + +mpv.make_static_snapshot_args = function(source_path, output_path, timestamp) + local encoder_args + if self.config.snapshot_format == 'avif' then + encoder_args = { + '--ovc=libaom-av1', + -- cpu-used < 6 can take a lot of time to encode. + '--ovcopts-add=cpu-used=6', + string.format('--ovcopts-add=crf=%d', quality_to_crf_avif(self.config.snapshot_quality)), + '--ovcopts-add=still-picture=1', + } + elseif self.config.snapshot_format == 'webp' then + encoder_args = { + '--ovc=libwebp', + '--ovcopts-add=compression_level=6', + string.format('--ovcopts-add=quality=%d', self.config.snapshot_quality), + } + else + encoder_args = { + '--ovc=mjpeg', + '--vf-add=scale=out_range=jpeg', + string.format( + '--ovcopts=global_quality=%d*QP2LAMBDA,flags=+qscale', + quality_to_jpeg_qscale(self.config.snapshot_quality) + ), + } + end + + return mpv.prepend_common_args( + source_path, + '--audio=no', + '--frames=1', + '--start=' .. toms(timestamp), + string.format('--vf-add=lavfi=[%s]', static_scale_filter()), + '-o=' .. output_path, + h.unpack(encoder_args) + ) +end + +mpv.make_animated_snapshot_args = function(source_path, output_path, start_timestamp, end_timestamp) + local encoder_args + if self.config.animated_snapshot_format == 'avif' then + encoder_args = { + '--ovc=libaom-av1', + -- cpu-used < 6 can take a lot of time to encode. + '--ovcopts-add=cpu-used=6', + string.format('--ovcopts-add=crf=%d', quality_to_crf_avif(self.config.animated_snapshot_quality)), + } + else + encoder_args = { + '--ovc=libwebp', + '--ovcopts-add=compression_level=6', + string.format('--ovcopts-add=quality=%d', self.config.animated_snapshot_quality), + } + end + + return mpv.prepend_common_args( + source_path, + '--audio=no', + '--start=' .. toms(start_timestamp), + '--end=' .. toms(end_timestamp), + '--ofopts-add=loop=0', + string.format('--vf-add=fps=%d', self.config.animated_snapshot_fps), + string.format('--vf-add=lavfi=[%s]', animated_scale_filter()), + '-o=' .. output_path, + h.unpack(encoder_args) + ) +end + +mpv.make_audio_args = function(source_path, output_path, + start_timestamp, end_timestamp, args_consumer) + local audio_track = h.get_active_track('audio') + local audio_track_id = mp.get_property("aid") + + if audio_track and audio_track.external == true then + source_path = audio_track['external-filename'] + audio_track_id = 'auto' + end + + local function make_mpvargs(...) + local args = mpv.prepend_common_args( + source_path, + '--video=no', + '--aid=' .. audio_track_id, + '--audio-channels=mono', + '--start=' .. toms(start_timestamp), + '--end=' .. toms(end_timestamp), + string.format( + '--volume=%d', + self.config.tie_volumes and mp.get_property('volume') or 100 + ), + ... + ) + for arg in string.gmatch(self.config.mpv_audio_args, "%S+") do + table.insert(args, arg) + end + return args + end + + local function make_encoding_args(loudnorm_args) + local encoder_args + if self.config.audio_format == 'opus' then + encoder_args = { + '--oac=libopus', + '--oacopts-add=application=voip', + '--oacopts-add=apply_phase_inv=0', -- Improves mono audio. + } + if self.config.opus_container == 'm4a' then + table.insert(encoder_args, '--of=mp4') + end + else + -- https://wiki.hydrogenaud.io/index.php?title=LAME#Recommended_encoder_settings: + -- "For very low bitrates, up to 100kbps, ABR is most often the best solution." + encoder_args = { + '--oac=libmp3lame', + '--oacopts-add=compression_level=0', + '--oacopts-add=abr=1', + } + end + + local args = make_mpvargs( + '--oacopts-add=b=' .. self.config.audio_bitrate, + '-o=' .. output_path, + h.unpack(encoder_args) + ) + if loudnorm_args then + table.insert(args, '--af-append=' .. loudnorm_args) + end + args_consumer(args) + end + + if not self.config.loudnorm then + make_encoding_args(nil) + return + end + + local loudnorm_targets = make_loudnorm_targets() + h.subprocess( + make_mpvargs( + '-v', + '--af-append=' .. loudnorm_targets .. ':print_format=json', + '--ao=null', + '--of=null' + ), + parse_loudnorm( + loudnorm_targets, + function(stdout, stderr) + local start, stop, json = string.find(stdout, '%[ffmpeg%] ({.-})') + if json then + json = string.gsub(json, '%[ffmpeg%]', '') + end + return json + end, + make_encoding_args + ) + ) +end + +------------------------------------------------------------ +-- main interface + +local create_animated_snapshot = function(start_timestamp, end_timestamp, source_path, output_path, on_finish_fn) + -- Creates the animated snapshot and then calls on_finish_fn + local args = self.encoder.make_animated_snapshot_args(source_path, output_path, start_timestamp, end_timestamp) + h.subprocess(args, on_finish_fn) +end + +local create_static_snapshot = function(timestamp, source_path, output_path, on_finish_fn) + -- Creates a static snapshot, in other words an image, and then calls on_finish_fn + if not self.config.screenshot then + local args = self.encoder.make_static_snapshot_args(source_path, output_path, timestamp) + h.subprocess(args, on_finish_fn) + else + local args = { 'screenshot-to-file', output_path, 'video', } + mp.command_native_async(args, on_finish_fn) + end + +end + +local report_creation_result = function(file_path) + return function(success, result) + -- result is nil on success for screenshot-to-file. + if success and (result == nil or result.status == 0) and h.file_exists(file_path) then + msg.info(string.format("Created file: %s", file_path)) + return true + else + msg.error(string.format("Couldn't create file: %s", file_path)) + return false + end + end +end + +local create_snapshot = function(start_timestamp, end_timestamp, current_timestamp, filename) + if h.is_empty(self.output_dir_path) then + return msg.error("Output directory wasn't provided. Image file will not be created.") + end + + -- Calls the proper function depending on whether or not the snapshot should be animated + if not h.is_empty(self.config.image_field) then + local source_path = mp.get_property("path") + local output_path = utils.join_path(self.output_dir_path, filename) + + local on_finish = report_creation_result(output_path) + if self.config.animated_snapshot_enabled then + create_animated_snapshot(start_timestamp, end_timestamp, source_path, output_path, on_finish) + else + create_static_snapshot(current_timestamp, source_path, output_path, on_finish) + end + else + print("Snapshot will not be created.") + end +end + +local background_play = function(file_path, on_finish) + return h.subprocess( + { mpv.exec, '--audio-display=no', '--force-window=no', '--keep-open=no', '--really-quiet', file_path }, + on_finish + ) +end + +local create_audio = function(start_timestamp, end_timestamp, filename, padding) + if h.is_empty(self.output_dir_path) then + return msg.error("Output directory wasn't provided. Audio file will not be created.") + end + + if not h.is_empty(self.config.audio_field) then + local source_path = mp.get_property("path") + local output_path = utils.join_path(self.output_dir_path, filename) + + if padding > 0 then + start_timestamp, end_timestamp = pad_timings(padding, start_timestamp, end_timestamp) + end + + local function start_encoding(args) + local on_finish = function(success, result) + local conversion_check = report_creation_result(output_path) + if conversion_check(success, result) and self.config.preview_audio then + background_play(output_path, function() + print("Played file: " .. output_path) + end) + end + end + + h.subprocess(args, on_finish) + end + + self.encoder.make_audio_args( + source_path, output_path, start_timestamp, end_timestamp, start_encoding + ) + else + print("Audio will not be created.") + end +end + +local make_snapshot_filename = function(start_time, end_time, timestamp) + -- Generate a filename for the snapshot, taking care of its extension and whether it's animated or static + if self.config.animated_snapshot_enabled then + return filename_factory.make_filename(start_time, end_time, self.config.animated_snapshot_extension) + else + return filename_factory.make_filename(timestamp, self.config.snapshot_extension) + end +end + +local make_audio_filename = function(start_time, end_time) + -- Generates a filename for the audio + return filename_factory.make_filename(start_time, end_time, self.config.audio_extension) +end + +local toggle_animation = function() + -- Toggles on and off animated snapshot generation at runtime. It is called whenever ctrl+g is pressed + self.config.animated_snapshot_enabled = not self.config.animated_snapshot_enabled + h.notify("Animation " .. (self.config.animated_snapshot_enabled and "enabled" or "disabled"), "info", 2) +end + +local init = function(config) + -- Sets the module to its preconfigured status + self.config = config + self.encoder = config.use_ffmpeg and ffmpeg or mpv +end + +local set_output_dir = function(dir_path) + -- Set directory where media files should be saved. + -- This function is called every time a card is created or updated. + self.output_dir_path = dir_path +end + +local create_job = function(type, sub, audio_padding) + local filename, run_async, current_timestamp + if type == 'snapshot' and h.has_video_track() then + current_timestamp = mp.get_property_number("time-pos", 0) + filename = make_snapshot_filename(sub['start'], sub['end'], current_timestamp) + run_async = function() + create_snapshot(sub['start'], sub['end'], current_timestamp, filename) + end + elseif type == 'audioclip' and h.has_audio_track() then + filename = make_audio_filename(sub['start'], sub['end']) + run_async = function() + create_audio(sub['start'], sub['end'], filename, audio_padding) + end + else + run_async = function() + print(type .. " will not be created.") + end + end + return { + filename = filename, + run_async = run_async, + } +end + +return { + init = init, + set_output_dir = set_output_dir, + snapshot = { + create_job = function(sub) + return create_job('snapshot', sub) + end, + toggle_animation = toggle_animation, + }, + audio = { + create_job = function(sub, padding) + return create_job('audioclip', sub, padding) + end, + }, +} diff --git a/config/mpv/scripts/subs2srsa/find_anki_col.sh b/config/mpv/scripts/subs2srsa/find_anki_col.sh new file mode 100755 index 0000000..a98199b --- /dev/null +++ b/config/mpv/scripts/subs2srsa/find_anki_col.sh @@ -0,0 +1,5 @@ +#!/bin/sh + +# Find full path to an opened Anki collection. + +readlink -f -- /proc/$(pgrep '^anki$')/fd/* | grep 'collection.anki2$' diff --git a/config/mpv/scripts/subs2srsa/helpers.lua b/config/mpv/scripts/subs2srsa/helpers.lua new file mode 100644 index 0000000..e8f911e --- /dev/null +++ b/config/mpv/scripts/subs2srsa/helpers.lua @@ -0,0 +1,280 @@ +--[[ +Copyright: Ren Tatsumoto and contributors +License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/gpl.html + +Various helper functions. +]] + +local mp = require('mp') +local msg = require('mp.msg') +local utils = require('mp.utils') +local this = {} + +this.unpack = unpack and unpack or table.unpack + +this.remove_all_spaces = function(str) + return str:gsub('%s*', '') +end + +this.table_get = function(table, key, default) + if table[key] == nil then + return default or 'nil' + else + return table[key] + end +end + +this.max_num = function(table) + local max = table[1] + for _, value in ipairs(table) do + if value > max then + max = value + end + end + return max +end + +this.get_last_n_added_notes = function(note_ids, n) + table.sort(note_ids) + return { this.unpack(note_ids, math.max(#note_ids - n + 1, 1), #note_ids) } +end + +this.contains = function(table, element) + for _, value in pairs(table) do + if value == element then + return true + end + end + return false +end + +this.minutes_ago = function(m) + return (os.time() - 60 * m) * 1000 +end + +this.is_wayland = function() + return os.getenv('WAYLAND_DISPLAY') ~= nil +end + +this.is_win = function() + return mp.get_property('options/vo-mmcss-profile') ~= nil +end + +this.is_mac = function() + return mp.get_property('options/macos-force-dedicated-gpu') ~= nil +end + +local function map(tab, func) + local t = {} + for k, v in pairs(tab) do + t[k] = func(v) + end + return t +end + +local function args_as_str(args) + return table.concat(map(args, function(str) return string.format("'%s'", str) end), " ") +end + +this.subprocess = function(args, completion_fn, override_settings) + -- if `completion_fn` is passed, the command is ran asynchronously, + -- and upon completion, `completion_fn` is called to process the results. + msg.info("Executing: " .. args_as_str(args)) + local command_native = type(completion_fn) == 'function' and mp.command_native_async or mp.command_native + local command_table = { + name = "subprocess", + playback_only = false, + capture_stdout = true, + capture_stderr = true, + args = args + } + if not this.is_empty(override_settings) then + for k,v in pairs(override_settings) do + command_table[k] = v + end + end + return command_native(command_table, completion_fn) +end + +this.subprocess_detached = function(args, completion_fn) + local overwrite_settings = { + detach=true, + capture_stdout = false, + capture_stderr = false, + } + return this.subprocess(args, completion_fn, overwrite_settings) +end + +this.is_empty = function(var) + return var == nil or var == '' or (type(var) == 'table' and next(var) == nil) +end + +this.contains_non_latin_letters = function(str) + return str:match("[^%c%p%s%w—]") +end + +this.capitalize_first_letter = function(string) + return string:gsub("^%l", string.upper) +end + +this.remove_leading_trailing_spaces = function(str) + return str:gsub('^%s*(.-)%s*$', '%1') +end + +this.remove_leading_trailing_dashes = function(str) + return str:gsub('^[%-_]*(.-)[%-_]*$', '%1') +end + +this.remove_text_in_parentheses = function(str) + -- Remove text like (泣き声) or (ドアの開く音) + -- No deletion is performed if there's no text after the parentheses. + -- Note: the modifier `-´ matches zero or more occurrences. + -- However, instead of matching the longest sequence, it matches the shortest one. + return str:gsub('(%b())(.)', '%2'):gsub('((.-))(.)', '%2') +end + +this.remove_newlines = function(str) + return str:gsub('[\n\r]+', ' ') +end + +this.trim = function(str) + str = this.remove_leading_trailing_spaces(str) + str = this.remove_text_in_parentheses(str) + str = this.remove_newlines(str) + return str +end + +this.escape_special_characters = (function() + local entities = { + ['&'] = '&', + ['"'] = '"', + ["'"] = ''', + ['<'] = '<', + ['>'] = '>', + } + return function(s) + return s:gsub('[&"\'<>]', entities) + end +end)() + +this.remove_extension = function(filename) + return filename:gsub('%.%w+$', '') +end + +this.remove_special_characters = function(str) + return str:gsub('[%c%p%s]', ''):gsub(' ', '') +end + +this.remove_text_in_brackets = function(str) + return str:gsub('%b[]', ''):gsub('【.-】', '') +end + +this.remove_filename_text_in_parentheses = function(str) + return str:gsub('%b()', ''):gsub('(.-)', '') +end + +this.remove_common_resolutions = function(str) + -- Also removes empty leftover parentheses and brackets. + return str:gsub("2160p", ""):gsub("1080p", ""):gsub("720p", ""):gsub("576p", ""):gsub("480p", ""):gsub("%(%)", ""):gsub("%[%]", "") +end + +this.human_readable_time = function(seconds) + if type(seconds) ~= 'number' or seconds < 0 then + return 'empty' + end + + local parts = { + h = math.floor(seconds / 3600), + m = math.floor(seconds / 60) % 60, + s = math.floor(seconds % 60), + ms = math.floor((seconds * 1000) % 1000), + } + + local ret = string.format("%02dm%02ds%03dms", parts.m, parts.s, parts.ms) + + if parts.h > 0 then + ret = string.format('%dh%s', parts.h, ret) + end + + return ret +end + +this.get_episode_number = function(filename) + -- Reverses the filename to start the search from the end as the media title might contain similar numbers. + local filename_reversed = filename:reverse() + + local ep_num_patterns = { + "[%s_](%d?%d?%d)[pP]?[eE]", -- Starting with E or EP (case-insensitive). "Example Series S01E01 [94Z295D1]" + "^(%d?%d?%d)[pP]?[eE]", -- Starting with E or EP (case-insensitive) at the end of filename. "Example Series S01E01" + "%)(%d?%d?%d)%(", -- Surrounded by parentheses. "Example Series (12)" + "%](%d?%d?%d)%[", -- Surrounded by brackets. "Example Series [01]" + "%s(%d?%d?%d)%s", -- Surrounded by whitespace. "Example Series 124 [1080p 10-bit]" + "_(%d?%d?%d)_", -- Surrounded by underscores. "Example_Series_04_1080p" + "^(%d?%d?%d)[%s_]", -- Ending to the episode number. "Example Series 124" + "(%d?%d?%d)%-edosipE", -- Prepended by "Episode-". "Example Episode-165" + } + + local s, e, episode_num + for _, pattern in pairs(ep_num_patterns) do + s, e, episode_num = string.find(filename_reversed, pattern) + if not this.is_empty(episode_num) then + return #filename - e, #filename - s, episode_num:reverse() + end + end +end + +this.notify = function(message, level, duration) + level = level or 'info' + duration = duration or 1 + msg[level](message) + mp.osd_message(message, duration) +end + +this.get_active_track = function(track_type) + -- track_type == audio|sub + for _, track in pairs(mp.get_property_native('track-list')) do + if track.type == track_type and track.selected == true then + return track + end + end + return nil +end + +this.has_video_track = function() + return mp.get_property_native('vid') ~= false +end + +this.has_audio_track = function() + return mp.get_property_native('aid') ~= false +end + +this.str_contains = function(s, pattern) + return not this.is_empty(s) and string.find(string.lower(s), string.lower(pattern)) ~= nil +end + +this.filter = function(arr, func) + local filtered = {} + for _, elem in ipairs(arr) do + if func(elem) == true then + table.insert(filtered, elem) + end + end + return filtered +end + +this.file_exists = function(filepath) + if not this.is_empty(filepath) then + local info = utils.file_info(filepath) + if info and info.is_file and info.size > 0 then + return true + end + end + return false +end + +this.get_loaded_tracks = function(track_type) + --- Return all sub tracks, audio tracks, etc. + return this.filter(mp.get_property_native('track-list'), function(track) return track.type == track_type end) +end + +return this diff --git a/config/mpv/scripts/subs2srsa/howto/add_dialog.md b/config/mpv/scripts/subs2srsa/howto/add_dialog.md new file mode 100644 index 0000000..22093c6 --- /dev/null +++ b/config/mpv/scripts/subs2srsa/howto/add_dialog.md @@ -0,0 +1,9 @@ +# Open the "Add" dialog + +1) Open a video in `mpv`. +1) Press <kbd>a</kbd> to open advanced menu. +1) Optionally, press <kbd>c</kbd> and select the desired subtitle lines with the interactive selection. +1) Press <kbd>g</kbd> to open the Add dialog in Anki. +1) Add dictionary definitions using software like GoldenDict, Qolibri, etc. + +After the card is created, you can find it by typing `added:1` in the Anki Browser. diff --git a/config/mpv/scripts/subs2srsa/howto/create_card.md b/config/mpv/scripts/subs2srsa/howto/create_card.md new file mode 100644 index 0000000..e92f77d --- /dev/null +++ b/config/mpv/scripts/subs2srsa/howto/create_card.md @@ -0,0 +1,14 @@ +# Make a simple sentence card + +To make a card from the currently visible subtitle line, press <kbd>Ctrl+n</kbd>. + +To make a card from two or more subtitle lines: + +1) Press <kbd>a</kbd> to open advanced menu. +2) Press <kbd>c</kbd> to start interactive selection. +3) Seek to the previous/next subtitle with <kbd>Shift+h</kbd> and <kbd>Shift+l</kbd>. +4) Press <kbd>n</kbd> to create a new card. + +After the card is created, you can find it by typing `added:1` in the Anki Browser. +The card doesn't contain dictionary definitions. +You need to add them yourself afterward, using software like GoldenDict, Qolibri, etc. diff --git a/config/mpv/scripts/subs2srsa/howto/create_quick_card.md b/config/mpv/scripts/subs2srsa/howto/create_quick_card.md new file mode 100644 index 0000000..619794a --- /dev/null +++ b/config/mpv/scripts/subs2srsa/howto/create_quick_card.md @@ -0,0 +1,31 @@ +# Quick Card Creation + +The goal of the quick card creation menu is to streamline a specific flow for media with **well-timed subtitles**: + +1) Add a new note to Anki via + [Rikaitan](https://tatsumoto.neocities.org/blog/setting-up-yomichan.html) + or any other tool that works with AnkiConnect. +2) Update the note via mpvacious. + +To update the most recently added card from the currently visible subtitle line, press <kbd>gg</kbd>. + +To make a card from two or more subtitle lines: + +1) Press <kbd>g</kbd> to open the quick card creation menu. +2) Press any number <kbd>[2-9]</kbd>. This number corresponds to the number of lines to create the card from. + +Note: <kbd>g1</kbd> is also valid. +However, <kbd>gg</kbd> is an additional bind to further streamline the most common scenario. + +For example, +<kbd>g2</kbd> creates a card using 2 subtitle lines. + +Like the advanced menu, you can also update multiple cards: + +1) Press <kbd>Alt+g</kbd> to select the number of cards for quick card creation. +2) Press any number <kbd>[2-9]</kbd>. This number corresponds to the number of cards to update. +3) Press any number <kbd>[1-9]</kbd> again. This is the number of lines. + +Note: upon completing the note update, the selected number of cards resets back to the default of one. + +For example, <kbd>(Alt+g)22</kbd> would update the last 2 notes using 2 subtitle lines. diff --git a/config/mpv/scripts/subs2srsa/howto/flatpak.md b/config/mpv/scripts/subs2srsa/howto/flatpak.md new file mode 100644 index 0000000..46a5787 --- /dev/null +++ b/config/mpv/scripts/subs2srsa/howto/flatpak.md @@ -0,0 +1,27 @@ +# Flatpak notes + +We think it's best to never use Flatpak. +Specifically, try not to use Flatpak to install `mpv` and `anki`. +Install packages from the official repositories of your distro or from the AUR. + +Read the following notes if you still decide to use Flatpak. + +Make these changes in Flatseal: + +* Enable "Filesystem > All system files" + so it could see `wl-copy`. + Unfortunately, there's no option to provide only a specific system file. +* Add `~/.var/app/net.ankiweb.Anki` to "Filesystem > Other Files" + so mpvacious could add encoded snapshots and audio to Anki. +* Add `PATH=/home/USERNAME/.local/bin:/home/USERNAME/bin:/app/bin:/usr/bin:/run/host/usr/bin` to "Environment > Variables". + There's no option to add a path to `PATH` in Flatseal, + so I opened container, + saved it's PATH and added `/run/host/usr/bin` + so mpvacuous could access `wl-copy`. +* Enable "Shared > Network". + It's enabled by default, but anyway. + +The mpv config root is `~/.var/app/io.mpv.Mpv/config/mpv` + +* `~/.var/app/io.mpv.Mpv/config/mpv/scripts` +* `~/.var/app/io.mpv.Mpv/config/mpv/script-opts` diff --git a/config/mpv/scripts/subs2srsa/howto/goldendict.md b/config/mpv/scripts/subs2srsa/howto/goldendict.md new file mode 100644 index 0000000..7f355c6 --- /dev/null +++ b/config/mpv/scripts/subs2srsa/howto/goldendict.md @@ -0,0 +1,45 @@ +# Modifying cards added with GoldenDict + +You can add a card first using GoldenDict, +and then append an audio clip and a picture to it. + +**Note:** the only version of GoldenDict that can create Anki cards with configurable fields is +[xiaoyifang's goldendict](https://github.com/xiaoyifang/goldendict-ng). +Read [Setting up GoldenDict](https://tatsumoto-ren.github.io/blog/setting-up-goldendict.html) and +[How to connect with Anki](https://github.com/xiaoyifang/goldendict-ng/blob/staged/website/docs/topic_anki.md) +if you are new to GoldenDict. + +To send subtitles from `mpv` directly to GoldenDict, +append the following line to `subs2srs.conf`: + +``` +autoclip_method=goldendict +``` + +**Note:** If `goldendict` is not in the PATH, +you have to [add it to the PATH](https://wiki.archlinux.org/title/Environment_variables#Per_user). + +1) Press <kbd>a</kbd> to open `advanced menu`. +2) Press <kbd>t</kbd> to toggle the `autoclip` option. + +Now as subtitles appear on the screen, +they will be immediately sent to GoldenDict instead of the system clipboard. + +1) Open GoldenDict. +2) Play a video in `mpv`. +3) When you find an unknown word, + select the definition text, + right-click and select "send word to anki" to make a card, + or press <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>N</kbd>. +4) Go back to mpv and add an image and an audio clip + to the card you've just made by pressing <kbd>m</kbd> while the `advanced menu` is open. + Pressing <kbd>Shift+m</kbd> will overwrite any existing data in media fields. + +https://github.com/Ajatt-Tools/mpvacious/assets/69171671/0fc02d24-d320-4d2c-b7a9-cb478e9f0067 + +Don't forget to set the right timings and join lines together +if the sentence is split between multiple subs. +To do it, enter interactive selection by pressing <kbd>c</kbd> +and seek to the next or previous subtitle. + +To pair Mecab and GoldenDict, install [gd-tools](https://github.com/Ajatt-Tools/gd-tools). diff --git a/config/mpv/scripts/subs2srsa/howto/yomichan.md b/config/mpv/scripts/subs2srsa/howto/yomichan.md new file mode 100644 index 0000000..9ea16d6 --- /dev/null +++ b/config/mpv/scripts/subs2srsa/howto/yomichan.md @@ -0,0 +1,24 @@ +# Modifying cards added with Rikaitan + +You can add a card first using +[Rikaitan](https://tatsumoto.neocities.org/blog/setting-up-yomichan.html), +and then append an audio clip and a picture to it. + +1) Press <kbd>a</kbd> to open `advanced menu`. +1) Press <kbd>t</kbd> to toggle the `autoclip` option. + +Now as subtitles appear on the screen, they will be immediately copied to the clipboard. +You can use it in combination with clipboard monitor. + +1) Open [Rikaitan Search](https://tatsumoto.neocities.org/blog/what-is-yomichan-search) + by pressing <kbd>Alt+Insert</kbd> in your web browser. +1) Play a video in `mpv`. +1) When you find an unknown word, click the <kbd>+</kbd> button to make a card for it. +4) Go back to mpv and add an image and an audio clip + to the card you've just made by pressing <kbd>m</kbd> while the `advanced menu` is open. + Pressing <kbd>Shift+m</kbd> will overwrite any existing data in media fields. + +Don't forget to set the right timings and join lines together +if the sentence is split between multiple subs. +To do it, enter interactive selection by pressing <kbd>c</kbd> +and seek to the next or previous subtitle. diff --git a/config/mpv/scripts/subs2srsa/main.lua b/config/mpv/scripts/subs2srsa/main.lua new file mode 100644 index 0000000..6817586 --- /dev/null +++ b/config/mpv/scripts/subs2srsa/main.lua @@ -0,0 +1 @@ +require('subs2srs') diff --git a/config/mpv/scripts/subs2srsa/menu.lua b/config/mpv/scripts/subs2srsa/menu.lua new file mode 100644 index 0000000..4d34937 --- /dev/null +++ b/config/mpv/scripts/subs2srsa/menu.lua @@ -0,0 +1,77 @@ +--[[ +Copyright: Ren Tatsumoto and contributors +License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/gpl.html + +Menu for mpvacious +]] + +local mp = require('mp') +local msg = require('mp.msg') +local h = require('helpers') + +local Menu = { + active = false, + keybindings = {}, + overlay = mp.create_osd_overlay and mp.create_osd_overlay('ass-events'), +} + +function Menu:new(o) + o = o or {} + setmetatable(o, self) + self.__index = self + return o +end + +function Menu:with_update(params) + return function() + local status, error = pcall(h.unpack(params)) + if not status then + msg['error'](error) + end + self:update() + end +end + +function Menu:make_osd() + return nil +end + +function Menu:update() + if self.active == false then return end + self.overlay.data = self:make_osd():get_text() + self.overlay:update() +end + +function Menu:open() + if self.overlay == nil then + h.notify("OSD overlay is not supported in " .. mp.get_property("mpv-version"), "error", 5) + return + end + + if self.active == true then + self:close() + return + end + + for _, val in pairs(self.keybindings) do + mp.add_forced_key_binding(val.key, val.key, val.fn) + end + + self.active = true + self:update() +end + +function Menu:close() + if self.active == false then + return + end + + for _, val in pairs(self.keybindings) do + mp.remove_key_binding(val.key) + end + + self.overlay:remove() + self.active = false +end + +return Menu diff --git a/config/mpv/scripts/subs2srsa/osd_styler.lua b/config/mpv/scripts/subs2srsa/osd_styler.lua new file mode 100644 index 0000000..3a865dc --- /dev/null +++ b/config/mpv/scripts/subs2srsa/osd_styler.lua @@ -0,0 +1,97 @@ +--[[ +A helper class for styling OSD messages +http://docs.aegisub.org/3.2/ASS_Tags/ + +Copyright (C) 2021 Ren Tatsumoto + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see <https://www.gnu.org/licenses/>. +]] + +local OSD = {} +OSD.__index = OSD + +function OSD:new() + return setmetatable({ messages = {} }, self) +end + +function OSD:append(s) + table.insert(self.messages, tostring(s)) + return self +end + +function OSD:newline() + return self:append([[\N]]) +end + +function OSD:tab() + return self:append([[\h\h\h\h]]) +end + +function OSD:size(size) + return self:append('{\\fs'):append(size):append('}') +end + +function OSD:font(name) + return self:append('{\\fn'):append(name):append('}') +end + +function OSD:align(number) + return self:append('{\\an'):append(number):append('}') +end + +function OSD:get_text() + return table.concat(self.messages) +end + +function OSD:color(code) + return self:append('{\\1c&H') + :append(code:sub(5, 6)) + :append(code:sub(3, 4)) + :append(code:sub(1, 2)) + :append('&}') +end + +function OSD:text(text) + return self:append(text) +end + +function OSD:new_layer() + return self:append('\n') +end + +function OSD:bold(s) + return self:append('{\\b1}'):append(s):append('{\\b0}') +end + +function OSD:italics(s) + return self:append('{\\i1}'):append(s):append('{\\i0}') +end + +function OSD:submenu(text) + return self:color('ffe1d0'):bold(text):color('ffffff') +end + +function OSD:item(text) + return self:color('fef6dd'):bold(text):color('ffffff') +end + +function OSD:selected(text) + return self:color('48a868'):bold(text):color('ffffff') +end + +function OSD:red(text) + return self:color('ff0000'):bold(text):color('ffffff') +end + +return OSD diff --git a/config/mpv/scripts/subs2srsa/platform/init.lua b/config/mpv/scripts/subs2srsa/platform/init.lua new file mode 100644 index 0000000..825d8d4 --- /dev/null +++ b/config/mpv/scripts/subs2srsa/platform/init.lua @@ -0,0 +1,14 @@ +--[[ +Copyright: Ren Tatsumoto and contributors +License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/gpl.html + +Platform-specific functions. +]] + +local h = require('helpers') + +if h.is_win() then + return require('platform.win') +else + return require('platform.nix') +end diff --git a/config/mpv/scripts/subs2srsa/platform/nix.lua b/config/mpv/scripts/subs2srsa/platform/nix.lua new file mode 100644 index 0000000..cbf6c85 --- /dev/null +++ b/config/mpv/scripts/subs2srsa/platform/nix.lua @@ -0,0 +1,49 @@ +--[[ +Copyright: Ren Tatsumoto and contributors +License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/gpl.html + +Platform-specific functions for *nix systems. +]] + +local h = require('helpers') +local self = { healthy = true, clip_util = "", clip_cmd = "", } + +if h.is_mac() then + self.clip_util = "pbcopy" + self.clip_cmd = "LANG=en_US.UTF-8 " .. self.clip_util +elseif h.is_wayland() then + local function is_wl_copy_installed() + local handle = h.subprocess { 'wl-copy', '--version' } + return handle.status == 0 and handle.stdout:match("wl%-clipboard") ~= nil + end + + self.clip_util = "wl-copy" + self.clip_cmd = self.clip_util + self.healthy = is_wl_copy_installed() +else + local function is_xclip_installed() + local handle = h.subprocess { 'xclip', '-version' } + return handle.status == 0 and handle.stderr:match("xclip version") ~= nil + end + + self.clip_util = "xclip" + self.clip_cmd = self.clip_util .. " -i -selection clipboard" + self.healthy = is_xclip_installed() +end + +self.tmp_dir = function() + return os.getenv("TMPDIR") or '/tmp' +end + +self.copy_to_clipboard = function(text) + local handle = io.popen(self.clip_cmd, 'w') + handle:write(text) + handle:close() +end + +self.curl_request = function(url, request_json, completion_fn) + local args = { 'curl', '-s', url, '-X', 'POST', '-d', request_json } + return h.subprocess(args, completion_fn) +end + +return self diff --git a/config/mpv/scripts/subs2srsa/platform/win.lua b/config/mpv/scripts/subs2srsa/platform/win.lua new file mode 100644 index 0000000..e40dd7a --- /dev/null +++ b/config/mpv/scripts/subs2srsa/platform/win.lua @@ -0,0 +1,72 @@ +--[[ +Copyright: Ren Tatsumoto and contributors +License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/gpl.html + +Platform-specific functions for Windows. +]] + +local mp = require('mp') +local h = require('helpers') +local utils = require('mp.utils') +local base64 = require('utils.base64') +local self = { windows = true, healthy = true, clip_util = "cmd", } +local tmp_files = {} + +mp.register_event('shutdown', function() + for _, file in ipairs(tmp_files) do + os.remove(file) + end +end) + +self.tmp_dir = function() + return os.getenv('TEMP') +end + +self.copy_to_clipboard = function(text) + local args = { + "powershell", "-NoLogo", "-NoProfile", "-WindowStyle", "Hidden", "-Command", + string.format( + "Set-Clipboard ([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('%s')))", + base64.enc(text) + ) + } + return h.subprocess_detached( + args, + function() + end + ) +end + +self.gen_random_tmp_file_path = function() + return utils.join_path(self.tmp_dir(), string.format('curl_tmp_%d.txt', math.random(10 ^ 9))) +end + +self.gen_unique_tmp_file_path = function() + local curl_tmpfile_path = self.gen_random_tmp_file_path() + while h.file_exists(curl_tmpfile_path) do + curl_tmpfile_path = self.gen_random_tmp_file_path() + end + return curl_tmpfile_path +end + +self.curl_request = function(url, request_json, completion_fn) + local curl_tmpfile_path = self.gen_unique_tmp_file_path() + local handle = io.open(curl_tmpfile_path, "w") + handle:write(request_json) + handle:close() + table.insert(tmp_files, curl_tmpfile_path) + local args = { + 'curl', + '-s', + url, + '-H', + 'Content-Type: application/json; charset=UTF-8', + '-X', + 'POST', + '--data-binary', + table.concat { '@', curl_tmpfile_path } + } + return h.subprocess(args, completion_fn) +end + +return self diff --git a/config/mpv/scripts/subs2srsa/subs2srs.lua b/config/mpv/scripts/subs2srsa/subs2srs.lua new file mode 100644 index 0000000..34c79a9 --- /dev/null +++ b/config/mpv/scripts/subs2srsa/subs2srs.lua @@ -0,0 +1,746 @@ +--[[ +Copyright (C) 2020-2022 Ren Tatsumoto and contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see <https://www.gnu.org/licenses/>. + +Requirements: +* mpv >= 0.32.0 +* AnkiConnect +* curl +* xclip (when running X11) +* wl-copy (when running Wayland) + +Usage: +1. Change `config` according to your needs +* Config path: ~/.config/mpv/script-opts/subs2srs.conf +* Config file isn't created automatically. + +2. Open a video + +3. Use key bindings to manipulate the script +* Open mpvacious menu - `a` +* Create a note from the current subtitle line - `Ctrl + n` + +For complete usage guide, see <https://github.com/Ajatt-Tools/mpvacious/blob/master/README.md> +]] + +local mp = require('mp') +local OSD = require('osd_styler') +local cfg_mgr = require('cfg_mgr') +local encoder = require('encoder.encoder') +local h = require('helpers') +local Menu = require('menu') +local ankiconnect = require('ankiconnect') +local switch = require('utils.switch') +local play_control = require('utils.play_control') +local secondary_sid = require('subtitles.secondary_sid') +local platform = require('platform.init') +local forvo = require('utils.forvo') +local subs_observer = require('subtitles.observer') +local codec_support = require('encoder.codec_support') + +local menu, quick_menu, quick_menu_card +local quick_creation_opts = { + _n_lines = nil, + _n_cards = 1, + set_cards = function(self, n) + self._n_cards = math.max(0, n) + end, + set_lines = function(self, n) + self._n_lines = math.max(0, n) + end, + get_cards = function(self) + return self._n_cards + end, + get_lines = function(self) + return self._n_lines + end, + increment_cards = function(self) + self:set_cards(self._n_cards + 1) + end, + decrement_cards = function(self) + self:set_cards(self._n_cards - 1) + end, + clear_options = function(self) + self._n_lines = nil + self._n_cards = 1 + end +} +------------------------------------------------------------ +-- default config + +local config = { + -- The user should not modify anything below. + + -- Common + nuke_spaces = false, -- remove all spaces from the primary subtitles on exported anki cards and when copying text to clipboard. + clipboard_trim_enabled = true, -- remove unnecessary characters from strings before copying to the clipboard + use_ffmpeg = false, -- if set to true, use ffmpeg to create audio clips and snapshots. by default use mpv. + reload_config_before_card_creation = true, -- for convenience, read config file from disk before a card is made. + + -- Clipboard and external communication + autoclip = false, -- enable copying subs to the clipboard when mpv starts + autoclip_method = "clipboard", -- one of the methods + autoclip_custom_args = "", -- command to run when autoclip is triggered and autoclip_method and set to "custom_command". + + -- Secondary subtitle + secondary_sub_auto_load = true, -- Automatically load secondary subtitle track when a video file is opened. + secondary_sub_lang = 'eng,en,rus,ru,jp,jpn,ja', -- Language of secondary subs that should be automatically loaded. + secondary_sub_area = 0.15, -- Hover area. Fraction of the window from the top. + secondary_sub_visibility = 'auto', -- One of: 'auto', 'never', 'always'. Controls secondary_sid visibility. Ctrl+V to cycle. + + -- Snapshots + snapshot_format = "avif", -- avif, webp or jpg + snapshot_quality = 15, -- from 0=lowest to 100=highest + snapshot_width = cfg_mgr.preserve_aspect_ratio, -- a positive integer or -2 for auto + snapshot_height = cfg_mgr.default_height_px, -- same + screenshot = false, -- create a screenshot instead of a snapshot; see example config. + + -- Animations + animated_snapshot_enabled = false, -- if enabled captures the selected segment of the video, instead of just a frame + animated_snapshot_format = "avif", -- avif or webp + animated_snapshot_fps = 10, -- positive integer between 0 and 30 (30 included) + animated_snapshot_width = cfg_mgr.preserve_aspect_ratio, -- positive integer or -2 to scale it maintaining ratio (height must not be -2 in that case) + animated_snapshot_height = cfg_mgr.default_height_px, -- positive integer or -2 to scale it maintaining ratio (width must not be -2 in that case) + animated_snapshot_quality = 5, -- positive integer between 0 and 100 (100 included) + + -- Audio clips + audio_format = "opus", -- opus or mp3 + opus_container = "ogg", -- ogg, opus, m4a, webm or caf + audio_bitrate = "18k", -- from 16k to 32k + audio_padding = 0.12, -- Set a pad to the dialog timings. 0.5 = audio is padded by .5 seconds. 0 = disable. + tie_volumes = false, -- if set to true, the volume of the outputted audio file depends on the volume of the player at the time of export + preview_audio = false, -- play created audio clips in background. + + -- Menu + menu_font_name = "Noto Sans CJK JP", + menu_font_size = 25, + show_selected_text = true, + + -- Make sure to remove loudnorm from ffmpeg_audio_args and mpv_audio_args before enabling. + loudnorm = false, + loudnorm_target = -16, + loudnorm_range = 11, + loudnorm_peak = -1.5, + + -- Custom encoding args + -- Defaults are for backward compatibility, in case someone + -- updates mpvacious without updating their config. + -- Better to remove loudnorm from custom args and enable two-pass loudnorm. + -- Enabling loudnorm both through the separate switch and through custom args + -- can lead to unpredictable results. + ffmpeg_audio_args = '-af loudnorm=I=-16:TP=-1.5:LRA=11:dual_mono=true', + mpv_audio_args = '--af-append=loudnorm=I=-16:TP=-1.5:LRA=11:dual_mono=true', + + -- Anki + create_deck = false, -- automatically create a deck for new cards + allow_duplicates = false, -- allow making notes with the same sentence field + deck_name = "Learning", -- name of the deck for new cards + model_name = "Japanese sentences", -- Tools -> Manage note types + sentence_field = "SentKanji", + secondary_field = "SentEng", + audio_field = "SentAudio", + audio_template = '[sound:%s]', + image_field = "Image", + image_template = '<img alt="snapshot" src="%s">', + append_media = true, -- True to append video media after existing data, false to insert media before + disable_gui_browse = false, -- Lets you disable anki browser manipulation by mpvacious. + ankiconnect_url = '127.0.0.1:8765', + + -- Note tagging + -- The tag(s) added to new notes. Spaces separate multiple tags. + -- Change to "" to disable tagging completely. + -- The following substitutions are supported: + -- %n - the name of the video + -- %t - timestamp + -- %d - episode number (if none found, returns nothing) + -- %e - SUBS2SRS_TAGS environment variable + note_tag = "subs2srs %n", + tag_nuke_brackets = true, -- delete all text inside brackets before substituting filename into tag + tag_nuke_parentheses = false, -- delete all text inside parentheses before substituting filename into tag + tag_del_episode_num = true, -- delete the episode number if found + tag_del_after_episode_num = true, -- delete everything after the found episode number (does nothing if tag_del_episode_num is disabled) + tag_filename_lowercase = false, -- convert filename to lowercase for tagging. + + -- Misc info + miscinfo_enable = true, + miscinfo_field = "Notes", -- misc notes and source information field + miscinfo_format = "%n EP%d (%t)", -- format string to use for the miscinfo_field, accepts note_tag-style format strings + + -- Forvo support + use_forvo = "yes", -- 'yes', 'no', 'always' + vocab_field = "VocabKanji", -- target word field + vocab_audio_field = "VocabAudio", -- target word audio +} + +-- Defines config profiles +-- Each name references a file in ~/.config/mpv/script-opts/*.conf +-- Profiles themselves are defined in ~/.config/mpv/script-opts/subs2srs_profiles.conf +local profiles = { + profiles = "subs2srs,subs2srs_english", + active = "subs2srs", +} + +------------------------------------------------------------ +-- utility functions +local function _(params) + return function() + return pcall(h.unpack(params)) + end +end + +local function escape_for_osd(str) + str = h.trim(str) + str = str:gsub('[%[%]{}]', '') + return str +end + +local function ensure_deck() + if config.create_deck == true then + ankiconnect.create_deck(config.deck_name) + end +end + +local function load_next_profile() + cfg_mgr.next_profile() + ensure_deck() + h.notify("Loaded profile " .. profiles.active) +end + +local function tag_format(filename) + filename = h.remove_extension(filename) + filename = h.remove_common_resolutions(filename) + + local s, e, episode_num = h.get_episode_number(filename) + + if config.tag_del_episode_num == true and not h.is_empty(s) then + if config.tag_del_after_episode_num == true then + -- Removing everything (e.g. episode name) after the episode number including itself. + filename = filename:sub(1, s) + else + -- Removing the first found instance of the episode number. + filename = filename:sub(1, s) .. filename:sub(e + 1, -1) + end + end + + if config.tag_nuke_brackets == true then + filename = h.remove_text_in_brackets(filename) + end + if config.tag_nuke_parentheses == true then + filename = h.remove_filename_text_in_parentheses(filename) + end + + if config.tag_filename_lowercase == true then + filename = filename:lower() + end + + filename = h.remove_leading_trailing_spaces(filename) + filename = filename:gsub(" ", "_") + filename = filename:gsub("_%-_", "_") -- Replaces garbage _-_ substrings with a underscore + filename = h.remove_leading_trailing_dashes(filename) + return filename, episode_num or '' +end + +local substitute_fmt = (function() + local function substitute_filename(tag, filename) + return tag:gsub("%%n", filename) + end + + local function substitute_episode_number(tag, episode) + return tag:gsub("%%d", episode) + end + + local function substitute_time_pos(tag) + local time_pos = h.human_readable_time(mp.get_property_number('time-pos')) + return tag:gsub("%%t", time_pos) + end + + local function substitute_envvar(tag) + local env_tags = os.getenv('SUBS2SRS_TAGS') or '' + return tag:gsub("%%e", env_tags) + end + + return function(tag) + if not h.is_empty(tag) then + local filename, episode = tag_format(mp.get_property("filename")) + tag = substitute_filename(tag, filename) + tag = substitute_episode_number(tag, episode) + tag = substitute_time_pos(tag) + tag = substitute_envvar(tag) + tag = h.remove_leading_trailing_spaces(tag) + end + return tag + end +end)() + +local function prepare_for_exporting(sub_text) + if not h.is_empty(sub_text) then + sub_text = h.trim(sub_text) + sub_text = h.escape_special_characters(sub_text) + end + return sub_text +end + +local function construct_note_fields(sub_text, secondary_text, snapshot_filename, audio_filename) + local ret = { + [config.sentence_field] = subs_observer.maybe_remove_all_spaces(prepare_for_exporting(sub_text)), + } + if not h.is_empty(config.secondary_field) then + ret[config.secondary_field] = prepare_for_exporting(secondary_text) + end + if not h.is_empty(config.image_field) and not h.is_empty(snapshot_filename) then + ret[config.image_field] = string.format(config.image_template, snapshot_filename) + end + if not h.is_empty(config.audio_field) and not h.is_empty(audio_filename) then + ret[config.audio_field] = string.format(config.audio_template, audio_filename) + end + if config.miscinfo_enable == true then + ret[config.miscinfo_field] = substitute_fmt(config.miscinfo_format) + end + return ret +end + +local function join_media_fields(new_data, stored_data) + for _, field in pairs { config.audio_field, config.image_field, config.miscinfo_field } do + if not h.is_empty(field) then + new_data[field] = h.table_get(stored_data, field, "") .. h.table_get(new_data, field, "") + end + end + return new_data +end + +local function update_sentence(new_data, stored_data) + -- adds support for TSCs + -- https://tatsumoto-ren.github.io/blog/discussing-various-card-templates.html#targeted-sentence-cards-or-mpvacious-cards + -- if the target word was marked by yomichan, this function makes sure that the highlighting doesn't get erased. + + if h.is_empty(stored_data[config.sentence_field]) then + -- sentence field is empty. can't continue. + return new_data + elseif h.is_empty(new_data[config.sentence_field]) then + -- *new* sentence field is empty, but old one contains data. don't delete the existing sentence. + new_data[config.sentence_field] = stored_data[config.sentence_field] + return new_data + end + + local _, opentag, target, closetag, _ = stored_data[config.sentence_field]:match('^(.-)(<[^>]+>)(.-)(</[^>]+>)(.-)$') + if target then + local prefix, _, suffix = new_data[config.sentence_field]:match(table.concat { '^(.-)(', target, ')(.-)$' }) + if prefix and suffix then + new_data[config.sentence_field] = table.concat { prefix, opentag, target, closetag, suffix } + end + end + return new_data +end + +local function audio_padding() + local video_duration = mp.get_property_number('duration') + if config.audio_padding == 0.0 or not video_duration then + return 0.0 + end + if subs_observer.user_altered() then + return 0.0 + end + return config.audio_padding +end + +------------------------------------------------------------ +-- front for adding and updating notes + +local function maybe_reload_config() + if config.reload_config_before_card_creation then + cfg_mgr.reload_from_disk() + end +end + +local function get_anki_media_dir_path() + return ankiconnect.get_media_dir_path() +end + +local function export_to_anki(gui) + maybe_reload_config() + local sub = subs_observer.collect_from_current() + + if not sub:is_valid() then + return h.notify("Nothing to export.", "warn", 1) + end + + if not gui and h.is_empty(sub['text']) then + sub['text'] = string.format("mpvacious wasn't able to grab subtitles (%s)", os.time()) + end + + encoder.set_output_dir(get_anki_media_dir_path()) + local snapshot = encoder.snapshot.create_job(sub) + local audio = encoder.audio.create_job(sub, audio_padding()) + + snapshot.run_async() + audio.run_async() + + local first_field = ankiconnect.get_first_field(config.model_name) + local note_fields = construct_note_fields(sub['text'], sub['secondary'], snapshot.filename, audio.filename) + + if not h.is_empty(first_field) and h.is_empty(note_fields[first_field]) then + note_fields[first_field] = "[empty]" + end + + ankiconnect.add_note(note_fields, substitute_fmt(config.note_tag), gui) + subs_observer.clear() +end + +local function update_last_note(overwrite) + maybe_reload_config() + local sub + local n_lines = quick_creation_opts:get_lines() + local n_cards = quick_creation_opts:get_cards() + if n_lines then + sub = subs_observer.collect_from_all_dialogues(n_lines) + else + sub = subs_observer.collect_from_current() + end + -- this now returns a table + local last_note_ids = ankiconnect.get_last_note_ids(n_cards) + n_cards = #last_note_ids + + if not sub:is_valid() then + return h.notify("Nothing to export. Have you set the timings?", "warn", 2) + end + + if h.is_empty(sub['text']) then + -- In this case, don't modify whatever existing text there is and just + -- modify the other fields we can. The user might be trying to add + -- audio to a card which they've manually transcribed (either the video + -- has no subtitles or it has image subtitles). + sub['text'] = nil + end + + --first element is the earliest + + if h.is_empty(last_note_ids) or last_note_ids[1] < h.minutes_ago(10) then + return h.notify("Couldn't find the target note.", "warn", 2) + end + + local anki_media_dir = get_anki_media_dir_path() + encoder.set_output_dir(anki_media_dir) + local snapshot = encoder.snapshot.create_job(sub) + local audio = encoder.audio.create_job(sub, audio_padding()) + + local create_media = function() + snapshot.run_async() + audio.run_async() + end + for i = 1, n_cards do + local new_data = construct_note_fields(sub['text'], sub['secondary'], snapshot.filename, audio.filename) + local stored_data = ankiconnect.get_note_fields(last_note_ids[i]) + if stored_data then + forvo.set_output_dir(anki_media_dir) + new_data = forvo.append(new_data, stored_data) + new_data = update_sentence(new_data, stored_data) + if not overwrite then + if config.append_media then + new_data = join_media_fields(new_data, stored_data) + else + new_data = join_media_fields(stored_data, new_data) + end + end + end + + -- If the text is still empty, put some dummy text to let the user know why + -- there's no text in the sentence field. + if h.is_empty(new_data[config.sentence_field]) then + new_data[config.sentence_field] = string.format("mpvacious wasn't able to grab subtitles (%s)", os.time()) + end + + ankiconnect.append_media(last_note_ids[i], new_data, create_media, substitute_fmt(config.note_tag)) + end + subs_observer.clear() + quick_creation_opts:clear_options() +end + +------------------------------------------------------------ +-- main menu + +menu = Menu:new { + hints_state = switch.new { 'basic', 'menu', 'global', 'hidden', }, +} + +menu.keybindings = { + { key = 'S', fn = menu:with_update { subs_observer.set_manual_timing_to_sub, 'start' } }, + { key = 'E', fn = menu:with_update { subs_observer.set_manual_timing_to_sub, 'end' } }, + { key = 's', fn = menu:with_update { subs_observer.set_manual_timing, 'start' } }, + { key = 'e', fn = menu:with_update { subs_observer.set_manual_timing, 'end' } }, + { key = 'c', fn = menu:with_update { subs_observer.set_to_current_sub } }, + { key = 'r', fn = menu:with_update { subs_observer.clear_and_notify } }, + { key = 'g', fn = menu:with_update { export_to_anki, true } }, + { key = 'n', fn = menu:with_update { export_to_anki, false } }, + { key = 'm', fn = menu:with_update { update_last_note, false } }, + { key = 'M', fn = menu:with_update { update_last_note, true } }, + { key = 'f', fn = menu:with_update { function() + quick_creation_opts:increment_cards() + end } }, + { key = 'F', fn = menu:with_update { function() + quick_creation_opts:decrement_cards() + end } }, + { key = 't', fn = menu:with_update { subs_observer.toggle_autocopy } }, + { key = 'T', fn = menu:with_update { subs_observer.next_autoclip_method } }, + { key = 'i', fn = menu:with_update { menu.hints_state.bump } }, + { key = 'p', fn = menu:with_update { load_next_profile } }, + { key = 'ESC', fn = function() + menu:close() + end }, + { key = 'q', fn = function() + menu:close() + end }, +} + +function menu:print_header(osd) + if self.hints_state.get() == 'hidden' then + return + end + osd:submenu('mpvacious options'):newline() + osd:item('Timings: '):text(h.human_readable_time(subs_observer.get_timing('start'))) + osd:item(' to '):text(h.human_readable_time(subs_observer.get_timing('end'))):newline() + osd:item('Clipboard autocopy: '):text(subs_observer.autocopy_status_str()):newline() + osd:item('Active profile: '):text(profiles.active):newline() + osd:item('Deck: '):text(config.deck_name):newline() + osd:item('# cards: '):text(quick_creation_opts:get_cards()):newline() +end + +function menu:print_bindings(osd) + if self.hints_state.get() == 'global' then + osd:submenu('Global bindings'):newline() + osd:tab():item('ctrl+c: '):text('Copy current subtitle to clipboard'):newline() + osd:tab():item('ctrl+h: '):text('Seek to the start of the line'):newline() + osd:tab():item('ctrl+g: '):text('Toggle animated snapshots'):newline() + osd:tab():item('ctrl+shift+h: '):text('Replay current subtitle'):newline() + osd:tab():item('shift+h/l: '):text('Seek to the previous/next subtitle'):newline() + osd:tab():item('alt+h/l: '):text('Seek to the previous/next subtitle and pause'):newline() + osd:italics("Press "):item('i'):italics(" to hide mpvacious options."):newline() + elseif self.hints_state.get() == 'menu' then + osd:submenu('Menu bindings'):newline() + osd:tab():item('c: '):text('Set timings to the current sub'):newline() + osd:tab():item('s: '):text('Set start time to current position'):newline() + osd:tab():item('e: '):text('Set end time to current position'):newline() + osd:tab():item('shift+s: '):text('Set start time to current subtitle'):newline() + osd:tab():item('shift+e: '):text('Set end time to current subtitle'):newline() + osd:tab():item('f: '):text('Increment # cards to update '):italics('(+shift to decrement)'):newline() + osd:tab():item('r: '):text('Reset timings'):newline() + osd:tab():item('n: '):text('Export note'):newline() + osd:tab():item('g: '):text('GUI export'):newline() + osd:tab():item('m: '):text('Update the last added note '):italics('(+shift to overwrite)'):newline() + osd:tab():item('t: '):text('Toggle clipboard autocopy'):newline() + osd:tab():item('T: '):text('Switch to the next clipboard method'):newline() + osd:tab():item('p: '):text('Switch to next profile'):newline() + osd:tab():item('ESC: '):text('Close'):newline() + osd:italics("Press "):item('i'):italics(" to show global bindings."):newline() + elseif self.hints_state.get() == 'hidden' then + -- Menu bindings are active but hidden + else + osd:italics("Press "):item('i'):italics(" to show menu bindings."):newline() + end +end + +function menu:warn_formats(osd) + if config.use_ffmpeg then + return + end + for type, codecs in pairs(codec_support) do + for codec, supported in pairs(codecs) do + if not supported and config[type .. '_codec'] == codec then + osd:red('warning: '):newline() + osd:tab():text(string.format("your version of mpv does not support %s.", codec)):newline() + osd:tab():text(string.format("mpvacious won't be able to create %s files.", type)):newline() + end + end + end +end + +function menu:warn_clipboard(osd) + if subs_observer.autocopy_current_method() == "clipboard" and platform.healthy == false then + osd:red('warning: '):text(string.format("%s is not installed.", platform.clip_util)):newline() + end +end + +function menu:print_legend(osd) + osd:new_layer():size(config.menu_font_size):font(config.menu_font_name):align(4) + self:print_header(osd) + self:print_bindings(osd) + self:warn_formats(osd) + self:warn_clipboard(osd) +end + +function menu:print_selection(osd) + if subs_observer.is_appending() and config.show_selected_text then + osd:new_layer():size(config.menu_font_size):font(config.menu_font_name):align(6) + osd:submenu("Primary text"):newline() + for _, s in ipairs(subs_observer.recorded_subs()) do + osd:text(escape_for_osd(s['text'])):newline() + end + if not h.is_empty(config.secondary_field) then + -- If the user wants to add secondary subs to Anki, + -- it's okay to print them on the screen. + osd:submenu("Secondary text"):newline() + for _, s in ipairs(subs_observer.recorded_secondary_subs()) do + osd:text(escape_for_osd(s['text'])):newline() + end + end + end +end + +function menu:make_osd() + local osd = OSD:new() + self:print_legend(osd) + self:print_selection(osd) + return osd +end + +------------------------------------------------------------ +--quick_menu line selection +local choose_cards = function(i) + quick_creation_opts:set_cards(i) + quick_menu_card:close() + quick_menu:open() +end +local choose_lines = function(i) + quick_creation_opts:set_lines(i) + update_last_note(true) + quick_menu:close() +end + +quick_menu = Menu:new() +quick_menu.keybindings = {} +for i = 1, 9 do + table.insert(quick_menu.keybindings, { key = tostring(i), fn = function() + choose_lines(i) + end }) +end +table.insert(quick_menu.keybindings, { key = 'g', fn = function() + choose_lines(1) +end }) +table.insert(quick_menu.keybindings, { key = 'ESC', fn = function() + quick_menu:close() +end }) +table.insert(quick_menu.keybindings, { key = 'q', fn = function() + quick_menu:close() +end }) +function quick_menu:print_header(osd) + osd:submenu('quick card creation: line selection'):newline() + osd:item('# lines: '):text('Enter 1-9'):newline() +end +function quick_menu:print_legend(osd) + osd:new_layer():size(config.menu_font_size):font(config.menu_font_name):align(4) + self:print_header(osd) + menu:warn_formats(osd) +end +function quick_menu:make_osd() + local osd = OSD:new() + self:print_legend(osd) + return osd +end + +-- quick_menu card selection +quick_menu_card = Menu:new() +quick_menu_card.keybindings = {} +for i = 1, 9 do + table.insert(quick_menu_card.keybindings, { key = tostring(i), fn = function() + choose_cards(i) + end }) +end +table.insert(quick_menu_card.keybindings, { key = 'ESC', fn = function() + quick_menu_card:close() +end }) +table.insert(quick_menu_card.keybindings, { key = 'q', fn = function() + quick_menu_card:close() +end }) +function quick_menu_card:print_header(osd) + osd:submenu('quick card creation: card selection'):newline() + osd:item('# cards: '):text('Enter 1-9'):newline() +end +function quick_menu_card:print_legend(osd) + osd:new_layer():size(config.menu_font_size):font(config.menu_font_name):align(4) + self:print_header(osd) + menu:warn_formats(osd) +end +function quick_menu_card:make_osd() + local osd = OSD:new() + self:print_legend(osd) + return osd +end + +------------------------------------------------------------ +-- main + +local main = (function() + local main_executed = false + return function() + if main_executed then + subs_observer.clear_all_dialogs() + return + else + main_executed = true + end + + cfg_mgr.init(config, profiles) + ankiconnect.init(config, platform) + forvo.init(config, platform) + encoder.init(config) + secondary_sid.init(config) + ensure_deck() + subs_observer.init(menu, config) + + -- Key bindings + mp.add_forced_key_binding("Ctrl+c", "mpvacious-copy-sub-to-clipboard", subs_observer.copy_current_primary_to_clipboard) + mp.add_key_binding("Ctrl+C", "mpvacious-copy-secondary-sub-to-clipboard", subs_observer.copy_current_secondary_to_clipboard) + mp.add_key_binding("Ctrl+t", "mpvacious-autocopy-toggle", subs_observer.toggle_autocopy) + mp.add_key_binding("Ctrl+g", "mpvacious-animated-snapshot-toggle", encoder.snapshot.toggle_animation) + + -- Secondary subtitles + mp.add_key_binding("Ctrl+v", "mpvacious-secondary-sid-toggle", secondary_sid.change_visibility) + mp.add_key_binding("Ctrl+k", "mpvacious-secondary-sid-prev", secondary_sid.select_previous) + mp.add_key_binding("Ctrl+j", "mpvacious-secondary-sid-next", secondary_sid.select_next) + + -- Open advanced menu + mp.add_key_binding("a", "mpvacious-menu-open", function() + menu:open() + end) + + -- Add note + mp.add_forced_key_binding("Ctrl+n", "mpvacious-export-note", menu:with_update { export_to_anki, false }) + + -- Note updating + mp.add_key_binding("Ctrl+m", "mpvacious-update-last-note", menu:with_update { update_last_note, false }) + mp.add_key_binding("Ctrl+M", "mpvacious-overwrite-last-note", menu:with_update { update_last_note, true }) + + mp.add_key_binding("g", "mpvacious-quick-card-menu-open", function() + quick_menu:open() + end) + mp.add_key_binding("Alt+g", "mpvacious-quick-card-sel-menu-open", function() + quick_menu_card:open() + end) + + -- Vim-like seeking between subtitle lines + mp.add_key_binding("H", "mpvacious-sub-seek-back", _ { play_control.sub_seek, 'backward' }) + mp.add_key_binding("L", "mpvacious-sub-seek-forward", _ { play_control.sub_seek, 'forward' }) + + mp.add_key_binding("Alt+h", "mpvacious-sub-seek-back-pause", _ { play_control.sub_seek, 'backward', true }) + mp.add_key_binding("Alt+l", "mpvacious-sub-seek-forward-pause", _ { play_control.sub_seek, 'forward', true }) + + mp.add_key_binding("Ctrl+h", "mpvacious-sub-rewind", _ { play_control.sub_rewind }) + mp.add_key_binding("Ctrl+H", "mpvacious-sub-replay", _ { play_control.play_till_sub_end }) + mp.add_key_binding("Ctrl+L", "mpvacious-sub-play-up-to-next", _ { play_control.play_till_next_sub_end }) + + mp.msg.warn("Press 'a' to open the mpvacious menu.") + end +end)() + +mp.register_event("file-loaded", main) diff --git a/config/mpv/scripts/subs2srsa/subtitles/observer.lua b/config/mpv/scripts/subs2srsa/subtitles/observer.lua new file mode 100644 index 0000000..d9284c5 --- /dev/null +++ b/config/mpv/scripts/subs2srsa/subtitles/observer.lua @@ -0,0 +1,344 @@ +--[[ +Copyright: Ren Tatsumoto and contributors +License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/gpl.html + +Observer waits for subtitles to appear on the screen and adds them to a list. +]] + +local h = require('helpers') +local timings = require('utils.timings') +local sub_list = require('subtitles.sub_list') +local Subtitle = require('subtitles.subtitle') +local mp = require('mp') +local platform = require('platform.init') +local switch = require('utils.switch') + +local self = {} + +local dialogs = sub_list.new() +local secondary_dialogs = sub_list.new() +local all_dialogs = sub_list.new() +local all_secondary_dialogs = sub_list.new() +local user_timings = timings.new() + +local append_dialogue = false +local autoclip_enabled = false +local autoclip_method = {} + + +------------------------------------------------------------ +-- private + +local function copy_primary_sub() + if autoclip_enabled then + autoclip_method.call() + end +end + +local function append_primary_sub() + local current_sub = Subtitle:now() + all_dialogs.insert(current_sub) + if append_dialogue and dialogs.insert(current_sub) then + self.menu:update() + end +end + +local function append_secondary_sub() + local current_secondary = Subtitle:now('secondary') + all_secondary_dialogs.insert(current_secondary) + if append_dialogue and secondary_dialogs.insert(Subtitle:now('secondary')) then + self.menu:update() + end +end + +local function start_appending() + append_dialogue = true + append_primary_sub() + append_secondary_sub() +end + +local function handle_primary_sub() + append_primary_sub() + copy_primary_sub() +end + +local function handle_secondary_sub() + append_secondary_sub() +end + +local function on_external_finish(success, result, error) + if success ~= true or error ~= nil then + h.notify("Command failed: " .. table.concat(result)) + end +end + +local function external_command_args(cur_lines) + local args = {} + for arg in string.gmatch(self.config.autoclip_custom_args, "%S+") do + if arg == '%MPV_PRIMARY%' then + arg = cur_lines.primary + elseif arg == '%MPV_SECONDARY%' then + arg = cur_lines.secondary + end + table.insert(args, arg) + end + return args +end + +local function call_external_command(cur_lines) + if not h.is_empty(self.config.autoclip_custom_args) then + h.subprocess(external_command_args(cur_lines), on_external_finish) + end +end + +local function current_subtitle_lines() + local primary = dialogs.get_text() + + if h.is_empty(primary) then + primary = mp.get_property("sub-text") + end + + if h.is_empty(primary) then + return nil + end + + local secondary = secondary_dialogs.get_text() + + if h.is_empty(secondary) then + secondary = mp.get_property("secondary-sub-text") or "" + end + + return { primary = self.clipboard_prepare(primary), secondary = secondary } +end + +local function ensure_goldendict_running() + --- Ensure that goldendict is running and is disowned by mpv. + --- Avoid goldendict getting killed when mpv exits. + if autoclip_enabled and self.autocopy_current_method() == "goldendict" then + os.execute("setsid -f goldendict") + end +end + +------------------------------------------------------------ +-- autoclip methods + +autoclip_method = (function() + local methods = { 'clipboard', 'goldendict', 'custom_command', } + local current_method = switch.new(methods) + + local function call() + local cur_lines = current_subtitle_lines() + if h.is_empty(cur_lines) then + return + end + + if current_method.get() == 'clipboard' then + self.copy_to_clipboard("autocopy action", cur_lines.primary) + elseif current_method.get() == 'goldendict' then + h.subprocess_detached({ 'goldendict', cur_lines.primary }, on_external_finish) + elseif current_method.get() == 'custom_command' then + call_external_command(cur_lines) + end + end + + return { + call = call, + get = current_method.get, + bump = current_method.bump, + set = current_method.set, + } +end)() + +local function copy_subtitle(subtitle_id) + self.copy_to_clipboard("copy-on-demand", mp.get_property(subtitle_id)) +end + +------------------------------------------------------------ +-- public + +self.copy_to_clipboard = function(_, text) + if platform.healthy == false then + h.notify(platform.clip_util .. " is not installed.", "error", 5) + end + if not h.is_empty(text) then + platform.copy_to_clipboard(self.clipboard_prepare(text)) + end +end + +self.clipboard_prepare = function(text) + text = self.config.clipboard_trim_enabled and h.trim(text) or h.remove_newlines(text) + text = self.maybe_remove_all_spaces(text) + return text +end + +self.maybe_remove_all_spaces = function(str) + if self.config.nuke_spaces == true and h.contains_non_latin_letters(str) then + return h.remove_all_spaces(str) + else + return str + end +end + +self.copy_current_primary_to_clipboard = function() + copy_subtitle("sub-text") +end + +self.copy_current_secondary_to_clipboard = function() + copy_subtitle("secondary-sub-text") +end + +self.user_altered = function() + --- Return true if the user manually set at least start or end. + return user_timings.is_set('start') or user_timings.is_set('end') +end + +self.get_timing = function(position) + if user_timings.is_set(position) then + return user_timings.get(position) + elseif not dialogs.is_empty() then + return dialogs.get_time(position) + end + return -1 +end + +self.collect_from_all_dialogues = function(n_lines) + local current_sub = Subtitle:now() + local current_secondary_sub = Subtitle:now('secondary') + all_dialogs.insert(current_sub) + all_secondary_dialogs.insert(current_secondary_sub) + if current_sub == nil then + return Subtitle:new() -- return a default empty new Subtitle to let consumer handle + end + local text, end_sub = all_dialogs.get_n_text(current_sub, n_lines) + local secondary_text, _ + if current_secondary_sub == nil then + secondary_text = '' + else + secondary_text, _ = all_secondary_dialogs.get_n_text(current_secondary_sub, n_lines) -- we'll use main sub's timing + end + return Subtitle:new { + ['text'] = text, + ['secondary'] = secondary_text, + ['start'] = current_sub['start'], + ['end'] = end_sub['end'], + } +end + +self.collect_from_current = function() + --- Return all recorded subtitle lines as one subtitle object. + --- The caller has to call subs_observer.clear() afterwards. + if dialogs.is_empty() then + dialogs.insert(Subtitle:now()) + end + if secondary_dialogs.is_empty() then + secondary_dialogs.insert(Subtitle:now('secondary')) + end + return Subtitle:new { + ['text'] = dialogs.get_text(), + ['secondary'] = secondary_dialogs.get_text(), + ['start'] = self.get_timing('start'), + ['end'] = self.get_timing('end'), + } +end + +self.set_manual_timing = function(position) + user_timings.set(position, mp.get_property_number('time-pos') - mp.get_property("audio-delay")) + h.notify(h.capitalize_first_letter(position) .. " time has been set.") + start_appending() +end + +self.set_manual_timing_to_sub = function(position) + local sub = Subtitle:now() + if sub then + user_timings.set(position, sub[position] - mp.get_property("audio-delay")) + h.notify(h.capitalize_first_letter(position) .. " time has been set.") + start_appending() + else + h.notify("There's no visible subtitle.", "info", 2) + end +end + +self.set_to_current_sub = function() + self.clear() + if Subtitle:now() then + start_appending() + h.notify("Timings have been set to the current sub.", "info", 2) + else + h.notify("There's no visible subtitle.", "info", 2) + end +end + +self.clear = function() + append_dialogue = false + dialogs = sub_list.new() + secondary_dialogs = sub_list.new() + user_timings = timings.new() +end + +self.clear_all_dialogs = function() + all_dialogs = sub_list.new() + all_secondary_dialogs = sub_list.new() +end + +self.clear_and_notify = function() + --- Clear then notify the user. + --- Called by the OSD menu when the user presses a button to drop recorded subtitles. + self.clear() + h.notify("Timings have been reset.", "info", 2) +end + +self.is_appending = function() + return append_dialogue +end + +self.recorded_subs = function() + return dialogs.get_subs_list() +end + +self.recorded_secondary_subs = function() + return secondary_dialogs.get_subs_list() +end + +self.autocopy_status_str = function() + return string.format( + "%s (%s)", + (autoclip_enabled and 'enabled' or 'disabled'), + autoclip_method.get():gsub('_', ' ') + ) +end + +self.autocopy_current_method = function() + return autoclip_method.get() +end + +local function notify_autocopy() + if autoclip_enabled then + copy_primary_sub() + end + h.notify(string.format("Clipboard autocopy has been %s.", self.autocopy_status_str())) +end + +self.toggle_autocopy = function() + autoclip_enabled = not autoclip_enabled + notify_autocopy() +end + +self.next_autoclip_method = function() + autoclip_method.bump() + notify_autocopy() +end + +self.init = function(menu, config) + self.menu = menu + self.config = config + + -- The autoclip state is copied as a local value + -- to prevent it from being reset when the user reloads the config file. + autoclip_enabled = self.config.autoclip + autoclip_method.set(self.config.autoclip_method) + + mp.observe_property("sub-text", "string", handle_primary_sub) + mp.observe_property("secondary-sub-text", "string", handle_secondary_sub) +end + +return self diff --git a/config/mpv/scripts/subs2srsa/subtitles/secondary_sid.lua b/config/mpv/scripts/subs2srsa/subtitles/secondary_sid.lua new file mode 100644 index 0000000..21576e8 --- /dev/null +++ b/config/mpv/scripts/subs2srsa/subtitles/secondary_sid.lua @@ -0,0 +1,196 @@ +--[[ +Copyright: Ren Tatsumoto and contributors +License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/gpl.html + +This module automatically finds and sets secondary sid if it's not already set. +Secondary sid will be shown when mouse is moved to the top part of the mpv window. +]] + +local mp = require('mp') +local h = require('helpers') + +local self = { + visibility = 'auto', + visibility_states = { auto = true, never = true, always = true, }, +} + +local function is_accepted_language(sub_lang) + -- for missing keys compares nil to true + return self.accepted_languages[sub_lang] == true +end + +local function is_selected_language(track, active_track) + return track.id == mp.get_property_native('sid') or (active_track and active_track.lang == track.lang) +end + +local function is_full(track) + return h.str_contains(track.title, 'full') +end + +local function is_garbage(track) + for _, keyword in pairs({ 'song', 'sign', 'caption', 'commentary' }) do + if h.str_contains(track.title, keyword) then + return true + end + end + return false +end + +local function prioritize_full_subs(tracks_list) + return table.sort(tracks_list, function(first, second) + return (is_full(first) and not is_full(second)) or (is_garbage(second) and not is_garbage(first)) + end) +end + +local function find_best_secondary_sid() + local active_track = h.get_active_track('sub') + local sub_tracks = h.get_loaded_tracks('sub') + prioritize_full_subs(sub_tracks) + for _, track in ipairs(sub_tracks) do + if is_accepted_language(track.lang) and not is_selected_language(track, active_track) then + return track.id + end + end + return nil +end + +local function window_height() + return mp.get_property_native('osd-dimensions/h') +end + +local function get_accepted_sub_langs() + local languages = {} + for lang in self.config.secondary_sub_lang:gmatch('[a-zA-Z-]+') do + languages[lang] = true + end + return languages +end + +local function on_mouse_move(_, state) + -- state = {x=int,y=int, hover=true|false, } + if mp.get_property_native('secondary-sid') and self.visibility == 'auto' and state ~= nil then + mp.set_property_bool( + 'secondary-sub-visibility', + state.hover and (state.y / window_height()) < self.config.secondary_sub_area + ) + end +end + +local function on_file_loaded() + -- If secondary sid is not already set, try to find and set it. + local secondary_sid = mp.get_property_native('secondary-sid') + if secondary_sid == false and self.config.secondary_sub_auto_load == true then + secondary_sid = find_best_secondary_sid() + if secondary_sid ~= nil then + mp.set_property_native('secondary-sid', secondary_sid) + end + end +end + +local function update_visibility() + mp.set_property_bool('secondary-sub-visibility', self.visibility == 'always') +end + +local function init(config) + self.config = config + self.visibility = config.secondary_sub_visibility + self.accepted_languages = get_accepted_sub_langs() + mp.register_event('file-loaded', on_file_loaded) + if config.secondary_sub_area > 0 then + mp.observe_property('mouse-pos', 'native', on_mouse_move) + end + update_visibility() +end + +local function change_visibility() + while true do + self.visibility = next(self.visibility_states, self.visibility) + if self.visibility ~= nil then + break + end + end + update_visibility() + h.notify("Secondary sid visibility: " .. self.visibility) +end + +local function compare_by_preference_then_id(track1, track2) + if is_accepted_language(track1.lang) and not is_accepted_language(track2.lang) then + return true + elseif not is_accepted_language(track1.lang) and is_accepted_language(track2.lang) then + return false + else + return (track1.id < track2.id) + end +end + +local function split_before_after(previous_tracks, next_tracks, all_tracks, current_track_id) + -- works like take_while() and drop_while() combined + local prev = true + for _, track in ipairs(all_tracks) do + if prev == true and track.id == current_track_id then + prev = false + end + if track.id ~= current_track_id then + if prev then + table.insert(previous_tracks, track) + else + table.insert(next_tracks, track) + end + end + end +end + +local function not_primary_sid(track) + return mp.get_property_native('sid') ~= track.id +end + +local function find_new_secondary_sub(direction) + local subtitle_tracks = h.filter(h.get_loaded_tracks('sub'), not_primary_sid) + table.sort(subtitle_tracks, compare_by_preference_then_id) + + local secondary_sid = mp.get_property_native('secondary-sid') + local new_secondary_sub = { id = false, title = "removed" } + + if #subtitle_tracks > 0 then + if not secondary_sid then + new_secondary_sub = (direction == 'prev') and subtitle_tracks[#subtitle_tracks] or subtitle_tracks[1] + else + local previous_tracks = {} + local next_tracks = {} + split_before_after(previous_tracks, next_tracks, subtitle_tracks, secondary_sid) + if direction == 'prev' and #previous_tracks > 0 then + new_secondary_sub = previous_tracks[#previous_tracks] + elseif direction == 'next' and #next_tracks > 0 then + new_secondary_sub = next_tracks[1] + end + end + end + return new_secondary_sub +end + +local function switch_secondary_sid(direction) + local new_secondary_sub = find_new_secondary_sub(direction) + + mp.set_property_native('secondary-sid', new_secondary_sub.id) + if new_secondary_sub.id == false then + h.notify("Removed secondary sid.") + else + h.notify(string.format( + "Secondary #%d: %s (%s)", + new_secondary_sub.id, + new_secondary_sub.title or "No title", + new_secondary_sub.lang or "Unknown" + )) + end +end + +return { + init = init, + change_visibility = change_visibility, + select_previous = function() + switch_secondary_sid('prev') + end, + select_next = function() + switch_secondary_sid('next') + end, +} diff --git a/config/mpv/scripts/subs2srsa/subtitles/sub_list.lua b/config/mpv/scripts/subs2srsa/subtitles/sub_list.lua new file mode 100644 index 0000000..de7e1c2 --- /dev/null +++ b/config/mpv/scripts/subs2srsa/subtitles/sub_list.lua @@ -0,0 +1,74 @@ +--[[ +Copyright: Ren Tatsumoto and contributors +License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/gpl.html + +Subtitle list remembers selected subtitle lines. +]] + +local h = require('helpers') + +local new_sub_list = function() + local subs_list = {} + + local find_i = function(sub) + for i, v in ipairs(subs_list) do + if sub < v then + return i + end + end + return #subs_list + 1 + end + local get_time = function(position) + local i = position == 'start' and 1 or #subs_list + return subs_list[i][position] + end + local get_text = function() + local speech = {} + for _, sub in ipairs(subs_list) do + table.insert(speech, sub['text']) + end + return table.concat(speech, ' ') + end + local get_n_text = function(sub, n_lines) + local speech = {} + local end_sub = sub + for _, v in ipairs(subs_list) do + if v['start'] - end_sub['end'] >= 20 then + break + end + if v >= sub and #speech < n_lines then + table.insert(speech, v['text']) + end_sub = v + end + end + return table.concat(speech, ' '), end_sub + end + local insert = function(sub) + if sub ~= nil and not h.contains(subs_list, sub) then + table.insert(subs_list, find_i(sub), sub) + return true + end + return false + end + local get_subs_list = function() + local copy = {} + for key, value in pairs(subs_list) do + copy[key] = value + end + return copy + end + return { + get_subs_list = get_subs_list, + get_time = get_time, + get_text = get_text, + get_n_text = get_n_text, + insert = insert, + is_empty = function() + return h.is_empty(subs_list) + end, + } +end + +return { + new = new_sub_list, +} diff --git a/config/mpv/scripts/subs2srsa/subtitles/subtitle.lua b/config/mpv/scripts/subs2srsa/subtitles/subtitle.lua new file mode 100644 index 0000000..4e8df41 --- /dev/null +++ b/config/mpv/scripts/subs2srsa/subtitles/subtitle.lua @@ -0,0 +1,56 @@ +--[[ +Copyright: Ren Tatsumoto and contributors +License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/gpl.html + +Subtitle class provides methods for storing and comparing subtitle lines. +]] + +local mp = require('mp') + +local Subtitle = { + ['text'] = '', + ['secondary'] = '', + ['start'] = -1, + ['end'] = -1, +} + +function Subtitle:new(o) + o = o or {} + setmetatable(o, self) + self.__index = self + return o +end + +function Subtitle:now(secondary) + local prefix = secondary and "secondary-" or "" + local this = self:new { + ['text'] = mp.get_property(prefix .. "sub-text"), + ['start'] = mp.get_property_number(prefix .. "sub-start"), + ['end'] = mp.get_property_number(prefix .. "sub-end"), + } + if this:is_valid() then + return this:delay(mp.get_property_native("sub-delay") - mp.get_property_native("audio-delay")) + else + return nil + end +end + +function Subtitle:delay(delay) + self['start'] = self['start'] + delay + self['end'] = self['end'] + delay + return self +end + +function Subtitle:is_valid() + return self['start'] and self['end'] and self['start'] >= 0 and self['end'] > self['start'] +end + +Subtitle.__eq = function(lhs, rhs) + return lhs['text'] == rhs['text'] +end + +Subtitle.__lt = function(lhs, rhs) + return lhs['start'] < rhs['start'] +end + +return Subtitle diff --git a/config/mpv/scripts/subs2srsa/test.lua b/config/mpv/scripts/subs2srsa/test.lua new file mode 100644 index 0000000..d1291d2 --- /dev/null +++ b/config/mpv/scripts/subs2srsa/test.lua @@ -0,0 +1,32 @@ +local helpers = require('helpers') + +local function assert_equals(expected, actual) + if expected ~= actual then + error(string.format("TEST FAILED: Expected '%s', got '%s'", expected, actual)) + end +end + +local function test_get_episode_number() + local test_cases = { + { nil, "A Whisker Away.mkv" }, + { nil, "[Placeholder] Gekijouban SHIROBAKO [Ma10p_1080p][x265_flac]" }, + { "06", "[Placeholder] Sono Bisque Doll wa Koi wo Suru - 06 [54E495D0]" }, + { "02", "(Hi10)_Kobayashi-san_Chi_no_Maid_Dragon_-_02_(BD_1080p)_(Placeholder)_(12C5D2B4)" }, + { "01", "[Placeholder] Koi to Yobu ni wa Kimochi Warui - 01 (1080p) [D517C9F0]" }, + { "01", "[Placeholder] Tsukimonogatari 01 [BD 1080p x264 10-bit FLAC] [5CD88145]" }, + { "01", "[Placeholder] 86 - Eighty Six - 01 (1080p) [1B13598F]" }, + { "00", "[Placeholder] Fate Stay Night - Unlimited Blade Works - 00 (BD 1080p Hi10 FLAC) [95590B7F]" }, + { "01", "House, M.D. S01E01 Pilot - Everybody Lies (1080p x265 Placeholder)" }, + { "165", "A Generic Episode-165" } + } + + for _, case in pairs(test_cases) do + local _, _, episode_num = helpers.get_episode_number(case[2]) + assert_equals(case[1], episode_num) + end +end + +-- Runs tests +test_get_episode_number() + +os.exit(print("Tests passed")) diff --git a/config/mpv/scripts/subs2srsa/utils/base64.lua b/config/mpv/scripts/subs2srsa/utils/base64.lua new file mode 100644 index 0000000..0fe2d06 --- /dev/null +++ b/config/mpv/scripts/subs2srsa/utils/base64.lua @@ -0,0 +1,46 @@ +--[[ +Copyright: Ren Tatsumoto and contributors +License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/gpl.html + +Encoding and decoding in base64 +]] + +-- http://lua-users.org/wiki/BaseSixtyFour + +-- character table string +local b = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' + +-- encoding +local function enc(data) + return ((data:gsub('.', function(x) + local r,b='',x:byte() + for i=8,1,-1 do r=r..(b%2^i-b%2^(i-1)>0 and '1' or '0') end + return r; + end)..'0000'):gsub('%d%d%d?%d?%d?%d?', function(x) + if (#x < 6) then return '' end + local c=0 + for i=1,6 do c=c+(x:sub(i,i)=='1' and 2^(6-i) or 0) end + return b:sub(c+1,c+1) + end)..({ '', '==', '=' })[#data%3+1]) +end + +-- decoding +local function dec(data) + data = string.gsub(data, '[^'..b..'=]', '') + return (data:gsub('.', function(x) + if (x == '=') then return '' end + local r,f='',(b:find(x)-1) + for i=6,1,-1 do r=r..(f%2^i-f%2^(i-1)>0 and '1' or '0') end + return r; + end):gsub('%d%d%d?%d?%d?%d?%d?%d?', function(x) + if (#x ~= 8) then return '' end + local c=0 + for i=1,8 do c=c+(x:sub(i,i)=='1' and 2^(8-i) or 0) end + return string.char(c) + end)) +end + +return { + enc = enc, + dec = dec, +} diff --git a/config/mpv/scripts/subs2srsa/utils/filename_factory.lua b/config/mpv/scripts/subs2srsa/utils/filename_factory.lua new file mode 100644 index 0000000..a794fc7 --- /dev/null +++ b/config/mpv/scripts/subs2srsa/utils/filename_factory.lua @@ -0,0 +1,89 @@ +--[[ +Copyright: Ren Tatsumoto and contributors +License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/gpl.html + +Creates image and audio filenames compatible with Anki. +]] + +local mp = require('mp') +local h = require('helpers') + +local filename + +local anki_compatible_length = (function() + -- Anki forcibly mutilates all filenames longer than 119 bytes when you run `Tools->Check Media...`. + local allowed_bytes = 119 + local timestamp_bytes = #'_99h99m99s999ms-99h99m99s999ms.webp' + + return function(str, timestamp) + -- if timestamp provided, recalculate limit_bytes + local limit_bytes = allowed_bytes - (timestamp and #timestamp or timestamp_bytes) + + if #str <= limit_bytes then + return str + end + + local bytes_per_char = h.contains_non_latin_letters(str) and #'車' or #'z' + local limit_chars = math.floor(limit_bytes / bytes_per_char) + + if limit_chars == limit_bytes then + return str:sub(1, limit_bytes) + end + + local ret = h.subprocess { + 'awk', + '-v', string.format('str=%s', str), + '-v', string.format('limit=%d', limit_chars), + 'BEGIN{print substr(str, 1, limit); exit}' + } + + if ret.status == 0 then + ret.stdout = h.remove_newlines(ret.stdout) + ret.stdout = h.remove_leading_trailing_spaces(ret.stdout) + return ret.stdout + else + return 'subs2srs_' .. os.time() + end + end +end)() + +local make_media_filename = function() + filename = mp.get_property("filename") -- filename without path + filename = h.remove_extension(filename) + filename = h.remove_filename_text_in_parentheses(filename) + filename = h.remove_text_in_brackets(filename) + filename = h.remove_special_characters(filename) +end + +local function timestamp_range(start_timestamp, end_timestamp, extension) + -- Generates a filename suffix of the form: _00h00m00s000ms-99h99m99s999ms.extension + -- Extension must already contain the dot. + return string.format( + '_%s_%s%s', + h.human_readable_time(start_timestamp), + h.human_readable_time(end_timestamp), + extension + ) +end + +local function timestamp_static(timestamp, extension) + -- Generates a filename suffix of the form: _00h00m00s000ms.extension + -- Extension must already contain the dot. + return string.format( + '_%s%s', + h.human_readable_time(timestamp), + extension + ) +end + +local make_filename = function(...) + local args = {...} + local timestamp = #args < 3 and timestamp_static(...) or timestamp_range(...) + return string.lower(anki_compatible_length(filename, timestamp) .. timestamp) +end + +mp.register_event("file-loaded", make_media_filename) + +return { + make_filename = make_filename, +} diff --git a/config/mpv/scripts/subs2srsa/utils/forvo.lua b/config/mpv/scripts/subs2srsa/utils/forvo.lua new file mode 100644 index 0000000..09bc596 --- /dev/null +++ b/config/mpv/scripts/subs2srsa/utils/forvo.lua @@ -0,0 +1,145 @@ +--[[ +Copyright: Ren Tatsumoto and contributors +License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/gpl.html + +Utils for downloading pronunciations from Forvo +]] + +local utils = require('mp.utils') +local msg = require('mp.msg') +local h = require('helpers') +local base64 = require('utils.base64') +local self = { + output_dir_path = nil, +} + +local function url_encode(url) + -- https://gist.github.com/liukun/f9ce7d6d14fa45fe9b924a3eed5c3d99 + local char_to_hex = function(c) + return string.format("%%%02X", string.byte(c)) + end + if url == nil then + return + end + url = url:gsub("\n", "\r\n") + url = url:gsub("([^%w _%%%-%.~])", char_to_hex) + url = url:gsub(" ", "+") + return url +end + +local function reencode(source_path, dest_path) + local args = { + 'mpv', + source_path, + '--loop-file=no', + '--keep-open=no', + '--video=no', + '--no-ocopy-metadata', + '--no-sub', + '--audio-channels=mono', + '--oacopts-add=vbr=on', + '--oacopts-add=application=voip', + '--oacopts-add=compression_level=10', + '--af-append=silenceremove=1:0:-50dB', + table.concat { '--oac=', self.config.audio_codec }, + table.concat { '--of=', self.config.audio_format }, + table.concat { '--oacopts-add=b=', self.config.audio_bitrate }, + table.concat { '-o=', dest_path } + } + return h.subprocess(args) +end + +local function reencode_and_store(source_path, filename) + local reencoded_path = utils.join_path(self.output_dir_path, filename) + local result = reencode(source_path, reencoded_path) + return result.status == 0 +end + +local function curl_save(source_url, save_location) + local curl_args = { 'curl', source_url, '-s', '-L', '-o', save_location } + return h.subprocess(curl_args).status == 0 +end + +local function get_pronunciation_url(word) + local file_format = self.config.audio_extension:sub(2) + local forvo_page = h.subprocess { 'curl', '-s', string.format('https://forvo.com/search/%s/ja', url_encode(word)) }.stdout + local play_params = string.match(forvo_page, "Play%((.-)%);") + + if play_params then + local iter = string.gmatch(play_params, "'(.-)'") + local formats = { mp3 = iter(), ogg = iter() } + return string.format('https://audio00.forvo.com/%s/%s', file_format, base64.dec(formats[file_format])) + end +end + +local function make_forvo_filename(word) + return string.format('forvo_%s%s', self.platform.windows and os.time() or word, self.config.audio_extension) +end + +local function get_forvo_pronunciation(word) + local audio_url = get_pronunciation_url(word) + + if h.is_empty(audio_url) then + msg.warn(string.format("Seems like Forvo doesn't have audio for word %s.", word)) + return + end + + local filename = make_forvo_filename(word) + local tmp_filepath = utils.join_path(self.platform.tmp_dir(), filename) + + local result + if curl_save(audio_url, tmp_filepath) and reencode_and_store(tmp_filepath, filename) then + result = string.format(self.config.audio_template, filename) + else + msg.warn(string.format("Couldn't download audio for word %s from Forvo.", word)) + end + + os.remove(tmp_filepath) + return result +end + +local append = function(new_data, stored_data) + if self.config.use_forvo == 'no' then + -- forvo functionality was disabled in the config file + return new_data + end + + if type(stored_data[self.config.vocab_audio_field]) ~= 'string' then + -- there is no field configured to store forvo pronunciation + return new_data + end + + if h.is_empty(stored_data[self.config.vocab_field]) then + -- target word field is empty. can't continue. + return new_data + end + + if self.config.use_forvo == 'always' or h.is_empty(stored_data[self.config.vocab_audio_field]) then + local forvo_pronunciation = get_forvo_pronunciation(stored_data[self.config.vocab_field]) + if not h.is_empty(forvo_pronunciation) then + if self.config.vocab_audio_field == self.config.audio_field then + -- improperly configured fields. don't lose sentence audio + new_data[self.config.audio_field] = forvo_pronunciation .. new_data[self.config.audio_field] + else + new_data[self.config.vocab_audio_field] = forvo_pronunciation + end + end + end + + return new_data +end + +local set_output_dir = function(dir_path) + self.output_dir_path = dir_path +end + +local function init(config, platform) + self.config = config + self.platform = platform +end + +return { + append = append, + init = init, + set_output_dir = set_output_dir, +} diff --git a/config/mpv/scripts/subs2srsa/utils/pause_timer.lua b/config/mpv/scripts/subs2srsa/utils/pause_timer.lua new file mode 100644 index 0000000..e37b0ea --- /dev/null +++ b/config/mpv/scripts/subs2srsa/utils/pause_timer.lua @@ -0,0 +1,33 @@ +--[[ +Copyright: Ren Tatsumoto and contributors +License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/gpl.html + +Pause timer stops playback when reaching a set timing. +]] + +local mp = require('mp') +local stop_time = -1 +local check_stop + +local set_stop_time = function(time) + stop_time = time + mp.observe_property("time-pos", "number", check_stop) +end + +local stop = function() + mp.unobserve_property(check_stop) + stop_time = -1 +end + +check_stop = function(_, time) + if time > stop_time then + stop() + mp.set_property("pause", "yes") + end +end + +return { + set_stop_time = set_stop_time, + check_stop = check_stop, + stop = stop, +} diff --git a/config/mpv/scripts/subs2srsa/utils/play_control.lua b/config/mpv/scripts/subs2srsa/utils/play_control.lua new file mode 100644 index 0000000..901377d --- /dev/null +++ b/config/mpv/scripts/subs2srsa/utils/play_control.lua @@ -0,0 +1,61 @@ +--[[ +Copyright: Ren Tatsumoto and contributors +License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/gpl.html + +Provides additional methods for controlling playback. +]] + +local mp = require('mp') +local h = require('helpers') +local pause_timer = require('utils.pause_timer') +local Subtitle = require('subtitles.subtitle') + +local current_sub + +local function stop_at_the_end(sub) + pause_timer.set_stop_time(sub['end'] - 0.050) + h.notify("Playing till the end of the sub...", "info", 3) +end + +local function play_till_sub_end() + local sub = Subtitle:now() + mp.commandv('seek', sub['start'], 'absolute') + mp.set_property("pause", "no") + stop_at_the_end(sub) +end + +local function sub_seek(direction, pause) + mp.commandv("sub_seek", direction == 'backward' and '-1' or '1') + mp.commandv("seek", "0.015", "relative+exact") + if pause then + mp.set_property("pause", "yes") + end + pause_timer.stop() +end + +local function sub_rewind() + mp.commandv('seek', Subtitle:now()['start'] + 0.015, 'absolute') + pause_timer.stop() +end + +local function check_sub() + local sub = Subtitle:now() + if sub and sub ~= current_sub then + mp.unobserve_property(check_sub) + stop_at_the_end(sub) + end +end + +local function play_till_next_sub_end() + current_sub = Subtitle:now() + mp.observe_property("sub-text", "string", check_sub) + mp.set_property("pause", "no") + h.notify("Waiting till next sub...", "info", 10) +end + +return { + play_till_sub_end = play_till_sub_end, + play_till_next_sub_end = play_till_next_sub_end, + sub_seek = sub_seek, + sub_rewind = sub_rewind, +} diff --git a/config/mpv/scripts/subs2srsa/utils/switch.lua b/config/mpv/scripts/subs2srsa/utils/switch.lua new file mode 100644 index 0000000..5dac1c6 --- /dev/null +++ b/config/mpv/scripts/subs2srsa/utils/switch.lua @@ -0,0 +1,38 @@ +--[[ +Copyright: Ren Tatsumoto and contributors +License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/gpl.html + +Switch cycles between values in a table. +]] + +local make_switch = function(states) + local self = { + states = states, + current_state = 1 + } + local bump = function() + self.current_state = self.current_state + 1 + if self.current_state > #self.states then + self.current_state = 1 + end + end + local get = function() + return self.states[self.current_state] + end + local set = function(new_state) + for idx, value in ipairs(self.states) do + if value == new_state then + self.current_state = idx + end + end + end + return { + bump = bump, + get = get, + set = set, + } +end + +return { + new = make_switch +} diff --git a/config/mpv/scripts/subs2srsa/utils/timings.lua b/config/mpv/scripts/subs2srsa/utils/timings.lua new file mode 100644 index 0000000..d2408d6 --- /dev/null +++ b/config/mpv/scripts/subs2srsa/utils/timings.lua @@ -0,0 +1,28 @@ +--[[ +Copyright: Ren Tatsumoto and contributors +License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/gpl.html + +Object that remembers manually set timings. +]] + +local new_timings = function() + local self = { ['start'] = -1, ['end'] = -1, } + local is_set = function(position) + return self[position] >= 0 + end + local set = function(position, time) + self[position] = time + end + local get = function(position) + return self[position] + end + return { + is_set = is_set, + set = set, + get = get, + } +end + +return { + new = new_timings, +} |
