-
-
Notifications
You must be signed in to change notification settings - Fork 15
/
index.js
490 lines (409 loc) · 15.8 KB
/
index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
#!/usr/bin/env node
/* eslint-disable no-eval */
/**
* gsap-video-export
* github: workeffortwaste
* twitter: @defaced
*
* Source: https://github.com/workeffortwaste/gsap-video-export/
* /
/* Use puppeteer extra to avoid being picked up as a bot */
import puppeteer from 'puppeteer-extra'
import StealthPlugin from 'puppeteer-extra-plugin-stealth'
/* Misc */
import tmp from 'tmp'
import path from 'path'
/* Video encoders */
import { path as ffmpegPath } from '@ffmpeg-installer/ffmpeg'
import ffmpeg from 'fluent-ffmpeg'
/* Command line helpers */
import cliProgress from 'cli-progress'
import yargs from 'yargs'
import { hideBin } from 'yargs/helpers'
import { parseArgsStringToArgv } from 'string-argv'
/* Image helpers */
import { PNG } from 'pngjs'
import rgbHex from 'rgb-hex'
import fs from 'fs'
puppeteer.use(StealthPlugin())
ffmpeg.setFfmpegPath(ffmpegPath)
/* Colors */
const colors = {
dim: '\x1b[2m',
reset: '\x1b[0m',
underscore: '\x1b[4m',
magenta: '\x1b[35m',
blue: '\x1b[34m',
green: '\x1b[32m',
red: '\x1b[31m',
yellow: '\x1b[33m'
}
/* CLI welcome message */
const { version } = JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url)))
console.log(`gsap-video-export ${version} / ${colors.blue}@defaced${colors.reset}`)
/* Support */
if (!process.env.WORKEFFORTWASTE_SUPPORTER) {
console.log(`${colors.magenta}
┃
┃ ${colors.underscore}Support this project! ${colors.reset}${colors.magenta}
┃
┃ Help support the work that goes into creating and maintaining my projects
┃ and buy me a coffee via Ko-fi or sponsor me on GitHub Sponsors.
┃
┃ Ko-fi: https://ko-fi.com/defaced
┃ GitHub Sponsors: https://github.com/sponsors/workeffortwaste/
┃${colors.reset}
`)
}
const _yargs = yargs(hideBin(process.argv))
/* CLI arguments config */
const options = _yargs
.wrap(Math.min(110, _yargs.terminalWidth()))
.default({ 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"' })
.usage('$0 <url>', 'Export GreenSock (GSAP) animation to video')
.describe('s', '[browser] Custom script')
.describe('S', '[browser] DOM selector')
.describe('t', '[browser] GSAP timeline object')
.describe('z', '[browser] Scale factor')
.describe('V', '[browser] Viewport size')
.describe('i', '[browser] Info only')
.describe('frame-start', '[browser] Start frame')
.describe('frame-end', '[browser] End frame')
.describe('p', '[video] Auto padding color')
.describe('c', '[video] Codec')
.describe('e', '[video] FFmpeg input options')
.describe('E', '[video] FFmpeg output options')
.describe('o', '[video] Filename')
.describe('f', '[video] Framerate')
.describe('v', '[video] Output resolution')
.alias('i', 'info')
.alias('o', 'output')
.alias('t', 'timeline')
.alias('f', 'fps')
.alias('c', 'codec')
.alias('S', 'selector')
.alias('s', 'script')
.alias('z', 'scale')
.alias('e', 'input-options')
.alias('E', 'output-options')
.alias('p', 'color')
.alias('V', 'viewport')
.alias('v', 'resolution')
.number(['q', 'f', 'z'])
.string(['e', 'E', 'S', 's', 'o', 't', 'v', 'V', 'c', 'p'])
.epilogue('For more information visit documentation at: \nhttp://github.com/workeffortwaste/gsap-video-export')
.argv
/* Explode viewport resolutions */
const resolutions = {
viewportWidth: parseInt(options.viewport.split('x')[0]), /* An additional 16px is required because puppeteer is coming up short */
viewportHeight: parseInt(options.viewport.split('x')[1])
}
/* CLI progress bar config */
const b1 = new cliProgress.SingleBar({
format: '{bar}' + ' {percentage}%',
barCompleteChar: '\u2588',
barIncompleteChar: '\u2591',
hideCursor: true,
autopadding: true,
barsize: 75
})
/**
* A helper function to format the time of an animation for the CLI.
* @param {number} seconds Time in seconds
* @returns {string} A string HsMsSs formatted string for the CLI
*/
const timeString = (value) => {
const sec = parseInt(value, 10)
const h = Math.floor(sec / 3600)
const m = Math.floor((sec - (h * 3600)) / 60)
const s = sec - (h * 3600) - (m * 60)
if (h) return `${h}h${m}m${s}s`
if (m) return `${m}m${s}s`
return `${s}s`
}
/**
* Helper function to pad two strings of text to the same length with a period.
* @param {string} item The first string to pad
* @param {item} status The second string to pad
* @returns {string} The padded string
*/
const padCenter = (first, last, fail = false) => {
/* The maximum length of our final string */
const MAX_LENGTH = 80
/* Total to pad */
const pad = MAX_LENGTH - first.length - last.length
/* Character to pad with */
const padChar = `${colors.dim}.${colors.reset}`
/* Return padded string */
return `${first}${padChar.repeat(pad)}${fail ? `${colors.red}${last}${colors.reset}` : `${colors.blue}${last}${colors.reset}`}`
}
/**
* Validates the given selector to see if it exists on the DOM.
* @param {string} selector
* @returns {boolean}
*/
const discoverSelector = (selector) => {
if (selector === 'document') return true
const selection = document.querySelector(selector)
return !!selection
}
/**
* A puppeteer function to advance the animation to the specified frame.
* @param {obj} timeline The greensock timeline to use
* @param {int} frame
*/
const animationProgressFrame = (timeline, frame) => {
let _eval = false
try { _eval = eval(timeline) } catch {}
// eslint-disable-next-line no-undef
const _tl = timeline === 'gsap' ? gsap.globalTimeline : _eval
_tl.pause()
_tl.progress(frame)
}
/**
* A puppeteer function to calculate the total number of seconds in the animation.
* @param {obj} timeline The greensock timeline to use
* @returns {int} Total number of seconds
*/
const animationDurationSeconds = (timeline) => {
let _eval = false
try { _eval = eval(timeline) } catch {}
// eslint-disable-next-line no-undef
const _tl = timeline === 'gsap' ? gsap.globalTimeline : _eval
if (!_tl) return { error: 'No timeline found.' }
const duration = _tl.duration()
return duration
}
/**
* A puppeteer function to calculate the total number of frames in the animation for the given framerate
* @param {obj} timeline The greensock timeline to use
* @param {int} fps The framerate to calculate for
* @returns {int} Total number of frames
*/
const animationDurationFrames = (timeline, fps) => {
let _eval = false
try { _eval = eval(timeline) } catch {}
// eslint-disable-next-line no-undef
const _tl = timeline === 'gsap' ? gsap.globalTimeline : _eval
if (!_tl) return { error: 'No timeline found.' }
const duration = _tl.duration()
const frames = Math.ceil(duration / 1 * fps)
return frames
}
/**
* A puppeteer function to check for the active greensock version.
* @returns {(boolean|string)} Greensock version
*/
const discoverGsapFramework = () => {
// eslint-disable-next-line no-undef
if (window?.gsapVersions) return gsapVersions[0]
return false
}
/**
* A puppeteer function to check for existence of a greensock timeline
* @param {string} timeline The greensock timeline object
* @returns {boolean} Whether the timeline exists
*/
const discoverTimeline = (timeline) => {
let _eval = false
try { _eval = eval(timeline) } catch {}
// eslint-disable-next-line no-undef
const _tl = timeline === 'gsap' ? gsap.globalTimeline : _eval
if (_tl) return true
return false
}
/**
* An url helper primarily to break pens out of their iframe for capturing,
* and to format URLs correctly for local files.
* @param {string} url
* @returns {string}
*/
const urlHelper = (url) => {
/* If the url doesn't begin with https:// or http://, then it's a local file and we need to format it for puppeteer. */
if (!url.startsWith('https://') && !url.startsWith('http://')) {
if (url.startsWith('file://')) return url /* The user has already formatted it as a file url */
/* Resolve the full dir of the file */
const file = path.resolve(process.cwd(), url)
return `file://${file}`
}
/* If a standard pen url is found convert it to an URL that works with this tool */
if (url.includes('//codepen.io/')) {
/* Use regex groups to reformat the URL */
const regex = /\/\/codepen.io\/(.*?)\/pen\/(.*?)$/g
const [match,, id] = regex.exec(url)
/* Return the debug codepen url if a match is found */
return match ? `https://cdpn.io/pen/debug/${id}` : url
}
/* Return the url as is without modification */
return url
}
/**
* Exit the function cleanly.
* @param {obj} browser The puppeteer browser object.
*/
const cleanExit = async (browser) => {
/* Close the browser process */
await browser.close()
/* Exit the script */
process.exit()
}
/**
* The main video export function
*/
const exportVideo = async () => {
console.log(`${options.url}\n`)
/* Start the browser fullscreen in the background (headless) */
const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox', '--allow-file-access-from-files', '--kiosk'] })
const page = await browser.newPage()
/* Set the viewport and scale from the cli options */
await page.setViewport({ width: resolutions.viewportWidth, height: resolutions.viewportHeight, deviceScaleFactor: options.scale })
/* Navigate to the specified URL and wait for all resources to load */
try {
await page.goto(urlHelper(options.url), { waitUntil: 'networkidle0' })
} catch (err) {
console.log(padCenter('Browser', 'FAIL', true))
await cleanExit(browser)
}
/* Print status text */
console.log(padCenter('Browser', 'OK'))
/* If a custom script is specified and exists */
if (options.script && fs.existsSync(options.script)) {
/* Load the script */
const customScript = fs.readFileSync(options.script, 'utf8')
/* Run the script within the page context */
await page.evaluate(customScript => { eval(customScript) }, customScript)
}
/* Wait for a bit because GSAP takes a little bit of time to initialise and the script was missing it. */
await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 2000)))
/* Check the selector exists */
const validSelector = await page.evaluate(discoverSelector, options.selector)
/* Print status text */
console.log(padCenter('Selector', validSelector ? `(${options.selector}) OK` : `(${options.selector}) FAIL`, !validSelector))
/* Exit if invalid selector */
if (!validSelector) await cleanExit(browser)
/* Scroll the selected element into view if it's not set as document */
if (options.selector !== 'document') {
await page.evaluate((selector) => { document.querySelector(selector).scrollIntoViewIfNeeded() }, options.selector)
}
/* Discover the gsap framework version */
const gsapVersion = await page.evaluate(discoverGsapFramework)
/* Print status text */
console.log(padCenter('GSAP', gsapVersion ? 'v' + gsapVersion : 'FAIL', !gsapVersion))
/* Exit if no gsap framework found on the window obj */
if (!gsapVersion) await cleanExit(browser)
/* Discover the gsap timeline object */
const timeline = await page.evaluate(discoverTimeline, options.timeline)
/* Print status text */
console.log(padCenter('Timeline', timeline ? `(${options.timeline}) OK` : 'FAIL', !timeline))
/* Exit if no gsap timeline is available */
if (!timeline) await cleanExit(browser)
/* Calculate the animation length */
const durationSeconds = await page.evaluate(animationDurationSeconds, options.timeline)
const duration = await page.evaluate(animationDurationFrames, options.timeline, options.fps)
/* Exit if it's an infinite loop */
if (durationSeconds > 3600) {
console.log(padCenter('Duration', 'INFINITE', true))
await cleanExit(browser)
}
/* Print status text */
console.log(padCenter('Duration', durationSeconds !== 0 ? timeString(durationSeconds.toFixed(1)) : 'FAIL', durationSeconds === 0))
/* Exit if the animation length is 0 */
if (durationSeconds === 0) await cleanExit(browser)
/* Print status text */
console.log(padCenter('Frames', duration.toString(), false))
/* If the info flag is toggled exit cleanly */
if (options.info) await cleanExit(browser)
/* Set up the tmp directory */
const tmpobj = tmp.dirSync()
/* Print status text */
console.log('\nExporting animation frames\n')
/* Set the start and end frames */
const startFrame = options['frame-start'] || 0
const endFrame = options['frame-end'] || duration
/* Start the CLI export progress bar */
b1.start(endFrame, startFrame)
/* Time frame export */
const timeFrames = process.hrtime()
/* Progress the animation and take a screenshot */
let frameStep = 0
for (let x = startFrame; x < endFrame; x++) {
const frame = x / duration
/* Progress the timeline to the specified frame */
await page.evaluate(animationProgressFrame, options.timeline, frame)
/* Select the DOM element via the specified selector */
const el = options.selector === 'document' ? page : await page.$(options.selector)
/* Take a screenshot */
await el.screenshot({ path: tmpobj.name + '/' + frameStep + '.png' })
/* Increment and update the CLI export progress bar */
b1.increment()
b1.update(x + 1)
/* Increment the frame step */
frameStep++
}
/* Time (stop) frame export */
const timeFramesStop = process.hrtime(timeFrames)
/* Stop the CLI export progress bar */
b1.stop()
/* Now we've captured all the frames quit the browser to focus on encoding the video */
await browser.close()
/* Read the first frame of the animation */
let png = PNG.sync.read(fs.readFileSync(tmpobj.name + '/0' + '.png'))
/* Get some basic image information for video rendering */
/* By getting the resoution this way we can make a video of the output video size regardless of browser viewport and scaling settings */
const image = {
height: png.height,
width: png.width,
pixelSample: rgbHex(png.data[0], png.data[1], png.data[2])
}
/* Free up a bit of memory */
png = null
/* Set output size */
const finalResolution = options.resolution === 'auto' ? `${image.width}x${image.height}` : options.resolution
/* Pad color */
const padColor = options.color === 'auto' ? image.pixelSample : options.color
/* Add some more information about the video we're making */
console.log('\n')
console.log(padCenter('Output resolution', `${options.resolution === 'auto' ? '(auto) ' : ''}${finalResolution}`))
console.log(padCenter('Padding color', `${options.color === 'auto' ? '(auto) ' : ''}#${padColor.toUpperCase()}`))
/* Timing vars */
let timeRender, timeRenderStop
/* Encode the video */
const render = ffmpeg()
.addInput(tmpobj.name + '/%d.png')
.videoCodec(options.codec)
.inputFPS(options.fps)
.size(finalResolution)
.autopad(padColor)
.format('mp4')
.output(options.output)
.on('start', function (commandLine) {
console.log('\nRendering video\n')
b1.start(100, 0)
/* Time render */
timeRender = process.hrtime()
})
.on('progress', function (progress) {
b1.increment()
b1.update(Math.ceil(progress.percent))
})
.on('end', function () {
/* Set the progress bar to 100% */
b1.increment()
b1.update(100)
/* Stop the timer */
b1.stop()
/* Time (stop) render */
timeRenderStop = process.hrtime(timeRender)
console.log('\nTime elapsed\n')
console.log(padCenter('Export', ((timeFramesStop[0] * 1e9 + timeFramesStop[1]) / 1e9).toFixed(2).toString() + 's', false))
console.log(padCenter('Render', ((timeRenderStop[0] * 1e9 + timeRenderStop[1]) / 1e9).toFixed(2).toString() + 's', false))
/* Success */
console.log(`\nVideo succesfully exported as ${colors.blue}${options.output}`)
})
/* Additional ffmpeg io options */
if (options['input-options']) render.inputOptions(...parseArgsStringToArgv(options['input-options'].slice(1, -1)))
if (options['output-options']) render.outputOptions(...parseArgsStringToArgv(options['output-options'].slice(1, -1)))
render.run()
}
/* GO */
exportVideo()