├── .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 |
--------------------------------------------------------------------------------