├── .gitattributes ├── .github └── FUNDING.yml ├── .gitignore ├── LICENSE ├── README.md ├── cli.js ├── eslint.config.js ├── index.js ├── package-lock.json └── package.json /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: workeffortwaste -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | *.mp4 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Chris Johnson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gsap-video-export 2 | 3 | _Expertly and easily export GreenSock (GSAP) animation to video._ 4 | 5 | `gsap-video-export` is a simple tool for exporting your [GreenSock (GSAP)](https://www.greensock.com) animations to video. Create video animations with the framework you know and love and use them in your video projects or share them on social media with ease. 6 | 7 | What makes `gsap-video-export` different from other solutions is rather than simply recording an animation as it plays, it instead steps through exporting frame by frame to ensure the result is seamless. 8 | 9 | > **Support this project**
Help support the work that goes into creating and maintaining my projects and sponsor me via [GitHub Sponsors](https://github.com/sponsors/workeffortwaste/). 10 | 11 | ## Contents 12 | 13 | - [gsap-video-export](#gsap-video-export) 14 | - [Contents](#contents) 15 | - [What's New](#whats-new) 16 | - [2.1.0 🆕](#210-) 17 | - [2.0.3 🆕](#203-) 18 | - [2.0.2 🆕](#202-) 19 | - [2.0.1 🆕](#201-) 20 | - [2.0.0 🆕](#200-) 21 | - [Getting Started](#getting-started) 22 | - [Installation](#installation) 23 | - [Usage](#usage) 24 | - [Command Line](#command-line) 25 | - [ESM Module 🆕](#esm-module-) 26 | - [Options](#options) 27 | - [Examples](#examples) 28 | - [Page Export](#page-export) 29 | - [Custom Timeline](#custom-timeline) 30 | - [Export Element](#export-element) 31 | - [Twitter Export](#twitter-export) 32 | - [Coloured Background](#coloured-background) 33 | - [Lossless\* Export](#lossless-export) 34 | - [Timeweb Frame Advancement 🆕](#timeweb-frame-advancement-) 35 | - [Alpha Transparency 🆕](#alpha-transparency-) 36 | - [Post Processing 🆕](#post-processing-) 37 | - [Advanced](#advanced) 38 | - [Cookies 🆕](#cookies-) 39 | - [Chrome 🆕](#chrome-) 40 | - [Headless 🆕](#headless-) 41 | - [FAQ](#faq) 42 | - [Sponsors](#sponsors) 43 | - [Bonus](#bonus) 44 | - [Author](#author) 45 | 46 | 47 | ## What's New 48 | 49 | ### 2.1.0 🆕 50 | 51 | * Added a `post-process` option, allowing a script to modify the image buffer before it's saved to temp storage. 52 | 53 | ### 2.0.3 🆕 54 | 55 | * Added `prepare-page` and `prepare-frame` options, allowing a script to be run once at page load or before each frame. 56 | * Removed references to Twitter. 57 | * Fixed the missing default video format when used as an ESM module. 58 | 59 | ### 2.0.2 🆕 60 | 61 | * When used an ESM module the additional script can now be passed as a function as well as string pointing to an external js file. 62 | 63 | ### 2.0.1 🆕 64 | 65 | * Fixed an issue that was causing blocked access to public codepen URLs. 66 | * Support for videos with alpha transparency. 67 | * Improved clean up of temporary files. 68 | 69 | ### 2.0.0 🆕 70 | 71 | * ESM module support. 72 | * Puppeteer updated to the latest version, and `puppeteer-stealth` has been swapped for `rebrowser-puppeteer` for better bot detection avoidance. 73 | * Added support for `timeweb` frame advancement to allow for capturing of elements animated outside of the GSAP timeline. 74 | * Optionally use the system installed version of Chrome for future compatibility. 75 | * Optionally disable headless mode for debugging. 76 | * Supply your own cookie JSON file to bypass cookie pop-ups and authentication. 77 | 78 | ## Getting Started 79 | 80 | ### Installation 81 | 82 | `gsap-video-export` is a command line tool that can be installed directly via NPM. 83 | 84 | ``` 85 | npm install -g gsap-video-export 86 | ``` 87 | 88 | ### Usage 89 | 90 | #### Command Line 91 | 92 | Once installed the tool can be used as per the following example. 93 | 94 | ``` 95 | gsap-video-export 96 | ``` 97 | 98 | > When using CodePen URLs `gsap-video-export` will automatically redirect to the full page debug preview. 99 | 100 | #### ESM Module 🆕 101 | 102 | This library can now be imported as an ESM module, opening up `gsap-video-export` to be used seamlessly as part of a production pipeline. 103 | 104 | ```javascript 105 | import { videoExport } from 'gsap-video-export' 106 | 107 | const videoDetails = await videoExport({ 108 | url: , 109 | }) 110 | 111 | console.log(videoDetails) 112 | /* { file: filename (string), exportTime: seconds (number), renderTime: seconds (number) */ 113 | ``` 114 | 115 | When running as an ESM module the output will be silent and any issues willl throw an error. You can check for problems ahead of time with the `info` option and wrapping the function with a try/catch block. 116 | 117 | ```javascript 118 | import { videoExport } from 'gsap-video-export' 119 | 120 | let videoDetails 121 | try { 122 | videoDetails = await videoExport({ 123 | url: , 124 | info: true 125 | }) 126 | } catch (err) { 127 | console.log(err) 128 | } 129 | 130 | console.log(videoDetails) 131 | /** 132 | * { 133 | * duration: seconds (number), 134 | * frames: frames (number), 135 | * gsap: version (string), 136 | * timeline: timeline (string) 137 | * } 138 | */ 139 | ``` 140 | 141 | ### Options 142 | 143 | All additional options are available when used as a module or via the CLI. 144 | 145 | | **CLI Argument** | **Module Option** | **Category** | **Description** | **Type** | **Default Value** | 146 | | ---------------------------------- | ----------------------- | ------------ | ------------------------------------------------- | -------------------- | ---------------------------- | 147 | | `--help` | | General | Show help | `boolean` | | 148 | | `--version` | | General | Show version number | `boolean` | | 149 | | `-q`, `--verbose` | `verbose` | General | Verbose output. | `boolean` | `true` | 150 | | `-i`, `--info` | `info` | General | Information only. | `boolean` | `false` | 151 | | `-s`, `--prepare-page`, `--script` | `preparePage`, `script` | Browser | Custom script to run once on page load. | `string`, `function` | | 152 | | `--prepare-frame` | `prepareFrame` | Browser | Custom script to run before every frame. | `string`, `function` | | 153 | | `--post-process` | `postProcess` | Browser | Custom script to modify the image buffer. | `string`, `function` | | 154 | | `-S`, `--selector` | `selector` | Browser | DOM selector of element to capture. | `string` | `"document"` | 155 | | `-t`, `--timeline` | `timeline` | Browser | GSAP timeline object. | `string` | `"gsap"` | 156 | | `-z`, `--scale` | `scale` | Browser | Scale factor for higher quality capture. | `number` | `1` | 157 | | `-V`, `--viewport` | `viewport` | Browser | Browser viewport size. | `string` | `"1920x1080"` | 158 | | `--frame-start` | `frameStart` | Browser | The frame number to begin capturing. | `number` | | 159 | | `--frame-end` | `frameEnd` | Browser | The frame number to stop capturing. | `number` | | 160 | | `--chrome` | `chrome` | Browser | Automatically use the system installed Chrome. | `boolean` | `false` | 161 | | `--cookies` | `cookies` | Browser | Cookie JSON file in a Puppeteer supported format. | `string` | | 162 | | `-a`, `--advance` | `advance` | Browser | The method to use for advancing frames. | `string` | `gsap` | 163 | | `-h`, `--headless` | `headless` | Browser | Headless mode. | `boolean` | `true` | 164 | | `-p`, `--color` | `color` | Video | Padding color. | `string` | `"auto"` | 165 | | `-c`, `--codec` | `codec` | Video | Video codec. | `string` | `"libx264"` | 166 | | `-C`, `--format` | `format` | Video | Video format. | `string` | `"mp4"` | 167 | | `-e`, `--input-options` | `inputOptions` | Video | Additional FFmpeg input options. | `string` | | 168 | | `-E`, `--output-options` | `outputOptions` | Video | Additional FFmpeg output options. | `string` | `"-pix_fmt yuv420p -crf 18"` | 169 | | `-o`, `--output` | `output` | Video | Output filename. | `string` | `"video.mp4"` | 170 | | `-f`, `--fps` | `fps` | Video | Target framerate. | `number` | `60` | 171 | | `-v`, `--resolution` | `resolution` | Video | Output resolution. | `string` | `"auto"` | 172 | 173 | ## Examples 174 | 175 | > A huge thanks to [@cassiecodes](https://twitter.com/cassiecodes) for letting me use her incredible GreenSock pens to demonstrate `gsap-video-export`. 176 | 177 | ### Page Export 178 | 179 | Supplying `gsap-video-export` with a URL will generate a `1920x1080` video file of the viewport, scrubbing through the GSAP global timeline object at `60fps`. 180 | 181 | ```bash 182 | # Animation by @cassiecodes 183 | gsap-video-export https://codepen.io/cassie-codes/pen/RwGEewq 184 | ``` 185 | 186 | https://user-images.githubusercontent.com/49479599/154277839-551542f6-9236-48cf-b3e1-b55484475e22.mp4 187 | 188 | ### Custom Timeline 189 | 190 | By default `gsap-video-export` will scrub through the global GSAP timeline object, although there may be instances where you want to specify which timeline you want to record. 191 | 192 | In the example below the global timeline fails due an infinite loop. 193 | 194 | ```bash 195 | # Animation by @SeeMax 196 | gsap-video-export https://codepen.io/SeeMax/pen/bGoxMwj 197 | ``` 198 | 199 | Using the `--timeline` `-t` argument you can specify a different timeline variable to use instead. 200 | 201 | ```bash 202 | # Animation by @SeeMax 203 | gsap-video-export https://codepen.io/SeeMax/pen/bGoxMwj -t tl 204 | ``` 205 | 206 | https://user-images.githubusercontent.com/49479599/154277884-148c21b5-2d23-48bf-8e2f-5321c64c0c62.mp4 207 | 208 | ### Export Element 209 | 210 | With the `--selector` `-S` argument you can specifiy a DOM selector to capture a specific element. The resulting output video will be the same dimensions as the as the selected element. 211 | 212 | `gsap-video-export` also allows you to run custom JavaScript on the page before the video capture begins with the `--script` `-s` argument. In this example a `custom.js` file is supplied with a snippet to remove the corner banner from the DOM. 213 | 214 | ```js 215 | // custom.js 216 | document.querySelector('img[alt="HTML5"]').remove() 217 | ``` 218 | 219 | ```bash 220 | # Animation by GreenSock 221 | gsap-video-export https://codepen.io/GreenSock/pen/DzXpme -S "#featureBox" -s custom.js 222 | ``` 223 | 224 | https://user-images.githubusercontent.com/49479599/154277903-fd6cfa40-af95-4ef9-83c6-89db2e8a098a.mp4 225 | 226 | ### Twitter Export 227 | 228 | > In this example if you visit the pen you might notice the animation is offscreen. This isn't an issue as `gsap-video-export` will automatically scroll the selected element into the viewport. 229 | 230 | It's possible to easily export videos for social media such as Twitter. Using the default settings `gsap-video-export` will automatically output video in a format that conforms to Twitter's video specifications. 231 | 232 | To render your video at the desired resolution use the `--resolution` `-v` argument with a `x` string. For Twitter I recommend using `1080x1080`. 233 | 234 | ```bash 235 | # Video by @cassiecodes 236 | gsap-video-export https://codepen.io/cassie-codes/pen/mNWxpL -S svg -v 1080x1080 237 | ``` 238 | 239 | The example above will select the `SVG` element on the page, with the resulting video resized and automatically padded to `1080x1080`. 240 | 241 | As the `SVG` element itself is not 1080 pixels in either direction it will ultimately be scaled up to hit the target resolution losing quality. 242 | 243 | Using the `--scale` `-z` you can supply a scale factor allowing you to capture the element at a much higher resolution resulting in better video quality. 244 | 245 | ```bash 246 | # Video by @cassiecodes 247 | gsap-video-export https://codepen.io/cassie-codes/pen/mNWxpL -S svg -v 1080x1080 -z 2 248 | ``` 249 | 250 | https://user-images.githubusercontent.com/49479599/154277921-0c5dfb39-9012-43c8-ac76-416a95c9bab0.mp4 251 | 252 | ### Coloured Background 253 | 254 | `gsap-video-export` will automatically detect the background colour to autopad the animation with. 255 | 256 | > It uses the first pixel of the first frame to determine colour of the background. You can override this with `--color` `-p` and supply a custom hex value. 257 | 258 | ```bash 259 | # Video by @cassiecodes 260 | gsap-video-export https://codepen.io/cassie-codes/pen/VwZBjRq -S svg -z 2 -v 1080x1080 261 | ``` 262 | 263 | https://user-images.githubusercontent.com/49479599/154277938-1db498b8-661b-4772-ad56-a50964d5c93e.mp4 264 | 265 | ### Lossless* Export 266 | 267 | >*When creating a video with the true lossless setting `-crf 0` it will preserve the colour space of the source PNGs and won't be compatible with some media players.

For compatibility simply setting the best lossy setting `-crf 1` is enough to create a near lossless video that's compatible with most media players. 268 | 269 | The `--output-options` `-E` argument will take a string of FFmpeg output arguments to allow a lot of flexability over the final render. This should be supplied last in the list of command line arguments after `--`. 270 | 271 | ```bash 272 | # Video by @cassiecodes 273 | gsap-video-export https://codepen.io/cassie-codes/pen/VweQjBw -S svg -z 2 -v 1920x1080 -E "'-pix_fmt yuv420p -crf 1'" 274 | ``` 275 | 276 | https://user-images.githubusercontent.com/49479599/154278049-ae6d585b-9491-45a8-bd2a-ea1f741580e2.mp4 277 | 278 | ### Timeweb Frame Advancement 🆕 279 | 280 | The default frame advancement method for `gsap-video-export` steps through a GSAP timeline to create a silky smooth video. 281 | 282 | Unfortunately if there are animations present that are not tied to the GSAP timeline then it may render incorrectly. [Timeweb](https://github.com/tungs/timeweb) is now included as an alternative method for advancing frames which largely resolves this issue. 283 | 284 | Using https://nodcoding.com/ as an example, even though the site is built with GSAP there is no exposed timeline so the standard usage of `gsap-video-export` will fail to start. 285 | 286 | Let's inject a simple GSAP timeline that scrolls the page to the bottom and use the default `gsap` frame advancement to render the video. 287 | 288 | ```bash 289 | gsap-video-export http://nodcoding.com/ --script "./scroll.js" 290 | ``` 291 | 292 | The video below has issues with the timing of animations that exist outside of the scroll timeline. 293 | 294 | https://github.com/user-attachments/assets/be461336-3605-4a81-af35-de962e5671bc 295 | 296 | 297 | Using the `timeweb` frame advancement option the native time handling is overwritten allowing us to globally advance the browser frame by frame. 298 | 299 | ```bash 300 | gsap-video-export http://nodcoding.com/ --script "./scroll.js" --advance timeweb 301 | ``` 302 | 303 | In the output below the scroll timeline and other animated elements are now captured perfectly. 304 | 305 | https://github.com/user-attachments/assets/b8a5b4c6-33ab-4e56-8218-b1761ab7b1c0 306 | 307 | ### Alpha Transparency 🆕 308 | 309 | `gsap-video-export` can now output video with alpha transparency when paired with compatible ffmpeg settings. 310 | 311 | Setting the `--color` argument to `transparent` will pad the video with transparent pixels `gsap-video-export` will also respect transparent backgrounds. 312 | 313 | > There should be no `background-color` set on the for `gsap-video-export` to correctly render transparency. 314 | 315 | ```bash 316 | gsap-video-export https://codepen.io/defaced/pen/GRVbwNQ -S svg -v 1080x1080 -o video.mov -p transparent -c prores_ks -C mov -E "'-pix_fmt yuva444p10le'" 317 | ``` 318 | The important part of the command is `-o video.mov -p transparent -c prores_ks -C mov -E "'-pix_fmt yuva444p10le'"` which sets ffmpeg to use a video format that's compatible with transparency and tells `gsap-video-export` to respect transparent backgrounds. 319 | 320 | ### Post Processing 🆕 321 | 322 | `gsap-video-export` now lets you modify the image buffer of each frame before it is saved to disk using the `post-processs` option. `post-process` supplies an image buffer to your function and expects you to return one. 323 | 324 | Here's an example that dithers each frame with a CGA palette. 325 | 326 | ```javascript 327 | import { videoExport } from 'gsap-video-export' 328 | import DitherJS from 'ditherjs/server.js' 329 | 330 | const dither = new DitherJS({ 331 | step: 6, 332 | algorithm: 'diffusion' 333 | }) 334 | 335 | await videoExport({ 336 | url: 'https://codepen.io/cassie-codes/pen/eYzOBGq', 337 | selector: 'svg', 338 | scale: 2, 339 | postProcess: async (buffer) => { return dither.dither(buffer) } 340 | }) 341 | ``` 342 | 343 | https://github.com/user-attachments/assets/467c6a87-e3d1-459d-99be-928d8749026e 344 | 345 | ## Advanced 346 | 347 | ### Cookies 🆕 348 | 349 | If you need to authenticate your session or disable a cookie popup then it's possible to supply your own cookies as a JSON file. 350 | 351 | I recommend using this Chrome Extension to export them in a compatible format. 352 | https://chromewebstore.google.com/detail/export-cookie-json-file-f/nmckokihipjgplolmcmjakknndddifde?hl=en 353 | 354 | ### Chrome 🆕 355 | 356 | It's now possible to use the system installed version of Chrome by adding the `--chrome` flag. The library will automatically find the Chrome install location and use that instead of the Chrome for Testing binary that's supplied with Puppeteer. 357 | 358 | ```bash 359 | gsap-video-export --chrome 360 | ``` 361 | 362 | ### Headless 🆕 363 | 364 | If you need to see what's happening 'on page' to debug issues you can disable headless mode to inspect the Chrome window. 365 | 366 | ```bash 367 | gsap-video-export --headless false 368 | ``` 369 | 370 | ## FAQ 371 | 372 | **Why does my video fail with the duration error `INFINITE`?** 373 | 374 | This can happen on some videos where the selected timeline infinitely repeats and GSAP reports a duration in the thousands of hours. 375 | 376 | `gsap-video-export` will not attempt to capture any video over an hour and will report the `INFINITE` error. 377 | 378 | **How can I disable other on screen elements?** 379 | 380 | You can supply a custom .js file with the `--script` argument which runs before the capture begins giving you the ability to manipulate the DOM. 381 | 382 | **Why does my video not render as expected?** 383 | 384 | `gsap-video-export` works by stepping manually through the specified timeline exporting each individual frame. As a rule of thumb if you can scrub through your timeline manually in GSAP you're not going to have any issues with the default `gsap` frame advance method. 385 | 386 | If you're triggering animations that are not locked to the GSAP timeline, or your page contains other active elements such as video then try the `timeweb` frame advance method, which instead overwrites native time handling in a web page to allow the capture of each frame. 387 | 388 | **Why does my timeline fail?** 389 | 390 | `gsap-video-export` can access block scoped `let` and `const` variables and variables on the global scope. If your timeline variable is not exposed at that level then `gsap-video-export` will not be able to access it. 391 | 392 | Consider moving your timeline to a scope the tool can access. 393 | 394 | 395 | ## Sponsors 396 | 397 | If you find this project useful please considering sponsoring me on [GitHub Sponsors](https://github.com/sponsors/workeffortwaste/) and help support the work that goes into creating and maintaining my projects. 398 | 399 | ### Bonus 400 | 401 | Sponsors are able to remove the project support message from all my CLI projects, as well as access other additional perks. 402 | 403 | ## Author 404 | 405 | Chris Johnson - [defaced.dev](https://defaced.dev) - [@defaced.dev](https://bsky.app/profile/defaced.dev) (Bluesky) 406 | 407 | 408 | -------------------------------------------------------------------------------- /cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /** 3 | * gsap-video-export 4 | * github: workeffortwaste 5 | * bluesky: @defaced.dev 6 | * 7 | * Source: https://github.com/workeffortwaste/gsap-video-export/ 8 | */ 9 | import { videoExport } from './index.js' 10 | import yargs from 'yargs' 11 | import { hideBin } from 'yargs/helpers' 12 | import fs from 'fs' 13 | 14 | /* Colors */ 15 | const colors = { 16 | dim: '\x1b[2m', 17 | reset: '\x1b[0m', 18 | underscore: '\x1b[4m', 19 | magenta: '\x1b[35m', 20 | blue: '\x1b[34m', 21 | green: '\x1b[32m', 22 | red: '\x1b[31m', 23 | yellow: '\x1b[33m' 24 | } 25 | 26 | /* CLI welcome message */ 27 | const { version } = JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url))) 28 | console.log(`gsap-video-export ${version} / ${colors.blue}@defaced.dev (bluesky)${colors.reset}`) 29 | 30 | /* Support */ 31 | if (!process.env.WORKEFFORTWASTE_SUPPORTER) { 32 | console.log(`${colors.magenta} 33 | ┃ 34 | ┃ ${colors.underscore}Support this project! ${colors.reset}${colors.magenta} 35 | ┃ 36 | ┃ Help support the work that goes into creating and maintaining my projects 37 | ┃ and consider donating via on GitHub Sponsors. 38 | ┃ 39 | ┃ GitHub Sponsors: https://github.com/sponsors/workeffortwaste/ 40 | ┃${colors.reset} 41 | `) 42 | } 43 | 44 | const _yargs = yargs(hideBin(process.argv)) 45 | 46 | /* CLI arguments config */ 47 | const options = _yargs 48 | .wrap(Math.min(110, _yargs.terminalWidth())) 49 | .default({ C: 'mp4', a: 'gsap', p: 'auto', c: 'libx264', o: 'video.mp4', t: 'gsap', f: 60, S: 'document', z: 1, V: '1920x1080', v: 'auto', E: '"-pix_fmt yuv420p -crf 18"', q: true, h: true, chrome: false }) 50 | .usage('$0 ', 'Export GreenSock (GSAP) animation to video') 51 | .describe('s', '[browser] Custom script (Page)') 52 | .describe('prepare-frame', '[browser] Custom script (Frame)') 53 | .describe('post-process', '[browser] Custom script (Post Process)') 54 | .describe('S', '[browser] DOM selector') 55 | .describe('t', '[browser] GSAP timeline object') 56 | .describe('z', '[browser] Scale factor') 57 | .describe('V', '[browser] Viewport size') 58 | .describe('i', '[browser] Info only') 59 | .describe('frame-start', '[browser] Start frame') 60 | .describe('frame-end', '[browser] End frame') 61 | .describe('chrome', '[browser] Use the system installed Chrome') 62 | .describe('cookies', '[browser] Cookie JSON file') 63 | .describe('a', '[browser] Frame advance method') 64 | .describe('h', '[browser] Headless mode') 65 | .describe('p', '[video] Auto padding color') 66 | .describe('c', '[video] Codec') 67 | .describe('C', '[video] Format') 68 | .describe('e', '[video] FFmpeg input options') 69 | .describe('E', '[video] FFmpeg output options') 70 | .describe('o', '[video] Filename') 71 | .describe('f', '[video] Framerate') 72 | .describe('v', '[video] Output resolution') 73 | .describe('q', '[tool] Verbose output') 74 | .alias('i', 'info') 75 | .alias('o', 'output') 76 | .alias('t', 'timeline') 77 | .alias('f', 'fps') 78 | .alias('c', 'codec') 79 | .alias('C', 'format') 80 | .alias('S', 'selector') 81 | .alias('s', 'prepare-page') 82 | .alias('s', 'script') 83 | .alias('z', 'scale') 84 | .alias('e', 'input-options') 85 | .alias('E', 'output-options') 86 | .alias('p', 'color') 87 | .alias('V', 'viewport') 88 | .alias('v', 'resolution') 89 | .alias('a', 'advance') 90 | .alias('q', 'verbose') 91 | .alias('h', 'headless') 92 | .number(['f', 'z', 'frame-start', 'frame-end']) 93 | .boolean(['i', 'q', 'h', 'chrome', 'info']) 94 | .string(['C', 'e', 'E', 'S', 's', 'o', 't', 'v', 'V', 'c', 'p', 'cookies', 'advance', 'prepare-frame']) 95 | .epilogue('For more information visit documentation at: \nhttp://github.com/workeffortwaste/gsap-video-export') 96 | .argv 97 | 98 | /* Add CLI flag */ 99 | options['cli'] = true 100 | videoExport(options) 101 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import neostandard from 'neostandard' 2 | 3 | export default neostandard({}) 4 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-eval */ 2 | /** 3 | * gsap-video-export 4 | * github: workeffortwaste 5 | * bluesky: @defaced.dev 6 | * 7 | * Source: https://github.com/workeffortwaste/gsap-video-export/ 8 | */ 9 | import puppeteer from 'puppeteer' 10 | import { findChrome } from 'find-chrome-bin' 11 | 12 | /* Misc */ 13 | import tmp from 'tmp' 14 | import path from 'path' 15 | 16 | /* Video encoders */ 17 | import { path as ffmpegPath } from '@ffmpeg-installer/ffmpeg' 18 | import ffmpeg from 'fluent-ffmpeg' 19 | 20 | /* Command line helpers */ 21 | import cliProgress from 'cli-progress' 22 | import { parseArgsStringToArgv } from 'string-argv' 23 | 24 | /* Image helpers */ 25 | import { PNG } from 'pngjs' 26 | import rgbHex from 'rgb-hex' 27 | import fs from 'fs' 28 | 29 | ffmpeg.setFfmpegPath(ffmpegPath) 30 | tmp.setGracefulCleanup() /* Better cleaup of tmp files */ 31 | /* Colors */ 32 | const colors = { 33 | dim: '\x1b[2m', 34 | reset: '\x1b[0m', 35 | underscore: '\x1b[4m', 36 | magenta: '\x1b[35m', 37 | blue: '\x1b[34m', 38 | green: '\x1b[32m', 39 | red: '\x1b[31m', 40 | yellow: '\x1b[33m' 41 | } 42 | 43 | /* CLI progress bar config */ 44 | const b1 = new cliProgress.SingleBar({ 45 | format: '{bar}' + ' {percentage}%', 46 | barCompleteChar: '\u2588', 47 | barIncompleteChar: '\u2591', 48 | hideCursor: true, 49 | autopadding: true, 50 | barsize: 75 51 | }) 52 | 53 | /** 54 | * A helper function to format the time of an animation for the CLI. 55 | * @param {number} seconds Time in seconds 56 | * @returns {string} A string HsMsSs formatted string for the CLI 57 | */ 58 | const timeString = (value) => { 59 | const sec = parseInt(value, 10) 60 | const h = Math.floor(sec / 3600) 61 | const m = Math.floor((sec - (h * 3600)) / 60) 62 | const s = sec - (h * 3600) - (m * 60) 63 | if (h) return `${h}h${m}m${s}s` 64 | if (m) return `${m}m${s}s` 65 | return `${s}s` 66 | } 67 | 68 | /** 69 | * Helper function to pad two strings of text to the same length with a period. 70 | * @param {string} item The first string to pad 71 | * @param {item} status The second string to pad 72 | * @returns {string} The padded string 73 | */ 74 | const padCenter = (first, last, fail = false) => { 75 | /* The maximum length of our final string */ 76 | const MAX_LENGTH = 80 77 | 78 | /* Total to pad */ 79 | const pad = MAX_LENGTH - first.length - last.length 80 | 81 | /* Character to pad with */ 82 | const padChar = `${colors.dim}.${colors.reset}` 83 | 84 | /* Return padded string */ 85 | return `${first}${padChar.repeat(pad)}${fail ? `${colors.red}${last}${colors.reset}` : `${colors.blue}${last}${colors.reset}`}` 86 | } 87 | 88 | /** 89 | * Validates the given selector to see if it exists on the DOM. 90 | * @param {string} selector 91 | * @returns {boolean} 92 | */ 93 | const discoverSelector = (selector) => { 94 | if (selector === 'document') return true 95 | const selection = document.querySelector(selector) 96 | return !!selection 97 | } 98 | 99 | /** 100 | * A puppeteer function to advance the gsap timeline to the specified frame. 101 | * @param {obj} timeline The greensock timeline to use 102 | * @param {int} frame 103 | */ 104 | const animationProgressFrame = (timeline, frame) => { 105 | let _eval = false 106 | try { _eval = eval(timeline) } catch { } 107 | // eslint-disable-next-line no-undef 108 | const _tl = timeline === 'gsap' ? gsap.globalTimeline : _eval 109 | _tl.pause() 110 | _tl.progress(frame) 111 | } 112 | 113 | /** 114 | * A puppeteer function to calculate the total number of seconds in the animation. 115 | * @param {obj} timeline The greensock timeline to use 116 | * @returns {int} Total number of seconds 117 | */ 118 | const animationDurationSeconds = (timeline) => { 119 | let _eval = false 120 | try { _eval = eval(timeline) } catch { } 121 | // eslint-disable-next-line no-undef 122 | const _tl = timeline === 'gsap' ? gsap.globalTimeline : _eval 123 | if (!_tl) return { error: 'No timeline found.' } 124 | const duration = _tl.duration() 125 | return duration 126 | } 127 | 128 | /** 129 | * A puppeteer function to calculate the total number of frames in the animation for the given framerate 130 | * @param {obj} timeline The greensock timeline to use 131 | * @param {int} fps The framerate to calculate for 132 | * @returns {int} Total number of frames 133 | */ 134 | const animationDurationFrames = (timeline, fps) => { 135 | let _eval = false 136 | try { _eval = eval(timeline) } catch { } 137 | // eslint-disable-next-line no-undef 138 | const _tl = timeline === 'gsap' ? gsap.globalTimeline : _eval 139 | if (!_tl) return { error: 'No timeline found.' } 140 | const duration = _tl.duration() 141 | const frames = Math.ceil(duration / 1 * fps) 142 | return frames 143 | } 144 | 145 | /** 146 | * A puppeteer function to check for the active greensock version. 147 | * @returns {(boolean|string)} Greensock version 148 | */ 149 | const discoverGsapFramework = () => { 150 | // eslint-disable-next-line no-undef 151 | if (window?.gsapVersions) return gsapVersions[0] 152 | return false 153 | } 154 | 155 | /** 156 | * A puppeteer function to check for existence of a greensock timeline 157 | * @param {string} timeline The greensock timeline object 158 | * @returns {boolean} Whether the timeline exists 159 | */ 160 | const discoverTimeline = (timeline) => { 161 | let _eval = false 162 | try { _eval = eval(timeline) } catch { } 163 | // eslint-disable-next-line no-undef 164 | const _tl = timeline === 'gsap' ? gsap.globalTimeline : _eval 165 | if (_tl) return true 166 | return false 167 | } 168 | 169 | /** 170 | * An url helper primarily to break pens out of their iframe for capturing, 171 | * and to format URLs correctly for local files. 172 | * @param {string} url 173 | * @returns {string} 174 | */ 175 | const urlHelper = (url) => { 176 | /* If the url doesn't begin with https:// or http://, then it's a local file and we need to format it for puppeteer. */ 177 | if (!url.startsWith('https://') && !url.startsWith('http://')) { 178 | if (url.startsWith('file://')) return url /* The user has already formatted it as a file url */ 179 | 180 | /* Resolve the full dir of the file */ 181 | const file = path.resolve(process.cwd(), url) 182 | return `file://${file}` 183 | } 184 | 185 | /* If a standard pen url is found convert it to an URL that works with this tool */ 186 | if (url.includes('//codepen.io/')) { 187 | /* Use regex groups to reformat the URL */ 188 | const regex = /\/\/codepen.io\/(.*?)\/pen\/(.*?)$/g 189 | const [match, user, id] = regex.exec(url) 190 | 191 | /* Return the debug codepen url if a match is found */ 192 | return match ? `https://cdpn.io/${user}/fullpage/${id}/?anon=true&view=` : url 193 | } 194 | 195 | /* Return the url as is without modification */ 196 | return url 197 | } 198 | 199 | /** 200 | * Exit the function cleanly. 201 | * @param {obj} browser The puppeteer browser object. 202 | */ 203 | const cleanExit = async (browser) => { 204 | /* Close the browser process */ 205 | if (browser) await browser.close() 206 | /* Exit the script */ 207 | process.exit() 208 | } 209 | 210 | /** 211 | * Exit the function with an error 212 | * @param {obj} browser The puppeteer browser object. 213 | */ 214 | const dirtyExit = async (browser, error) => { 215 | /* Close the browser process */ 216 | if (browser) await browser.close() 217 | /* Exit the script */ 218 | throw new Error(error) 219 | } 220 | 221 | /** Log 222 | * A helper function to log messages to the console. 223 | * @param {string} msg The message to log 224 | * @param {boolean} verbose Whether to log the message or not 225 | */ 226 | const log = (msg, verbose) => { 227 | if (verbose) console.log(msg) 228 | } 229 | 230 | /** 231 | * The main video export function 232 | */ 233 | const videoExport = async (options) => { 234 | if (!options.url) { 235 | log('No URL specified', options.verbose) 236 | cleanExit() 237 | } 238 | 239 | log(`${options.url}\n`, options.verbose) 240 | 241 | /* Set defaults if they don't exist */ 242 | options.viewport = options.viewport || '1920x1080' 243 | options.scale = options.scale || 1 244 | options.advance = options.advance || 'gsap' 245 | options.color = options.color || 'auto' 246 | options.codec = options.codec || 'libx264' 247 | options.fps = options.fps || 60 248 | options.output = options.output || 'video.mp4' 249 | options.selector = options.selector || 'document' 250 | options.resolution = options.resolution || 'auto' 251 | options['output-options'] = options['output-options'] || '"-pix_fmt yuv420p -crf 18"' 252 | options.quiet = options.quiet !== undefined ? options.quiet : true /* Default to quiet mode if not specified */ 253 | options.headless = options.headless !== undefined ? options.headless : true /* Default to headless mode if not specified */ 254 | options.timeline = options.timeline || 'gsap' 255 | options.chrome = options.chrome !== undefined ? options.chrome : false 256 | options.cookies = options.cookies || null 257 | options.format = options.format || 'mp4' 258 | 259 | /* Explode viewport resolutions */ 260 | const resolutions = { 261 | viewportWidth: parseInt(options.viewport.split('x')[0]), /* An additional 16px is required because puppeteer is coming up short */ 262 | viewportHeight: parseInt(options.viewport.split('x')[1]) 263 | } 264 | 265 | /* Start the browser fullscreen in the background (headless) */ 266 | let browser 267 | let executablePath = null 268 | 269 | if (options.chrome) { 270 | const chromeLocation = await findChrome() 271 | executablePath = chromeLocation.executablePath 272 | } 273 | 274 | if (options.headless) { 275 | browser = await puppeteer.launch({ executablePath, headless: true, defaultViewport: null, args: ['--disable-blink-features=AutomationControlled', '--no-sandbox', '--allow-file-access-from-files', '--kiosk'] }) 276 | } else { 277 | browser = await puppeteer.launch({ executablePath, headless: false, defaultViewport: null, args: ['--disable-blink-features=AutomationControlled', '--no-sandbox', '--allow-file-access-from-files'] }) 278 | } 279 | const page = await browser.newPage() 280 | 281 | if (options.cookies) { 282 | const cookies = JSON.parse(fs.readFileSync(options.cookies)) 283 | await page.setCookie(...cookies) 284 | } 285 | 286 | /* Set the viewport and scale from the cli options */ 287 | await page.setViewport({ width: resolutions.viewportWidth, height: resolutions.viewportHeight, deviceScaleFactor: options.scale }) 288 | 289 | /* Pause animations */ 290 | if (options.advance === 'timeweb') { 291 | /* Load the script */ 292 | const timeweb = fs.readFileSync('./node_modules/timeweb/dist/timeweb.js', 'utf8') 293 | 294 | /* Run the script within the page context */ 295 | await page.evaluateOnNewDocument(timeweb => { eval(timeweb) }, timeweb) 296 | } 297 | 298 | /* Navigate to the specified URL and wait for all resources to load */ 299 | try { 300 | await page.goto(urlHelper(options.url), { waitUntil: 'networkidle0', referer: new URL(options.url).origin }) 301 | } catch (err) { 302 | log(padCenter('Browser', 'FAIL', true), options.verbose) 303 | if (options.cli) { 304 | await cleanExit(browser) 305 | } else { 306 | await dirtyExit(browser, 'Unable to load the specified URL') 307 | } 308 | } 309 | 310 | /* Print status text */ 311 | log(padCenter('Browser', 'OK'), options.verbose) 312 | 313 | /* If a custom script is specified and exists */ 314 | if (options.script) options.preparePage = options.script 315 | if (options.preparePage) { 316 | let customScript 317 | 318 | if (typeof options.preparePage === 'function') { 319 | customScript = options.preparePage 320 | } else { 321 | if (!fs.existsSync(options.preparePage)) { 322 | await dirtyExit(browser, 'The specified script does not exist') 323 | } 324 | /* Load the script */ 325 | customScript = fs.readFileSync(options.preparePage, 'utf8') 326 | } 327 | /* Run the script within the page context */ 328 | try { 329 | if (typeof customScript === 'function') { 330 | await page.evaluate(customScript) 331 | } else { 332 | await page.evaluate(customScript => { eval(customScript) }, customScript) 333 | } 334 | } catch (err) { 335 | if (options.cli) { 336 | await cleanExit(browser) 337 | } else { 338 | await dirtyExit(browser, 'Unable to run the specified script: ' + err) 339 | } 340 | } 341 | } 342 | 343 | // /* Wait for a bit because GSAP takes a little bit of time to initialise and the script was missing it. */ 344 | await new Promise(resolve => setTimeout(resolve, 2000)) 345 | // await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 12000))) 346 | 347 | /* Check the selector exists */ 348 | const validSelector = await page.evaluate(discoverSelector, options.selector) 349 | 350 | /* Print status text */ 351 | log(padCenter('Selector', validSelector ? `(${options.selector}) OK` : `(${options.selector}) FAIL`, !validSelector), options.verbose) 352 | 353 | /* Exit if invalid selector */ 354 | if (!validSelector) { 355 | if (options.cli) { 356 | await cleanExit(browser) 357 | } else { 358 | await dirtyExit(browser, 'Invalid selector') 359 | } 360 | } 361 | 362 | /* Scroll the selected element into view if it's not set as document */ 363 | if (options.selector !== 'document') { 364 | await page.evaluate((selector) => { document.querySelector(selector).scrollIntoViewIfNeeded() }, options.selector) 365 | } 366 | 367 | /* Discover the gsap framework version */ 368 | const gsapVersion = await page.evaluate(discoverGsapFramework) 369 | 370 | /* Print status text */ 371 | log(padCenter('GSAP', gsapVersion ? 'v' + gsapVersion : 'FAIL', !gsapVersion), options.verbose) 372 | 373 | /* Exit if no gsap framework found on the window obj */ 374 | if (!gsapVersion) { 375 | if (options.cli) { 376 | await cleanExit(browser) 377 | } else { 378 | await dirtyExit(browser, 'GSAP framework not found') 379 | } 380 | } 381 | 382 | /* Discover the gsap timeline object */ 383 | const timeline = await page.evaluate(discoverTimeline, options.timeline) 384 | 385 | /* Print status text */ 386 | log(padCenter('Timeline', timeline ? `(${options.timeline}) OK` : 'FAIL', !timeline), options.verbose) 387 | 388 | /* Exit if no gsap timeline is available */ 389 | if (!timeline) { 390 | if (options.cli) { 391 | await cleanExit(browser) 392 | } else { 393 | await dirtyExit(browser, 'GSAP timeline not found') 394 | } 395 | } 396 | 397 | /* Calculate the animation length */ 398 | const durationSeconds = await page.evaluate(animationDurationSeconds, options.timeline) 399 | const duration = await page.evaluate(animationDurationFrames, options.timeline, options.fps) 400 | 401 | /* Exit if it's an infinite loop */ 402 | if (durationSeconds > 3600) { 403 | log(padCenter('Duration', 'INFINITE', true), options.verbose) 404 | 405 | if (options.cli) { 406 | await cleanExit(browser) 407 | } else { 408 | await dirtyExit(browser, 'Infinite loop detected') 409 | } 410 | } 411 | 412 | /* Print status text */ 413 | log(padCenter('Duration', durationSeconds !== 0 ? timeString(durationSeconds.toFixed(1)) : 'FAIL', durationSeconds === 0), options.verbose) 414 | 415 | /* Exit if the animation length is 0 */ 416 | if (durationSeconds === 0) { 417 | if (options.cli) { 418 | await cleanExit(browser) 419 | } else { 420 | await dirtyExit(browser, 'Animation duration is 0') 421 | } 422 | } 423 | 424 | /* Print status text */ 425 | log(padCenter('Frames', `(${options.advance}) ${duration.toString()}`, false), options.verbose) 426 | 427 | /* If the info flag is toggled exit cleanly */ 428 | if (options.info && !options.cli) { 429 | await browser.close() 430 | return { 431 | duration: durationSeconds, 432 | frames: duration, 433 | gsap: gsapVersion, 434 | timeline: options.timeline 435 | } 436 | } 437 | if (options.info) await cleanExit(browser) 438 | 439 | /* Set up the tmp directory */ 440 | const tmpobj = tmp.dirSync() 441 | 442 | /* Print status text */ 443 | log('\nExporting animation frames\n', options.verbose) 444 | 445 | /* Set the start and end frames */ 446 | const startFrame = options['frame-start'] || 0 447 | const endFrame = options['frame-end'] || duration 448 | 449 | /* Start the CLI export progress bar */ 450 | if (options.verbose) b1.start(endFrame, startFrame) 451 | 452 | /* Time frame export */ 453 | const timeFrames = process.hrtime() 454 | 455 | /* Progress the animation and take a screenshot */ 456 | let frameStep = 0 457 | let wasteStep = 0 458 | 459 | /* Timeweb won't properly jump to a startFrame for me so we're going to waste some frames before we start capturing to ensure compatibility with the custom startFrame. */ 460 | if (options.advance === 'timeweb') { 461 | for (let x = 0; x < startFrame; x++) { 462 | /* Time in ms to advance the frames */ 463 | const interval = 1000 / options.fps 464 | 465 | /* Shift the ms along slightly to avoid errors with weird gsap code */ 466 | const ms = interval * wasteStep + 1 467 | await page.evaluate(async (ms) => { 468 | /* Prepare frame */ 469 | await window.timeweb.goTo(ms) 470 | }, ms) 471 | 472 | wasteStep++ 473 | } 474 | } 475 | 476 | /* The recording loop */ 477 | for (let x = startFrame; x < endFrame; x++) { 478 | const frame = x / duration 479 | 480 | /* If a custom frame script is specified and exists */ 481 | if (options.prepareFrame) { 482 | let customScript 483 | 484 | if (typeof options.prepareFrame === 'function') { 485 | customScript = options.prepareFrame 486 | } else { 487 | if (!fs.existsSync(options.prepareFrame)) { 488 | await dirtyExit(browser, 'The specified script does not exist') 489 | } 490 | /* Load the script */ 491 | customScript = fs.readFileSync(options.prepareFrame, 'utf8') 492 | } 493 | 494 | /* Prepare additional details to send through to the script about the current animation state */ 495 | const details = { 496 | frame, frameStep, fps: options.fps, startFrame, endFrame, duration, ms: (1000 / options.fps) * frameStep + 1 497 | } 498 | 499 | /* Run the script within the page context */ 500 | try { 501 | if (typeof customScript === 'function') { 502 | await page.evaluate(customScript, details) 503 | } else { 504 | await page.evaluate((customScript, details) => { eval(customScript) }, customScript, details) 505 | } 506 | } catch (err) { 507 | if (options.cli) { 508 | await cleanExit(browser) 509 | } else { 510 | await dirtyExit(browser, 'Unable to run the specified script: ' + err) 511 | } 512 | } 513 | } 514 | 515 | /* Progress the timeline to the specified frame */ 516 | if (options.advance === 'gsap') { 517 | await page.evaluate(animationProgressFrame, options.timeline, frame) 518 | } else { 519 | /* Time in ms to advance the frames */ 520 | const interval = 1000 / options.fps 521 | /* Shift the ms along slightly to avoid errors with weird gsap code */ 522 | const ms = interval * frameStep + 1 523 | 524 | await page.evaluate((ms) => { 525 | window.timeweb.goTo(ms) 526 | }, ms) 527 | } 528 | 529 | /* Select the DOM element via the specified selector */ 530 | const el = options.selector === 'document' ? page : await page.$(options.selector) 531 | 532 | /* If we're not supplying a post processor script then we can just take a screenshot and save it */ 533 | if (!options.postProcess) await el.screenshot({ path: tmpobj.name + '/' + frameStep + '.png', omitBackground: options.color === 'transparent' }) 534 | 535 | /* Otherwise we need to take a screenshot and then run the post processor script */ 536 | if (options.postProcess) { 537 | /* Take a screenshot */ 538 | const screenshot = await el.screenshot({ omitBackground: options.color === 'transparent' }) 539 | 540 | /* If a custom frame script is specified and exists */ 541 | let customScript 542 | 543 | if (typeof options.postProcess === 'function') { 544 | customScript = options.postProcess 545 | } else { 546 | if (!fs.existsSync(options.postProcess)) { 547 | await dirtyExit(browser, 'The specified script does not exist') 548 | } 549 | /* Load the script */ 550 | customScript = fs.readFileSync(options.postProcess, 'utf8') 551 | } 552 | 553 | const screenshotBuffer = Buffer.from(screenshot, 'base64') 554 | 555 | let updatedScreenshotBuffer 556 | try { 557 | if (typeof customScript === 'function') { 558 | updatedScreenshotBuffer = await customScript(screenshotBuffer) 559 | } else { 560 | // eslint-disable-next-line no-new-func 561 | updatedScreenshotBuffer = await (new Function('imageBuffer', customScript))(screenshotBuffer) 562 | } 563 | 564 | /* Write the updated screenshot to the tmp directory */ 565 | fs.writeFileSync(tmpobj.name + '/' + frameStep + '.png', updatedScreenshotBuffer) 566 | } catch (err) { 567 | if (options.cli) { 568 | await cleanExit(browser) 569 | } else { 570 | await dirtyExit(browser, 'Unable to run the specified script: ' + err) 571 | } 572 | } 573 | } 574 | 575 | /* Increment and update the CLI export progress bar */ 576 | if (options.verbose) b1.increment() 577 | if (options.verbose) b1.update(x + 1) 578 | 579 | /* Increment the frame step */ 580 | frameStep++ 581 | } 582 | 583 | /* Time (stop) frame export */ 584 | const timeFramesStop = process.hrtime(timeFrames) 585 | 586 | /* Stop the CLI export progress bar */ 587 | if (options.verbose) b1.stop() 588 | 589 | /* Now we've captured all the frames quit the browser to focus on encoding the video */ 590 | await browser.close() 591 | 592 | /* Read the first frame of the animation */ 593 | let png = PNG.sync.read(fs.readFileSync(tmpobj.name + '/0' + '.png')) 594 | 595 | /* Get some basic image information for video rendering */ 596 | /* By getting the resoution this way we can make a video of the output video size regardless of browser viewport and scaling settings */ 597 | const image = { 598 | height: png.height, 599 | width: png.width, 600 | pixelSample: rgbHex(png.data[0], png.data[1], png.data[2]) 601 | } 602 | 603 | /* Free up a bit of memory */ 604 | png = null 605 | 606 | /* Set output size */ 607 | const finalResolution = options.resolution === 'auto' ? `${image.width}x${image.height}` : options.resolution 608 | 609 | /* Pad color */ 610 | const padColor = options.color === 'auto' ? image.pixelSample : options.color !== 'transparent' ? options.color : '0x00000000' 611 | 612 | /* Add some more information about the video we're making */ 613 | log('\n', options.verbose) 614 | log(padCenter('Output resolution', `${options.resolution === 'auto' ? '(auto) ' : ''}${finalResolution}`), options.verbose) 615 | log(padCenter('Padding color', `${options.color === 'auto' ? '(auto) ' : ''}${padColor.toUpperCase()}`), options.verbose) 616 | 617 | /* Timing vars */ 618 | let timeRender, timeRenderStop 619 | 620 | /* Encode the video */ 621 | return new Promise((resolve, reject) => { 622 | const render = ffmpeg() 623 | .addInput(tmpobj.name + '/%d.png') 624 | .videoCodec(options.codec) 625 | .inputFPS(options.fps) 626 | .size(finalResolution) 627 | .autopad(padColor) 628 | .format(options.format) 629 | .output(options.output) 630 | .on('start', function (commandLine) { 631 | log('\nRendering video\n', options.verbose) 632 | if (options.verbose) b1.start(100, 0) 633 | /* Time render */ 634 | timeRender = process.hrtime() 635 | }) 636 | .on('progress', function (progress) { 637 | if (options.verbose) b1.increment() 638 | if (options.verbose) b1.update(Math.ceil(progress.percent)) 639 | }) 640 | .on('end', function () { 641 | /* Set the progress bar to 100% */ 642 | if (options.verbose) b1.increment() 643 | if (options.verbose) b1.update(100) 644 | 645 | /* Stop the timer */ 646 | if (options.verbose) b1.stop() 647 | 648 | /* Time (stop) render */ 649 | timeRenderStop = process.hrtime(timeRender) 650 | 651 | log('\nTime elapsed\n', options.verbose) 652 | log(padCenter('Export', ((timeFramesStop[0] * 1e9 + timeFramesStop[1]) / 1e9).toFixed(2).toString() + 's', false), options.verbose) 653 | log(padCenter('Render', ((timeRenderStop[0] * 1e9 + timeRenderStop[1]) / 1e9).toFixed(2).toString() + 's', false), options.verbose) 654 | 655 | /* Success */ 656 | log(`\nVideo succesfully exported as ${colors.blue}${options.output}${colors.reset}`, options.verbose) 657 | resolve({ 658 | file: options.output, 659 | exportTime: +((timeFramesStop[0] * 1e9 + timeFramesStop[1]) / 1e9).toFixed(2), 660 | renderTime: +((timeRenderStop[0] * 1e9 + timeRenderStop[1]) / 1e9).toFixed(2) 661 | }) 662 | }) 663 | .on('error', function (err) { 664 | reject(err) 665 | }) 666 | 667 | /* Additional ffmpeg io options */ 668 | if (options['input-options']) render.inputOptions(...parseArgsStringToArgv(options['input-options'].slice(1, -1))) 669 | if (options['output-options']) render.outputOptions(...parseArgsStringToArgv(options['output-options'].slice(1, -1))) 670 | 671 | render.run() 672 | }) 673 | } 674 | 675 | /* GO */ 676 | export { videoExport } 677 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gsap-video-export", 3 | "version": "2.1.0", 4 | "description": "Export GreenSock (GSAP) animation to video.", 5 | "main": "index.js", 6 | "type": "module", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "keywords": [ 11 | "video", 12 | "gsap", 13 | "greensock", 14 | "animation" 15 | ], 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/workeffortwaste/gsap-video-export.git" 19 | }, 20 | "bin": { 21 | "gsap-video-export": "cli.js" 22 | }, 23 | "bugs": { 24 | "url": "https://github.com/workeffortwaste/gsap-video-export/issues" 25 | }, 26 | "homepage": "https://github.com/workeffortwaste/gsap-video-export", 27 | "author": "Chris Johnson (https://defaced.dev)", 28 | "license": "MIT", 29 | "dependencies": { 30 | "@ffmpeg-installer/ffmpeg": "^1.1.0", 31 | "cli-progress": "^3.12.0", 32 | "eslint": "^9.15.0", 33 | "fluent-ffmpeg": "^2.1.3", 34 | "neostandard": "^0.11.8", 35 | "pngjs": "^7.0.0", 36 | "find-chrome-bin": "^2.0.2", 37 | "puppeteer": "npm:rebrowser-puppeteer@^23.6.101", 38 | "rgb-hex": "^4.1.0", 39 | "string-argv": "^0.3.2", 40 | "timeweb": "^0.3.2", 41 | "tmp": "^0.2.3", 42 | "yargs": "^17.7.2" 43 | } 44 | } 45 | --------------------------------------------------------------------------------