Skip to content
168 changes: 122 additions & 46 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ const ERRORS = {
// the different capture modes
const CAPTURE_MODES = ["CANVAS", "VIEWPORT"]
// the list of accepted trigger modes
const TRIGGER_MODES = ["DELAY", "FN_TRIGGER"]
const TRIGGER_MODES = ["DELAY", "FN_TRIGGER", "FN_TRIGGER_GIF"]

//
// UTILITY FUNCTIONS
Expand All @@ -94,7 +94,7 @@ function isUrlValid(url) {
}

// is a trigger valid ? looks at the trigger mode and trigger settings
function isTriggerValid(triggerMode, delay) {
function isTriggerValid(triggerMode, delay, playbackFps) {
if (!TRIGGER_MODES.includes(triggerMode)) {
return false
}
Expand All @@ -106,8 +106,15 @@ function isTriggerValid(triggerMode, delay) {
delay >= DELAY_MIN &&
delay <= DELAY_MAX
)
} else if (triggerMode === "FN_TRIGGER_GIF") {
return (
typeof playbackFps !== undefined &&
!isNaN(playbackFps) &&
playbackFps >= GIF_DEFAULTS.MIN_FPS &&
playbackFps <= GIF_DEFAULTS.MAX_FPS
)
} else if (triggerMode === "FN_TRIGGER") {
// fn trigger doesn't need any param
// fn trigger and fn trigger gif don't need any params
return true
}
}
Expand Down Expand Up @@ -323,6 +330,7 @@ const resizeCanvas = async (image, resX, resY) => {
}
const performCapture = async (
mode,
triggerMode,
page,
canvasSelector,
resX,
Expand All @@ -337,14 +345,22 @@ const performCapture = async (
// if viewport mode, use the native puppeteer page.screenshot
if (mode === "VIEWPORT") {
// we simply take a capture of the viewport
return captureViewport(page, gif, frameCount, captureInterval, playbackFps)
return captureViewport(
page,
triggerMode,
gif,
frameCount,
captureInterval,
playbackFps
)
}
// if the mode is canvas, we need to execute som JS on the client to select
// the canvas and generate a dataURL to bridge it in here
else if (mode === "CANVAS") {
const canvas = await captureCanvas(
page,
canvasSelector,
triggerMode,
gif,
frameCount,
captureInterval,
Expand Down Expand Up @@ -419,7 +435,7 @@ const validateParams = ({
if (!url || !mode) throw ERRORS.MISSING_PARAMETERS
if (!isUrlValid(url)) throw ERRORS.UNSUPPORTED_URL
if (!CAPTURE_MODES.includes(mode)) throw ERRORS.INVALID_PARAMETERS
if (!isTriggerValid(triggerMode, delay))
if (!isTriggerValid(triggerMode, delay, playbackFps))
throw ERRORS.INVALID_TRIGGER_PARAMETERS

if (gif && !validateGifParams(frameCount, captureInterval, playbackFps))
Expand Down Expand Up @@ -456,29 +472,21 @@ const validateParams = ({
}
}

async function captureViewport(
page,
isGif,
async function captureFramesWithTiming(
captureFrameFunction,
frameCount,
captureInterval,
playbackFps
captureInterval
) {
if (!isGif) {
return await page.screenshot()
}

const frames = []
let lastCaptureStart = performance.now()

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

// Capture raw pixels
const frameBuffer = await page.screenshot({
encoding: "binary",
})
frames.push(frameBuffer)
// Use the provided capture function to get the frame
const frame = await captureFrameFunction()
frames.push(frame)

// Calculate how long the capture took
const captureDuration = performance.now() - captureStart
Expand All @@ -502,6 +510,78 @@ async function captureViewport(
lastCaptureStart = performance.now()
}

return frames
}

async function captureFramesProgrammatically(page, captureFrameFunction) {
const frames = []

// set up the event listener and capture loop
await page.exposeFunction("captureFrame", async () => {
const frame = await captureFrameFunction()
frames.push(frame)
return frames.length
})

// wait for events in browser context
await page.evaluate(
function (maxFrames, delayMax) {
return new Promise(function (resolve) {
const handleFrameCapture = async event => {
const frameCount = await window.captureFrame()

if (event.detail?.isLastFrame || frameCount >= maxFrames) {
window.removeEventListener(
"fxhash-capture-frame",
handleFrameCapture
)
resolve()
}
}

window.addEventListener("fxhash-capture-frame", handleFrameCapture)

// timeout fallback
setTimeout(() => {
window.removeEventListener("fxhash-capture-frame", handleFrameCapture)
resolve()
}, delayMax)
})
},
GIF_DEFAULTS.MAX_FRAMES,
DELAY_MAX
)

return frames
}

async function captureViewport(
page,
triggerMode,
isGif,
frameCount,
captureInterval,
playbackFps
) {
if (!isGif) {
return await page.screenshot()
}

const captureViewportFrame = async () => {
return await page.screenshot({
encoding: "binary",
})
}

const frames =
triggerMode === "FN_TRIGGER_GIF"
? await captureFramesProgrammatically(page, captureViewportFrame)
: await captureFramesWithTiming(
captureViewportFrame,
frameCount,
captureInterval
)

const viewport = page.viewport()
return await captureFramesToGif(
frames,
Expand All @@ -514,6 +594,7 @@ async function captureViewport(
async function captureCanvas(
page,
canvasSelector,
triggerMode,
isGif,
frameCount,
captureInterval,
Expand All @@ -532,37 +613,25 @@ async function captureCanvas(
return Buffer.from(pureBase64, "base64")
}

const frames = []
let lastCaptureStart = Date.now()

for (let i = 0; i < frameCount; i++) {
const captureStart = Date.now()

const captureCanvasFrame = async () => {
// Get raw pixel data from canvas
const base64 = await page.$eval(canvasSelector, el => {
if (!el || el.tagName !== "CANVAS") return null
return el.toDataURL()
})
if (!base64) throw null
frames.push(base64)

// Calculate timing adjustments
const captureDuration = Date.now() - captureStart
const adjustedInterval = Math.max(0, captureInterval - captureDuration)

console.log(`Frame ${i + 1}/${frameCount}:`, {
captureDuration,
adjustedInterval,
totalFrameTime: Date.now() - lastCaptureStart,
})

if (adjustedInterval > 0) {
await sleep(adjustedInterval)
}

lastCaptureStart = Date.now()
if (!base64) throw new Error("Canvas capture failed")
return base64
}

const frames =
triggerMode === "FN_TRIGGER_GIF"
? await captureFramesProgrammatically(page, captureCanvasFrame)
: await captureFramesWithTiming(
captureCanvasFrame,
frameCount,
captureInterval
)

const dimensions = await page.$eval(canvasSelector, el => ({
width: el.width,
height: el.height,
Expand Down Expand Up @@ -671,6 +740,7 @@ exports.handler = async (event, context) => {
const processCapture = async () => {
const capture = await performCapture(
mode,
triggerMode,
page,
canvasSelector,
resX,
Expand All @@ -687,10 +757,16 @@ exports.handler = async (event, context) => {
return upload
}

if (useFallbackCaptureOnTimeout) {
await waitPreviewWithFallback(context, triggerMode, page, delay)
if (triggerMode === "FN_TRIGGER_GIF") {
// for FN_TRIGGER_GIF mode, skip preview waiting entirely
// the capture functions will handle event listening internally
console.log("Using FN_TRIGGER_GIF mode - skipping preview wait")
} else {
await waitPreview(triggerMode, page, delay)
if (useFallbackCaptureOnTimeout) {
await waitPreviewWithFallback(context, triggerMode, page, delay)
} else {
await waitPreview(triggerMode, page, delay)
}
}

httpResponse = await processCapture()
Expand Down
Loading