diff --git a/index.js b/index.js index cc47a60..0ba47e7 100644 --- a/index.js +++ b/index.js @@ -1,27 +1,36 @@ -const { Command } = require("commander") -const fs = require("fs") -const puppeteer = require("puppeteer-core") -const sharp = require("sharp") -const { S3Client, PutObjectCommand } = require("@aws-sdk/client-s3") - +const { Command } = require("commander"); +const fs = require("fs"); +const puppeteer = require("puppeteer-core"); +const sharp = require("sharp"); +const { S3Client, PutObjectCommand } = require("@aws-sdk/client-s3"); +const PNG = require("pngjs").PNG; +const { GIFEncoder, quantize, applyPalette } = require("gifenc"); // // DEFINITIONS // -const DELAY_MIN = 0 -const DELAY_MAX = 300000 +const DELAY_MIN = 0; +const DELAY_MAX = 300000; + +// GIF specific constants +const GIF_DEFAULTS = { + FRAME_COUNT: 30, + CAPTURE_INTERVAL: 100, // milliseconds between capturing frames + PLAYBACK_FPS: 10, // default playback speed in frames per second + QUALITY: 10, + MIN_FRAMES: 2, + MAX_FRAMES: 100, + MIN_CAPTURE_INTERVAL: 20, + MAX_CAPTURE_INTERVAL: 15000, + MIN_FPS: 1, + MAX_FPS: 50, +}; // the different capture modes -const CAPTURE_MODES = [ - "CANVAS", - "VIEWPORT", -] +const CAPTURE_MODES = ["CANVAS", "VIEWPORT"]; // the different trigger modes -const TRIGGER_MODES = [ - "DELAY", - "FN_TRIGGER", -] +const TRIGGER_MODES = ["DELAY", "FN_TRIGGER"]; // possible output errors const ERRORS = { UNKNOWN: "UNKNOWN", @@ -33,101 +42,286 @@ const ERRORS = { CANVAS_CAPTURE_FAILED: "CANVAS_CAPTURE_FAILED", TIMEOUT: "TIMEOUT", EXTRACT_FEATURES_FAILED: "EXTRACT_FEATURES_FAILED", -} + INVALID_GIF_PARAMETERS: "INVALID_GIF_PARAMETERS", +}; // // UTILITY FUNCTIONS // // sleep X milliseconds -const sleep = (time) => new Promise(resolve => { - setTimeout(resolve, time) -}) +const sleep = (time) => + new Promise((resolve) => { + setTimeout(resolve, time); + }); + +function validateGifParams(frameCount, captureInterval, playbackFps) { + if ( + frameCount < GIF_DEFAULTS.MIN_FRAMES || + frameCount > GIF_DEFAULTS.MAX_FRAMES + ) { + return false; + } + if ( + captureInterval < GIF_DEFAULTS.MIN_CAPTURE_INTERVAL || + captureInterval > GIF_DEFAULTS.MAX_CAPTURE_INTERVAL + ) { + return false; + } + if ( + playbackFps < GIF_DEFAULTS.MIN_FPS || + playbackFps > GIF_DEFAULTS.MAX_FPS + ) { + return false; + } + return true; +} + +async function captureFramesToGif(frames, width, height, playbackFps) { + const gif = GIFEncoder(); + const playbackDelay = Math.round(1000 / playbackFps); + console.log( + `Creating GIF with playback delay: ${playbackDelay}ms (${playbackFps} FPS)` + ); + + for (const frame of frames) { + let pngData; + if (typeof frame === "string") { + // For base64 data from canvas + const pureBase64 = frame.replace(/^data:image\/png;base64,/, ""); + const buffer = Buffer.from(pureBase64, "base64"); + pngData = await new Promise((resolve, reject) => { + new PNG().parse(buffer, (err, data) => { + if (err) reject(err); + resolve(data); + }); + }); + } else { + // For binary data from viewport + pngData = await new Promise((resolve, reject) => { + new PNG().parse(frame, (err, data) => { + if (err) reject(err); + resolve(data); + }); + }); + } + + // Convert to format expected by gifenc + const pixels = new Uint8Array(pngData.data); + const palette = quantize(pixels, 256); + const index = applyPalette(pixels, palette); + + gif.writeFrame(index, width, height, { + palette, + delay: playbackDelay, + }); + } + + gif.finish(); + return Buffer.from(gif.bytes()); +} // generic function which resolves once the waiting conditions to take a preview // are met (delay, programmatic trigger) -const waitPreview = (triggerMode, page, delay) => new Promise(async (resolve) => { - let resolved = false - if (triggerMode === "DELAY") { - console.log("waiting for delay:", delay) - await sleep(delay) - resolve() +const waitPreview = (triggerMode, page, delay) => + new Promise(async (resolve) => { + let resolved = false; + if (triggerMode === "DELAY") { + console.log("waiting for delay:", delay); + await sleep(delay); + resolve(); + } else if (triggerMode === "FN_TRIGGER") { + console.log("waiting for function trigger..."); + Promise.race([ + // add event listener and wait for event to fire before returning + page.evaluate(function () { + return new Promise(function (resolve, reject) { + window.addEventListener("fxhash-preview", function () { + resolve(); // resolves when the event fires + }); + }); + }), + sleep(DELAY_MAX), + ]).then(resolve); + } + }); + +async function captureViewport( + page, + isGif, + frameCount, + captureInterval, + playbackFps +) { + if (!isGif) { + return await page.screenshot(); } - else if (triggerMode === "FN_TRIGGER") { - console.log("waiting for function trigger...") - Promise.race([ - // add event listener and wait for event to fire before returning - page.evaluate(function () { - return new Promise(function (resolve, reject) { - window.addEventListener("fxhash-preview", function () { - resolve() // resolves when the event fires - }) - }) - }), - sleep(DELAY_MAX) - ]).then(resolve) + + const frames = []; + for (let i = 0; i < frameCount; i++) { + const frameBuffer = await page.screenshot({ + encoding: "binary", + }); + frames.push(frameBuffer); + await sleep(captureInterval); } -}) + + const viewport = page.viewport(); + return await captureFramesToGif( + frames, + viewport.width, + viewport.height, + playbackFps + ); +} + +async function captureCanvas( + page, + selector, + isGif, + frameCount, + captureInterval, + playbackFps +) { + if (!isGif) { + console.log("converting canvas to PNG with selector:", selector); + const base64 = await page.$eval(selector, (el) => { + if (!el || el.tagName !== "CANVAS") return null; + return el.toDataURL(); + }); + if (!base64) throw null; + const pureBase64 = base64.replace(/^data:image\/png;base64,/, ""); + return Buffer.from(pureBase64, "base64"); + } + + const frames = []; + for (let i = 0; i < frameCount; i++) { + const base64 = await page.$eval(selector, (el) => { + if (!el || el.tagName !== "CANVAS") return null; + return el.toDataURL(); + }); + if (!base64) throw null; + frames.push(base64); + await sleep(captureInterval); + } + + const dimensions = await page.$eval(selector, (el) => ({ + width: el.width, + height: el.height, + })); + + return await captureFramesToGif( + frames, + dimensions.width, + dimensions.height, + playbackFps + ); +} // given a trigger mode and an optionnal delay, returns true of false depending on the // validity of the trigger input settings function isTriggerValid(triggerMode, delay) { if (!TRIGGER_MODES.includes(triggerMode)) { - return false + return false; } if (triggerMode === "DELAY") { // delay must be defined if trigger mode is delay - return typeof delay !== undefined && !isNaN(delay) && delay >= DELAY_MIN && delay <= DELAY_MAX - } - else if (triggerMode === "FN_TRIGGER") { + return ( + typeof delay !== undefined && + !isNaN(delay) && + delay >= DELAY_MIN && + delay <= DELAY_MAX + ); + } else if (triggerMode === "FN_TRIGGER") { // fn trigger doesn't need any param - return true + return true; } } // process the raw features extracted into attributes function processRawTokenFeatures(rawFeatures) { - const features = [] + const features = []; // first check if features are an object - if (typeof rawFeatures !== "object" || Array.isArray(rawFeatures) || !rawFeatures) { - throw null + if ( + typeof rawFeatures !== "object" || + Array.isArray(rawFeatures) || + !rawFeatures + ) { + throw null; } // go through each property and process it for (const name in rawFeatures) { // chack if propery is accepted type - if (!(typeof rawFeatures[name] === "boolean" || typeof rawFeatures[name] === "string" || typeof rawFeatures[name] === "number")) { - continue + if ( + !( + typeof rawFeatures[name] === "boolean" || + typeof rawFeatures[name] === "string" || + typeof rawFeatures[name] === "number" + ) + ) { + continue; } // all good, the feature can be added safely features.push({ name, - value: rawFeatures[name] - }) + value: rawFeatures[name], + }); } - return features + return features; } // process the command line arguments -const program = new Command() +const program = new Command(); program - .requiredOption('--url ', 'The URL of the resource to fetch') - .requiredOption('--mode ', 'The mode of the capture') - .option('--trigger ', 'The trigger mode of the capture (DELAY, FN_TRIGGER)') - .option('--delay ', 'The delay before the capture is taken') - .option('--resX ', 'The width of the viewport, in case of mode VIEWPORT') - .option('--resY ', 'The height of the viewport, in case of mode VIEWPORT') - .option('--selector ', 'The CSS selector to target the CANVAS, in case of a capture') - -program.parse(process.argv) + .requiredOption("--url ", "The URL of the resource to fetch") + .requiredOption("--mode ", "The mode of the capture") + .option( + "--trigger ", + "The trigger mode of the capture (DELAY, FN_TRIGGER)" + ) + .option("--delay ", "The delay before the capture is taken") + .option( + "--resX ", + "The width of the viewport, in case of mode VIEWPORT" + ) + .option( + "--resY ", + "The height of the viewport, in case of mode VIEWPORT" + ) + .option( + "--selector ", + "The CSS selector to target the CANVAS, in case of a capture" + ) + .option("--gif", "Create an animated GIF instead of a static image") + .option("--frameCount ", "Number of frames for GIF") + .option( + "--captureInterval ", + "Interval between frames for GIF" + ) + .option("--playbackFps ", "Playback speed for GIF"); + +program.parse(process.argv); const main = async () => { // global definitions let capture, captureName, - features = [] + features = []; try { - let { url, mode, trigger: triggerMode, delay, resX, resY, selector, features } = program.opts() + let { + url, + mode, + trigger: triggerMode, + delay, + resX, + resY, + selector, + gif = false, + frameCount = GIF_DEFAULTS.FRAME_COUNT, + captureInterval = GIF_DEFAULTS.CAPTURE_INTERVAL, + playbackFps = GIF_DEFAULTS.PLAYBACK_FPS, + } = program.opts(); console.log("running capture with params:", { url, @@ -137,11 +331,15 @@ const main = async () => { triggerMode, delay, selector, - }) + gif, + frameCount, + captureInterval, + playbackFps, + }); // default parameter for triggerMode if (typeof triggerMode === "undefined") { - triggerMode = "DELAY" + triggerMode = "DELAY"; } // @@ -150,192 +348,194 @@ const main = async () => { // general parameters if (!url || !mode) { - throw ERRORS.MISSING_PARAMETERS + throw ERRORS.MISSING_PARAMETERS; } if (!CAPTURE_MODES.includes(mode)) { - throw ERRORS.INVALID_PARAMETERS + throw ERRORS.INVALID_PARAMETERS; + } + + // validate GIF parameters if GIF mode is enabled + if (gif && !validateGifParams(frameCount, captureInterval, playbackFps)) { + throw ERRORS.INVALID_GIF_PARAMETERS; } // parameters based on selected mode if (mode === "VIEWPORT") { if (!resX || !resY) { - throw ERRORS.MISSING_PARAMETERS + throw ERRORS.MISSING_PARAMETERS; } - resX = Math.round(resX) - resY = Math.round(resY) - if (isNaN(resX) || isNaN(resY) || resX < 256 || resX > 2048 || resY < 256 || resY > 2048) { - throw ERRORS.INVALID_PARAMETERS + resX = Math.round(resX); + resY = Math.round(resY); + if ( + isNaN(resX) || + isNaN(resY) || + resX < 256 || + resX > 2048 || + resY < 256 || + resY > 2048 + ) { + throw ERRORS.INVALID_PARAMETERS; } if (delay < DELAY_MIN || delay > DELAY_MAX) { - throw ERRORS.INVALID_PARAMETERS + throw ERRORS.INVALID_PARAMETERS; } - } - else if (mode === "CANVAS") { + } else if (mode === "CANVAS") { if (!selector) { - throw ERRORS.INVALID_PARAMETERS + throw ERRORS.INVALID_PARAMETERS; } } - console.log("bootstrapping chromium...") + console.log("bootstrapping chromium..."); const browser = await puppeteer.launch({ headless: true, args: [ - '--no-sandbox', - '--disable-setuid-sandbox', - '--disable-dev-shm-usage', - '--use-gl=egl', - '--enable-logging' + "--no-sandbox", + "--disable-setuid-sandbox", + "--disable-dev-shm-usage", + "--use-gl=egl", + "--enable-logging", ], - executablePath: process.env.PUPPETEER_EXECUTABLE_PATH - }) + executablePath: process.env.PUPPETEER_EXECUTABLE_PATH, + }); + + console.log("configuring page..."); - console.log("configuring page...") - // browse to the page const viewportSettings = { deviceScaleFactor: 1, - } + }; if (mode === "VIEWPORT") { - viewportSettings.width = resX - viewportSettings.height = resY + viewportSettings.width = resX; + viewportSettings.height = resY; + } else { + viewportSettings.width = 800; + viewportSettings.height = 800; } - else { - viewportSettings.width = 800 - viewportSettings.height = 800 - } - let page = await browser.newPage() - await page.setViewport(viewportSettings) + let page = await browser.newPage(); + await page.setViewport(viewportSettings); - page.on('console', msg => console.log('PAGE LOG:', msg.text())) + page.on("console", (msg) => console.log("PAGE LOG:", msg.text())); // try to reach the page - let response + let response; try { - console.log("navigating to: ", url) + console.log("navigating to: ", url); response = await page.goto(url, { timeout: 200000, - waitUntil: "domcontentloaded" - }) - console.log(`navigated to URL with response status: ${response.status()}`); - } - catch (err) { - console.log(err) + waitUntil: "domcontentloaded", + }); + console.log( + `navigated to URL with response status: ${response.status()}` + ); + } catch (err) { + console.log(err); if (err && err.name && err.name === "TimeoutError") { - throw ERRORS.TIMEOUT - } - else { - throw ERRORS.UNKNOWN + throw ERRORS.TIMEOUT; + } else { + throw ERRORS.UNKNOWN; } } // if the response is not 200 (success), we want to throw if (response.status() !== 200) { - throw ERRORS.HTTP_ERROR + throw ERRORS.HTTP_ERROR; } try { + await waitPreview(triggerMode, page, delay); + // based on the capture mode use different capture strategies if (mode === "VIEWPORT") { - await waitPreview(triggerMode, page, delay) - // we simply take a capture of the viewport - capture = await page.screenshot() + capture = await captureViewport( + page, + gif, + frameCount, + captureInterval, + playbackFps + ); + } else if (mode === "CANVAS") { + capture = await captureCanvas( + page, + selector, + gif, + frameCount, + captureInterval, + playbackFps + ); } - else if (mode === "CANVAS") { - await waitPreview(triggerMode, page, delay) - console.log("converting canvas to PNG with selector:", selector) - // get the base64 image from the CANVAS targetted - const base64 = await page.$eval(selector, (el) => { - if (!el || el.tagName !== "CANVAS") return null - return el.toDataURL() - }) - if (!base64) throw null - const pureBase64 = base64.replace(/^data:image\/png;base64,/, "") - capture = Buffer.from(pureBase64, "base64") - } - - // if the capture is too big, we want to reduce its size - // ! we don't need to reduce the size anymore since S3 is used to store the images - // if (capture.byteLength > 10*1024*1024) { - // capture = await sharp(capture) - // .resize(1024, 1024, { fit: "inside" }) - // .jpeg({ quality: 100 }) - // .toBuffer() - // } + } catch (err) { + console.log(err); + throw ERRORS.CANVAS_CAPTURE_FAILED; } - catch (err) { - console.log(err) - throw ERRORS.CANVAS_CAPTURE_FAILED - } - // EXTRACT FEATURES - console.log("extracting features...") + console.log("extracting features..."); // find $fxhashFeatures in the window object - let rawFeatures = null + let rawFeatures = null; try { - const extractedFeatures = await page.evaluate( - () => { - // v3 syntax - if (window.$fx?._features) return JSON.stringify(window.$fx._features) - // deprecated syntax - return JSON.stringify(window.$fxhashFeatures) - } - ) - rawFeatures = (extractedFeatures && JSON.parse(extractedFeatures)) || null - } - catch { - throw ERRORS.EXTRACT_FEATURES_FAILED + const extractedFeatures = await page.evaluate(() => { + // v3 syntax + if (window.$fx?._features) return JSON.stringify(window.$fx._features); + // deprecated syntax + return JSON.stringify(window.$fxhashFeatures); + }); + rawFeatures = + (extractedFeatures && JSON.parse(extractedFeatures)) || null; + } catch { + throw ERRORS.EXTRACT_FEATURES_FAILED; } - // turn raw features into attributed + // turn raw features into attributes try { - features = processRawTokenFeatures(rawFeatures) - } - catch { } + features = processRawTokenFeatures(rawFeatures); + } catch {} // if features are still undefined, we assume that there are none - features = features || [] + features = features || []; // call for the close of the browser, but don't wait for it - browser.close() + browser.close(); // create the S3 client const client = new S3Client({ region: process.env.AWS_S3_REGION, - }) + }); // the base key path - const baseKey = process.env.AWS_BATCH_JOB_ID + const baseKey = process.env.AWS_BATCH_JOB_ID; console.log("uploading capture to S3..."); - // upload the preview PNG - await client.send(new PutObjectCommand({ - Bucket: process.env.AWS_S3_BUCKET, - Key: `${baseKey}/preview.png`, - Body: capture, - ContentType: "image/png", - })) + // upload the preview file (PNG or GIF) + await client.send( + new PutObjectCommand({ + Bucket: process.env.AWS_S3_BUCKET, + Key: `${baseKey}/preview.${gif ? "gif" : "png"}`, + Body: capture, + ContentType: gif ? "image/gif" : "image/png", + }) + ); // upload the features object to a JSON file - await client.send(new PutObjectCommand({ - Bucket: process.env.AWS_S3_BUCKET, - Key: `${baseKey}/features.json`, - Body: JSON.stringify(features), - ContentType: "application/json", - })) + await client.send( + new PutObjectCommand({ + Bucket: process.env.AWS_S3_BUCKET, + Key: `${baseKey}/features.json`, + Body: JSON.stringify(features), + ContentType: "application/json", + }) + ); console.log("successfully uploaded capture to S3"); // it's a success, we write success to cloud watch - console.log(`Successfully processed ${url}`) - process.exit(0) - } - catch (error) { - console.error(error) - process.exit(1) + console.log(`Successfully processed ${url}`); + process.exit(0); + } catch (error) { + console.error(error); + process.exit(1); } -} +}; -main() \ No newline at end of file +main(); diff --git a/package.json b/package.json index e877781..5ded8f7 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,9 @@ "@aws-sdk/client-s3": "^3.48.0", "commander": "^8.3.0", "puppeteer-core": "^13.0.0", - "sharp": "^0.29.3" + "sharp": "^0.29.3", + "gifenc": "1.0.3", + "pngjs": "7.0.0" }, "devDependencies": { "@types/node": "^16.11.12" diff --git a/yarn.lock b/yarn.lock index cda4235..c1e17b6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1097,6 +1097,11 @@ get-stream@^5.1.0: dependencies: pump "^3.0.0" +gifenc@1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/gifenc/-/gifenc-1.0.3.tgz#3a1a97fa5ab70ebefd000350b9aac8313b30b3aa" + integrity sha512-xdr6AdrfGBcfzncONUOlXMBuc5wJDtOueE3c5rdG0oNgtINLD+f2iFZltrBRZYzACRbKr+mSVU/x98zv2u3jmw== + github-from-package@0.0.0: version "0.0.0" resolved "https://registry.yarnpkg.com/github-from-package/-/github-from-package-0.0.0.tgz#97fb5d96bfde8973313f20e8288ef9a167fa64ce" @@ -1305,6 +1310,11 @@ pkg-dir@4.2.0: dependencies: find-up "^4.0.0" +pngjs@7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-7.0.0.tgz#a8b7446020ebbc6ac739db6c5415a65d17090e26" + integrity sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow== + prebuild-install@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-7.0.0.tgz#3c5ce3902f1cb9d6de5ae94ca53575e4af0c1574"