├── .gitignore ├── LICENSE ├── docs ├── ffmpeg.images.rolling.md ├── ffmpeg.img2video.custom.transitions.md ├── ffmpeg.img2video.md ├── ffmpeg.subtitle.stack.md ├── ffmpeg.video2bookmark.md ├── ffmpeg.watermark.help.md ├── ffmpeg.watermark.md ├── ffmpeg.xfade.md └── imgs │ └── subtitle.stack.demo.jpg ├── ffmpeg.images.rolling.js ├── ffmpeg.img2video.js ├── ffmpeg.subtitle.stack.js ├── ffmpeg.video2bookmark.js ├── ffmpeg.watermark.js ├── preset ├── dvd.preset ├── idcard.preset ├── random.preset └── xfade │ ├── BowTieHorizontal.txt │ ├── BowTieVertical.txt │ ├── Dreamy.txt │ ├── FlipOver.txt │ ├── WaterDrop.txt │ ├── alternateProgressive.txt │ ├── directionalwarp.txt │ ├── mosaic.txt │ ├── pinwheel.txt │ ├── progressive.txt │ ├── readme.md │ ├── windowblind.txt │ └── windowslice.txt └── readme.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | dist/ 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | **/*.log 8 | 9 | tests/**/coverage/ 10 | tests/e2e/reports 11 | selenium-debug.log 12 | 13 | # Editor directories and files 14 | .idea 15 | .vscode 16 | *.suo 17 | *.ntvs* 18 | *.njsproj 19 | *.sln 20 | *.local 21 | 22 | package-lock.json 23 | yarn.lock 24 | temp/ 25 | debug/ 26 | test/ 27 | testfile/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | -------------------------------------------------------------------------------- /docs/ffmpeg.images.rolling.md: -------------------------------------------------------------------------------- 1 | # 以水平滚动的方式,展示多张图片 2 | 3 | - [效果](#效果) 4 | - [前言](#前言) 5 | - [技术分析](#技术分析) 6 | - [图片移动](#图片移动) 7 | - [多张图片依次进行移动](#多张图片依次进行移动) 8 | - [图片周围增加间隔](#图片周围增加间隔) 9 | - [背景图片或背景颜色](#背景图片或背景颜色) 10 | - [标题和脚注](#标题和脚注) 11 | - [帧率](#帧率) 12 | - [还可以实现的](#还可以实现的) 13 | - [运行效率](#运行效率) 14 | 15 | 16 | ## 效果 17 | 18 | 19 | 20 | ## 前言 21 | 22 | 前段时间看到一个视频,对某个游戏的物体进行了评分排行,然后用滚动的方式展示出来,想着这种方式应该可以用自动化的方式实现,于是就想着用 ffmpeg 实现一下。 23 | 24 | ## 技术分析 25 | 26 | ### 图片移动 27 | 28 | 使用[overlay](https://ffmpeg.org/ffmpeg-filters.html#overlay-1)过滤器。 29 | 30 | 这个过滤器,是“把一个视频叠加在另一个视频上”。 31 | 其中,你可以通过`x`和`y`参数来控制叠加的位置。x和y都支持表达式。 32 | 注意这个过滤器是支持“[时间轴](https://ffmpeg.org/ffmpeg-filters.html#Timeline-editing)”的。 33 | 于是可以使用表达式来达到不同时间计算出不同的x、y值来达到`移动`的效果。 34 | 具体使用方式可以参考官方文档。 35 | 36 | ### 多张图片依次进行移动 37 | 38 | 这里我实现的效果比较简单,一开始所有图片都是排好队进行移动的。 39 | 因此,使用了`水平拼接`过滤器[hstack](https://ffmpeg.org/ffmpeg-filters.html#hstack-1),将图片依次拼接,然后对拼接后的图片进行移动,即可达到效果。 40 | 41 | ### 图片周围增加间隔 42 | 43 | `hstack`没有类似`padding`的参数可以来指定每个视频间的间距。 44 | 虽然可以使用 `pad`+`overlay`的方式在排列所有图片,但是需要计算。 45 | 因此,我选择对输入图片进行处理。在输入图片四周增加透明像素,来达到间隔的效果。 46 | 主要用到`scale`和`pad`过滤器。 47 | 用scale是因为所有图片要统一等比例缩放到相同的高度。(宽度可以不一样)。 48 | 49 | 假设要在图片上增加间隔分别是left、right、top、bottom,统一高度是height。 50 | 先`scale`图片高度为 `height-top-bottom`,宽度等比(-2表示保持源比例,并使计算出来的值能被2整除) 51 | ``` 52 | scale=-2:height-top-bottom 53 | ``` 54 | 再使用`pad`过滤器,将宽度设置为scale之后的图片的宽度+left+right,高度+top+bottom,并把图片在pad上的坐标设置为x=left,y=top,同时,pad的color设置为完全透明。 55 | ``` 56 | pad=w=iw+left+right:h=ih+top+bottom:x=left:y=top:color=black@0 57 | ``` 58 | 59 | > 目前脚本会对所有图片进行增加padding操作,包括第一张和最后一张,因此,如果设置了leftPadding,则第二帧有可能还看到不到图片。 60 | 61 | ### 背景图片或背景颜色 62 | 63 | 输出视频需要指定分辨率(默认1920x1080),如果使用背景颜色,则通过[lavfi](https://ffmpeg.org/ffmpeg-devices.html#lavfi)来设置 64 | ``` 65 | -f lavfi -t 1 -r 1/1000 -i color=c=black:s=1920x1080 66 | ``` 67 | 68 | 如果使用背景图片,则将背景图片scale到指定分辨率即可。脚本里在scale之前,增加了一个模糊滤镜,可以将背景图进行`高斯模糊`[gblur](https://ffmpeg.org/ffmpeg-filters.html#gblur)。 69 | 当然如果不需要模糊,则使用命令行参数 `-blursigma 0`覆盖默认值即可。 70 | 71 | ### 标题和脚注 72 | 73 | 这是一个附加功能,使用[drawtext](https://ffmpeg.org/ffmpeg-filters.html#drawtext-1)实现。 74 | 75 | ### 帧率 76 | 77 | 图片每一帧的x坐标都在变化,如果视频帧率和显示器帧率无法很好匹配,参考影视飓风的视频[《影视飓风将停止制作25帧视频》](https://www.bilibili.com/video/BV1hp4y1f7B5),将会看到很明显的卡顿的情况,请根据你实际的情况选择合适的帧率(-fps,默认值是25)。 78 | 79 | ## 还可以实现的 80 | 81 | 因为输出是视频,所以,视频相关可以设置的参数实在是太多了。本脚本为了简单,调用ffmpeg的时候,只使用了`-crf 23`,其它参数则是ffmpeg默认。 82 | 这表示,你可以用`-o out.mkv`来直接输出mkv封装的视频,也可以用`-o out.webp`来直接输出webp动态图片(但是很慢,我试过,慢20倍)。 83 | 如果需要其他自定义的ffmpeg输出视频参数,请自行修改脚本。 84 | 85 | ## 运行效率 86 | 87 | [效果](#效果)中的视频,使用10张270x400的图片,视频分辨率1280x480,fps=29.97,总时长30秒。在我的电脑上运行是1.98秒,CPU占用率极低。仅供参考。 88 | -------------------------------------------------------------------------------- /docs/ffmpeg.img2video.custom.transitions.md: -------------------------------------------------------------------------------- 1 | # ffmpeg.img2video.js 中自定义动画效果预览及使用 2 | 3 | 以下自定义动画效果脚本均在[preset/xfade](../preset/xfade)下。 4 | 5 | ## 效果预览 6 | 7 | windowblind 8 | 9 | 10 | 11 | ---- 12 | 13 | windowslice 14 | 15 | 16 | 17 | ---- 18 | 19 | WaterDrop 20 | 21 | 22 | 23 | ---- 24 | 25 | BowTieVertical 26 | 27 | 28 | 29 | ---- 30 | 31 | BowTieHorizontal 32 | 33 | 34 | 35 | ---- 36 | 37 | FlipOver 38 | 39 | 40 | 41 | ---- 42 | 43 | mosaic 44 | 45 | 46 | 47 | ---- 48 | 49 | directionalwarp 50 | 51 | 52 | 53 | ---- 54 | 55 | progressive 56 | 57 | 58 | 59 | ---- 60 | 61 | alternateProgressive 62 | 63 | 64 | 65 | ---- 66 | 67 | Dreamy 68 | 69 | 70 | 71 | ---- 72 | 73 | pinwheel 74 | 75 | 76 | 77 | ---- 78 | 79 | 80 | ## 使用方式 81 | 82 | 示例: 83 | 84 | ```shell 85 | node ffmpeg.img2video.js -i -disable_buildin_transitions -transitions "pinwheel,Dreamy" 86 | ``` 87 | 88 | ``` 89 | -transitions 要使用的转场动画集,使用逗号分隔,如 fade,wipeleft,wiperight,wipeup,mytran1 90 | 其中,支持自定义的转场动画,如 mytran1 表示 ./preset/xfade/mytran1.txt 91 | 自定义转场动画的编写请参考github(https://github.com/jifengg/ffmpeg-script)。 92 | -disable_buildin_transitions 93 | 禁用脚本中内置的ffmpeg的转场动画,只使用-transitions定义的,默认:false 94 | ``` -------------------------------------------------------------------------------- /docs/ffmpeg.img2video.md: -------------------------------------------------------------------------------- 1 | # 将多张图片转换成类似幻灯片的视频,支持多种转场效果 2 | 3 | - [效果](#效果) 4 | - [前言](#前言) 5 | - [技术分析](#技术分析) 6 | - [以图片做为输入并当成视频流处理](#以图片做为输入并当成视频流处理) 7 | - [视频与视频合并时,增加转场效果](#视频与视频合并时增加转场效果) 8 | - [还能做得更好的](#还能做得更好的) 9 | - [运行效率](#运行效率) 10 | 11 | ## 效果 12 | 13 | 14 | 15 | ## 前言 16 | 17 | 从图片快速生成幻灯片视频,使用多种转场效果,并配上音乐和歌词。这可以快速的为自己制作一个酷炫的个人图片展示视频。 18 | 它有很多适用的场景,不过,也可能会被用于制造一些低质量的视频,请不要滥用本脚本。 19 | 20 | 这个脚本,最终生成的是一条ffmpeg的命令行,可以直接复制到控制台运行,效果和脚本是一模一样的。 21 | 脚本本身只是组织并生成了这条命令,并没有处理任何媒体文件。这让我知道了ffmpeg的强大。 22 | 23 | 这个脚本其实可以算是我编写的最早的一个ffmpeg脚本,只是一直没有再好好整理,这次正好趁着往脚本集添加的机会,重新梳理了一些逻辑、修复了一些bug、增加了一些配置项。 24 | 25 | ## 技术分析 26 | 27 | ### 以图片做为输入并当成视频流处理 28 | 29 | 在ffmpeg的[官方wiki页面](https://trac.ffmpeg.org/wiki/Slideshow#Singleimage)有介绍: 30 | 31 | ```shell 32 | # 将一张图片转换成30秒的视频文件,它的帧率是默认的25fps 33 | ffmpeg -loop 1 -i img.jpg -c:v libx264 -t 30 -pix_fmt yuv420p out.mp4 34 | ``` 35 | 36 | 其中,`-loop 1 -i img.jpg`,ffmpeg会根据`img.jpg`推断输入文件格式是`image2`(查看[image2官方文档](https://ffmpeg.org/ffmpeg-formats.html#image2-1))。 37 | `-loop 1`则是表示需要循环播放,因为我们的输入其实只有一帧,如果不加这个,即使`-t 30`,最后出来的也是只有一帧的mp4。 38 | 39 | 这里,有一个很重要的优化点,就是,`image2`的一个参数`framerate`,默认是25。 40 | ffmpeg内部的逻辑我不清楚(源码看不懂),但是,我们通过以下的一个测试可以发现,如果把帧率设置为一个很小的值(1/1000,也就是1000秒只有1帧),再通过`fps`过滤器改变为25,ffmpeg的效率会提高很多。 41 | 42 | > 1.jpg的分辨率是1440x900 43 | > Take time 表示耗时 44 | > CPU max 表示CPU占用率 45 | > Memory max 表示内存占用率 46 | > 经过比较,out.mp4和out2.mp4文件hash值完全一样 47 | 48 | ```shell 49 | ffmpeg -y -loop 1 -r 1/1000 -i 1.jpg -filter_complex fps=25 -c:v libx264 -t 30 -pix_fmt yuv420p out.mp4 50 | # Take time: 2.019 s 51 | # CPU max: 106.300 min: 0.000 avg: 17.717 52 | # Memory max: 696,909,824 min: 652,161,024 avg: 680,211,797 53 | 54 | ffmpeg -y -loop 1 -i 1.jpg -c:v libx264 -t 30 -pix_fmt yuv420p out2.mp4 55 | # Take time: 4.973 s 56 | # CPU max: 50.000 min: 0.000 avg: 9.653 57 | # Memory max: 666,927,104 min: 580,550,656 avg: 636,126,870 58 | ``` 59 | 60 | 可以看到,使用`fps`过滤器的执行耗时,比默认的快很多。图片分辨率越大,这个效率提升会更明显。 61 | 62 | 以下1.jpg分辨率为`6000x4000`,使用`scale`转换到高度为1080,输出的两个文件hash值完全一样,可以更明显地看到效率的差别。 63 | 64 | ```shell 65 | 66 | ffmpeg -y -loop 1 -r 1/1000 -i 1.jpg -filter_complex scale=-2:1080,fps=25 -c:v libx264 -t 30 -pix_fmt yuv420p out.mp4 67 | # Take time: 2.942 s 68 | # CPU max: 342.200 min: 0.000 avg: 45.160 69 | # Memory max: 1,413,341,184 min: 1,177,849,856 avg: 1,268,655,308 70 | 71 | ffmpeg -y -loop 1 -i 1.jpg -filter_complex scale=-2:1080 -c:v libx264 -t 30 -pix_fmt yuv420p out2.mp4 72 | # Take time: 122.466 s 73 | # CPU max: 95.400 min: 0.000 avg: 4.231 74 | # Memory max: 1,138,155,520 min: 262,033,408 avg: 1,066,734,656 75 | ``` 76 | 77 | 综上,不管你输入图片分辨率是多少,都建议你使用1/1000fps做为输入帧率,对输入进行必要的处理之后,再转换成25fps。 78 | 当然,你要理解,我们能这么做,是因为图片转视频,每一帧都是一样的。 79 | 我们做的优化其实是: 80 | - 减少ffmpeg通过image2格式生成输入视频流的耗时 81 | - 减少需要处理的视频帧数量。 82 | 83 | > 有一种情况不适用,就是输入是`图片序列`([Wiki](https://trac.ffmpeg.org/wiki/Slideshow#Sequential)),每个图片表示一帧,这种情况就不能使用这个方法了。 84 | 85 | ### 视频与视频合并时,增加转场效果 86 | 87 | 直接看ffmpeg文档[xfade](https://ffmpeg.org/ffmpeg-filters.html#xfade)。 88 | 它的输入是2个视频源。 89 | 90 | 需要理解几个参数: 91 | - duration:转场动画的时长,单位秒 92 | - offset:从第1个视频的第几秒开始转场。 93 | - transition:转场动画的过渡效果。例如fade表示淡入淡出 94 | 95 | 举例,两个9.0秒的视频,`transition=fade:duration=2:offset=5`表示: 96 | - 0.0~5.0秒:第一个视频的画面 97 | - 5.0~7.0秒:第一个视频画面渐渐消失,同时第二个视频画面渐渐出现 98 | - 7.0~14.0秒:第二个视频画面 99 | 100 | 总共生成14.0秒时长的视频,其中,第一个视频在转场结束的时候还没播完,画面将不再出现。转场结束之后,会持续到第二个视频结束。 101 | 102 | > 如果第二个视频的时长不足2秒会怎样?请自行实践。 103 | 104 | 这个过滤器还有一个参数`expr`,它允许我们自定义过渡效果。不过这个在网上资料很少,正在研究,有点成果但不多。等我研究透了再更新。 105 | 106 | ## 还能做得更好的 107 | 108 | xfade提供有56种内置效果,能满足大部分需求。不过,更复杂的过渡效果(例如翻页)还没有。 109 | 使用custom+expr能做到什么效果,还有待研究。 110 | 111 | 脚本目前的配置还比较粗糙,转场效果用的是随机的,时长控制是统一的。后续考虑增加一些`项目文件`之类的文件,可以自主的控制每个图片的转场效果、持续时长、显示类型等等。 112 | 113 | ## 运行效率 114 | 115 | [效果](#效果)中的视频,使用5张1440x900的图片,视频分辨率600 x 336,fps=12,总时长10秒。在我的电脑上运行是0.536秒,仅供参考。 116 | -------------------------------------------------------------------------------- /docs/ffmpeg.subtitle.stack.md: -------------------------------------------------------------------------------- 1 | # 使用视频画面拼接自定义的字幕 2 | 3 | - [前言](#前言) 4 | - [效果](#效果) 5 | - [技术分析](#技术分析) 6 | - [裁剪视频画面](#裁剪视频画面) 7 | - [绘制文字](#绘制文字) 8 | - [截取指定时间点的画面](#截取指定时间点的画面) 9 | - [将多个画面垂直堆叠](#将多个画面垂直堆叠) 10 | - [快速定位到开始时间点](#快速定位到开始时间点) 11 | - [关于运行效率](#关于运行效率) 12 | 13 | ## 前言 14 | 15 | 网络上经常可以看到一些“截图某一张视频画面,并把后续字幕连续拼接在一起”的图片,可以快速传递视频内的内容。 16 | 当然,这很容易造假。所以当你看到类似内容的时候,需要自行判断。 17 | 这个脚本,就是用来实现这个功能的。需要说明的是,这个脚本不是为了造假而生的,仅仅是出于娱乐与交流的目的。 18 | 19 | ## 效果 20 | 21 | ![效果图](imgs/subtitle.stack.demo.jpg) 22 | 23 | > 这是“Kung.Fu.Panda.4”`7:38.416`开始的画面,并间隔10秒截取一个字幕区。内容当然是我自己编写的。 24 | 25 | ## 技术分析 26 | 27 | ### 裁剪视频画面 28 | 29 | 使用`crop`过滤器,[官方文档](https://ffmpeg.org/ffmpeg-filters.html#crop)。 30 | 其中第一帧是完整的画面,后续的是截取底下一部分画面(可通过-sh控制要截取的比例)。 31 | 32 | 在代码中,我使用了取巧的方式,例如当sh=0.2(20%)时,把第一帧画面设置为第一帧高度的80%,剩下的20%和后续所有帧一样用同样的截取和绘制文字的代码,省去了判断是否第一帧的逻辑。 33 | 34 | 关键命令: 35 | ```js 36 | // 第一帧上部分 37 | crop=w=iw:h=ih-ih*${cropH}:x=0:y=0 38 | // 后续字幕部分 39 | crop=w=iw:h=ih*${cropH}:x=0:y=ih-oh 40 | ``` 41 | 42 | ### 绘制文字 43 | 44 | 使用`drawtext`过滤器,[官方文档](https://ffmpeg.org/ffmpeg-filters.html#drawtext-1)。 45 | ffmpeg支持各种字幕格式文件,不过为了时间精准,也为了避免在脚本里解析字幕文件,于是采用绘制文字的方式。 46 | 47 | 其中,让文字在画面水平居中和垂直居中,用的命令是`x=(w-tw)/2:y=(h-th)/2`, 48 | 多行文字时,默认两行文字是左对其的,要让其居中对齐,则用`text_align=center+middle`, 49 | 如果不指定`fontfile`,ffmpeg支持通过`fontconfig`来指定字体的配置,但是配置起来比较麻烦且缺少灵活性,所以还是指定`fontfile`参数,这个需要传递字体文件的完整路径,而且,在windows下,路径需要使用`/`而不能用`\`,如果路径包含空格则需要用单引号包围,例如`fontfile='c:/my fonts/cn/demo.ttf'`。 50 | 51 | #### 文本文件格式 52 | 53 | 为了支持多行字幕,因此采用的文本文件格式如下: 54 | 55 | ``` 56 | 第一行 57 | first line 58 | 59 | 第二行 60 | second line 61 | 62 | 其它 63 | others 64 | ``` 65 | 66 | 使用一个空行(\n\n)来分隔两个文本信息,只间隔一个\n的文本会以多行字幕的形式绘制到画面上。 67 | 68 | ### 截取指定时间点的画面 69 | 70 | 使用`trim`过滤器,[官方文档](https://ffmpeg.org/ffmpeg-filters.html#trim)。 71 | 因为不能只截取一帧,所以指定`duration=0.01`。如果不指定duration的话会增加内存占用及cpu消耗。 72 | 73 | ### 将多个画面垂直堆叠 74 | 75 | 使用`vstack`过滤器,[官方文档](https://ffmpeg.org/ffmpeg-filters.html#vstack-1)。 76 | 77 | ### 快速定位到开始时间点 78 | 79 | 在`-i `之前,通过`-ss`指定开始时间点,ffmpeg将使用解析关键帧的方式快速定位到该时间点,而不用解码所有帧。 80 | 81 | ## 关于运行效率 82 | 83 | 因为使用-ss,所以开始时间不会限制效率,最影响效率的是你输入的文本的行数。 84 | 以上面的示例图片为准,在我的电脑上运行是1.4秒,CPU占用率极低。仅供参考。 -------------------------------------------------------------------------------- /docs/ffmpeg.video2bookmark.md: -------------------------------------------------------------------------------- 1 | # 分析视频场景帧,并生成播放器支持的书签文件(PotPlayer) 2 | 3 | - [前言](#前言) 4 | - [试图分析PotPlayer的书签文件格式](#试图分析potplayer的书签文件格式) 5 | - [ffmpeg生成缩略图的研究](#ffmpeg生成缩略图的研究) 6 | - [阅读ffmpeg的wiki](#阅读ffmpeg的wiki) 7 | - [用哪种方式?](#用哪种方式) 8 | - [如何记录图片对应的时间点?](#如何记录图片对应的时间点) 9 | - [合并后的命令行](#合并后的命令行) 10 | - [后续优化](#后续优化) 11 | - [脚本编写](#脚本编写) 12 | - [关于运行效率](#关于运行效率) 13 | 14 | ## 前言 15 | 16 | 很多播放器都有一个生成缩略图墙的功能,能够给一个视频生成n*m张缩略图并拼成一张大图,一般在发布该视频的文章中可以看到。 17 | 如PotPlayer,快捷键是Alt+N(唤起完整菜单快捷键是K)。 18 | 不方便的地方是没法快速跳转到对应的时间点。 19 | 要能跳转时间点,那么用书签功能是很合适的,于是就是看怎么生成播放器能解析的书签文件。 20 | 21 | ## 试图分析PotPlayer的书签文件格式 22 | 23 | PotPlayer支持后缀名为`.pbf`的书签格式,可以使用快捷键H唤起书签菜单查看更多功能。 24 | 勾选“将书签保存在视频文件夹”后,按P创建一个书签,关闭播放器后就会在视频同目录下看到创建的pbf文件。 25 | 26 | 可以使用文本编辑器打开该文件,可以发现其基本格式 27 | 28 | ```ini 29 | [Bookmark] 30 | 0=83458*书签 1*2800000048000000480000000100200004000000000000000000000000000000000000000000000000083458FFD8FFE000104A46494600010..... 31 | 1=87750*书签 2*2800000048000000480000000100200004000000000000000000000000000000000000000000000000087750FFD8FFE000104A46494600010..... 32 | 2 33 | ``` 34 | 35 | 官网没有对该文件格式的说明,google也未搜索到,于是对其简单测试后猜测 36 | 37 | ``` 38 | <序号>=<毫秒>*<书签名>*<40个固定的字符><48个变化的字符><图片文件的字节16进制表示> 39 | ``` 40 | 41 | 其中`40个固定的字符`=`2800000048000000480000000100200004000000`。 42 | 而`48个变化的字符`未发现其规律,但是值又不影响功能,所以在我的脚本中用了毫秒数前补位0。 43 | `图片文件的字节16进制表示`,主要是因为,`FFD8FF`正好是`jpg`文件的文件头,于是省了好多猜测时间,连base64都省了。 44 | 45 | 经过测试这个16进制保存成二进制文件后确实是一个jpg文件。 46 | 47 | 这是JavaScript的脚本: 48 |
49 | 50 | ```javascript 51 | const fs = require('fs'); 52 | /** 53 | * 将16进制或二进制表示的字节码转成Uint8Array,如"FF02"转成[0xff, 0x02] 54 | * @param {String} str 55 | * @param {16|2} radix 56 | * @returns {Uint8Array} 57 | */ 58 | function str2bin(str, radix = 16) { 59 | if (radix != 16 && radix != 2) throw new Error('radix must be 16 or 2'); 60 | let padNum = radix == 16 ? 2 : 8; 61 | if (str.length / padNum % 1 != 0) throw new Error('str length must be multiple of ' + padNum); 62 | bufferDecode = []; 63 | for (let i = 0; i < str.length; i += padNum) { 64 | let byte2 = str.substring(i, i + padNum); 65 | let b = parseInt(byte2, radix); 66 | bufferDecode.push(b); 67 | } 68 | return Uint8Array.from(bufferDecode); 69 | } 70 | 71 | /** 72 | * 73 | * @param {String} str 74 | * @param {String} filepath 75 | * @returns {Promise} 76 | */ 77 | function hexstr2file(str, filepath) { 78 | let bufs = str2bin(str); 79 | return new Promise((resolve, reject) => { 80 | fs.createWriteStream(filepath).write(bufs, (err) => { 81 | if (err) { 82 | reject(err); 83 | } else { 84 | resolve(); 85 | } 86 | }); 87 | }) 88 | } 89 | 90 | hexstr2file("FFD8FF....","path/to/file.jpg"); 91 | ``` 92 |
93 | 94 | ## ffmpeg生成缩略图的研究 95 | 96 | ### 阅读ffmpeg的wiki 97 | 98 | [ffmpeg的wiki](https://trac.ffmpeg.org/wiki)中有一篇文章[Create a thumbnail image every X seconds of the video](https://trac.ffmpeg.org/wiki/Create%20a%20thumbnail%20image%20every%20X%20seconds%20of%20the%20video),详细介绍了几种从视频中生成帧图片的方法 99 | 100 | > 注意:文档中`thumbnail`的命令行应该用`ffmpeg -i input.flv -vf thumbnail=n=100 -vsync vfr thumb%04d.png`才能间隔100帧生成一张图片,否则会按照源帧率生成 101 | 102 | ### 用哪种方式? 103 | 104 | - `-frames`:适合抽取一张,不适合跳着抽取; 105 | - `fps`:按照固定的间距抽取,如果对结果要求不高可以使用这个方式; 106 | - `thumbnail`:类似`fps`,但是,它会在n帧内按照算法自行选择一帧,不是每n帧取第一帧,基本也可以算是固定间隔; 107 | - `select`:有丰富的选项,而且从wiki中的命令`eq(pict_type,PICT_TYPE_I)`就可以看到,它可以按照帧类型选择(I帧),这感觉更符合需求; 108 | 109 | > `select`过滤器[官方文档](https://ffmpeg.org/ffmpeg-filters.html#select_002c-aselect) 110 | 111 | 通过阅读文档,还发现`select`有一个计算值`scene`,取值0.0 ~ 1.0之间,用来表示“当前帧是一个新场景”的概率,通过底下的`Examples`官方建议值是0.3 ~ 0.5之前比较合理。 112 | 113 | 经过测试,取0.5时,效果已经非常好,当然可能不同的视频内容会需要不同的值,因此后续在脚本中也会将这个值作为可选参数列出。 114 | 115 | ## 如何记录图片对应的时间点? 116 | 117 | 如果你跑了上面的命令,会发现图片序列已经生成好了,但是,我们并不知道每张图片对应的时间点。 118 | 因此,还需要一个手段可以把时间点记录下来。 119 | 120 | 好在,ffmpeg已经提供了能打印丰富帧信息的过滤器[showinfo](https://ffmpeg.org/ffmpeg-filters.html#showinfo) 121 | 会打印输入流的每一帧的信息,包含帧序号、pts、pts时间等。 122 | 以下是某个输出: 123 | ``` 124 | [Parsed_showinfo_0 @ 00000149d13733c0] n:3043 pts:1558016 pts_time:101.433333 duration: 512 duration_time:0.0333333 fmt:yuv420p cl:left sar:1/1 s:1920x804 i:P iskey:0 type:P checksum:FB3D9DD5 plane_checksum:[4091A083 CD555D97 509C9FAC] mean:[91 129 124] stdev:[46.2 1.9 3.1] 125 | [Parsed_showinfo_0 @ 00000149d13733c0] color_range:tv color_space:bt709 color_primaries:bt709 color_trc:bt709 126 | ``` 127 | 128 | 其中,`pts_time`单位是毫秒,在没有其他修改pts过滤器的情况下,你可以认为就是这一帧在原视频的时间点。 129 | 这个输出信息是和其他信息一样在ffmpeg的error输出流里的,除了重定向,没法通过给ffmpeg传一个文件路径的方式保存这些信息,因此后续脚本需要对这个信息进行处理。 130 | 131 | 因此,我们只需要在`select`过滤器后面带上`showinfo`,就可以打印选中的帧信息了。 132 | 133 | ## 合并后的命令行 134 | 135 | ``` 136 | ffmpeg -y -hide_banner -i "input.mp4" -filter_complex select='gt(scene,0.5)*eq(pict_type,I)',scale=-2:72,showinfo=checksum=0 -vsync vfr scene/preview%5d.jpg 137 | ``` 138 | 139 | 命令解析: 140 | 141 | - `-y`:覆盖输出文件 142 | - `-hide_banner`:不打印ffmpeg的版本信息 143 | - `-i`:输入文件 144 | - `-filter_complex`:复合过滤器 145 | - `select`:`帧选择`过滤器,`gt(scene,0.5)`表示选择`scene`值大于0.5的帧,`eq(pict_type,I)`表示选择I帧 146 | - `scale`:`缩放`过滤器,`-2:72`表示高度缩放为72像素,宽度按比例缩放并且保持2的倍数 147 | - `showinfo`:`打印帧信息`过滤器,`checksum=0`表示不打印帧校验码 148 | - `-vsync vfr`:`视频同步`过滤器,`vfr`表示按照帧率计算时间 149 | - `scene/preview%5d.jpg`:输出文件及占位符,`%5d`表示根据帧序号生成5位数的数字,如“00001”。鉴于是关键帧且scene大于0.5,5个数字可以认为已足够。 150 | 151 | 152 | ## 后续优化 153 | 154 | 使用以上命令输出后,会发现,有些时间区间生成的帧很密集,比如有些画面切换很快的场景。 155 | 而有些时间段,可能十几分钟都没有一帧。这样在播放器中选择的时候信息量不够。 156 | 我们可能想要: 157 | 158 | - 如果当前帧距离上一个选择的帧太近(20秒),那么就不要这一帧; 159 | - 如果当前帧距离上一个选择的帧太远(60秒),那么就选择这一帧; 160 | 161 | 好在,`select`提供的表达式及字段能够满足这个需求 162 | 163 | - `prev_selected_t`:上一次选择的帧的时间点,如果每选过就是NAN; 164 | 165 | 此时,需要再复习一下`select`的语法,它后面默认跟着一个表达式expr,如果这个表达式计算结果等于0,则舍弃这一帧。 166 | 非0时,会根据值来决定帧发送给哪个输出流,因为我们只用一个输出流,因此可以认为非0都是选中的帧。 167 | 168 |
169 | 官方文档摘录 170 | 171 | This filter accepts the following options: 172 | 173 | expr, e 174 | Set expression, which is evaluated for each input frame. 175 | 176 | If the expression is evaluated to zero, the frame is discarded. 177 | 178 | If the evaluation result is negative or NaN, the frame is sent to the first output; otherwise it is sent to the output with index ceil(val)-1, assuming that the input index starts from 0. 179 | 180 | For example a value of 1.2 corresponds to the output with index ceil(1.2)-1 = 2-1 = 1, that is the second output. 181 | 182 | outputs, n 183 | Set the number of outputs. The output to which to send the selected frame is based on the result of the evaluation. Default value is 1. 184 | 185 |
186 | 187 | 因此把select过滤器改为: 188 | ``` 189 | select='(isnan(prev_selected_t)+gte(t-prev_selected_t,20))*(gt(scene,0.5)*eq(pict_type,I)+gte(t-prev_selected_t,60))' 190 | ``` 191 | 其中, 192 | - `isnan(prev_selected_t)+gte(t-prev_selected_t,20)`: 193 | - 如果还没选过帧,可以计算这一帧,此时,isnan(prev_selected_t)=1; 194 | - 如果距离上一帧超过20秒,可以计算这一帧,此时,gte(t-prev_selected_t,20)=1; 195 | - 也就是说,如果选过帧了且这一帧距离上一帧小于20秒,则不用计算了,因为两个值都是0; 196 | - `gte(t-prev_selected_t,60)`:如果距离上一帧超过60秒,则选择这一帧;没超过60秒的,就看`gt(scene,0.5)*eq(pict_type,I)`的计算结果了。 197 | 198 | ## 脚本编写 199 | 200 | 有了以上的理论基础,就可以开始编写脚本了。 201 | 脚本首先实现根据showinfo的输出,将对应的图片转换后,生成对应的pbf文件。 202 | 接着增加遍历文件夹的功能,使可以进行批处理,并使用后缀名简单区分视频文件。 203 | 又根据实际情况,开发了一些可以自行修改的参数,可以适配不同的需求。 204 | 205 | ## 关于运行效率 206 | 207 | 由于`select`过滤器需要`解码`整个视频流,加上计算`scene`和`scale`的一些耗时,因此运行效率会受cpu性能影响,本脚本暂时没测试使用`硬件编码器`的加速效果。 208 | 在`N5105`cpu的机器上,运行解析1080P视频,效率大概是8倍速,也就是8分钟的视频需要1分钟时间。 209 | 其他CPU可以自行测试,使用`-debug`查看ffmpeg的运行信息。 210 | -------------------------------------------------------------------------------- /docs/ffmpeg.watermark.help.md: -------------------------------------------------------------------------------- 1 | # ffmpeg.watermark.js的使用帮助 2 | 3 | ## 脚本输出的帮助信息 4 | 5 | 运行`node ffmpeg.watermark.js -h`后可以得到 6 | 7 | ```shell 8 | -preset 本脚本除了-preset之外的所有参数,均可以通过传递preset文件来设置。 9 | 如果使用./preset/abc.preset来设置,则-preset abc即可。 10 | preset文件的编写请参考github(https://github.com/jifengg/ffmpeg-script)。 11 | -i [必须]要处理的文件或目录 12 | -y 是否覆盖已经存在的pbf文件,默认:false 13 | -h 显示这个帮助信息 14 | -debug 是否开启debug模式,打印更详细的日志 15 | -[text|file] [必须]水印的文本内容或文件路径,必须至少传一组。 16 | 与ffmpeg参数传递规则类似,水印有很多可定义的参数,且支持传多个水印。 17 | 因此,-text/file之前的参数是用来设置这一组水印信息的,之后的参数是下一组水印的。 18 | 如:-fontsize 30 -text 此水印字号为30 -fontsize 40 -text 此水印字号为40 19 | -text 水印的文本内容 20 | -fontsize 文字的字号,默认:20 21 | -fontcolor 文字颜色,值的格式同ffmpeg的color,默认:white 22 | -fontborderwidth 23 | 文字边框大小,单位像素,默认:0 24 | -fontbordercolor 25 | 文字边框颜色,值的格式同ffmpeg的color,默认:black 26 | -fontfile 文字字体文件路径,非windows下使用时必传,默认:c:/Windows/Fonts/msyh.ttc(微软雅黑) 27 | -file 水印文件的路径,支持视频或图片 28 | -scale 水印文件的缩放尺寸,值的格式同ffmpeg的scale过滤器。如“1920:1080” 29 | -alpha 水印的透明度,取值范围:0.0 - 1.0,默认:1.0[完全不透明] 30 | -[left|right|top|bottom] 31 | 水印的左、右、上、下边距,单位:像素。默认:right=20,top=20 32 | -move 水印的移动方式,可选:dvd、random;默认不移动 33 | -xspeed move=dvd时生效。表示每秒水平移动的像素。默认:400 34 | -yspeed move=dvd时生效。表示每秒垂直移动的像素。默认:300 35 | -xstart move=dvd时生效。表示初始水平位置。默认:0 36 | -ystart move=dvd时生效。表示初始垂直位置。默认:0 37 | -interval move=random时生效。表示多少秒变换一个位置。默认:10 38 | -seed move=random时生效。表示随机数种子。不传则随机生成 39 | -repeat 是否用水印重复填充整个画面,默认:false 40 | -boxw 启用填充时,每个水印的宽度,如果水印是-file,则不能小于-scale的宽度,默认:200 41 | -boxh 启用填充时,每个水印的高度,如果水印是-file,则不能小于-scale的高度,默认:100 42 | -rotate 启用填充时,每个水印的旋转角度,注意是角度而不是弧度,默认:0 43 | ``` 44 | 45 | ## 多个水印信息的配置 46 | 47 | 本次脚本支持的水印有文本(`-text`)和文件(`-file`),且支持同时设置多个水印信息。因此,需要了解如何在命令行参数里通过相同的参数设置不同的水印配置。 48 | 49 | 和ffmpeg类似,当你使用-text的时候,可以用-fontsize、-fontcolor等参数来定义文本的格式。 50 | 脚本规定:`定义水印信息的参数,均必须在-text或-file之前`,如果你把-fontsize放在-text之后,那它其实定义的是下一个水印(如果有的话)的字号。 51 | 52 | 例如, 53 | ```shell 54 | node ffmpeg.watermark.js -i input.mp4 ^ 55 | -fontsize 40 -fontcolor blue -left 0 -top 0 -text "左上角蓝色的40号文字" ^ 56 | -left 0.5 -top 0.5 -text "画面中间的文本" ^ 57 | -move dvd -xspeed 400 -yspeed 300 -file "模拟dvd待机运动的图片.png" ^ 58 | -repeat -boxw 400 -boxh 40 -fontsize 30 -text "填充水印内容" ^ 59 | -right 0 -bottom 0 -scale 120:-1 -file "宽度缩放到120显示到右下角的图片.png" 60 | ``` 61 | 62 | > 为了方便观看,使用windows下的命令行分隔符^进行了多行分隔 63 | 64 | 这个示例里,分别定义了5个水印。其中第一个文本水印`-text "左上角蓝色的40号文字"`,它的自定义参数是`-fontsize 40 -fontcolor blue -text -left 0 -top 0`,这个参数列只会对这个-text生效。 65 | 66 | 第二个水印文本`-text "画面中间的文本"`,未定义`-fontsize`,前面的`-fontsize 40`也不会对它生效,因此它的字号是默认的`20`,同理这个文本的颜色也是默认的`white`而不是`blue`,其它参数同理。 67 | 68 | 第三个水印这是一个png图片,定义了“dvd”的运动方式,水平速度400px/s,垂直速度300px/s:`-move dvd -xspeed 400 -yspeed 300`,命令行里其它参数对它无效。 69 | 70 | 第四个、第五个同理,也都只有它前面的参数定义,如果未设置则使用默认值,而不是使用别的水印的设置。 71 | 72 | ## preset的编写 73 | 74 | 本次脚本,由于增加了很多配置参数,如果每次使用的时候都需要编写一长串参数,那么显然是十分麻烦的。因此,本次脚本支持使用-preset来定义一组参数。 75 | 76 | 首先需要编写preset文件,它是一个`utf8`编码的`文本文件`。文件名及存储路径可以随意。 77 | 比如创建一个文本文件`C:\mypreset\custom.txt`,并填写内容: 78 | 79 | ```shell 80 | # 这是一个自定义的preset文件。这一行是注释 81 | -fontsize 82 | 40 83 | -fontcolor 84 | blue 85 | -left 86 | 0 87 | -top 88 | 0 89 | ``` 90 | 91 | ### 编写规则: 92 | 93 | - 在命令行里用空格分隔的每个参数,在这里用换行符分隔。 94 | - 前后不需要用引号,即使参数有空格也不需要引号,且含空格的行也算一个参数。 95 | - 如果有对齐参数的需求,可以在前面使用空格。前后的空格均会被忽略。 96 | - 以#或//开头的为注释。目前只支持行注释。如果要传的参数是#或//开头,则只需要在前面加空格即可。 97 | - 脚本的所有参数,除了-preset,均可以在preset文件里定义。 98 | 99 | > 在项目目录 `/preset` 有一些示例文件可以参考。 100 | 101 | 现在,上面示例的第一个水印则可以改成 102 | 103 | ```shell 104 | node ffmpeg.watermark.js -i input.mp4 -preset "C:\mypreset\custom.txt" -text "左上角蓝色的40号文字" ^ 105 | ``` 106 | 107 | ### 使用方式 108 | 109 | - 在`-preset xxx`的地方,可以看作是在命令行这里插入`xxx`中定义的参数,同样会受到参数顺序的影响; 110 | - 可以多次使用`-preset`。如果有可能被覆盖的参数,则后定义的会覆盖先定义的; 111 | - 如果文件是存放在脚本所在目录的`/preset/abc.preset`,则直接写`-preset abc`即可。 112 | -------------------------------------------------------------------------------- /docs/ffmpeg.watermark.md: -------------------------------------------------------------------------------- 1 | # 为图片或视频添加自定义的水印,具有动态水印等多种高级功能。 2 | 3 | - [效果预览](#效果预览) 4 | - [前言](#前言) 5 | - [技术分析](#技术分析) 6 | - [运动路径:模拟DVD待机画面](#运动路径模拟dvd待机画面) 7 | - [设置透明度](#设置透明度) 8 | - [水印填充](#水印填充) 9 | 10 | ## 效果预览 11 | 12 |
13 | 模拟DVD待机画面 14 | 15 |
16 | 17 | ---- 18 | 19 |
20 | 每1秒随机变换水印位置 21 | 22 |
23 | 24 | ---- 25 | 26 | 身份证添加水印: 27 | 28 | ![idcard_watermark](https://github.com/jifengg/ffmpeg-script/assets/17020523/7e8dff92-feec-40e3-978f-54df1fabdad5) 29 | 30 | 31 | ## 前言 32 | 33 | 给图片或视频增加水印,这是一个很常见的功能。在ffmpeg里,其实就是一个`overlay`画面叠加的过滤器。 34 | 这个过滤器的x、y值支持表达式,于是可以在表达式上根据播放进度t来动态计算x、y值,实现动态水印。 35 | 而如果是要叠加文本,则是用`drawtext`过滤器,它是直接在画面上绘制文本,同时x、y也支持表达式。 36 | 37 | 通过动态算法,可以实现很多有趣的效果,比如模拟DVD待机画面、每n秒随机变换水印位置等。 38 | 39 | ## 技术分析 40 | 41 | ### 运动路径:模拟DVD待机画面 42 | 43 | 从上面效果预览可以看到,这个运动是一个匀速运动,会在画面内有类似“回弹”的效果。 44 | 这个可以做个拆解,水平方向x轴的运动,和垂直方向y轴的运动。 45 | 只要实现了一个方向的往复运动,两个方向同时运动即可达到预想效果。 46 | 47 | > 先了解:视频宽度W,水印宽度w,为了让水印在水平运动有触底回弹的效果,因此x轴最小为0,最大则为W-w。 48 | 49 | #### 方法一:绝对值和取余函数 50 | 51 | 一开始我尝试了取余函数`mod(t,W-w)`,但是这个效果是,水印运动到最右边之后,会立刻在最左边出现。并没有“回来”的效果。 52 | 我们可以在“数字帝国”这个网站上看到[函数图(x%10)](https://zh.numberempire.com/graphingcalculator.php?functions=x%2510&xmin=0&xmax=100&ymin=-40&ymax=40&var=x) 53 | 54 | > 假设W-w=10,其中x轴表示时间 55 | 56 | ![image](https://github.com/jifengg/ffmpeg-script/assets/17020523/19e13c56-20bd-45a5-93a4-44b4fad7a9b1) 57 | 58 | 不过,通过函数图我们可以看到,y值从0到10,如果函数变成 x%10-10/2,也就是减去最大值的一半,则有一半会变成负数: 59 | 60 | > x%10-5 61 | 62 | ![image](https://github.com/jifengg/ffmpeg-script/assets/17020523/fd88e459-d0f1-4467-987c-0cc154b95e7e) 63 | 64 | 如果,再对y值取绝对值,那负数就可以变成正数了: 65 | 66 | > abs(x%10-5) 67 | 68 | ![image](https://github.com/jifengg/ffmpeg-script/assets/17020523/c9b4ccba-8d7b-4024-9fe7-20b18aec9107) 69 | 70 | 等等,x=0的时候,y=5,还需要将函数图整体`向左移动5`,才能使得x=0时,y=0 71 | 72 | > abs((x+5)%10-5) 73 | 74 | ![image](https://github.com/jifengg/ffmpeg-script/assets/17020523/f030e263-090a-4b2c-af7b-fa3262b77a8a) 75 | 76 | 我们希望得到一个在0和n之间回弹的函数,那么就是`abs((x+n)%(2*n)-n)`,我这里不把n提取出来,是因为要设置初始坐标、移动速度等都是以像素为单位,只需要修改函数里x的初始值及倍数即可。例如初始坐标100,速度是每秒移动200像素,那么就用`abs((100+200*x+n)%(2*n)-n)`即可,其中x表示时间,单位秒。 77 | 78 | 79 | #### 方法二:正弦与反正弦函数 80 | 81 | > 注意,这里三角函数里的值用的是`弧度`,而不是`角度`; 82 | 83 | 后来我又想起来,数学里有名的正弦函数sin(x),它的函数图就是一个波浪形的曲线: 84 | 85 | ![image](https://github.com/jifengg/ffmpeg-script/assets/17020523/2a872804-200c-4f04-afc7-1e23d695c04c) 86 | 87 | 只不过,它不是一个线性变化的曲线,如果直接用它来表示x轴的运动,会有加速减速的效果,且模拟出来的就不是回弹的效果了。 88 | 因此,需要有什么手段把函数图的曲线变成直线。 89 | 此时,可以再加一个反正弦函数,变成`asin(sin(x))`,就会发现,当x取值在(0,2𝜋)时,y值在(-0.5𝜋,0.5𝜋)之间线性变化。 90 | 因此,如果我们希望得到一个x取值(0,1)和(1,2)时,y值从0到1,再从1到0的一个函数,就改成`asin(sin((x-0.5)*pi))/pi+0.5` 91 | 92 | ![image](https://github.com/jifengg/ffmpeg-script/assets/17020523/11330d9f-e3ce-4dd6-ad7c-2ae013557db4) 93 | 94 | 虽然这个函数可以达到回弹的效果,但是涉及到要设置初始位置、运动速度时,公式就会变得比较麻烦,所以,目前在ffmpeg里我采用了第一种方式来实现。 95 | 96 | ### 设置透明度 97 | 98 | 本次学习到了2种“使画面变半透明”的方法。分别是[geq](https://ffmpeg.org/ffmpeg-filters.html#geq)和[colorchannelmixer](https://ffmpeg.org/ffmpeg-filters.html#colorchannelmixer)过滤器。 99 | 不过要注意使用这个过滤器之前要保证画面是argb格式的,因此还需要经过[format](https://ffmpeg.org/ffmpeg-filters.html#format-1)转换一次。 100 | 以下两种方式均演示了把输入画面的透明度设置为50%: 101 | 102 | ```shell 103 | [1:v]format=argb,geq=a='0.5*alpha(X,Y)'[out1] 104 | [1:v]format=argb,colorchannelmixer=aa=0.5[out1] 105 | ``` 106 | 107 | ### 水印填充 108 | 109 | ffmpeg本身没有提供类似背景填充的功能,需要我们自己实现。 110 | 我们可以在脚本里计算好需要填充的数量及相应的位置,然后一个个通过overlay叠加上去。 111 | 但是我们可以变换一种思路,先把水印通过`hstack`过滤器水平拼接m个,再把水平拼接好的通过`vstack`过滤器垂直拼接n个,这样就可以把水印填充成mxn的大小。再通过`rotate`旋转指定的角度后,和原画面进行叠加。 112 | 脚本这里假设不知道原画面的大小,于是填充了一张`足够大`的图来进行叠加。 113 | 114 | -------------------------------------------------------------------------------- /docs/ffmpeg.xfade.md: -------------------------------------------------------------------------------- 1 | # ffmpeg过滤器xfade自定义动画的研究 2 | 3 | - [前言](#前言) 4 | - [效果预览](#效果预览) 5 | - [水滴](#水滴) 6 | - [百叶窗](#百叶窗) 7 | - [简易翻页](#简易翻页) 8 | - [ffmpeg官方wiki](#ffmpeg官方wiki) 9 | - [ffmpeg官方文档翻译](#ffmpeg官方文档翻译) 10 | - [理解 P](#理解-p) 11 | - [理解 X,Y,W,H](#理解-xywh) 12 | - [理解 PLANE,A,B,a0(x,y),...,b0(x,y),...](#理解-planeaba0xyb0xy) 13 | - [理解 expr](#理解-expr) 14 | - [尝试1,实现渐隐渐显效果](#尝试1实现渐隐渐显效果) 15 | - [尝试2,实现擦除效果](#尝试2实现擦除效果) 16 | - [尝试3,实现推走效果](#尝试3实现推走效果) 17 | - [小结](#小结) 18 | - [性能](#性能) 19 | - [其它转场过滤器](#其它转场过滤器) 20 | - [xfade\_opencl](#xfade_opencl) 21 | - [gl-transition](#gl-transition) 22 | - [结语](#结语) 23 | 24 | 25 | ## 前言 26 | 27 | 使用`xfade`过滤器做视频转场切换效果,本身ffmpeg已经提供了56种效果,能满足大部分需求。不过,更复杂的过渡效果(例如翻页)还没有。 28 | 根据文档,使用transition=custom+expr,可以实现自定义的效果。但是,官方文档并没有对`expr`如何编写做详细说明,也没有google到。 29 | 因此,对其进行了一番研究,尝试实现了几种效果。简单做一个使用教程,希望能够帮助到有需要的人。 30 | 31 | ## 效果预览 32 | 33 | ### 水滴 34 | 35 | 36 | 37 | ### 百叶窗 38 | 39 | 40 | 41 | ### 简易翻页 42 | 43 | 44 | 45 | ## ffmpeg官方wiki 46 | 47 | [https://trac.ffmpeg.org/wiki/Xfade](https://trac.ffmpeg.org/wiki/Xfade) 48 | 49 | ## ffmpeg官方文档翻译 50 | 51 | 以下翻译自[FFmpeg xfade官方文档](https://ffmpeg.org/ffmpeg-filters.html#xfade) 52 | 53 |
54 | 55 |
xfade
 56 | 
 57 | 将淡入淡出从一个输入视频流应用到另一个输入视频流。淡入淡出将持续指定的时间。
 58 | 两个输入必须是恒定帧速率,并且具有相同的分辨率、像素格式、帧速率和时间基准。
 59 | 
 60 | 该过滤器接受以下选项:
 61 | 
 62 | transition
 63 |     'custom'
 64 |     [忽略]
 65 | 
 66 | duration
 67 |     设置交叉淡入淡出持续时间(以秒为单位)。范围为 0 至 60 秒。默认持续时间为 1 秒。
 68 | 
 69 | offset
 70 |     设置相对于第一个输入流的交叉淡入淡出开始时间(以秒为单位)。默认偏移量为 0。
 71 | 
 72 | expr
 73 |     设置自定义过渡效果的表达式。
 74 |     表达式可以使用以下变量和函数:
 75 | 
 76 |     X
 77 |     Y
 78 |         当前样本的坐标。
 79 | 
 80 |     W
 81 |     H
 82 |         图像的宽度和高度。
 83 | 
 84 |     P
 85 |         过渡效果的进展。
 86 |         【译注】过渡开始时,P=1.0,过渡结束时,P=0.0。
 87 | 
 88 |     PLANE
 89 |         目前正在处理的平面。
 90 |         【译注】这里的平面,其实就是指像素格式的分量。
 91 |         【译注】取值范围由输入流的像素格式pix_fmt决定,如 yuv420p,则取值范围是0,1,2;如 rgba,则取值范围是0,1,2,3。
 92 | 
 93 |     A
 94 |         返回第一个输入流在当前位置和平面的值。
 95 | 
 96 |     B
 97 |         返回第二个输入流在当前位置和平面的值。
 98 | 
 99 |     a0(x,y)
100 |     a1(x,y)
101 |     a2(x,y)
102 |     a3(x,y)
103 |         返回第一个输入的第一/第二/第三/第四个分量的 位置 (x,y) 处的像素的值。
104 |         【译注】例如,像素格式是yuv420p,a0返回的是 Y 分量。a1返回的是 U 分量。a2返回的是 V 分量。没有a3
105 | 
106 |     b0(x,y)
107 |     b1(x,y)
108 |     b2(x,y)
109 |     b3(x,y)
110 |         返回第二个输入的第一/第二/第三/第四个分量的 位置 (x,y) 处的像素的值。
111 | 
112 |
113 | 114 | ## 理解 P 115 | 116 | 一般来说,ffmpeg中支持时间轴编辑的过滤器,都有`t`和`n`参数可以用在表达式中,其中`t`表示时间秒,`n`表示帧数。 117 | 但是xfade里却是用的P,它不是`t`或`n`。如果你理解错了,会发现自定义效果完全没效。 118 | 因为,它表示的是过渡效果的进度,而且,重要的是,它是个递减的数。 119 | - 过渡动画开始的时候,P=1.0; 120 | - 过渡动画结束的时候,P=0.0; 121 | - 它的值是按帧线性递减的,例如,duration=4,fps=25,那么第二帧的时候,P=1.0-1/(4*25)=0.99; 122 | - 可以通过数学函数来改变P的“线性”,例如 P\*P\*(3-2P),([Smoothstep](https://en.wikipedia.org/wiki/Smoothstep),[函数图](https://zh.numberempire.com/graphingcalculator.php?functions=x*x*(3-2*x)&xmin=0&xmax=1&ymin=0&ymax=1&var=x))。 123 | - 注意,P是从1.0到0.0,因此查看函数图的时候要注意从右往左看。 124 | - 如果你觉得从右往左看不直观,把所有P都改成(1-P)吧。 125 | - win11自带的计算器有一个“绘图”功能,能够很好的显示各种数学函数的图形,可以用来辅助理解。 126 | 127 | ## 理解 X,Y,W,H 128 | 129 | X,Y表示坐标,是指“当前正在计算表达式的像素的坐标”,按照我们要实现的效果,决定该像素对应的颜色码。 130 | 131 | W,H是图像的宽高,这个在整个渐变过程是保持不变的。 132 | 133 | ## 理解 PLANE,A,B,a0(x,y),...,b0(x,y),... 134 | 135 | a0(x,y)表示第一个视频坐标x,y处的像素的第一个分量值。 136 | PLANE表示当前是计算的第几个分量值。 137 | A是一个简写,当PLANE=0时,A=a0(X,Y);PLANE=1时,A=a1(X,Y);PLANE=2时,A=a2(X,Y);以此类推。 138 | b和B同a和A。 139 | 140 | > 注意,无法通过类似`a(plane,x,y)`的方法来获得指定坐标指定分量的值,因此在像素有位移的时候,表达式会比较长。如`if(eq(PLANE,0),a0(X,Y),if(eq(PLANE,1),a1(X,Y),if(eq(PLANE,2),a2(X,Y),0)))` 141 | 142 | ## 理解 expr 143 | 144 | `xfade`的`expr`,返回一个值,但是这个值是什么含义呢,一般人看文档很难理解。 145 | 以 `300x200` 的输入源为例,假设其像素格式是yuv420p,则其分量个数是3。(ffmpeg支持的像素格式及格式信息,可以通过`ffmpeg -pix_fmts`查看)。 146 | 像素点是`60000`个,每一帧的像素分量总数就是`60000*3=18万`个。 147 | 那么,过渡开始的第一帧,ffmpeg会遍历每个像素点的每个分量,分别调用`expr`,并设置X,Y,PLANE等值。总共调用`18万`次获得对应的值,来完成第一帧的渲染。 148 | 如果我们希望每一帧就是显示第一个视频的画面,那么可以写`expr=A`即可。`A`表示的就是第一个视频当前像素当前分量的值。 149 | 150 | ### 尝试1,实现渐隐渐显效果 151 | 152 | 如果我们希望实现第一个视频渐渐变透明,第二个视频由透明渐渐显现,类似`xfade`默认的效果`fade`,那么可以写`expr='A*P+B*(1-P)'`。 153 | 因为P是从1.0线性变成0.0的。所以一开始P=1,表达式计算结果=`A`,看到的就是只有第一个视频画面,到一半时,P=0.5,结果=`0.5A+0.5B`,画面就是两个视频分别半透明叠加在一起。最后P=0.0时,结果=`B`,就只剩下第二个视频的画面了。 154 | 155 | ### 尝试2,实现擦除效果 156 | 157 | 同样的,如果我们希望实现一个从右往左擦除的效果(图片引用自[https://trac.ffmpeg.org/wiki/Xfade](https://trac.ffmpeg.org/wiki/Xfade)): 158 | ![wipeleft](https://trac.ffmpeg.org/raw-attachment/wiki/Xfade/wipeleft.gif) 159 | 160 | 分析一下,分割线在画面水平线上的位置X,除以宽度W,其实就是等于P,于是,我们可以让分割线左边的显示画面A,右边的显示画面B。 161 | `expr='if(lt(X/W,P),A,B)'`:当`X/W 分割线上显示A还是B,影响不大。这里是显示了B,如果要显示A,可以用`lte`代替`lt`。 164 | 165 | ### 尝试3,实现推走效果 166 | 167 | 从上面两个例子你大概能理解expr要返回什么内容了。我们接着第三个例子。 168 | 如果我们希望实现的是一个从右往左`推走`的效果: 169 | ![slideleft](https://trac.ffmpeg.org/raw-attachment/wiki/Xfade/slideleft.gif) 170 | 171 | 你会发现,变得更复杂了。你可以先暂停试试自己能否写出来。 172 | 173 | 为什么更复杂了?以坐标(0,0)为例,他显示的像素时刻都在变化(因为画面在往左移动)。 174 | 例如,在P=0.8的时候,它(0,0)应该是视频A X=W*0.2,Y=0坐标处的像素值。(这里需要好好理解,参考下图帮忙理解) 175 | 176 | ![image](https://github.com/jifengg/ffmpeg-script/assets/17020523/c8e6a23c-03f2-4f56-9db4-1b7afe0d383b) 177 | 178 | 在`X/W>P`的地方,应该显示视频B的画面,其坐标转换关系是(X-P*W,Y)。 179 | 注意,此时你没法再用值`A`和`B`了,因为它们是坐标(X,Y)的分量,而我们要在(X,Y)处显示别的坐标的像素,这个我们在上面[理解 PLANE,A,B,a0(x,y),...,b0(x,y),...](#理解-planeaba0xyb0xy)的地方说过了。 180 | 181 | 那么这个表达式要怎么写呢? 182 | 183 | ``` 184 | expr='if(lt(X/W,P),^ 185 | if(eq(PLANE,0),a0(X+(1-P)*W,Y),^ 186 | if(eq(PLANE,1),a1(X+(1-P)*W,Y),^ 187 | if(eq(PLANE,2),a2(X+(1-P)*W,Y),0)))^ 188 | ,^ 189 | if(eq(PLANE,0),b0(X-P*W,Y),^ 190 | if(eq(PLANE,1),b1(X-P*W,Y),^ 191 | if(eq(PLANE,2),b2(X-P*W,Y),0)))^ 192 | )' 193 | ``` 194 | 195 | > 我测试的时候用的是windows的bat脚本,为了方便理解和修改,用^进行了换行。注意不要有空格,否则会报错。 196 | > 测试的时候用的是yuv420p像素格式,因此表达式没有用到a3,如果是用了4个分量的像素格式需要把a3按照上面的格式加进去。 197 | 198 | 其中,分割线左边显示视频A的画面,且x坐标左移了(1-P)*W个像素,因此其x坐标表达式是`X+(1-P)*W`; 199 | 右边显示视频B的画面,且x坐标右移到了分割线右边,因此其x坐标表达式是`X-P*W`。 200 | 因为是水平移动,所以y坐标保持`Y`即可。 201 | 202 | 于是,随着P从1.0渐变到0.0,视频A就像被视频B从右边推到了左边,完成了一个过渡效果。 203 | 204 | ## 小结 205 | 206 | 现在,你已经了解了expr要怎么编写来实现过渡效果了。我还实现了一些其它效果,包括示例里的,你可以在GitHub上[查看](https://github.com/jifengg/ffmpeg-script/tree/main/preset/xfade)。 207 | 208 | ## 性能 209 | 210 | 在windows下创建2个bat文件,分别输入测试命令: 211 | 212 | ```bat 213 | @echo off 214 | @REM 使用custom实现slideleft效果 215 | ffmpeg -y -hide_banner ^ 216 | -f lavfi -i "pal100bars=r=1/1000" ^ 217 | -f lavfi -i "colorchart=r=1/1000" ^ 218 | -filter_complex ^ 219 | [0:v]format=yuv420p,scale=960:480,fps=25,trim=duration=40[v1];^ 220 | [1:v]format=yuv420p,scale=960:480,fps=25,trim=duration=40.04[v2];^ 221 | [v1][v2]xfade=duration=40:offset=0:transition=custom:^ 222 | expr='if(lt(X/W,P),^ 223 | if(eq(PLANE,0),a0(X+(1-P)*W,Y),^ 224 | if(eq(PLANE,1),a1(X+(1-P)*W,Y),^ 225 | if(eq(PLANE,2),a2(X+(1-P)*W,Y),0)))^ 226 | ,^ 227 | if(eq(PLANE,0),b0(X-P*W,Y),^ 228 | if(eq(PLANE,1),b1(X-P*W,Y),^ 229 | if(eq(PLANE,2),b2(X-P*W,Y),0)))^ 230 | )' ^ 231 | -crf 23 -c:v h264 -pix_fmt yuv420p -movflags +faststart -r 25 -aspect 960:480 ^ 232 | out1.mp4 233 | ``` 234 | 235 | ```bat 236 | @echo off 237 | @REM 使用内置的slideleft效果 238 | ffmpeg -y -hide_banner ^ 239 | -f lavfi -i "pal100bars=r=1/1000" ^ 240 | -f lavfi -i "colorchart=r=1/1000" ^ 241 | -filter_complex ^ 242 | [0:v]format=yuv420p,scale=960:480,fps=25,trim=duration=40[v1];^ 243 | [1:v]format=yuv420p,scale=960:480,fps=25,trim=duration=40.04[v2];^ 244 | [v1][v2]xfade=duration=40:offset=0:transition=slideleft ^ 245 | -crf 23 -c:v h264 -pix_fmt yuv420p -movflags +faststart -r 25 -aspect 960:480 ^ 246 | out2.mp4 247 | ``` 248 | 249 | 这里使用的动画时长是40秒,可以自行修改成0~60秒。 250 | 在我电脑上运行,耗时分别是:自定义`17.514秒`,内置`1.605秒`。 251 | 可以看出,使用自定义的效果,远比内置效果更耗时。原因我们在“[理解 expr](#理解-expr)”有提过,因为每一帧需要调用expr次数=960×480×3=1,382,400。一百多万次。而且是纯CPU运算,因此效率自然底下。 252 | 253 | 好在一般的过场时长是3、4秒左右,影响还在可接受范围内。 254 | 255 | 如果你在寻找更高效的自定义效果,可以考虑使用`xfade_opencl`过滤器,或者自行编译ffmpeg,加入`gl-transition`过滤器。 256 | 257 | ## 其它转场过滤器 258 | 259 | ### xfade_opencl 260 | 261 | 要使用`xfade_opencl`,需要编译的时候加入`--enable-opencl`,且运行的机器有支持opencl的设备(一般指显卡)。 262 | 要查看当前机器有哪些opencl的设备,可以运行以下命令: 263 | ``` 264 | ffmpeg -v debug -init_hw_device opencl 265 | ``` 266 | 267 | 打印出类似信息: 268 | ``` 269 | [AVHWDeviceContext @ 0000027894f28400] 1 OpenCL platforms found. 270 | [AVHWDeviceContext @ 0000027894f28400] 1 OpenCL devices found on platform "NVIDIA CUDA". 271 | [AVHWDeviceContext @ 0000027894f28400] 0.0: NVIDIA CUDA / NVIDIA GeForce RTX ***** 272 | ``` 273 | 其中`0.0`就是可用的opencl设备编号,在ffmpeg命令中指定使用该设备: 274 | 275 | ``` 276 | ffmpeg -y -hide_banner -init_hw_device opencl=ocldev:0.0 -filter_hw_device ocldev ^ 277 | -f lavfi -r 25 -t 40 -i "pal100bars" ^ 278 | -f lavfi -r 25 -t 40.04 -i "colorchart" ^ 279 | -filter_complex ^ 280 | [0:v]format=yuv420p,scale=960:480,hwupload[v0];^ 281 | [1:v]format=yuv420p,scale=960:480,hwupload[v1];^ 282 | [v0][v1]xfade_opencl=duration=40:offset=0:transition=slideleft,hwdownload,format=yuv420p ^ 283 | -c:v h264_nvenc -pix_fmt yuv420p -movflags +faststart -r 25 -aspect 960:480 ^ 284 | out3.mp4 285 | ``` 286 | 287 | 性能比自定义xfade效果好很多,唯一要求就是需要支持opencl的设备(一般指显卡)。 288 | 且,`xfade_opencl`也是支持自定义效果的,[官方文档](https://ffmpeg.org/ffmpeg-filters.html#xfade_005fopencl)。 289 | 内置的几个效果的源码可以查看GitHub上ffmpeg的源码:[https://github.com/FFmpeg/FFmpeg/blob/master/libavfilter/opencl/xfade.cl](https://github.com/FFmpeg/FFmpeg/blob/master/libavfilter/opencl/xfade.cl) 290 | 291 | ### gl-transition 292 | 293 | [gl-transitions](https://gl-transitions.com/)是由开发者 Gilles Lamothe 创建的,它封装了大量的GPU加速过渡效果,包括但不限于溶解、推拉、旋转等多种类型。这些过渡效果可以轻松地整合到你的图形应用程序中,无论你是开发游戏、视频编辑软件还是实验性的艺术项目。 294 | 它使用OpenGL进行加速,因此,也需要支持OpenGL的设备(一般指显卡)。 295 | 它不是ffmpeg专属的,但是可以做为一个过滤器添加到ffmpeg中。参考这个GitHub项目[transitive-bullshit/ffmpeg-gl-transition](https://github.com/transitive-bullshit/ffmpeg-gl-transition)。 296 | 编译后,你将可以使用其官网上的[所有效果](https://gl-transitions.com/gallery),当然也可以自己编写自定义的效果。 297 | 298 | 性能方面,因为我没有自行编译测试,所以无法给出具体数据。 299 | 300 | 它使用GLSL语言编写,如果你看了上面OpenCL的部分,你会发现它们有很多共同点。 301 | 甚至,我在编写`xfade`自定义表达式的时候,也参考了它的GLSL代码。比如效果预览中的[水滴](#水滴),就是参考了[WaterDrop](https://gl-transitions.com/editor/WaterDrop)。 302 | 303 | ## 结语 304 | 305 | 不知道是ffmpeg官方觉得xfade的expr编写太过容易,还是觉得性能不行不建议使用,反正官方文档及wiki都没有示例,也没有提及如何编写。 306 | 我自己基本上是自己看着文档猜测、尝试,慢慢的摸索出来一些门道。想着网上没有一个类似的教程,于是变写了这个文章。 307 | 如果你发现文章哪里有问题,欢迎指出,大家共同进步。 308 | -------------------------------------------------------------------------------- /docs/imgs/subtitle.stack.demo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jifengg/ffmpeg-script/376ea76b9783eadf78c3b4ea0a2d0a0def1b800b/docs/imgs/subtitle.stack.demo.jpg -------------------------------------------------------------------------------- /ffmpeg.images.rolling.js: -------------------------------------------------------------------------------- 1 | 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | const child_process = require('child_process'); 5 | 6 | let boolArgsKey = [ 7 | 'y', 'h', 'v', 'debug', 8 | ] 9 | function parseArgs(args) { 10 | /* 11 | -name hello -t 1 12 | */ 13 | let rs = { 14 | '_': [] 15 | }; 16 | let key = null; 17 | for (let i = 0; i < args.length; i++) { 18 | let v = args[i]; 19 | if (v.startsWith('-')) { 20 | key = v.substring(1); 21 | if (boolArgsKey.includes(key)) { 22 | rs[key] = true; 23 | key = null; 24 | } 25 | } else { 26 | if (key != null) { 27 | rs[key] = v; 28 | key = null; 29 | } else { 30 | rs._.push(v); 31 | } 32 | } 33 | } 34 | return rs; 35 | } 36 | 37 | function parseNumber(str, defaultValue) { 38 | if (str != null) { 39 | let num = Number(str); 40 | if (!isNaN(num)) { 41 | return num; 42 | } 43 | } 44 | return defaultValue; 45 | } 46 | 47 | function parseTimeString2ms(timeStr) { 48 | try { 49 | // 将时间字符串拆分成小时、分钟、秒和毫秒 50 | const [hours, minutes, seconds] = timeStr.trim().split(':'); 51 | // 转换成毫秒 52 | const totalMilliseconds = 53 | parseInt(hours) * 60 * 60 * 1000 + 54 | parseInt(minutes) * 60 * 1000 + 55 | parseFloat(seconds) * 1000; 56 | return totalMilliseconds; 57 | } catch { 58 | 59 | } 60 | } 61 | 62 | //frame= 13 fps=8.4 q=1.6 size= 1498kB time=00:01:26.20 bitrate=N/A speed=55.8x 63 | let ffmpegProgressReg = /frame=(.+?) fps=(.+?) q=(.+?) [L]*size=(.+?) time=(.+?) bitrate=(.+?) speed=([^ ]+)/ig; 64 | function tryParseProgress(line) { 65 | let match = ffmpegProgressReg.exec(line); 66 | if (match != null) { 67 | return { 68 | frame: parseInt(match[1].trim()), 69 | fps: parseFloat(match[2].trim()), 70 | q: parseFloat(match[3].trim()), 71 | size: match[4].trim(), 72 | time: parseTimeString2ms(match[5].trim()), 73 | bitrate: match[6].trim(), 74 | speed: parseFloat(match[7].trim()), 75 | } 76 | } 77 | } 78 | 79 | function showCmdHelp() { 80 | let msg = `${process.argv.slice(0, 2).join(' ')} -i [-o ...] 81 | -i [必须]图片所在的目录 82 | -duration 每张图片从出现到消失的时长(秒),默认:20 83 | -direction 图片滚动的方向,可选:rl(从右到左,默认),lr(从左到右) 84 | -margin 85 | 图片之间的间距,支持的格式:all、vertical|horizontal、top|right|bottom|left,默认all=20 86 | -o 输出视频的路径,默认为输入目录下的output.mp4 87 | -fps 输出视频的帧率,默认:25 88 | -y 是否覆盖已经存在的输出文件,默认:false 89 | -bgimage 背景图片的路径,比bgcolor优先,默认:无 90 | -blursigma 背景图片虚化的sigma值,为0表示不虚化,默认:15 91 | -bgcolor 背景颜色,值的格式同ffmpeg的color,默认:black 92 | -width 输出视频的宽度,默认:1920 93 | -height 输出视频的高度,默认:1080 94 | -top 图片区距离视频顶部的距离,默认:0 95 | -bottom 图片区距离视频底部的距离,默认:0 96 | -title 视频的标题,显示在画面上方,默认:无 97 | -tsize 标题文字大小,默认:80 98 | -tcolor 标题文字颜色,值的格式同ffmpeg的color,默认:white 99 | -tbordercolor 标题边框颜色,值的格式同ffmpeg的color,默认:black 100 | -tfont 标题字体文件路径,非windows下使用时必传,默认:c:/Windows/Fonts/msyh.ttc(微软雅黑) 101 | -footer 视频的底部文字(脚注),显示在画面下方,默认:无 102 | -fsize 脚注文字大小,默认:30 103 | -fcolor 脚注文字颜色,值的格式同ffmpeg的color,默认:white 104 | -fbordercolor 脚注边框颜色,值的格式同ffmpeg的color,默认:black 105 | -ffont 脚注字体文件路径,非windows下使用时必传,默认:c:/Windows/Fonts/msyh.ttc(微软雅黑) 106 | -h 显示这个帮助信息 107 | -debug 是否开启debug模式,打印更详细的日志 108 | `; 109 | console.log(msg); 110 | } 111 | 112 | let videoFormat = ['mp4', 'avi', 'mov', 'mkv', 'wmv', 'flv', 'm4v', 'mpg', 'mpeg', '3gp', 'ts', 'webm', 'mpv']; 113 | function isVideo(filepath) { 114 | return videoFormat.includes(path.extname(filepath).substring(1).toLowerCase()); 115 | } 116 | 117 | let imageFormat = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'webp', 'svg', 'tif', 'raw', 'ico']; 118 | function isImage(filepath) { 119 | return imageFormat.includes(path.extname(filepath).substring(1).toLowerCase()); 120 | } 121 | 122 | function getAllImageFile(dir) { 123 | let list = fs.readdirSync(dir, { withFileTypes: true }); 124 | let rs = []; 125 | for (const item of list) { 126 | let fullpath = path.join(dir, item.name); 127 | if (item.isFile()) { 128 | if (isImage(fullpath)) { 129 | rs.push(fullpath); 130 | } 131 | } else if (item.isDirectory()) { 132 | let sublist = getAllImageFile(fullpath); 133 | rs.push(...sublist); 134 | } 135 | } 136 | return rs; 137 | } 138 | 139 | function getDrawtextFilter(text, size, color, bordercolor, fontfile, istitle) { 140 | text = text.replace(/\\n/img, '\n'); 141 | return `drawtext=text=${text}:fontsize=${size}:fontcolor=${color}:bordercolor=${bordercolor}:borderw=2:x=(w-tw)/2:y=${istitle ? size / 2 : `h-th-${size / 2}`}:fontfile='${fontfile}'`; 142 | } 143 | 144 | let debug = false; 145 | let defaultFontfile = 'c:/Windows/Fonts/msyh.ttc'; 146 | 147 | async function start(args) { 148 | if (args == null) { 149 | args = parseArgs(process.argv.slice(2)); 150 | } 151 | let input = args.i; 152 | if (input == null || !!args.h) { 153 | showCmdHelp(); 154 | return; 155 | } 156 | if (!fs.existsSync(input)) { 157 | console.log('输入文件夹不存在', input); 158 | return; 159 | } 160 | let overwrite = !!args.y; 161 | debug = !!args.debug; 162 | let width = parseNumber(args.width, 1920); 163 | let height = parseNumber(args.height, 1080); 164 | let top = parseNumber(args.top, 0); 165 | let bottom = parseNumber(args.bottom, 0); 166 | let oneDuration = parseNumber(args.duration, 20); 167 | oneDuration = Math.max(1, oneDuration); 168 | 169 | let images = getAllImageFile(input); 170 | if (images.length == 0) { 171 | console.log('没有找到图片文件'); 172 | return; 173 | } 174 | let outputVideo = args.o || path.join(input, 'output.mp4'); 175 | if (fs.existsSync(outputVideo) && !overwrite) { 176 | console.log('输出文件已存在', outputVideo); 177 | return; 178 | } 179 | let direction = args.direction == 'lr' ? 'lr' : 'rl'; 180 | let bgimage = args.bgimage; 181 | let bgcolor = args.bgcolor || 'black'; 182 | let blursigma = parseNumber(args.blursigma, 15); 183 | let title = args.title; 184 | let titlefontSize = parseNumber(args.tsize, 80); 185 | let titlecolor = args.tcolor || 'white'; 186 | let titlebordercolor = args.tbordercolor || 'black'; 187 | let titlefontPath = path.resolve(args.tfont || defaultFontfile); 188 | if (title && !fs.existsSync(titlefontPath)) { 189 | console.log('字体文件不存在', titlefontPath); 190 | return; 191 | } 192 | titlefontPath = titlefontPath.replace(/\\/g, '/'); 193 | let titleHeight = top;//+ title ? titlefontSize * 2 : 0; 194 | 195 | let footer = args.footer; 196 | let footerfontSize = parseNumber(args.fsize, 30); 197 | let footercolor = args.fcolor || 'white'; 198 | let footerbordercolor = args.fbordercolor || 'black'; 199 | let footerfontPath = path.resolve(args.ffont || defaultFontfile); 200 | if (footer && !fs.existsSync(footerfontPath)) { 201 | console.log('字体文件不存在', footerfontPath); 202 | return; 203 | } 204 | footerfontPath = footerfontPath.replace(/\\/g, '/'); 205 | let footerHeight = bottom;//+ footer ? footerfontSize * 2 : 0; 206 | 207 | let leftMargin = 0; 208 | let topMargin = 0; 209 | let rightMargin = 0; 210 | let bottomMargin = 0; 211 | let marginStr = args.margin || '20'; 212 | if (/^\d+$/.test(marginStr)) { 213 | leftMargin = rightMargin = topMargin = bottomMargin = parseNumber(marginStr, 0); 214 | } else if (/^\d+\|\d+$/.test(marginStr)) { 215 | let [v, h] = marginStr.split('|'); 216 | leftMargin = rightMargin = parseNumber(h, 0); 217 | topMargin = bottomMargin = parseNumber(v, 0); 218 | } else if (/^\d+\|\d+\|\d+\|\d+$/.test(marginStr)) { 219 | let [t, r, b, l] = marginStr.split('|'); 220 | leftMargin = parseNumber(l, 0); 221 | rightMargin = parseNumber(r, 0); 222 | topMargin = parseNumber(t, 0); 223 | bottomMargin = parseNumber(b, 0); 224 | } else { 225 | console.log('margin参数设置无效:“', marginStr, '”,将使用默认值0'); 226 | } 227 | 228 | let fps = parseNumber(args.fps, 25); 229 | 230 | let startTime = Date.now(); 231 | console.log('开始处理。'); 232 | console.log( 233 | `输入目录:${input} 234 | 图片数量:${images.length} 235 | 视频分辨率:${width}x${height}`); 236 | let cmd = 'ffmpeg'; 237 | let filter_complex = ''; 238 | let duration = oneDuration * images.length; 239 | let bginputs = []; 240 | if (bgimage != null) { 241 | bginputs = [...'-loop 1 -r 1/1000 -i'.split(' '), bgimage]; 242 | } else { 243 | bginputs = [...'-f lavfi -t 1 -r 1/1000 -i'.split(' '), `color=c=${bgcolor}:s=${width}x${height}`]; 244 | } 245 | let imageInputs = []; 246 | for (let i = 0; i < images.length; i++) { 247 | imageInputs.push('-i', images[i]); 248 | } 249 | filter_complex += `[0:v]${bgimage ? `gblur=sigma=${blursigma},scale=${width}:${height},` : ''}` 250 | + `${title ? getDrawtextFilter(title, titlefontSize, titlecolor, titlebordercolor, titlefontPath, true) + ',' : ''}` 251 | + `${footer ? getDrawtextFilter(footer, footerfontSize, footercolor, footerbordercolor, footerfontPath, false) + ',' : ''}` 252 | + `fps=${fps},trim=duration=${duration}[v0];`; 253 | let imageHeight = height - footerHeight - titleHeight; 254 | for (let i = 0; i < images.length; i++) { 255 | filter_complex += `[${i + 1}:v]scale=-2:${imageHeight}-${topMargin + bottomMargin},` 256 | + `pad=iw+${leftMargin + rightMargin}:h=ih+${topMargin + bottomMargin}:x=${leftMargin}:y=${topMargin}:color=black@0[v${i + 1}];`; 257 | } 258 | let speed = `((W+w)/${duration})`; 259 | let xStep = direction == 'rl' ? `W-t*${speed}` : `t*${speed}-w`; 260 | filter_complex += `${new Array(images.length).fill(0).map((v, i) => `[v${i + 1}]`).join('')}hstack=inputs=${images.length}[fg];` 261 | + `[v0][fg]overlay=x=${xStep}:y=${titleHeight}`; 262 | let ffmpeg_args = [ 263 | '-y', '-hide_banner', 264 | ...bginputs, ...imageInputs, 265 | '-filter_complex', filter_complex, 266 | // 输出视频的一些参数,这里只用了质量控制参数 -crf 23,可自行添加如 -c:v libx265 等 267 | '-crf', '23', 268 | outputVideo 269 | ]; 270 | if (debug) { 271 | console.log(cmd, ffmpeg_args.map(i => i.includes(' ') ? `"${i}"` : i).join(' ')); 272 | } 273 | let output = ''; 274 | let offset = 0; 275 | let progressPosition = 0; 276 | await new Promise((resolve, reject) => { 277 | let p = child_process.execFile(cmd, ffmpeg_args, {}); 278 | p.on('exit', (code) => { 279 | if (process.stdin.isTTY) { 280 | process.stdout.write('\n'); 281 | } 282 | if (code == 0) { 283 | resolve(code); 284 | } else { 285 | reject(code); 286 | } 287 | }); 288 | p.stderr.on('data', (chunk) => { 289 | output += chunk + ''; 290 | while (true) { 291 | let index = output.indexOf('\n', offset); 292 | let index2 = output.indexOf('\r', offset); 293 | if (index == -1 && index2 == -1) { 294 | break; 295 | } 296 | if (index == -1) { 297 | index = Number.MAX_SAFE_INTEGER; 298 | } 299 | if (index2 == -1) { 300 | index2 = Number.MAX_SAFE_INTEGER; 301 | } 302 | index = Math.min(index, index2); 303 | let line = output.substring(offset, index); 304 | offset = index + 1; 305 | let progress = tryParseProgress(line); 306 | if (progress != null) { 307 | progressPosition = progress.time; 308 | } else { 309 | continue; 310 | } 311 | if (isNaN(progressPosition)) { 312 | continue; 313 | } 314 | let progressStr = duration != null && progressPosition != 0 ? `处理进度:${(progressPosition / 1000).toFixed(2).padStart(7, ' ')} 秒(${(progressPosition / 1000 / duration * 100).toFixed(2).padStart(5, ' ')}%)` : ''; 315 | let msg = progressStr; 316 | if (!process.stdin.isTTY) { 317 | console.log(msg); 318 | } else { 319 | process.stdout.write('\r' + msg); 320 | } 321 | } 322 | if (debug) { 323 | if (!process.stdin.isTTY) { 324 | console.log(chunk + ''); 325 | } else { 326 | process.stdout.write(chunk); 327 | } 328 | } 329 | }); 330 | }); 331 | let processTime = Date.now() - startTime; 332 | console.log('处理完毕。耗时:', processTime / 1000, '秒'); 333 | 334 | } 335 | 336 | module.exports = { start } 337 | 338 | if (process.argv[1] == __filename) { 339 | start(); 340 | } -------------------------------------------------------------------------------- /ffmpeg.img2video.js: -------------------------------------------------------------------------------- 1 | const child_process = require('child_process'); 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | 5 | /** 6 | * @type {[string|{name:string,expr:string}]} 7 | */ 8 | let builtinTransitions = [ 9 | // 'custom', 10 | 'fade', 11 | 'wipeleft', 12 | 'wiperight', 13 | 'wipeup', 14 | 'wipedown', 15 | 'slideleft', 16 | 'slideright', 17 | 'slideup', 18 | 'slidedown', 19 | 'circlecrop', 20 | 'rectcrop', 21 | 'distance', 22 | 'fadeblack', 23 | 'fadewhite', 24 | 'radial', 25 | 'smoothleft', 26 | 'smoothright', 27 | 'smoothup', 28 | 'smoothdown', 29 | 'circleopen', 30 | 'circleclose', 31 | 'vertopen', 32 | 'vertclose', 33 | 'horzopen', 34 | 'horzclose', 35 | 'dissolve', 36 | 'pixelize', 37 | 'diagtl', 38 | 'diagtr', 39 | 'diagbl', 40 | 'diagbr', 41 | 'hlslice', 42 | 'hrslice', 43 | 'vuslice', 44 | 'vdslice', 45 | 'hblur', 46 | 'fadegrays', 47 | 'wipetl', 48 | 'wipetr', 49 | 'wipebl', 50 | 'wipebr', 51 | 'squeezeh', 52 | 'squeezev', 53 | // 'zoomin',//效果不好,不用 54 | 'fadefast', 55 | 'fadeslow', 56 | 'hlwind', 57 | 'hrwind', 58 | 'vuwind', 59 | 'vdwind', 60 | 'coverleft', 61 | 'coverright', 62 | 'coverup', 63 | 'coverdown', 64 | 'revealleft', 65 | 'revealright', 66 | 'revealup', 67 | 'revealdown', 68 | ]; 69 | let transitions = builtinTransitions; 70 | 71 | //-transitions fade,wipeleft,wiperight,wipeup,wipedown,slideleft,slideright,slideup,slidedown,circlecrop,rectcrop,distance,fadeblack,fadewhite,radial,smoothleft,smoothright,smoothup,smoothdown,circleopen,circleclose,vertopen,vertclose,horzopen,horzclose,dissolve,pixelize,diagtl,diagtr,diagbl,diagbr,hlslice,hrslice,vuslice,vdslice,hblur,fadegrays,wipetl,wipetr,wipebl,wipebr,squeezeh,squeezev,fadefast,fadeslow,hlwind,hrwind,vuwind,vdwind,coverleft,coverright,coverup,coverdown,revealleft,revealright,revealup,revealdown 72 | 73 | let boolArgsKey = [ 74 | 'y', 'h', 'v', 'debug', 'repeat', 'disable_buildin_transitions' 75 | ] 76 | 77 | let groupArgsKey = []; 78 | 79 | let groupArgsEndKey = []; 80 | 81 | let groupArgsKeyAll = [...groupArgsKey, ...groupArgsEndKey]; 82 | 83 | function parseArgs(args) { 84 | /* 85 | -name hello -t 1 86 | 支持分组的,类似ffmpeg的 87 | -size 30 -color green -alpha 0.5 -text 文字 ||| -size 20 -color red -alpha 0.35 -text 其他 ||| -width 100 -height 80 -alpha 0.75 -file path/to/image.jpg 88 | */ 89 | let rs = { 90 | '_': [], 91 | '__groups': [] 92 | }; 93 | let group = null; 94 | let isGroupKey = false; 95 | let key = null; 96 | for (let i = 0; i < args.length; i++) { 97 | let v = args[i]; 98 | // 兼容传负数值类似 -2 或 -1:100 等情况,减号后面跟着数字则认为是“值”而不是“key” 99 | if (v.startsWith('-') && (v.length > 1 && isNaN(Number(v[1])))) { 100 | key = v.substring(1); 101 | if (groupArgsKeyAll.includes(key)) { 102 | // 是组的key 103 | isGroupKey = true; 104 | if (group == null) { 105 | group = {}; 106 | rs.__groups.push(group); 107 | } 108 | } else { 109 | isGroupKey = false; 110 | } 111 | if (boolArgsKey.includes(key)) { 112 | if (isGroupKey) { 113 | group[key] = true; 114 | if (groupArgsEndKey.includes(key)) { 115 | group = null; 116 | } 117 | } else { 118 | rs[key] = true; 119 | } 120 | key = null; 121 | } 122 | } else { 123 | if (key != null) { 124 | if (isGroupKey) { 125 | group[key] = v; 126 | if (groupArgsEndKey.includes(key)) { 127 | group = null; 128 | } 129 | } else { 130 | rs[key] = v; 131 | if (key == 'preset') { 132 | let params = loadPreset(v); 133 | args.splice(i + 1, 0, ...params); 134 | } 135 | } 136 | key = null; 137 | } else { 138 | rs._.push(v); 139 | } 140 | } 141 | } 142 | return rs; 143 | } 144 | 145 | function loadPreset(filepath = '') { 146 | let exist = true; 147 | if (!fs.existsSync(filepath)) { 148 | // 如果路径不含文件路径分隔符,则尝试在指定目录中查找 149 | if (!path.normalize(filepath).includes(path.sep)) { 150 | filepath = path.join('preset', filepath); 151 | // 再加上后缀试试 152 | if (!fs.existsSync(filepath)) { 153 | filepath = `${filepath}.preset`; 154 | exist = fs.existsSync(filepath); 155 | } 156 | } else { 157 | exist = false; 158 | } 159 | } 160 | if (!exist) { 161 | throw `预设置文件不存在:${filepath}`; 162 | } 163 | let lines = fs.readFileSync(filepath).toString().replace(/\r/g, '').split('\n'); 164 | // 移除lines中的空白行,并去除每行前后的空格。#开头的为注释行,也忽略 165 | lines = lines.filter(line => line.trim().length > 0 && !line.startsWith('#') && !line.startsWith('//')).map(line => line.trim()); 166 | return lines; 167 | } 168 | 169 | function parseNumber(str, defaultValue) { 170 | if (str != null) { 171 | let num = Number(str); 172 | if (!isNaN(num)) { 173 | return num; 174 | } 175 | } 176 | return defaultValue; 177 | } 178 | 179 | /** 180 | * 获取媒体文件的时长 181 | * @param {string} filepath 182 | * @returns {number} 如果能获取到时长,则返回毫秒,否则返回null 183 | */ 184 | async function getMediaDuration(filepath) { 185 | let cmd = 'ffmpeg'; 186 | let args = ['-hide_banner', '-i', filepath]; 187 | return await new Promise((resolve, reject) => { 188 | let p = child_process.execFile(cmd, args, {}, function (err, stdout, stderr) { 189 | if (stderr != null) { 190 | resolve(tryParseDuration(stderr)); 191 | } else { 192 | resolve(null); 193 | } 194 | }); 195 | }); 196 | } 197 | 198 | let ffmpegDurationReg = /Duration: (.+?), start: .+?, bitrate: .+/ig; 199 | function tryParseDuration(line) { 200 | let match = ffmpegDurationReg.exec(line); 201 | if (match != null) { 202 | //02:09:44.74 203 | return parseTimeString2ms(match[1]); 204 | } 205 | return null; 206 | } 207 | 208 | function parseTimeString2ms(timeStr) { 209 | try { 210 | // 将时间字符串拆分成小时、分钟、秒和毫秒 211 | const [hours, minutes, seconds] = timeStr.trim().split(':'); 212 | // 转换成毫秒 213 | const totalMilliseconds = 214 | parseInt(hours) * 60 * 60 * 1000 + 215 | parseInt(minutes) * 60 * 1000 + 216 | parseFloat(seconds) * 1000; 217 | return totalMilliseconds; 218 | } catch { 219 | 220 | } 221 | } 222 | 223 | //frame= 13 fps=8.4 q=1.6 size= 1498kB time=00:01:26.20 bitrate=N/A speed=55.8x 224 | let ffmpegProgressReg = /frame=(.+?) fps=(.+?) q=(.+?) [L]*size=(.+?) time=(.+?) bitrate=(.+?) speed=([^ ]+)/ig; 225 | function tryParseProgress(line) { 226 | let match = ffmpegProgressReg.exec(line); 227 | if (match != null) { 228 | return { 229 | frame: parseInt(match[1].trim()), 230 | fps: parseFloat(match[2].trim()), 231 | q: parseFloat(match[3].trim()), 232 | size: match[4].trim(), 233 | time: parseTimeString2ms(match[5].trim()), 234 | bitrate: match[6].trim(), 235 | speed: parseFloat(match[7].trim()), 236 | } 237 | } 238 | } 239 | 240 | let Display = { 241 | Contain: 'contain', 242 | Original: 'original', 243 | Cover: 'cover', 244 | Fill: 'fill' 245 | } 246 | 247 | function showCmdHelp() { 248 | let msg = `${process.argv.slice(0, 2).join(' ')} -i [-o ...] 249 | -preset 本脚本除了-preset之外的所有参数,均可以通过传递preset文件来设置。 250 | 如果使用./preset/abc.preset来设置,则-preset abc即可。 251 | preset文件的编写请参考github(https://github.com/jifengg/ffmpeg-script)。 252 | -i [必须]要处理的图片/音频/字幕文件所在的目录,扫描时不包含子目录。 253 | 支持的图片:jpg jpeg png bmp webp 254 | 支持的音频:mp3 aac wav flac wma ape 255 | 支持的字幕:lrc srt ass 256 | -o 视频文件的保存路径,默认为输入目录/output.mp4 257 | -display 图片的显示方式,默认为contain。可选值为: 258 | original:原图; 259 | contain:等比例缩放至显示全图,可能有黑边; 260 | cover:等比例缩放至能覆盖整个画面,可能有裁剪。 261 | fill:拉伸变形至填充整个画面 262 | -fps 输出视频的帧率,默认:25 263 | -crf ffmpeg控制输出视频质量的参数,越小画面质量越好,视频文件也会越大,建议18~30之间。默认:23 264 | -c:v 输出视频的编码器,默认:h264 265 | -c:a 输出视频的音频编码器,默认:aac 266 | -width 输出视频的宽度,默认:1920 267 | -height 输出视频的高度,默认:1080 268 | -td 图片切换动画时长,默认为4秒 269 | -sd 图片独立显示时长,默认为7秒 270 | -repeat 图片数量太少导致视频时长比音频时长短的时候,循环图片以达到音频的时长。默认:不循环 271 | -transitions 要使用的转场动画集,使用逗号分隔,如 fade,wipeleft,wiperight,wipeup,mytran1 272 | 其中,支持自定义的转场动画,如 mytran1 表示 ./preset/xfade/mytran1.txt 273 | 自定义转场动画的编写请参考github(https://github.com/jifengg/ffmpeg-script)。 274 | -disable_buildin_transitions 275 | 禁用脚本中内置的ffmpeg的转场动画,只使用-transitions定义的,默认:false 276 | -y 覆盖已经存在的输出文件,默认:false 277 | -h 显示这个帮助信息 278 | -debug 开启debug模式,打印更详细的日志 279 | `; 280 | console.log(msg); 281 | } 282 | 283 | /** 284 | * 285 | * @param {{imgs:[string],audio_file:string,subtitle_file:string,output_file:string, width:number, height:number,showDuration:number,tranDuration:number,repeat:boolean,fps:number,crf:number}} param0 286 | */ 287 | async function run({ imgs, audio_file, subtitle_file, output_file, 288 | width, height, showDuration, tranDuration, repeat, display, 289 | fps, crf, video_codec, audio_codec, 290 | }) { 291 | let w = Math.floor((width || 1920) / 4) * 4; 292 | let h = Math.floor((height || (w * 9 / 16)) / 4) * 4; 293 | console.log('输出视频分辨率:', w, 'x', h); 294 | let cmd = 'ffmpeg'; 295 | let args = ['-y', '-hide_banner']; 296 | let filters_lain = ''; 297 | let lain_index = 0; 298 | let input_image_start_index = 0; 299 | let audio_duration = -1; 300 | if (audio_file) { 301 | //获取音频时长,单位秒 302 | audio_duration = await getMediaDuration(audio_file); 303 | if (audio_duration == null) { 304 | console.warn('音频文件读取失败,将忽略音频文件') 305 | } else { 306 | args.push('-i', audio_file); 307 | input_image_start_index++; 308 | audio_duration = audio_duration / 1000; 309 | console.log('音频时长:', audio_duration, '秒'); 310 | } 311 | } 312 | //输入图片循环时长 313 | let loopDuration = showDuration + tranDuration * 2; 314 | let imgDuration = imgs.length * (showDuration + tranDuration); 315 | let duration = imgDuration; 316 | if (repeat && imgDuration < audio_duration) { 317 | //如果图片动画时长不够,则循环 318 | let toAdd = Math.ceil((audio_duration - imgDuration) / (showDuration + tranDuration)); 319 | let i = 0; 320 | let list = []; 321 | console.log('图片数量不足,将循环补足', toAdd, '张'); 322 | while (toAdd > 0) { 323 | list.push(imgs[i % imgs.length]); 324 | toAdd--; 325 | i++; 326 | } 327 | imgs.push(...list); 328 | duration = audio_duration; 329 | }; 330 | console.log('图片数量', imgs.length); 331 | console.log('图片动画时长', tranDuration, '秒'); 332 | console.log('图片独立显示时长', showDuration, '秒'); 333 | console.log('输出视频时长为', duration, '秒'); 334 | 335 | for (let i = 0; i < imgs.length; i++) { 336 | const img = imgs[i]; 337 | args.push( 338 | '-loop', '1', '-r', `1/1000`, '-i', img 339 | ); 340 | // filters_lain += `[${i}]setsar=1/1,scale=${w}:${h}[v${i}_${lain_index}];`; 341 | // force_original_aspect_ratio=decrease,increase 342 | // decrease: 保持宽高比,缩小图片,搭配pad做居中和黑边 343 | // increase: 保持宽高比,放大图片,搭配crop=1920:1080做裁剪 344 | let display_filter = ''; 345 | switch (display) { 346 | case Display.Cover: 347 | display_filter = `scale=${w}:${h}:force_original_aspect_ratio=increase:force_divisible_by=4,crop=w=${w}:h=${h}`; 348 | break; 349 | case Display.Fill: 350 | display_filter = `scale=${w}:${h}`; 351 | break; 352 | case Display.Original: 353 | // 如果图片尺寸太大,则先裁剪。裁剪后放在视频画面大小的画板上居中 354 | display_filter = `crop=w='if(gt(iw,${w}),${w},iw)':h='if(gt(ih,${h}),${h},ih)',pad=w=${w}:h=${h}:x=(ow-iw)/2:y=(oh-ih)/2:color=black`; 355 | break; 356 | case Display.Contain: 357 | default: 358 | display_filter = `scale=${w}:${h}:force_original_aspect_ratio=decrease:force_divisible_by=4,pad=w=${w}:h=${h}:x=(ow-iw)/2:y=(oh-ih)/2:color=black`; 359 | break; 360 | } 361 | // 最后一张图片不需要消失的转场,因此时长需要减去一个动画时长 362 | filters_lain += `[${i + input_image_start_index}]setsar=1/1,${display_filter},fps=${fps},trim=duration=${loopDuration - (i == imgs.length - 1 ? tranDuration : 0)}[v${i}_${lain_index}];`; 363 | } 364 | let last_output_label = `v0_${lain_index}`; 365 | for (let i = 1; i < imgs.length; i++) { 366 | let transition = getTransition(); 367 | let isCustomTransition = typeof (transition) == 'object'; 368 | let duration = tranDuration; 369 | let offset = i * (showDuration + tranDuration) - tranDuration; 370 | let output_label = `ov${i}_${lain_index}`; 371 | filters_lain += `[${last_output_label}][v${i}_${lain_index}]xfade=transition=` 372 | + (isCustomTransition ? `custom:expr='${transition.expr}'` : transition) 373 | + `:duration=${duration}:offset=${offset}[${output_label}];`; 374 | last_output_label = output_label; 375 | } 376 | if (subtitle_file) { 377 | let output_label = 'ov_with_sub'; 378 | filters_lain += `[${last_output_label}]subtitles=filename='${subtitle_file.replace(/\\/g, '/').replace(/:/g, '\\:')}'[${output_label}];`; 379 | last_output_label = output_label; 380 | } 381 | filters_lain = filters_lain.substring(0, filters_lain.length - 1 - last_output_label.length - 2); 382 | args.push( 383 | '-filter_complex', filters_lain 384 | ); 385 | args.push( 386 | // '-map', `[${last_output_label}]`, 387 | //-keyint_min 30 -g 30 -sc_threshold 0 //设置i帧最小间距为30帧 388 | ...`-crf ${crf} -c:v ${video_codec} -pix_fmt yuv420p -movflags +faststart -r ${fps} -aspect ${w}:${h}`.split(' '), 389 | ); 390 | args.push( 391 | ...`-c:a ${audio_codec} -b:a 128k -ac 2 -ar 44100`.split(' '), 392 | ); 393 | args.push( 394 | '-t', duration + '', '-shortest', output_file 395 | ) 396 | let line = [cmd, ...args].map(v => (v + '').includes(' ') ? `"${v}"` : v).join(' '); 397 | if (debug) { 398 | console.log('即将开始使用ffmpeg处理,命令行:'); 399 | console.log(line); 400 | } 401 | let start = Date.now(); 402 | let output = ''; 403 | let offset = 0; 404 | let progressPosition = 0; 405 | await new Promise((resolve, reject) => { 406 | let p = child_process.execFile(cmd, args, {}); 407 | p.on('exit', (code) => { 408 | if (process.stdin.isTTY) { 409 | process.stdout.write('\n'); 410 | } 411 | if (code == 0) { 412 | resolve(code); 413 | } else { 414 | reject(code); 415 | } 416 | }); 417 | p.stderr.on('data', (chunk) => { 418 | output += chunk + ''; 419 | while (true) { 420 | let index = output.indexOf('\n', offset); 421 | let index2 = output.indexOf('\r', offset); 422 | if (index == -1 && index2 == -1) { 423 | break; 424 | } 425 | if (index == -1) { 426 | index = Number.MAX_SAFE_INTEGER; 427 | } 428 | if (index2 == -1) { 429 | index2 = Number.MAX_SAFE_INTEGER; 430 | } 431 | index = Math.min(index, index2); 432 | let line = output.substring(offset, index); 433 | offset = index + 1; 434 | let progress = tryParseProgress(line); 435 | if (progress != null) { 436 | progressPosition = progress.time; 437 | } else { 438 | continue; 439 | } 440 | if (isNaN(progressPosition)) { 441 | continue; 442 | } 443 | let progressStr = duration != null && progressPosition != 0 ? `处理进度:${(progressPosition / 1000).toFixed(2).padStart(7, ' ')} 秒(${(progressPosition / 1000 / duration * 100).toFixed(2).padStart(5, ' ')}%)` : ''; 444 | let msg = progressStr; 445 | if (!process.stdin.isTTY) { 446 | console.log(msg); 447 | } else { 448 | process.stdout.write('\r' + msg); 449 | } 450 | } 451 | if (debug) { 452 | if (!process.stdin.isTTY) { 453 | console.log(chunk + ''); 454 | } else { 455 | process.stdout.write(chunk); 456 | } 457 | } 458 | }); 459 | }); 460 | 461 | let haoshi = Date.now() - start; 462 | console.log('处理完毕,输出文件:', output_file); 463 | console.log('耗时:', haoshi / 1000, '秒'); 464 | } 465 | 466 | function getTransition() { 467 | let len = transitions.length; 468 | let i = Math.floor(Math.random() * len); 469 | return transitions[i]; 470 | } 471 | 472 | function getMediaFiles(process_path) { 473 | let imgs = []; 474 | let audio_file = null; 475 | let subtitle_file = null; 476 | let list = fs.readdirSync(process_path, { withFileTypes: true }); 477 | list = list.filter(v => v.isFile()); 478 | list.sort((a, b) => { 479 | return a.name > b.name ? 1 : -1; 480 | }); 481 | console.log('文件列表:', list.length == 0 ? '无' : ''); 482 | list.length > 0 && console.log(list.map(v => v.name).join('\n')); 483 | for (const f of list) { 484 | let fullpath = path.join(process_path, f.name); 485 | if (isImage(fullpath)) { 486 | imgs.push(fullpath); 487 | } else if (isAudio(fullpath)) { 488 | if (!audio_file) { 489 | audio_file = fullpath; 490 | } 491 | } else if (isSubtitle(fullpath)) { 492 | if (!subtitle_file) { 493 | subtitle_file = fullpath; 494 | } 495 | } 496 | } 497 | console.log('图片列表:', imgs.length == 0 ? '无' : ''); 498 | imgs.length > 0 && console.log(imgs.join('\n')); 499 | console.log('音频文件:', audio_file || '无'); 500 | console.log('字幕文件:', subtitle_file || '无'); 501 | return { 502 | imgs, audio_file, subtitle_file 503 | } 504 | } 505 | 506 | const IMAGE = 'image'; 507 | const AUDIO = 'audio'; 508 | const SUBTITLE = 'subtitle'; 509 | /** 510 | * @type {{image:[string],audio:[string],subtitle:[string]}} 511 | */ 512 | const FileExt = { 513 | 'image': 'jpg jpeg png bmp webp'.split(' '), 514 | 'audio': 'mp3 aac wav flac wma ape'.split(' '), 515 | 'subtitle': 'lrc srt ass'.split(' '), 516 | } 517 | function isFileType(type, name) { 518 | return FileExt[type].includes(path.extname(name).toLowerCase().substr(1)); 519 | } 520 | function isImage(name) { 521 | return isFileType(IMAGE, name); 522 | } 523 | function isAudio(name) { 524 | return isFileType(AUDIO, name); 525 | } 526 | function isSubtitle(name) { 527 | return isFileType(SUBTITLE, name); 528 | } 529 | 530 | function parseTransitions(args) { 531 | if (args.transitions != null) { 532 | let trans = Array.from(new Set(args.transitions.split(',').map(s => s.trim()).filter(i => i.length > 0))); 533 | let disable_buildin_transitions = !!args.disable_buildin_transitions; 534 | let new_trans = []; 535 | // 读取文件信息,文件存在./preset/xfade/ 下 536 | for (let trans_name of trans) { 537 | if (builtinTransitions.includes(trans_name)) { 538 | new_trans.push(trans_name); 539 | continue; 540 | } 541 | let trans_file = path.join(__dirname, 'preset', 'xfade', trans_name + '.txt'); 542 | if (!fs.existsSync(trans_file)) { 543 | console.warn(`转场文件【${trans_file}】不存在。`); 544 | continue; 545 | } else { 546 | let lines = fs.readFileSync(trans_file).toString().replace(/\r/g, '').split('\n'); 547 | // 移除lines中的空白行,并去除每行前后的空格。#开头的为注释行,也忽略 548 | lines = lines.filter(line => line.trim().length > 0 && !line.startsWith('#') && !line.startsWith('//')).map(line => line.trim()); 549 | new_trans.push({ 550 | name: trans_name, 551 | expr: lines.join(''), 552 | }); 553 | } 554 | } 555 | 556 | if (new_trans.length == 0) { 557 | if (disable_buildin_transitions) { 558 | throw '禁用内置效果,但未指定转场效果。必须至少有一个转场效果'; 559 | } 560 | } else { 561 | if (disable_buildin_transitions) { 562 | transitions = new_trans; 563 | } else { 564 | transitions = Array.from(new Set([...transitions, ...new_trans])); 565 | } 566 | } 567 | } 568 | } 569 | 570 | let debug = false; 571 | 572 | /** 573 | * 574 | * @param {{[x:string]:string}} args 575 | * @returns 576 | */ 577 | async function start(args) { 578 | if (args == null) { 579 | args = parseArgs(process.argv.slice(2)); 580 | } 581 | let input = args.i; 582 | if (input == null || !!args.h) { 583 | showCmdHelp(); 584 | return; 585 | } 586 | if (!fs.existsSync(input)) { 587 | console.log('输入文件(夹)不存在', input); 588 | return; 589 | } 590 | debug = !!args.debug; 591 | console.log('启动【图片转视频】'); 592 | console.log('处理目录:', input); 593 | let overwrite = !!args.y; 594 | let output_file = args.o || path.join(input, 'output.mp4'); 595 | if (fs.existsSync(output_file) && !overwrite) { 596 | console.log('输出文件已存在', output_file); 597 | return; 598 | } 599 | console.log('输出视频:', output_file) 600 | let { imgs, audio_file, subtitle_file } = getMediaFiles(input); 601 | if (imgs.length == 0) { 602 | console.log(`目录下无图片文件【${FileExt.image}】。`); 603 | return; 604 | } 605 | let display = args.display || Display.Contain; 606 | if (Object.values(Display).includes(display) === false) { 607 | console.log('display参数值【', display, '】错误,将使用默认值“contain”'); 608 | display = Display.Contain; 609 | } 610 | parseTransitions(args); 611 | return await run({ 612 | output_file, imgs, audio_file, subtitle_file, 613 | width: parseNumber(args.width, 1920), 614 | height: parseNumber(args.height, null), 615 | fps: parseNumber(args.fps, 25), 616 | crf: parseNumber(args.crf, 23), 617 | video_codec: args['c:v'] || 'h264', 618 | audio_codec: args['c:a'] || 'aac', 619 | display: display, 620 | repeat: !!args.repeat, 621 | tranDuration: parseNumber(args.td, 4), 622 | showDuration: parseNumber(args.sd, 7), 623 | }); 624 | } 625 | 626 | module.exports = { start } 627 | 628 | process.on('uncaughtException', (err) => { 629 | console.error(err); 630 | }); 631 | process.on('unhandledRejection', (err) => { 632 | console.error(err); 633 | }); 634 | 635 | if (process.argv[1] == __filename) { 636 | start(); 637 | } -------------------------------------------------------------------------------- /ffmpeg.subtitle.stack.js: -------------------------------------------------------------------------------- 1 | 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | const child_process = require('child_process'); 5 | 6 | let boolArgsKey = [ 7 | 'y', 'h', 'v', 'debug', 8 | ] 9 | function parseArgs(args) { 10 | /* 11 | -name hello -t 1 12 | */ 13 | let rs = { 14 | '_': [] 15 | }; 16 | let key = null; 17 | for (let i = 0; i < args.length; i++) { 18 | let v = args[i]; 19 | if (v.startsWith('-')) { 20 | key = v.substring(1); 21 | if (boolArgsKey.includes(key)) { 22 | rs[key] = true; 23 | key = null; 24 | } 25 | } else { 26 | if (key != null) { 27 | rs[key] = v; 28 | key = null; 29 | } else { 30 | rs._.push(v); 31 | } 32 | } 33 | } 34 | return rs; 35 | } 36 | 37 | function parseNumber(str, defaultValue) { 38 | if (str != null) { 39 | let num = Number(str); 40 | if (!isNaN(num)) { 41 | return num; 42 | } 43 | } 44 | return defaultValue; 45 | } 46 | 47 | let ffmpegDurationReg = /Duration: (.+?), start: .+?, bitrate: .+/ig; 48 | function tryParseDuration(line) { 49 | let match = ffmpegDurationReg.exec(line); 50 | if (match != null) { 51 | //02:09:44.74 52 | return parseTimeString2ms(match[1]); 53 | } 54 | return null; 55 | } 56 | 57 | function parseTimeString2ms(timeStr) { 58 | try { 59 | // 将时间字符串拆分成小时、分钟、秒和毫秒 60 | const [hours, minutes, seconds] = timeStr.trim().split(':'); 61 | // 转换成毫秒 62 | const totalMilliseconds = 63 | parseInt(hours) * 60 * 60 * 1000 + 64 | parseInt(minutes) * 60 * 1000 + 65 | parseFloat(seconds) * 1000; 66 | return totalMilliseconds; 67 | } catch { 68 | 69 | } 70 | } 71 | 72 | //frame= 13 fps=8.4 q=1.6 size= 1498kB time=00:01:26.20 bitrate=N/A speed=55.8x 73 | let ffmpegProgressReg = /frame=(.+?) fps=(.+?) q=(.+?) size=(.+?) time=(.+?) bitrate=(.+?) speed=([^ ]+)/ig; 74 | function tryParseProgress(line) { 75 | let match = ffmpegProgressReg.exec(line); 76 | if (match != null) { 77 | return { 78 | frame: parseInt(match[1].trim()), 79 | fps: parseFloat(match[2].trim()), 80 | q: parseFloat(match[3].trim()), 81 | size: match[4].trim(), 82 | time: parseTimeString2ms(match[5].trim()), 83 | bitrate: match[6].trim(), 84 | speed: parseFloat(match[7].trim()), 85 | } 86 | } 87 | } 88 | 89 | function showCmdHelp() { 90 | let msg = `${process.argv.slice(0, 2).join(' ')} -i -t -font [-o ...] 91 | -i [必须]视频文件路径 92 | -t [必须]文本文件路径。用两个换行符分隔的字幕,支持用一个换行符实现字幕换行 93 | -o 生成的图片文件路径,默认:<输入视频文件名>_subtitle.jpg 94 | -y 是否覆盖已经存在的图片文件,默认:false 95 | -size 生成的图片宽度,默认:400 96 | -ss 从视频的第几秒开始处理,格式同ffmpeg的-ss,默认:0 97 | -interval 截取视频画面的两帧之间的间距,单位秒,默认:5 98 | -sh 要截取的字幕区域占画面高度的比例,取值 0.0(不含) ~ 1.0(含),默认:0.1666 99 | -font 字体文件路径,非windows下使用时必传,默认:c:/Windows/Fonts/msyh.ttc(微软雅黑) 100 | -h 显示这个帮助信息 101 | -debug 是否开启debug模式,打印更详细的日志 102 | `; 103 | console.log(msg); 104 | } 105 | 106 | let videoFormat = ['mp4', 'avi', 'mov', 'mkv', 'wmv', 'flv', 'm4v', 'mpg', 'mpeg', '3gp', 'ts', 'webm', 'mpv']; 107 | function isVideo(filepath) { 108 | return videoFormat.includes(path.extname(filepath).substring(1).toLowerCase()); 109 | } 110 | 111 | function getDrawtextFilter(text) { 112 | //字体为视频高度的百分比 113 | let fontsizeRatio = maxLineNum + 1; 114 | return `drawtext=text='${text}':fontsize=h/${fontsizeRatio}:fontcolor=white:x=(w-tw)/2:y=(h-th)/2:fontfile='${fontfile}':borderw=2:text_align=center+middle`; 115 | } 116 | 117 | async function getTextLines(textfile) { 118 | let text = fs.readFileSync(textfile).toString(); 119 | let lines = text.replace(/\r/g, '').split(/[\n]{2,}/img).filter(i => i != null && (maxLineNum = Math.max(maxLineNum, i.trim().split('\n').length), i.trim().length > 0)); 120 | return lines; 121 | } 122 | 123 | async function makeImage(input, lines, startPosition, interval, size, output) { 124 | let cmd = 'ffmpeg'; 125 | let filter_complex = ''; 126 | let lineIndex = 0; 127 | let trim = 0; 128 | //[p0][p1][p2][p3]...[pn] 129 | let outputChain = new Array(lines.length + 1).fill(0).map((v, i) => `[p${i}]`).join(''); 130 | filter_complex += `[0:v]crop=w=iw:h=ih-ih*${cropH}:x=0:y=0,trim=${trim}:duration=0.01,setpts=PTS-STARTPTS[p0];`; 131 | for (let i = 0; i < lines.length; i++) { 132 | lineIndex++; 133 | let line = lines[i]; 134 | filter_complex += `[0:v]trim=${trim}:duration=0.01,setpts=PTS-STARTPTS,crop=w=iw:h=ih*${cropH}:x=0:y=ih-oh,${getDrawtextFilter(line)}[p${lineIndex}];`; 135 | trim += interval; 136 | } 137 | 138 | filter_complex += `${outputChain}vstack=${lines.length + 1}:shortest=1,scale=-2:${size}`; 139 | let args = [ 140 | '-y', '-hide_banner', '-ss', startPosition, '-i', input, 141 | '-filter_complex', filter_complex, 142 | '-frames:v', '1', 143 | output 144 | ]; 145 | if (debug) { 146 | console.log(cmd, args.map(i => i.includes(' ') ? `"${i}"` : i).join(' ')); 147 | } 148 | await new Promise((resolve, reject) => { 149 | let p = child_process.execFile(cmd, args, {}); 150 | p.on('exit', (code) => { 151 | if (process.stdin.isTTY) { 152 | process.stdout.write('\n'); 153 | } 154 | resolve(code); 155 | }); 156 | p.stderr.on('data', (chunk) => { 157 | if (debug) { 158 | if (!process.stdin.isTTY) { 159 | console.log(chunk + ''); 160 | } else { 161 | process.stdout.write(chunk); 162 | } 163 | } 164 | }); 165 | }); 166 | } 167 | 168 | let debug = false; 169 | 170 | // 拼接字幕的视频高度 171 | let cropH = 1 / 6; 172 | // 绘制的文本最大的行数 173 | let maxLineNum = 1; 174 | 175 | let fontfile = 'c:/Windows/Fonts/msyh.ttc'; 176 | 177 | async function start(args) { 178 | if (args == null) { 179 | args = parseArgs(process.argv.slice(2)); 180 | } 181 | let input = args.i; 182 | let textfile = args.t; 183 | if (input == null || textfile == null || !!args.h) { 184 | showCmdHelp(); 185 | return; 186 | } 187 | if (!fs.existsSync(input)) { 188 | console.log('输入文件不存在', input); 189 | return; 190 | } 191 | if (!isVideo(input)) { 192 | console.log('输入文件不是视频文件', input); 193 | return; 194 | } 195 | if (!fs.existsSync(textfile)) { 196 | console.log('字幕文件不存在', textfile); 197 | return; 198 | } 199 | let overwrite = !!args.y; 200 | debug = !!args.debug; 201 | let size = parseNumber(args.size, 400); 202 | let startPosition = args.ss || '0'; 203 | let interval = parseNumber(args.interval, 5); 204 | let sh = parseNumber(args.sh, cropH); 205 | if (sh <= 0 || sh > 1) { 206 | sh = cropH; 207 | } 208 | cropH = sh; 209 | let fontPath = path.resolve(args.font || fontfile); 210 | if (!fs.existsSync(fontPath)) { 211 | console.log('字体文件不存在', fontPath); 212 | return; 213 | } 214 | fontfile = fontPath.replace(/\\/g, '/'); 215 | let lines = await getTextLines(textfile); 216 | if (lines.length == 0) { 217 | console.log('字幕文件内容不能为空!'); 218 | return; 219 | } 220 | let output = args.o || path.join(path.dirname(input), path.basename(input, path.extname(input)) + '_subtitle.jpg'); 221 | if (!overwrite && fs.existsSync(output)) { 222 | console.log('输出文件已存在,跳过', output); 223 | return; 224 | } 225 | let startTime = Date.now(); 226 | await makeImage(input, lines, startPosition, interval, size, output); 227 | console.log('输出文件:', output); 228 | console.log(`处理完毕,耗时: ${(Date.now() - startTime) / 1000}s`); 229 | } 230 | 231 | module.exports = { start } 232 | if (process.argv[1] == __filename) { 233 | start(); 234 | } -------------------------------------------------------------------------------- /ffmpeg.video2bookmark.js: -------------------------------------------------------------------------------- 1 | 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | const child_process = require('child_process'); 5 | 6 | /** 7 | * 8 | * @param {number[]|Uint8Array} buffers 9 | * @param {16|2} radix 10 | * @returns 11 | */ 12 | function bin2str(buffers, radix = 16) { 13 | if (radix != 16 && radix != 2) throw new Error('radix must be 16 or 2'); 14 | let padNum = radix == 16 ? 2 : 8; 15 | 16 | let list = []; 17 | buffers.forEach(i => list.push(i.toString(radix).padStart(padNum, '0').toUpperCase())); 18 | return list.join('') 19 | } 20 | 21 | function file2hexstr(filepath) { 22 | return new Promise((resolve, reject) => { 23 | fs.readFile(filepath, (err, data) => { 24 | if (err) { 25 | reject(err); 26 | } else { 27 | resolve(bin2str(data)); 28 | } 29 | }); 30 | }); 31 | } 32 | 33 | const regex = /(\w+): *([^ ]+)/g; 34 | function parseShowinfo(input) { 35 | let index = input.indexOf('[Parsed_showinfo'); 36 | let hasKey = input.indexOf('pts_time') > 0; 37 | if (index >= 0 && hasKey) { 38 | let line = index == 0 ? input : input.substring(index); 39 | let result = {}; 40 | let match; 41 | while ((match = regex.exec(line)) !== null) { 42 | result[match[1]] = match[2]; 43 | } 44 | return result; 45 | } 46 | return null; 47 | } 48 | 49 | let boolArgsKey = [ 50 | 'y', 'h', 'v', 'debug', 51 | ] 52 | function parseArgs(args) { 53 | /* 54 | -name hello -t 1 55 | */ 56 | let rs = { 57 | '_': [] 58 | }; 59 | let key = null; 60 | for (let i = 0; i < args.length; i++) { 61 | let v = args[i]; 62 | if (v.startsWith('-')) { 63 | key = v.substring(1); 64 | if (boolArgsKey.includes(key)) { 65 | rs[key] = true; 66 | key = null; 67 | } 68 | } else { 69 | if (key != null) { 70 | rs[key] = v; 71 | key = null; 72 | } else { 73 | rs._.push(v); 74 | } 75 | } 76 | } 77 | return rs; 78 | } 79 | 80 | function parseNumber(str, defaultValue) { 81 | if (str != null) { 82 | let num = Number(str); 83 | if (!isNaN(num)) { 84 | return num; 85 | } 86 | } 87 | return defaultValue; 88 | } 89 | 90 | async function makePbfContent(infoList, tempDir) { 91 | let pbfContent = '[Bookmark]\r\n'; 92 | let bookmarkIndex = 0; 93 | for (let i = 0; i < infoList.length; i++) { 94 | let info = infoList[i]; 95 | let { n, pts_time } = info; 96 | let millseconds = Math.round(pts_time * 1000); 97 | let imgFile = `${tempDir}/preview${(i + 1).toString().padStart(5, '0')}.jpg`; 98 | if (fs.existsSync(imgFile)) { 99 | let hexstr = await file2hexstr(imgFile); 100 | let title = '书签 ' + (bookmarkIndex + 1); 101 | pbfContent += `${bookmarkIndex}=${millseconds}*${title}*2800000048000000480000000100200004000000${millseconds.toString().padStart(48, '0')}${hexstr}\r\n`; 102 | bookmarkIndex++; 103 | } else { 104 | console.log('书签图片不存在', imgFile); 105 | } 106 | } 107 | pbfContent += `${bookmarkIndex}=\r\n\r\n`; 108 | return pbfContent; 109 | } 110 | 111 | async function getSceneByFfmpeg(video, tempDir, height = 72, sceneScore = 0.5, minInterval = 20, maxInterval = 60,) { 112 | let cmd = 'ffmpeg'; 113 | let args = [ 114 | '-y', '-hide_banner', '-i', video, 115 | // '-filter_complex', `select='gt(scene\\,${sceneScore})*eq(pict_type\\,I)',scale=-2:${height},showinfo=checksum=0`, 116 | '-filter_complex', `select='(isnan(prev_selected_t)+gte(t-prev_selected_t,${minInterval}))*(gt(scene,${sceneScore})*eq(pict_type,I)+(gte(t-prev_selected_t,${maxInterval})))',scale=-2:${height},showinfo=checksum=0`, 117 | '-vsync', 'vfr',//-vsync -fps_mode,使用 -vsync 兼容老版本ffmpeg(4.4) 118 | `${tempDir}/preview%5d.jpg` 119 | ]; 120 | if (debug) { 121 | console.log(cmd, args.map(i => i.includes(' ') ? `"${i}"` : i).join(' ')); 122 | } 123 | let output = ''; 124 | let offset = 0; 125 | let infoList = []; 126 | let duration = -1; 127 | let progressPosition = 0; 128 | await new Promise((resolve, reject) => { 129 | let p = child_process.execFile(cmd, args, {}); 130 | p.on('exit', (code) => { 131 | if (process.stdin.isTTY) { 132 | process.stdout.write('\n'); 133 | } 134 | resolve(code); 135 | }); 136 | p.stderr.on('data', (chunk) => { 137 | output += chunk + ''; 138 | while (true) { 139 | let index = output.indexOf('\n', offset); 140 | if (index == -1) { 141 | break; 142 | } 143 | let line = output.substring(offset, index); 144 | if (duration == -1) { 145 | let maybeDuration = tryParseDuration(line); 146 | if (maybeDuration != null) { 147 | duration = maybeDuration; 148 | } 149 | } 150 | let progress = tryParseProgress(line); 151 | if (progress != null) { 152 | progressPosition = progress.time; 153 | } 154 | offset = index + 1; 155 | let info = parseShowinfo(line); 156 | if (info != null) { 157 | infoList.push(info); 158 | } 159 | let progressStr = duration != null && progressPosition != 0 ? `,解析进度:${(progressPosition / duration * 100).toFixed(2)}%` : ''; 160 | let msg = `解析到场景数:${infoList.length}${progressStr}`; 161 | if (!process.stdin.isTTY) { 162 | console.log(msg); 163 | } else { 164 | process.stdout.write('\r' + msg); 165 | } 166 | } 167 | if (debug) { 168 | if (!process.stdin.isTTY) { 169 | console.log(chunk + ''); 170 | } else { 171 | process.stdout.write(chunk); 172 | } 173 | } 174 | }); 175 | }); 176 | return infoList; 177 | } 178 | 179 | let ffmpegDurationReg = /Duration: (.+?), start: .+?, bitrate: .+/ig; 180 | function tryParseDuration(line) { 181 | let match = ffmpegDurationReg.exec(line); 182 | if (match != null) { 183 | //02:09:44.74 184 | return parseTimeString2ms(match[1]); 185 | } 186 | return null; 187 | } 188 | 189 | function parseTimeString2ms(timeStr) { 190 | try { 191 | // 将时间字符串拆分成小时、分钟、秒和毫秒 192 | const [hours, minutes, seconds] = timeStr.trim().split(':'); 193 | // 转换成毫秒 194 | const totalMilliseconds = 195 | parseInt(hours) * 60 * 60 * 1000 + 196 | parseInt(minutes) * 60 * 1000 + 197 | parseFloat(seconds) * 1000; 198 | return totalMilliseconds; 199 | } catch { 200 | 201 | } 202 | } 203 | 204 | //frame= 13 fps=8.4 q=1.6 size= 1498kB time=00:01:26.20 bitrate=N/A speed=55.8x 205 | let ffmpegProgressReg = /frame=(.+?) fps=(.+?) q=(.+?) size=(.+?) time=(.+?) bitrate=(.+?) speed=([^ ]+)/ig; 206 | function tryParseProgress(line) { 207 | let match = ffmpegProgressReg.exec(line); 208 | if (match != null) { 209 | return { 210 | frame: parseInt(match[1].trim()), 211 | fps: parseFloat(match[2].trim()), 212 | q: parseFloat(match[3].trim()), 213 | size: match[4].trim(), 214 | time: parseTimeString2ms(match[5].trim()), 215 | bitrate: match[6].trim(), 216 | speed: parseFloat(match[7].trim()), 217 | } 218 | } 219 | } 220 | 221 | async function test() { 222 | let video = ""; 223 | let tempDir = ''; 224 | let infoList = await getSceneByFfmpeg(video, tempDir, 72, 0.5); 225 | console.log(infoList.length, infoList.map(i => JSON.stringify(i)).join('\n')); 226 | 227 | let pbfContent = await makePbfContent(infoList, tempDir); 228 | let pbfFile = path.join(path.dirname(video), path.basename(video, path.extname(video)) + '.pbf'); 229 | fs.writeFileSync(pbfFile, pbfContent); 230 | console.log('END'); 231 | } 232 | 233 | function showCmdHelp() { 234 | let msg = `${process.argv.slice(0, 2).join(' ')} -i [-temp ...] 235 | -i [必须]要处理的文件或目录 236 | -y 是否覆盖已经存在的pbf文件,默认:false 237 | -size 缩略图高度,默认:72 238 | -score 0.0到1.0之间的值,表示视频帧可能为新场景的概率;建议设置在0.3到0.5之间。太小的值会出现过多场景帧,而太大的值会导致过少的场景帧。默认:0.5 239 | -temp 缓存目录,默认为脚本所在目录下的“temp”目录 240 | -min-interval 两个场景帧之间的最小间隔,间隔比这个值小的场景帧将被丢弃,这个值保证书签不会太密集,单位秒,默认:20.0 241 | -max-interval 两个场景帧之间的最大间隔,如果间隔比这个值大,将在他们之间每max-interval秒取一帧,这个值保证书签不会太稀疏,单位秒,默认:60.0 242 | -h 显示这个帮助信息 243 | -debug 是否开启debug模式,打印更详细的日志 244 | `; 245 | console.log(msg); 246 | } 247 | 248 | let videoFormat = ['mp4', 'avi', 'mov', 'mkv', 'wmv', 'flv', 'm4v', 'mpg', 'mpeg', '3gp', 'ts', 'webm', 'mpv']; 249 | function isVideo(filepath) { 250 | return videoFormat.includes(path.extname(filepath).substring(1).toLowerCase()); 251 | } 252 | 253 | function getAllVideoFile(dir) { 254 | let list = fs.readdirSync(dir, { withFileTypes: true }); 255 | let rs = []; 256 | for (const item of list) { 257 | let fullpath = path.join(dir, item.name); 258 | if (item.isFile()) { 259 | if (isVideo(fullpath)) { 260 | rs.push(fullpath); 261 | } 262 | } else if (item.isDirectory()) { 263 | let sublist = getAllVideoFile(fullpath); 264 | rs.push(...sublist); 265 | } 266 | } 267 | return rs; 268 | } 269 | 270 | let debug = false; 271 | 272 | async function start(args) { 273 | if (args == null) { 274 | args = parseArgs(process.argv.slice(2)); 275 | } 276 | let input = args.i; 277 | if (input == null || !!args.h) { 278 | showCmdHelp(); 279 | return; 280 | } 281 | if (!fs.existsSync(input)) { 282 | console.log('输入文件(夹)不存在', input); 283 | return; 284 | } 285 | let tempDir = args.temp || path.join(__dirname, 'temp'); 286 | let overwrite = !!args.y; 287 | debug = !!args.debug; 288 | let height = parseNumber(args.size, 72); 289 | let score = parseNumber(args.score, 0.5); 290 | let minInterval = parseNumber(args['min-interval'], 20); 291 | let maxInterval = parseNumber(args['max-interval'], 60); 292 | minInterval = Math.max(minInterval, 1); 293 | maxInterval = Math.max(maxInterval, minInterval); 294 | fs.mkdirSync(tempDir, { recursive: true }); 295 | let stat = fs.statSync(input); 296 | let filelist = []; 297 | if (stat.isDirectory()) { 298 | filelist = getAllVideoFile(input); 299 | } else { 300 | if (isVideo(input)) { 301 | filelist.push(input); 302 | } 303 | } 304 | if (filelist.length == 0) { 305 | console.log('没有找到视频文件'); 306 | return; 307 | } 308 | for (let i = 0; i < filelist.length; i++) { 309 | const videoFile = filelist[i]; 310 | // pbf文件与视频文件同目录 311 | let pbfFile = path.join(path.dirname(videoFile), path.basename(videoFile, path.extname(videoFile)) + '.pbf'); 312 | if (overwrite == false && fs.existsSync(pbfFile)) { 313 | console.log('pbf文件已存在,跳过', pbfFile); 314 | continue; 315 | } 316 | console.log('开始处理:', i + 1, '/', filelist.length, videoFile); 317 | let infoList = await getSceneByFfmpeg(videoFile, tempDir, height, score, minInterval, maxInterval); 318 | let pbfContent = await makePbfContent(infoList, tempDir); 319 | fs.writeFileSync(pbfFile, pbfContent); 320 | console.log('处理完毕:', videoFile); 321 | } 322 | console.log('所有文件处理完毕,即将退出'); 323 | } 324 | 325 | module.exports = { start } 326 | 327 | // test(); 328 | if (process.argv[1] == __filename) { 329 | start(); 330 | } -------------------------------------------------------------------------------- /ffmpeg.watermark.js: -------------------------------------------------------------------------------- 1 | 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | const child_process = require('child_process'); 5 | 6 | let boolArgsKey = [ 7 | 'y', 'h', 'v', 'debug', 'repeat', 8 | ] 9 | 10 | let groupArgsKey = [ 11 | 'fontsize', 'fontcolor', 'fontfile', 'fontborderwidth', 'fontbordercolor', 12 | 'alpha', 'left', 'top', 'right', 'bottom', 13 | 'move', 'xspeed', 'yspeed', 'interval', 'seed', 'xstart', 'ystart', 14 | 'repeat', 'boxw', 'boxh', 'rotate', 15 | 'scale', 16 | ]; 17 | let groupArgsEndKey = ['text', 'file']; 18 | 19 | let groupArgsKeyAll = [...groupArgsKey, ...groupArgsEndKey]; 20 | 21 | function parseArgs(args) { 22 | /* 23 | -name hello -t 1 24 | 支持分组的,类似ffmpeg的 25 | -size 30 -color green -alpha 0.5 -text 文字 ||| -size 20 -color red -alpha 0.35 -text 其他 ||| -width 100 -height 80 -alpha 0.75 -file path/to/image.jpg 26 | */ 27 | let rs = { 28 | '_': [], 29 | '__groups': [] 30 | }; 31 | let group = null; 32 | let isGroupKey = false; 33 | let key = null; 34 | for (let i = 0; i < args.length; i++) { 35 | let v = args[i]; 36 | // 兼容传负数值类似 -2 或 -1:100 等情况,减号后面跟着数字则认为是“值”而不是“key” 37 | if (v.startsWith('-') && (v.length > 1 && isNaN(Number(v[1])))) { 38 | key = v.substring(1); 39 | if (groupArgsKeyAll.includes(key)) { 40 | // 是组的key 41 | isGroupKey = true; 42 | if (group == null) { 43 | group = {}; 44 | rs.__groups.push(group); 45 | } 46 | } else { 47 | isGroupKey = false; 48 | } 49 | if (boolArgsKey.includes(key)) { 50 | if (isGroupKey) { 51 | group[key] = true; 52 | if (groupArgsEndKey.includes(key)) { 53 | group = null; 54 | } 55 | } else { 56 | rs[key] = true; 57 | } 58 | key = null; 59 | } 60 | } else { 61 | if (key != null) { 62 | if (isGroupKey) { 63 | group[key] = v; 64 | if (groupArgsEndKey.includes(key)) { 65 | group = null; 66 | } 67 | } else { 68 | rs[key] = v; 69 | if (key == 'preset') { 70 | let params = loadPreset(v); 71 | args.splice(i + 1, 0, ...params); 72 | } 73 | } 74 | key = null; 75 | } else { 76 | rs._.push(v); 77 | } 78 | } 79 | } 80 | return rs; 81 | } 82 | 83 | function loadPreset(filepath = '') { 84 | let exist = true; 85 | if (!fs.existsSync(filepath)) { 86 | // 如果路径不含文件路径分隔符,则尝试在指定目录中查找 87 | if (!path.normalize(filepath).includes(path.sep)) { 88 | filepath = path.join('preset', filepath); 89 | // 再加上后缀试试 90 | if (!fs.existsSync(filepath)) { 91 | filepath = `${filepath}.preset`; 92 | exist = fs.existsSync(filepath); 93 | } 94 | } else { 95 | exist = false; 96 | } 97 | } 98 | if (!exist) { 99 | throw `预设置文件不存在:${filepath}`; 100 | } 101 | let lines = fs.readFileSync(filepath).toString().replace(/\r/g, '').split('\n'); 102 | // 移除lines中的空白行,并去除每行前后的空格。#开头的为注释行,也忽略 103 | lines = lines.filter(line => line.trim().length > 0 && !line.startsWith('#') && !line.startsWith('//')).map(line => line.trim()); 104 | return lines; 105 | } 106 | 107 | function parseNumber(str, defaultValue) { 108 | if (str != null) { 109 | let num = Number(str); 110 | if (!isNaN(num)) { 111 | return num; 112 | } 113 | } 114 | return defaultValue; 115 | } 116 | 117 | let ffmpegDurationReg = /Duration: (.+?), start: .+?, bitrate: .+/ig; 118 | function tryParseDuration(line) { 119 | let match = ffmpegDurationReg.exec(line); 120 | if (match != null) { 121 | //02:09:44.74 122 | return parseTimeString2ms(match[1]); 123 | } 124 | return null; 125 | } 126 | 127 | function parseTimeString2ms(timeStr) { 128 | try { 129 | // 将时间字符串拆分成小时、分钟、秒和毫秒 130 | const [hours, minutes, seconds] = timeStr.trim().split(':'); 131 | // 转换成毫秒 132 | const totalMilliseconds = 133 | parseInt(hours) * 60 * 60 * 1000 + 134 | parseInt(minutes) * 60 * 1000 + 135 | parseFloat(seconds) * 1000; 136 | return totalMilliseconds; 137 | } catch { 138 | 139 | } 140 | } 141 | 142 | //frame= 13 fps=8.4 q=1.6 size= 1498kB time=00:01:26.20 bitrate=N/A speed=55.8x 143 | let ffmpegProgressReg = /frame=(.+?) fps=(.+?) q=(.+?) [L]*size=(.+?) time=(.+?) bitrate=(.+?) speed=([^ ]+)/ig; 144 | function tryParseProgress(line) { 145 | let match = ffmpegProgressReg.exec(line); 146 | if (match != null) { 147 | return { 148 | frame: parseInt(match[1].trim()), 149 | fps: parseFloat(match[2].trim()), 150 | q: parseFloat(match[3].trim()), 151 | size: match[4].trim(), 152 | time: parseTimeString2ms(match[5].trim()), 153 | bitrate: match[6].trim(), 154 | speed: parseFloat(match[7].trim()), 155 | } 156 | } 157 | } 158 | 159 | function showCmdHelp() { 160 | let msg = `${process.argv.slice(0, 2).join(' ')} -i [-o ...] 161 | -preset 本脚本除了-preset之外的所有参数,均可以通过传递preset文件来设置。 162 | 如果使用./preset/abc.preset来设置,则-preset abc即可。 163 | preset文件的编写请参考github(https://github.com/jifengg/ffmpeg-script)。 164 | -i [必须]要处理的文件或目录 165 | -y 是否覆盖已经存在的输出文件,默认:false 166 | -h 显示这个帮助信息 167 | -debug 是否开启debug模式,打印更详细的日志 168 | -[text|file] [必须]水印的文本内容或文件路径,必须至少传一组。 169 | 与ffmpeg参数传递规则类似,水印有很多可定义的参数,且支持传多个水印。 170 | 因此,-text/file之前的参数是用来设置这一组水印信息的,之后的参数是下一组水印的。 171 | 如:-fontsize 30 -text 此水印字号为30 -fontsize 40 -text 此水印字号为40 172 | -text 水印的文本内容 173 | -fontsize 文字的字号,默认:20 174 | -fontcolor 文字颜色,值的格式同ffmpeg的color,默认:white 175 | -fontborderwidth 176 | 文字边框大小,单位像素,默认:0 177 | -fontbordercolor 178 | 文字边框颜色,值的格式同ffmpeg的color,默认:black 179 | -fontfile 文字字体文件路径,非windows下使用时必传,默认:c:/Windows/Fonts/msyh.ttc(微软雅黑) 180 | -file 水印文件的路径,支持视频或图片 181 | -scale 水印文件的缩放尺寸,值的格式同ffmpeg的scale过滤器。如“1920:1080” 182 | -alpha 水印的透明度,取值范围:0.0 - 1.0,默认:1.0[完全不透明] 183 | -[left|right|top|bottom] 184 | 水印的左、右、上、下边距。默认:right=20,top=20 185 | 当值≤1.0时,表示整个画面的百分比,也就是说left=0.5时表示在画面水平居中 186 | 当值>1.0时,表示像素值,如left=200表示距离画面左边200像素。 187 | 如果你要定义1像素,请使用“1.1” 188 | -move 水印的移动方式,可选:dvd、random;默认不移动 189 | -xspeed move=dvd时生效。表示每秒水平移动的像素。默认:400 190 | -yspeed move=dvd时生效。表示每秒垂直移动的像素。默认:300 191 | -xstart move=dvd时生效。表示初始水平位置。默认:0 192 | -ystart move=dvd时生效。表示初始垂直位置。默认:0 193 | -interval move=random时生效。表示多少秒变换一个位置。默认:10 194 | -seed move=random时生效。表示随机数种子。不传则随机生成 195 | -repeat 是否用水印重复填充整个画面,默认:false 196 | -boxw 启用填充时,每个水印的宽度,如果水印是-file,则不能小于-scale的宽度,默认:200 197 | -boxh 启用填充时,每个水印的高度,如果水印是-file,则不能小于-scale的高度,默认:100 198 | -rotate 启用填充时,每个水印的旋转角度,注意是角度而不是弧度,默认:0 199 | `; 200 | console.log(msg); 201 | } 202 | 203 | let videoFormat = ['mp4', 'avi', 'mov', 'mkv', 'wmv', 'flv', 'm4v', 'mpg', 'mpeg', '3gp', 'ts', 'webm', 'mpv']; 204 | function isVideo(filepath) { 205 | return videoFormat.includes(path.extname(filepath).substring(1).toLowerCase()); 206 | } 207 | 208 | let imageFormat = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'webp', 'svg', 'tif', 'raw', 'ico']; 209 | function isImage(filepath) { 210 | return imageFormat.includes(path.extname(filepath).substring(1).toLowerCase()); 211 | } 212 | 213 | function getAllMediaFile(dir) { 214 | let list = fs.readdirSync(dir, { withFileTypes: true }); 215 | let rs = []; 216 | for (const item of list) { 217 | let fullpath = path.join(dir, item.name); 218 | if (item.isFile()) { 219 | if (isImage(fullpath) || isVideo(fullpath)) { 220 | rs.push(fullpath); 221 | } 222 | } else if (item.isDirectory()) { 223 | let sublist = getAllMediaFile(fullpath); 224 | rs.push(...sublist); 225 | } 226 | } 227 | return rs; 228 | } 229 | 230 | function getRepeatFilter(boxw, boxh, rotate, inputFilterName, sourceIndex) { 231 | let colNum = Math.max(2, Math.ceil(8000 / boxw)); 232 | let rowNum = Math.max(2, Math.ceil(6000 / boxh)); 233 | let colOutput = new Array(colNum).fill(0).map((v, i) => `[col_${sourceIndex}_${i}]`).join(''); 234 | let rowOutput = new Array(rowNum).fill(0).map((v, i) => `[row_${sourceIndex}_${i}]`).join(''); 235 | return `split=${colNum}${colOutput};` 236 | + `${colOutput}hstack=inputs=${colNum},split=${rowNum}${rowOutput};` 237 | + `${rowOutput}vstack=inputs=${rowNum},rotate=${rotate}*PI/180:fillcolor=black@0[overlay_${sourceIndex}];` 238 | + `${inputFilterName}[overlay_${sourceIndex}]overlay=(W-w)/2:(H-h)/2`; 239 | } 240 | 241 | let debug = false; 242 | let defaultFontfile = 'c:/Windows/Fonts/msyh.ttc'; 243 | 244 | let moveType_DVD = 'dvd'; 245 | let moveType_RANDOM = 'random'; 246 | 247 | async function addWatermark(input, outputfile, args) { 248 | let isvideo = isVideo(input); 249 | let startTime = Date.now(); 250 | let cmd = 'ffmpeg'; 251 | let filter_complex = ''; 252 | 253 | let groups = args.__groups; 254 | // 水印文件在ffmpeg -i 中的序号 255 | let sourceIndex = 1; 256 | let sourceinputs = []; 257 | let inputFilterName = '[0:v]'; 258 | let outputFilterIndex = 1; 259 | let outputFilterName = inputFilterName; 260 | for (let i = 0; i < groups.length; i++) { 261 | const group = groups[i]; 262 | outputFilterName = `[v${outputFilterIndex++}]`; 263 | let left = parseNumber(group.left, null); 264 | let top = parseNumber(group.top, null); 265 | let right = parseNumber(group.right, null); 266 | let bottom = parseNumber(group.bottom, null); 267 | if (left == null && right == null) { 268 | right = 20; 269 | } 270 | if (top == null && bottom == null) { 271 | top = 20; 272 | } 273 | let alpha = parseNumber(group.alpha, 1); 274 | let moveType = group.move || null; 275 | // 如果不是视频则不需要移动 276 | if (!isvideo) { 277 | moveType = null; 278 | } 279 | let xMax = group.text ? 'w-tw' : 'W-w'; 280 | let yMax = group.text ? 'h-th' : 'H-h'; 281 | let xexp = ''; 282 | let yexp = ''; 283 | let repeat = !!group.repeat; 284 | // 如果需要重复填充,则认为是不需要移动的 285 | if (repeat) { 286 | xexp = `(${xMax})/2`; 287 | yexp = `(${yMax})/2`; 288 | } else { 289 | switch (moveType) { 290 | case moveType_DVD: 291 | let xspeed = parseNumber(group.xspeed, 400); 292 | let yspeed = parseNumber(group.yspeed, 300); 293 | let xstart = parseNumber(group.xstart, 0); 294 | let ystart = parseNumber(group.ystart, 0); 295 | xexp = `abs(mod(t*${xspeed}+(${xMax})+${xstart},(${xMax})*2)-(${xMax}))`; 296 | yexp = `abs(mod(t*${yspeed}+(${yMax})+${ystart},(${yMax})*2)-(${yMax}))`; 297 | break; 298 | case moveType_RANDOM: 299 | // 变化间隔,单位秒 300 | let interval = parseNumber(group.interval, 10); 301 | let seed = parseNumber(group.seed, Math.random() * 9e4 + 1e4); 302 | // t是时间,将它除以变化间隔后取整则可以保证在变化间隔内计算式都是同样的值,看起来就是间隔内没有移动。 303 | // 随机移动采用一个取巧的方式。因为ffmpeg的random函数需要设置seed,相同的seed出来的随机数是一样的,但是在计算式之间没法共享seed。 304 | // 因此,x轴使用平方函数,且基数较大,这样在相差1的时候平方值也会有较大的幅度。 305 | // y轴使用一个正弦函数,并随机一个初始弧度。x,y 306 | // xexp = `abs(mod(floor(t/${interval})*${seed}+(${xMax}),(${xMax})*2)-(${xMax}))`; 307 | // yexp = `abs(mod(floor(t/${interval})*${seedy}+(${yMax}),(${yMax})*2)-(${yMax}))`; 308 | xexp = `mod(pow(${seed}+floor(t/${interval}),2),(${xMax}))`; 309 | // yexp = `mod(pow(${seedy}+floor(t/${interval}),2),(${yMax}))`; 310 | // xexp = `(sin(floor(t/${interval})*${seedx})+1)/2*(${xMax})`; 311 | yexp = `(sin(floor(t/${interval})+${seed})+1)/2*(${yMax})`; 312 | break; 313 | default: 314 | xexp = right == null ? (left > 1 ? left : `(${xMax})*${left}`) : (right > 1 ? `${xMax}-${right}` : `(${xMax})*${1 - right}`); 315 | yexp = bottom == null ? (top > 1 ? top : `(${yMax})*${top}`) : (bottom > 1 ? `${yMax}-${bottom}` : `(${yMax})*${1 - bottom}`); 316 | break; 317 | } 318 | } 319 | let boxw = parseNumber(group.boxw, 200); 320 | let boxh = parseNumber(group.boxh, 100); 321 | let rotate = parseNumber(group.rotate, 0); 322 | if (group.text) { 323 | // 文字。文字直接绘制在源画面上 324 | let text = group.text.replace(/\\n/g, '\n'); 325 | let fontsize = parseNumber(group.fontsize, 20); 326 | let fontcolor = group.fontcolor || 'white'; 327 | let fontfile = path.resolve(group.fontfile || defaultFontfile); 328 | let fontBorderWidth = parseNumber(group.fontborderwidth, 0); 329 | let fontBorderColor = group.fontbordercolor || 'black'; 330 | if (!fs.existsSync(fontfile)) { 331 | console.error('字体文件不存在', fontfile); 332 | return; 333 | } 334 | fontfile = fontfile.replace(/\\/g, '/'); 335 | let drawtextFilter = `drawtext=text='${text}':fontsize=${fontsize}:fontcolor=${fontcolor}@${alpha}:` 336 | + `x='${xexp}':` 337 | + `y='${yexp}':` 338 | + `fontfile='${fontfile}':` 339 | + `borderw=${fontBorderWidth}:bordercolor=${fontBorderColor}@${alpha}:text_align=center+middle`; 340 | if (repeat) { 341 | // 要重复填充,文本需要一个空白的画布,而不是在画面上直接写。 342 | sourceinputs.push(...`-f lavfi -r 1 -t 1 -i color=s=${boxw}x${boxh}`.split(' ')); 343 | filter_complex += `[${sourceIndex}:v]format=argb,colorchannelmixer=aa=0,${drawtextFilter},` 344 | + getRepeatFilter(boxw, boxh, rotate, inputFilterName, sourceIndex) 345 | + `${outputFilterName};`; 346 | sourceIndex++; 347 | } else { 348 | filter_complex += `${inputFilterName}${drawtextFilter}${outputFilterName};`; 349 | } 350 | inputFilterName = outputFilterName; 351 | } else if (group.file) { 352 | let file = group.file; 353 | if (!fs.existsSync(file)) { 354 | console.error('-file 文件不存在', file); 355 | return; 356 | } 357 | // 图片或视频 358 | sourceinputs.push('-i', file); 359 | let scale = group.scale; 360 | let sourceFilterName = `[${sourceIndex}:v]`; 361 | if (scale || alpha != 1) { 362 | let filterName = `[v_p_${sourceIndex}]`; 363 | let preProcessArr = []; 364 | if (scale) { 365 | preProcessArr.push(`scale=${scale}`); 366 | } 367 | if (alpha != 1) { 368 | preProcessArr.push(`format=argb,colorchannelmixer=aa=${alpha}`); 369 | } 370 | filter_complex += `${sourceFilterName}${preProcessArr.join(',')}${filterName};`; 371 | sourceFilterName = filterName; 372 | } 373 | if (repeat) { 374 | filter_complex += `${sourceFilterName}pad=${boxw}:${boxh}:(ow-iw)/2:(oh-ih)/2:color=black@0,` 375 | + getRepeatFilter(boxw, boxh, rotate, inputFilterName, sourceIndex) 376 | + `${outputFilterName};`; 377 | } else { 378 | filter_complex += `${inputFilterName}${sourceFilterName}overlay=x='${xexp}':y='${yexp}'${outputFilterName};`; 379 | } 380 | inputFilterName = outputFilterName; 381 | sourceIndex++; 382 | } 383 | } 384 | 385 | let crf = parseNumber(args.crf, 23); 386 | let fps = parseNumber(args.fps, null); 387 | 388 | // 移除最后一个输出,如[v12]; 389 | filter_complex = filter_complex.substring(0, filter_complex.length - 1 - outputFilterName.length); 390 | 391 | let ffmpeg_args = [ 392 | '-y', '-hide_banner', 393 | '-i', input, 394 | ...sourceinputs, 395 | '-filter_complex', filter_complex, 396 | // 输出视频的一些参数,这里只用了质量控制参数 -crf 23,可自行添加如 -c:v libx265 等 397 | ...(isvideo ? ['-shortest', '-crf', crf,] : ['-frames:v', '1']), 398 | ...(fps != null ? ['-r', fps] : []), 399 | outputfile 400 | ]; 401 | if (debug) { 402 | console.log(cmd, ffmpeg_args.map(i => i.toString().includes(' ') ? `"${i}"` : i).join(' ')); 403 | } 404 | let output = ''; 405 | // 输入视频的时长,单位毫秒 406 | let duration = null; 407 | let offset = 0; 408 | let progressPosition = 0; 409 | await new Promise((resolve, reject) => { 410 | let p = child_process.execFile(cmd, ffmpeg_args, {}); 411 | p.on('exit', (code) => { 412 | if (process.stdin.isTTY) { 413 | process.stdout.write('\n'); 414 | } 415 | if (code == 0) { 416 | resolve(code); 417 | } else { 418 | reject(code); 419 | } 420 | }); 421 | p.stderr.on('data', (chunk) => { 422 | output += chunk + ''; 423 | while (true) { 424 | let index = output.indexOf('\n', offset); 425 | let index2 = output.indexOf('\r', offset); 426 | if (index == -1 && index2 == -1) { 427 | break; 428 | } 429 | if (index == -1) { 430 | index = Number.MAX_SAFE_INTEGER; 431 | } 432 | if (index2 == -1) { 433 | index2 = Number.MAX_SAFE_INTEGER; 434 | } 435 | index = Math.min(index, index2); 436 | let line = output.substring(offset, index); 437 | offset = index + 1; 438 | duration = duration || tryParseDuration(line); 439 | let progress = tryParseProgress(line); 440 | if (progress != null) { 441 | progressPosition = progress.time; 442 | } else { 443 | continue; 444 | } 445 | if (isNaN(progressPosition)) { 446 | continue; 447 | } 448 | let progressStr = duration != null && progressPosition != 0 ? `处理进度:${(progressPosition / 1000).toFixed(2).padStart(7, ' ')} 秒(${(progressPosition / duration * 100).toFixed(2).padStart(5, ' ')}%)` : ''; 449 | let msg = progressStr; 450 | if (!process.stdin.isTTY) { 451 | console.log(msg); 452 | } else { 453 | process.stdout.write('\r' + msg); 454 | } 455 | } 456 | if (debug) { 457 | if (!process.stdin.isTTY) { 458 | console.log(chunk + ''); 459 | } else { 460 | process.stdout.write(chunk); 461 | } 462 | } 463 | }); 464 | }); 465 | let processTime = Date.now() - startTime; 466 | console.log('处理完毕。耗时:', processTime / 1000, '秒', '保存文件:', outputfile); 467 | } 468 | 469 | async function start(args) { 470 | if (args == null) { 471 | args = parseArgs(process.argv.slice(2)); 472 | } 473 | let input = args.i; 474 | if (input == null || !!args.h) { 475 | showCmdHelp(); 476 | return; 477 | } 478 | input = path.resolve(input); 479 | if (!fs.existsSync(input)) { 480 | console.error('输入文件(夹)不存在', input); 481 | return; 482 | } 483 | if (args.__groups.length == 0) { 484 | console.error('未设置', groupArgsEndKey.join('、'), '参数'); 485 | return; 486 | } 487 | let overwrite = !!args.y; 488 | debug = !!args.debug; 489 | let inputStat = fs.statSync(input); 490 | // 不管单个文件还是目录,均当成文件列表来处理 491 | let filelist = []; 492 | if (inputStat.isDirectory()) { 493 | filelist = getAllMediaFile(input); 494 | } else { 495 | filelist = [input]; 496 | } 497 | console.log('开始处理。'); 498 | console.log( 499 | `输入文件(夹):${input} 500 | 待处理文件数量:${filelist.length}`); 501 | // 如果输入文件超过1个,则输出必须是一个目录 502 | if (filelist.length > 1 && args.o) { 503 | let output = path.resolve(args.o.trim()); 504 | if (fs.existsSync(output)) { 505 | if (!fs.statSync(output).isDirectory()) { 506 | console.error('同时处理多个文件时,-o 必须是一个目录', output); 507 | return; 508 | } 509 | } else { 510 | fs.mkdirSync(output, { recursive: true }); 511 | } 512 | } 513 | // 遍历文件列表 514 | for (let i = 0; i < filelist.length; i++) { 515 | let inputfile = filelist[i]; 516 | let output = args.o || path.dirname(inputfile); 517 | output = path.resolve(output); 518 | if (fs.existsSync(output) && fs.statSync(output).isDirectory()) { 519 | output = path.join(output, path.basename(inputfile, path.extname(inputfile)) + '_watermark' + path.extname(inputfile)); 520 | } 521 | if (!overwrite && fs.existsSync(output)) { 522 | console.log('输出文件已存在,跳过', output); 523 | continue; 524 | } 525 | fs.mkdirSync(path.dirname(output), { recursive: true }); 526 | console.log('开始处理:[', i + 1, '/', filelist.length, ']', inputfile); 527 | await addWatermark(inputfile, output, args); 528 | } 529 | console.log('全部处理完成。即将退出脚本。'); 530 | } 531 | 532 | module.exports = { start } 533 | 534 | process.on('uncaughtException', (err) => { 535 | console.error(err); 536 | }); 537 | process.on('unhandledRejection', (err) => { 538 | console.error(err); 539 | }); 540 | 541 | // test(); 542 | if (process.argv[1] == __filename) { 543 | start(); 544 | } -------------------------------------------------------------------------------- /preset/dvd.preset: -------------------------------------------------------------------------------- 1 | # 编写此文件时,每个参数用一行表示,前后不需要用引号,即使有空格也不需要引号,且含空格的行也算一个参数 2 | # 如果有对齐参数的需求,可以在前面使用空格。 3 | # 以#或//开头的为注释。目前只支持行注释。 4 | 5 | -move 6 | dvd 7 | -xspeed 8 | 444 9 | -yspeed 10 | 333 11 | -alpha 12 | 0.75 13 | -scale 14 | 200:-2 15 | # 起始位置 16 | -xstart 17 | 100 18 | -ystart 19 | 200 20 | -file 21 | test\800px-DVD_video_logo_purple.png -------------------------------------------------------------------------------- /preset/idcard.preset: -------------------------------------------------------------------------------- 1 | # 编写此文件时,每个参数用一行表示,前后不需要用引号,即使有空格也不需要引号,且含空格的行也算一个参数 2 | # 如果有对齐参数的需求,可以在前面使用空格。 3 | # 以#或//开头的为注释。目前只支持行注释。 4 | 5 | -repeat 6 | -fontsize 7 | 30 8 | -boxw 9 | 450 10 | -boxh 11 | 120 12 | -alpha 13 | 0.75 14 | # 顺时针旋转45度 15 | -rotate 16 | 45 17 | -text 18 | 仅限某某用途使用,它用无效 -------------------------------------------------------------------------------- /preset/random.preset: -------------------------------------------------------------------------------- 1 | # 编写此文件时,每个参数用一行表示,前后不需要用引号,即使有空格也不需要引号,且含空格的行也算一个参数 2 | # 如果有对齐参数的需求,可以在前面使用空格。 3 | # 以#或//开头的为注释。目前只支持行注释。 4 | 5 | -move 6 | random 7 | # 每1秒变换一次位置 8 | -interval 9 | 1 10 | -fontsize 11 | 35 12 | -fontcolor 13 | blue 14 | -alpha 15 | 0.5 16 | -text 17 | 防伪标志 -------------------------------------------------------------------------------- /preset/xfade/BowTieHorizontal.txt: -------------------------------------------------------------------------------- 1 | # BowTieVertical的水平实现 2 | # https://gl-transitions.com/editor/BowTieHorizontal 3 | if( 4 | lt(0.5-abs(X/W-0.5),abs(mod(Y/H+0.5,1)-0.5)-0.5+(1-P)) 5 | ,B,A) -------------------------------------------------------------------------------- /preset/xfade/BowTieVertical.txt: -------------------------------------------------------------------------------- 1 | # 从顶部慢慢移动到底部的倒三角+从底部慢慢移动到顶部的倒三角 2 | # (if(gt(Y,H/2),H-Y,Y)) 3 | # 0.5-abs(Y/H-0.5) 4 | # 这两个表达式,当Y值是y或H-y时,计算出来都是y。也就是相对H/2镜像 5 | # https://gl-transitions.com/editor/BowTieVertical 6 | if( 7 | lt(0.5-abs(Y/H-0.5),abs(mod(X/W+0.5,1)-0.5)-0.5+(1-P)) 8 | ,B,A) -------------------------------------------------------------------------------- /preset/xfade/Dreamy.txt: -------------------------------------------------------------------------------- 1 | # https://gl-transitions.com/editor/Dreamy 2 | # 画面变得像波浪一样上下起伏 3 | # H/20:控制上下起伏的幅度,这个值表示高度的1/20。可以换成常量,也可以改成其它比例 4 | # 7*PI:控制起伏的宽度,7表示水平上有7/2=3.5个波峰 5 | P*( 6 | if(eq(PLANE,0),a0(X,Y+H/20*sin((X/W+P)*7*PI)*(1-P)),0)+ 7 | if(eq(PLANE,1),a1(X,Y+H/20*sin((X/W+P)*7*PI)*(1-P)),0)+ 8 | if(eq(PLANE,2),a2(X,Y+H/20*sin((X/W+P)*7*PI)*(1-P)),0)+ 9 | if(eq(PLANE,3),a3(X,Y+H/20*sin((X/W+P)*7*PI)*(1-P)),0) 10 | ) 11 | +(1-P)*( 12 | if(eq(PLANE,0),b0(X,Y+H/20*sin((X/W+P)*7*PI)*P),0)+ 13 | if(eq(PLANE,1),b1(X,Y+H/20*sin((X/W+P)*7*PI)*P),0)+ 14 | if(eq(PLANE,2),b2(X,Y+H/20*sin((X/W+P)*7*PI)*P),0)+ 15 | if(eq(PLANE,3),b3(X,Y+H/20*sin((X/W+P)*7*PI)*P),0) 16 | ) -------------------------------------------------------------------------------- /preset/xfade/FlipOver.txt: -------------------------------------------------------------------------------- 1 | # 简易的翻页效果 2 | # 3.5 是倾斜度(参考y=3.5x函数图的倾斜度),可以自行全部替换(计算式里有常量0,1,2,3,替换的时候如果是这些值最好写成小数形式0.0,1.0,2.0,3.0,方便后续替换成别的) 3 | # a0(X,Y)*0.2+0.8*a0(***,***) 是做了20%透明度处理,如果不要透明度则去掉,剩下 a0(***,***) 4 | # 应用了点到线的距离公式,以及点相对线对称的另一个点坐标公式 5 | # 其中,P时刻,线的公式为 3.5*X-Y+(H-3.5*W*P-H*P) 6 | if( 7 | lt((X-W+(W+H/3.5)*(1-P))*3.5,Y) 8 | , 9 | if(between(-(-2*3.5*Y+(3.5*3.5-1)*X+2*3.5*(H-3.5*W*P-H*P))/(3.5*3.5+1),0,W)* 10 | between(-(-2*3.5*X+(1-3.5*3.5)*Y-2*(H-3.5*W*P-H*P))/(3.5*3.5+1),0,H) 11 | # 这里做了卷轴的效果,且卷轴越来越大,如果不要则去掉这一行即可。100和50表示,卷轴的半径在50~100之间渐渐变大 12 | *lt(abs(3.5*X-Y+(H-3.5*W*P-H*P))/hypot(3.5,-1),100*(1-P)+50) 13 | , 14 | if(eq(PLANE,0),a0(X,Y)*0.2+0.8*a0(-(-2*3.5*Y+(3.5*3.5-1)*X+2*3.5*(H-3.5*W*P-H*P))/(3.5*3.5+1),-(-2*3.5*X+(1-3.5*3.5)*Y-2*(H-3.5*W*P-H*P))/(3.5*3.5+1)),0)+ 15 | if(eq(PLANE,1),a1(X,Y)*0.2+0.8*a1(-(-2*3.5*Y+(3.5*3.5-1)*X+2*3.5*(H-3.5*W*P-H*P))/(3.5*3.5+1),-(-2*3.5*X+(1-3.5*3.5)*Y-2*(H-3.5*W*P-H*P))/(3.5*3.5+1)),0)+ 16 | if(eq(PLANE,2),a2(X,Y)*0.2+0.8*a2(-(-2*3.5*Y+(3.5*3.5-1)*X+2*3.5*(H-3.5*W*P-H*P))/(3.5*3.5+1),-(-2*3.5*X+(1-3.5*3.5)*Y-2*(H-3.5*W*P-H*P))/(3.5*3.5+1)),0)+ 17 | if(eq(PLANE,3),a3(X,Y)*0.2+0.8*a3(-(-2*3.5*Y+(3.5*3.5-1)*X+2*3.5*(H-3.5*W*P-H*P))/(3.5*3.5+1),-(-2*3.5*X+(1-3.5*3.5)*Y-2*(H-3.5*W*P-H*P))/(3.5*3.5+1)),0) 18 | ,A) 19 | ,B 20 | ) -------------------------------------------------------------------------------- /preset/xfade/WaterDrop.txt: -------------------------------------------------------------------------------- 1 | # 水滴效果,其中,振幅=10,速度=30 2 | # 修改参数的效果可以直接看这个在线用例 3 | # https://gl-transitions.com/editor/WaterDrop?amplitude=10&speed=30 4 | if(gt(hypot(X/W-0.5,Y/H-0.5),(1-P)), 5 | A*P+B*(1-P) 6 | , 7 | if(eq(PLANE,0),(1-P)*b0(X,Y)+P*a0(X+(X-W/2)*sin(hypot(X/W-0.5,Y/H-0.5)*10-(1-P)*30),Y+(Y-H/2)*sin(hypot(X/W-0.5,Y/H-0.5)*10-(1-P)*30)),0)+ 8 | if(eq(PLANE,1),(1-P)*b1(X,Y)+P*a1(X+(X-W/2)*sin(hypot(X/W-0.5,Y/H-0.5)*10-(1-P)*30),Y+(Y-H/2)*sin(hypot(X/W-0.5,Y/H-0.5)*10-(1-P)*30)),0)+ 9 | if(eq(PLANE,2),(1-P)*b2(X,Y)+P*a2(X+(X-W/2)*sin(hypot(X/W-0.5,Y/H-0.5)*10-(1-P)*30),Y+(Y-H/2)*sin(hypot(X/W-0.5,Y/H-0.5)*10-(1-P)*30)),0)+ 10 | if(eq(PLANE,3),(1-P)*b3(X,Y)+P*a3(X+(X-W/2)*sin(hypot(X/W-0.5,Y/H-0.5)*10-(1-P)*30),Y+(Y-H/2)*sin(hypot(X/W-0.5,Y/H-0.5)*10-(1-P)*30)),0) 11 | ) -------------------------------------------------------------------------------- /preset/xfade/alternateProgressive.txt: -------------------------------------------------------------------------------- 1 | # 交替逐行扫描 2 | # 20:左右分别8行 3 | # 0.95:1-1/20,10是上面的10行。 4 | # 当这个值小于(1-1/20)时,第一行没扫描完第二行就开始了 5 | # 当这个值大于(1-1/20)时,第一行扫描完,要等一会第二行才开始 6 | # 当这个值等于0时,则所有行同时开始扫描 7 | # 当这个值等于1时,则行扫描的动画会立即完成 8 | if( 9 | (lt(X/W,0+(P-0.95+floor(Y*20/H)*(0.95/20))/(1-0.95))+mod(floor(Y*20/H),2))* 10 | (gt(X/W,1-(P-0.95+floor(Y*20/H)*(0.95/20))/(1-0.95))+mod(floor(Y*20/H)+1,2)) 11 | ,A,B) -------------------------------------------------------------------------------- /preset/xfade/directionalwarp.txt: -------------------------------------------------------------------------------- 1 | # https://gl-transitions.com/editor/directionalwarp 2 | # 控制方向:全局搜索替换“clip (0.5*X/W+0.5*Y/H-(0.5”,其中有三个0.5,前2个0.5表示运动方向。第三个0.5是前面两个0.5的和除以2 3 | # 0.5,0.5 表示 从左上角到右下角,第三个为0.5, 要替换成(删掉clip后面的空格) clip (0.5*X/W+0.5*Y/H-(0.5 4 | # -0.5,0.5 表示 从右上角到左下角,第三个为0, 要替换成(删掉clip后面的空格) clip (-0.5*X/W+0.5*Y/H-(0 5 | # -0.5,-0.5 表示 从右下角到左上角,第三个为-0.5,要替换成(删掉clip后面的空格) clip (-0.5*X/W-0.5*Y/H-(-0.5 6 | # 0.5,-0.5 表示 从左下角到右上角,第三个为0, 要替换成(删掉clip后面的空格) clip (0.5*X/W-0.5*Y/H-(0 7 | # 控制模糊程度:0.45 8 | (clip(0.5*X/W+0.5*Y/H-(0.5-0.5+(1-P)*(1+0.45)),-0.45,0)/(-0.45))*( 9 | if(eq(PLANE,0),b0(((X/W-0.5)*(clip(0.5*X/W+0.5*Y/H-(0.5-0.5+(1-P)*(1+0.45)),-0.45,0)/(-0.45))+0.5)*W,H*((Y/H-0.5)*(clip(0.5*X/W+0.5*Y/H-(0.5-0.5+(1-P)*(1+0.45)),-0.45,0)/(-0.45))+0.5)),0)+ 10 | if(eq(PLANE,1),b1(((X/W-0.5)*(clip(0.5*X/W+0.5*Y/H-(0.5-0.5+(1-P)*(1+0.45)),-0.45,0)/(-0.45))+0.5)*W,H*((Y/H-0.5)*(clip(0.5*X/W+0.5*Y/H-(0.5-0.5+(1-P)*(1+0.45)),-0.45,0)/(-0.45))+0.5)),0)+ 11 | if(eq(PLANE,2),b2(((X/W-0.5)*(clip(0.5*X/W+0.5*Y/H-(0.5-0.5+(1-P)*(1+0.45)),-0.45,0)/(-0.45))+0.5)*W,H*((Y/H-0.5)*(clip(0.5*X/W+0.5*Y/H-(0.5-0.5+(1-P)*(1+0.45)),-0.45,0)/(-0.45))+0.5)),0)+ 12 | if(eq(PLANE,3),b3(((X/W-0.5)*(clip(0.5*X/W+0.5*Y/H-(0.5-0.5+(1-P)*(1+0.45)),-0.45,0)/(-0.45))+0.5)*W,H*((Y/H-0.5)*(clip(0.5*X/W+0.5*Y/H-(0.5-0.5+(1-P)*(1+0.45)),-0.45,0)/(-0.45))+0.5)),0) 13 | ) 14 | + 15 | (1-clip(0.5*X/W+0.5*Y/H-(0.5-0.5+(1-P)*(1+0.45)),-0.45,0)/(-0.45))*( 16 | if(eq(PLANE,0),a0(((X/W-0.5)*(1-clip(0.5*X/W+0.5*Y/H-(0.5-0.5+(1-P)*(1+0.45)),-0.45,0)/(-0.45))+0.5)*W,H*((Y/H-0.5)*(1-clip(0.5*X/W+0.5*Y/H-(0.5-0.5+(1-P)*(1+0.45)),-0.45,0)/(-0.45))+0.5)),0)+ 17 | if(eq(PLANE,1),a1(((X/W-0.5)*(1-clip(0.5*X/W+0.5*Y/H-(0.5-0.5+(1-P)*(1+0.45)),-0.45,0)/(-0.45))+0.5)*W,H*((Y/H-0.5)*(1-clip(0.5*X/W+0.5*Y/H-(0.5-0.5+(1-P)*(1+0.45)),-0.45,0)/(-0.45))+0.5)),0)+ 18 | if(eq(PLANE,2),a2(((X/W-0.5)*(1-clip(0.5*X/W+0.5*Y/H-(0.5-0.5+(1-P)*(1+0.45)),-0.45,0)/(-0.45))+0.5)*W,H*((Y/H-0.5)*(1-clip(0.5*X/W+0.5*Y/H-(0.5-0.5+(1-P)*(1+0.45)),-0.45,0)/(-0.45))+0.5)),0)+ 19 | if(eq(PLANE,3),a3(((X/W-0.5)*(1-clip(0.5*X/W+0.5*Y/H-(0.5-0.5+(1-P)*(1+0.45)),-0.45,0)/(-0.45))+0.5)*W,H*((Y/H-0.5)*(1-clip(0.5*X/W+0.5*Y/H-(0.5-0.5+(1-P)*(1+0.45)),-0.45,0)/(-0.45))+0.5)),0) 20 | ) -------------------------------------------------------------------------------- /preset/xfade/mosaic.txt: -------------------------------------------------------------------------------- 1 | # 马赛克 2 | # 500 是最大的马赛克尺寸系数。在这个表达式里,P=0.5时,(P)*(P)*500=125,就表示最大马赛克是125px 3 | # 用P*P将线性P转变成平方,效果就是马赛克变大的速度慢慢加快。 4 | # +0.5 是采样的时候选取中间的像素点 5 | if( 6 | lt(P,0.5) 7 | , 8 | if(eq(PLANE,0),b0(floor(X/(1+(P)*(P)*500)+0.5)*(1+(P)*(P)*500),floor(Y/(1+(P)*(P)*500)+0.5)*(1+(P)*(P)*500)),0)+ 9 | if(eq(PLANE,1),b1(floor(X/(1+(P)*(P)*500)+0.5)*(1+(P)*(P)*500),floor(Y/(1+(P)*(P)*500)+0.5)*(1+(P)*(P)*500)),0)+ 10 | if(eq(PLANE,2),b2(floor(X/(1+(P)*(P)*500)+0.5)*(1+(P)*(P)*500),floor(Y/(1+(P)*(P)*500)+0.5)*(1+(P)*(P)*500)),0)+ 11 | if(eq(PLANE,3),b3(floor(X/(1+(P)*(P)*500)+0.5)*(1+(P)*(P)*500),floor(Y/(1+(P)*(P)*500)+0.5)*(1+(P)*(P)*500)),0) 12 | , 13 | if(eq(PLANE,0),a0(floor(X/(1+(1-P)*(1-P)*500)+0.5)*(1+(1-P)*(1-P)*500),floor(Y/(1+(1-P)*(1-P)*500)+0.5)*(1+(1-P)*(1-P)*500)),0)+ 14 | if(eq(PLANE,1),a1(floor(X/(1+(1-P)*(1-P)*500)+0.5)*(1+(1-P)*(1-P)*500),floor(Y/(1+(1-P)*(1-P)*500)+0.5)*(1+(1-P)*(1-P)*500)),0)+ 15 | if(eq(PLANE,2),a2(floor(X/(1+(1-P)*(1-P)*500)+0.5)*(1+(1-P)*(1-P)*500),floor(Y/(1+(1-P)*(1-P)*500)+0.5)*(1+(1-P)*(1-P)*500)),0)+ 16 | if(eq(PLANE,3),a3(floor(X/(1+(1-P)*(1-P)*500)+0.5)*(1+(1-P)*(1-P)*500),floor(Y/(1+(1-P)*(1-P)*500)+0.5)*(1+(1-P)*(1-P)*500)),0) 17 | ) -------------------------------------------------------------------------------- /preset/xfade/pinwheel.txt: -------------------------------------------------------------------------------- 1 | # n个扇形擦除,像“风车”一样 2 | # n=12 3 | # W/2,H/2:中心点 4 | # P*4:旋转整个风车,值越大转得越快 5 | if( 6 | lt(mod((atan2(Y-H/2,X-W/2)+P*4+PI)/(PI*2/12),1),P) 7 | ,A,B) -------------------------------------------------------------------------------- /preset/xfade/progressive.txt: -------------------------------------------------------------------------------- 1 | # 逐行扫描 2 | # 10:10行 3 | # 0.9:1-1/10,10是上面的10行。 4 | # 当这个值小于(1-1/10)时,第一行没扫描完第二行就开始了 5 | # 当这个值大于(1-1/10)时,第一行扫描完,要等一会第二行才开始 6 | # 当这个值等于0时,则所有行同时开始扫描 7 | # 当这个值等于1时,则行扫描的动画会立即完成 8 | if( 9 | lt(X/W,0+(P-0.9+floor(Y*10/H)*(0.9/10))/(1-0.9)) 10 | ,A,B) -------------------------------------------------------------------------------- /preset/xfade/readme.md: -------------------------------------------------------------------------------- 1 | 2 | 将你编写的`xfade`的自定义动画脚本放在这里。 3 | 4 | 效果预览请至[docs/ffmpeg.img2video.custom.transitions.md](../../docs/ffmpeg.img2video.custom.transitions.md) -------------------------------------------------------------------------------- /preset/xfade/windowblind.txt: -------------------------------------------------------------------------------- 1 | # 百叶窗效果,通过windowslice.txt修改而来 2 | # https://gl-transitions.com/editor/windowslice?count=10&smoothness=0.6 3 | if( 4 | lt(mod(Y*10,H)/H,1-((1-P)-0.6+floor(Y*10/H)*(0.6/10))/(1-0.6)) 5 | ,A,B) -------------------------------------------------------------------------------- /preset/xfade/windowslice.txt: -------------------------------------------------------------------------------- 1 | # 分成10部分,从右边慢慢擦除,第1部分先擦除,等一会之后第2部分再开始,再等一个第3部分开始,穿插着 2 | # floor(X*10/W) 表示第几部分 3 | # 0.6表示动画到1-0.6=40%的时候,第1部分正好完全擦除 4 | # 1-0.6 最好不要小于 1/10,否则前一个擦除完了后一个还没开始擦 5 | # https://gl-transitions.com/editor/windowslice?count=10&smoothness=0.6 6 | if( 7 | lt(mod(X*10,W)/W,(P-0.6+floor(X*10/W)*(0.6/10))/(1-0.6)) 8 | ,A,B) -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # ffmpeg-script 2 | 3 | 一个使用ffmpeg实现各种功能的脚本集(nodejs) 4 | 5 | - [运行环境](#运行环境) 6 | - [分析视频场景帧,并生成播放器支持的书签文件(PotPlayer)](#分析视频场景帧并生成播放器支持的书签文件potplayer) 7 | - [命令行示例](#命令行示例) 8 | - [使用视频画面拼接自定义的字幕](#使用视频画面拼接自定义的字幕) 9 | - [命令行示例](#命令行示例-1) 10 | - [以水平滚动的方式,展示多张图片](#以水平滚动的方式展示多张图片) 11 | - [命令行示例](#命令行示例-2) 12 | - [为图片或视频添加自定义的水印,具有动态水印等多种高级功能。](#为图片或视频添加自定义的水印具有动态水印等多种高级功能) 13 | - [命令行示例](#命令行示例-3) 14 | - [将多张图片转换成类似幻灯片的视频,支持多种转场效果](#将多张图片转换成类似幻灯片的视频支持多种转场效果) 15 | - [命令行示例](#命令行示例-4) 16 | 17 | ## 运行环境 18 | 19 | | 名称 | 版本 | 注意 | 20 | | ------ | -------- | ------------------------ | 21 | | nodejs | 18.16.1+ | | 22 | | ffmpeg | 4.4.2+ | 需要添加到环境变量path中 | 23 | 24 | > 脚本为了方便使用,尽量不使用第三方库,因此,如果没有特殊说明,每个文件均可以独立运行。 25 | > 无需执行`npm install`; 26 | 27 | ## 分析视频场景帧,并生成播放器支持的书签文件(PotPlayer) 28 | 29 | ### 命令行示例 30 | 31 | ```bash 32 | node ffmpeg.video2bookmark.js -i "视频文件(夹)完整路径" 33 | ``` 34 | 35 | 将在视频文件同目录下创建同名的.pbf文件,该文件为PotPlayer支持的书签文件。打开视频文件将自动加载。更多书签的使用方式,请自行研究PotPlayer。 36 | 37 | - 分析需要对视频进行解码,受CPU性能影响; 38 | - 更多参数可执行`node ffmpeg.video2bookmark.js -h`查看帮助文档 39 | - 关于这个脚本的记录文章可以查看这里[docs/ffmpeg.video2bookmark.md](docs/ffmpeg.video2bookmark.md) 40 | 41 | 42 | ## 使用视频画面拼接自定义的字幕 43 | 44 | ### 命令行示例 45 | 46 | ```bash 47 | node ffmpeg.subtitle.stack.js -i "视频文件路径" -t "文本文件路径" -font "字体文件路径" 48 | ``` 49 | 50 | - 更多参数可执行`node ffmpeg.subtitle.stack.js -h`查看帮助文档 51 | - 关于这个脚本的记录文章可以查看这里[docs/ffmpeg.subtitle.stack.md](docs/ffmpeg.subtitle.stack.md) 52 | 53 | 54 | ## 以水平滚动的方式,展示多张图片 55 | 56 |
57 | 示例视频 58 | 59 |
60 | 61 | ### 命令行示例 62 | 63 | ```bash 64 | node ffmpeg.images.rolling.js -i "图片文件夹路径" 65 | ``` 66 | 67 | - 更多参数可执行`node ffmpeg.images.rolling.js -h`查看帮助文档 68 | - 关于这个脚本的记录文章可以查看这里[docs/ffmpeg.images.rolling.md](docs/ffmpeg.images.rolling.md) 69 | 70 | 71 | ## 为图片或视频添加自定义的水印,具有动态水印等多种高级功能。 72 | 73 | 74 |
75 | 模拟DVD待机画面 76 | 77 |
78 | 79 | ---- 80 | 81 |
82 | 每1秒随机变换水印位置 83 | 84 |
85 | 86 | ---- 87 | 88 | 身份证添加水印: 89 | 90 | ![idcard_watermark](https://github.com/jifengg/ffmpeg-script/assets/17020523/7e8dff92-feec-40e3-978f-54df1fabdad5) 91 | 92 | 93 | ### 命令行示例 94 | 95 | ```bash 96 | node ffmpeg.watermark.js -i "图片文件夹路径" 97 | ``` 98 | 99 | - 更多参数可执行`node ffmpeg.watermark.js -h`查看帮助文档 100 | - 更详细的帮助文档可以查看这里[docs/ffmpeg.watermark.help.md](docs/ffmpeg.watermark.help.md) 101 | - 如果你对ffmpeg的开发感兴趣,关于这个脚本的技术文章可以查看这里[docs/ffmpeg.watermark.md](docs/ffmpeg.watermark.md) 102 | 103 | 104 | ## 将多张图片转换成类似幻灯片的视频,支持多种转场效果 105 | 106 |
107 | 效果预览 108 | 109 |
110 | 111 | ### 命令行示例 112 | 113 | ```bash 114 | node ffmpeg.img2video.js -i "图片文件夹路径" 115 | ``` 116 | 117 | - 将一个目录里的图片文件按顺序生成幻灯片视频,如果目录下有音频和字幕,也将第一个音频和字幕添加到视频里 118 | - 更多参数可执行`node ffmpeg.img2video.js -h`查看帮助文档 119 | - 如果你对ffmpeg的开发感兴趣,关于这个脚本的技术文章可以查看这里[docs/ffmpeg.img2video.md](docs/ffmpeg.img2video.md) 120 | - 增加了对自定义转场效果的支持,并在[preset/xfade](preset/xfade)里预置了一些效果,点击[这里](docs/ffmpeg.img2video.custom.transitions.md)预览效果。 121 | - 关于自定义转场效果要怎么写,可以查看这个文档[docs/ffmpeg.xfade.md](docs/ffmpeg.xfade.md) --------------------------------------------------------------------------------