Skip to content

Commit 427bff1

Browse files
committed
implement programmatic gif capture
1 parent 61e0ce2 commit 427bff1

1 file changed

Lines changed: 113 additions & 46 deletions

File tree

index.js

Lines changed: 113 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ const ERRORS = {
7777
// the different capture modes
7878
const CAPTURE_MODES = ["CANVAS", "VIEWPORT"]
7979
// the list of accepted trigger modes
80-
const TRIGGER_MODES = ["DELAY", "FN_TRIGGER"]
80+
const TRIGGER_MODES = ["DELAY", "FN_TRIGGER", "FN_TRIGGER_GIF"]
8181

8282
//
8383
// UTILITY FUNCTIONS
@@ -94,7 +94,7 @@ function isUrlValid(url) {
9494
}
9595

9696
// is a trigger valid ? looks at the trigger mode and trigger settings
97-
function isTriggerValid(triggerMode, delay) {
97+
function isTriggerValid(triggerMode, delay, playbackFps) {
9898
if (!TRIGGER_MODES.includes(triggerMode)) {
9999
return false
100100
}
@@ -106,8 +106,15 @@ function isTriggerValid(triggerMode, delay) {
106106
delay >= DELAY_MIN &&
107107
delay <= DELAY_MAX
108108
)
109+
} else if (triggerMode === "FN_TRIGGER_GIF") {
110+
return (
111+
typeof playbackFps !== undefined &&
112+
!isNaN(playbackFps) &&
113+
playbackFps >= GIF_DEFAULTS.MIN_FPS &&
114+
playbackFps <= GIF_DEFAULTS.MAX_FPS
115+
)
109116
} else if (triggerMode === "FN_TRIGGER") {
110-
// fn trigger doesn't need any param
117+
// fn trigger and fn trigger gif don't need any params
111118
return true
112119
}
113120
}
@@ -323,6 +330,7 @@ const resizeCanvas = async (image, resX, resY) => {
323330
}
324331
const performCapture = async (
325332
mode,
333+
triggerMode,
326334
page,
327335
canvasSelector,
328336
resX,
@@ -337,14 +345,22 @@ const performCapture = async (
337345
// if viewport mode, use the native puppeteer page.screenshot
338346
if (mode === "VIEWPORT") {
339347
// we simply take a capture of the viewport
340-
return captureViewport(page, gif, frameCount, captureInterval, playbackFps)
348+
return captureViewport(
349+
page,
350+
triggerMode,
351+
gif,
352+
frameCount,
353+
captureInterval,
354+
playbackFps
355+
)
341356
}
342357
// if the mode is canvas, we need to execute som JS on the client to select
343358
// the canvas and generate a dataURL to bridge it in here
344359
else if (mode === "CANVAS") {
345360
const canvas = await captureCanvas(
346361
page,
347362
canvasSelector,
363+
triggerMode,
348364
gif,
349365
frameCount,
350366
captureInterval,
@@ -419,7 +435,7 @@ const validateParams = ({
419435
if (!url || !mode) throw ERRORS.MISSING_PARAMETERS
420436
if (!isUrlValid(url)) throw ERRORS.UNSUPPORTED_URL
421437
if (!CAPTURE_MODES.includes(mode)) throw ERRORS.INVALID_PARAMETERS
422-
if (!isTriggerValid(triggerMode, delay))
438+
if (!isTriggerValid(triggerMode, delay, playbackFps))
423439
throw ERRORS.INVALID_TRIGGER_PARAMETERS
424440

425441
if (gif && !validateGifParams(frameCount, captureInterval, playbackFps))
@@ -456,29 +472,21 @@ const validateParams = ({
456472
}
457473
}
458474

459-
async function captureViewport(
460-
page,
461-
isGif,
475+
async function captureFramesWithTiming(
476+
captureFrameFunction,
462477
frameCount,
463-
captureInterval,
464-
playbackFps
478+
captureInterval
465479
) {
466-
if (!isGif) {
467-
return await page.screenshot()
468-
}
469-
470480
const frames = []
471481
let lastCaptureStart = performance.now()
472482

473483
for (let i = 0; i < frameCount; i++) {
474484
// Record start time of screenshot operation
475485
const captureStart = performance.now()
476486

477-
// Capture raw pixels
478-
const frameBuffer = await page.screenshot({
479-
encoding: "binary",
480-
})
481-
frames.push(frameBuffer)
487+
// Use the provided capture function to get the frame
488+
const frame = await captureFrameFunction()
489+
frames.push(frame)
482490

483491
// Calculate how long the capture took
484492
const captureDuration = performance.now() - captureStart
@@ -502,6 +510,69 @@ async function captureViewport(
502510
lastCaptureStart = performance.now()
503511
}
504512

513+
return frames
514+
}
515+
516+
async function captureFramesProgrammatically(page, captureFrameFunction) {
517+
const frames = []
518+
519+
// set up the event listener and capture loop
520+
await page.exposeFunction("captureFrame", async () => {
521+
const frame = await captureFrameFunction()
522+
frames.push(frame)
523+
console.log(`programmatic frame ${frames.length} captured`)
524+
return frames.length
525+
})
526+
527+
// wait for events in browser context
528+
await page.evaluate(function () {
529+
return new Promise(function (resolve) {
530+
window.addEventListener("fxhash-capture-frame", async event => {
531+
const frameCount = await window.captureFrame()
532+
533+
if (
534+
event.detail?.isLastFrame ||
535+
frameCount >= GIF_DEFAULTS.MAX_FRAMES
536+
) {
537+
resolve()
538+
}
539+
})
540+
541+
// timeout fallback
542+
setTimeout(() => resolve(), DELAY_MAX)
543+
})
544+
})
545+
546+
return frames
547+
}
548+
549+
async function captureViewport(
550+
page,
551+
triggerMode,
552+
isGif,
553+
frameCount,
554+
captureInterval,
555+
playbackFps
556+
) {
557+
if (!isGif) {
558+
return await page.screenshot()
559+
}
560+
561+
const captureViewportFrame = async () => {
562+
return await page.screenshot({
563+
encoding: "binary",
564+
})
565+
}
566+
567+
const frames =
568+
triggerMode === "FN_TRIGGER_GIF"
569+
? await captureFramesProgrammatically(page, captureViewportFrame)
570+
: await captureFramesWithTiming(
571+
captureViewportFrame,
572+
frameCount,
573+
captureInterval
574+
)
575+
505576
const viewport = page.viewport()
506577
return await captureFramesToGif(
507578
frames,
@@ -514,6 +585,7 @@ async function captureViewport(
514585
async function captureCanvas(
515586
page,
516587
canvasSelector,
588+
triggerMode,
517589
isGif,
518590
frameCount,
519591
captureInterval,
@@ -532,37 +604,25 @@ async function captureCanvas(
532604
return Buffer.from(pureBase64, "base64")
533605
}
534606

535-
const frames = []
536-
let lastCaptureStart = Date.now()
537-
538-
for (let i = 0; i < frameCount; i++) {
539-
const captureStart = Date.now()
540-
607+
const captureCanvasFrame = async () => {
541608
// Get raw pixel data from canvas
542609
const base64 = await page.$eval(canvasSelector, el => {
543610
if (!el || el.tagName !== "CANVAS") return null
544611
return el.toDataURL()
545612
})
546-
if (!base64) throw null
547-
frames.push(base64)
548-
549-
// Calculate timing adjustments
550-
const captureDuration = Date.now() - captureStart
551-
const adjustedInterval = Math.max(0, captureInterval - captureDuration)
552-
553-
console.log(`Frame ${i + 1}/${frameCount}:`, {
554-
captureDuration,
555-
adjustedInterval,
556-
totalFrameTime: Date.now() - lastCaptureStart,
557-
})
558-
559-
if (adjustedInterval > 0) {
560-
await sleep(adjustedInterval)
561-
}
562-
563-
lastCaptureStart = Date.now()
613+
if (!base64) throw new Error("Canvas capture failed")
614+
return base64
564615
}
565616

617+
const frames =
618+
triggerMode === "FN_TRIGGER_GIF"
619+
? await captureFramesProgrammatically(page, captureCanvasFrame)
620+
: await captureFramesWithTiming(
621+
captureCanvasFrame,
622+
frameCount,
623+
captureInterval
624+
)
625+
566626
const dimensions = await page.$eval(canvasSelector, el => ({
567627
width: el.width,
568628
height: el.height,
@@ -671,6 +731,7 @@ exports.handler = async (event, context) => {
671731
const processCapture = async () => {
672732
const capture = await performCapture(
673733
mode,
734+
triggerMode,
674735
page,
675736
canvasSelector,
676737
resX,
@@ -687,10 +748,16 @@ exports.handler = async (event, context) => {
687748
return upload
688749
}
689750

690-
if (useFallbackCaptureOnTimeout) {
691-
await waitPreviewWithFallback(context, triggerMode, page, delay)
751+
if (triggerMode === "FN_TRIGGER_GIF") {
752+
// for FN_TRIGGER_GIF mode, skip preview waiting entirely
753+
// the capture functions will handle event listening internally
754+
console.log("Using FN_TRIGGER_GIF mode - skipping preview wait")
692755
} else {
693-
await waitPreview(triggerMode, page, delay)
756+
if (useFallbackCaptureOnTimeout) {
757+
await waitPreviewWithFallback(context, triggerMode, page, delay)
758+
} else {
759+
await waitPreview(triggerMode, page, delay)
760+
}
694761
}
695762

696763
httpResponse = await processCapture()

0 commit comments

Comments
 (0)