|
|
import gifshot from 'gifshot'; |
|
|
|
|
|
export interface GifGenerationOptions { |
|
|
images: string[]; |
|
|
interval?: number; |
|
|
gifWidth?: number; |
|
|
gifHeight?: number; |
|
|
quality?: number; |
|
|
} |
|
|
|
|
|
export interface GifGenerationResult { |
|
|
success: boolean; |
|
|
image?: string; |
|
|
error?: string; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const addStepCounter = async ( |
|
|
imageSrc: string, |
|
|
stepNumber: number, |
|
|
totalSteps: number, |
|
|
width: number, |
|
|
height: number |
|
|
): Promise<string> => { |
|
|
return new Promise((resolve, reject) => { |
|
|
const img = new Image(); |
|
|
img.crossOrigin = 'anonymous'; |
|
|
|
|
|
img.onload = () => { |
|
|
const canvas = document.createElement('canvas'); |
|
|
canvas.width = width; |
|
|
canvas.height = height; |
|
|
const ctx = canvas.getContext('2d'); |
|
|
|
|
|
if (!ctx) { |
|
|
reject(new Error('Cannot get canvas context')); |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
ctx.drawImage(img, 0, 0, width, height); |
|
|
|
|
|
|
|
|
const fontSize = Math.max(11, Math.floor(height * 0.05)); |
|
|
const padding = Math.max(5, Math.floor(height * 0.02)); |
|
|
const text = `${stepNumber}/${totalSteps}`; |
|
|
|
|
|
ctx.font = `bold ${fontSize}px Arial, sans-serif`; |
|
|
const textMetrics = ctx.measureText(text); |
|
|
const textWidth = textMetrics.width; |
|
|
|
|
|
|
|
|
const actualHeight = textMetrics.actualBoundingBoxAscent + textMetrics.actualBoundingBoxDescent; |
|
|
|
|
|
|
|
|
const boxWidth = textWidth + padding * 2; |
|
|
const boxHeight = actualHeight + padding * 2; |
|
|
|
|
|
|
|
|
const margin = Math.max(8, Math.floor(height * 0.015)); |
|
|
const boxX = width - boxWidth - margin; |
|
|
const boxY = height - boxHeight - margin; |
|
|
|
|
|
|
|
|
ctx.fillStyle = 'rgba(255, 255, 255, 0.85)'; |
|
|
const borderRadius = 4; |
|
|
ctx.beginPath(); |
|
|
ctx.roundRect(boxX, boxY, boxWidth, boxHeight, borderRadius); |
|
|
ctx.fill(); |
|
|
|
|
|
|
|
|
ctx.fillStyle = '#000000'; |
|
|
ctx.textAlign = 'center'; |
|
|
ctx.textBaseline = 'alphabetic'; |
|
|
|
|
|
const textX = boxX + boxWidth / 2; |
|
|
const textY = boxY + padding + textMetrics.actualBoundingBoxAscent; |
|
|
ctx.fillText(text, textX, textY); |
|
|
|
|
|
|
|
|
resolve(canvas.toDataURL('image/png')); |
|
|
}; |
|
|
|
|
|
img.onerror = () => { |
|
|
reject(new Error('Failed to load image')); |
|
|
}; |
|
|
|
|
|
img.src = imageSrc; |
|
|
}); |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const getImageDimensions = (imageSrc: string): Promise<{ width: number; height: number }> => { |
|
|
return new Promise((resolve, reject) => { |
|
|
const img = new Image(); |
|
|
img.crossOrigin = 'anonymous'; |
|
|
|
|
|
img.onload = () => { |
|
|
resolve({ width: img.naturalWidth, height: img.naturalHeight }); |
|
|
}; |
|
|
|
|
|
img.onerror = () => { |
|
|
reject(new Error('Failed to load image to get dimensions')); |
|
|
}; |
|
|
|
|
|
img.src = imageSrc; |
|
|
}); |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export const generateGif = async ( |
|
|
options: GifGenerationOptions |
|
|
): Promise<GifGenerationResult> => { |
|
|
const { |
|
|
images, |
|
|
interval = 1.5, |
|
|
gifWidth, |
|
|
gifHeight, |
|
|
quality = 10, |
|
|
} = options; |
|
|
|
|
|
if (!images || images.length === 0) { |
|
|
return { |
|
|
success: false, |
|
|
error: 'No images provided to generate GIF', |
|
|
}; |
|
|
} |
|
|
|
|
|
try { |
|
|
|
|
|
let width = gifWidth; |
|
|
let height = gifHeight; |
|
|
|
|
|
if (!width || !height) { |
|
|
const dimensions = await getImageDimensions(images[0]); |
|
|
width = width || dimensions.width; |
|
|
height = height || dimensions.height; |
|
|
} |
|
|
|
|
|
|
|
|
const imagesWithCounter = await Promise.all( |
|
|
images.map((img, index) => |
|
|
addStepCounter(img, index + 1, images.length, width, height) |
|
|
) |
|
|
); |
|
|
|
|
|
return new Promise((resolve) => { |
|
|
gifshot.createGIF( |
|
|
{ |
|
|
images: imagesWithCounter, |
|
|
interval, |
|
|
gifWidth: width, |
|
|
gifHeight: height, |
|
|
numFrames: imagesWithCounter.length, |
|
|
frameDuration: interval, |
|
|
sampleInterval: quality, |
|
|
}, |
|
|
(obj: { error: boolean; errorMsg?: string; image?: string }) => { |
|
|
if (obj.error) { |
|
|
resolve({ |
|
|
success: false, |
|
|
error: obj.errorMsg || 'Error during GIF generation', |
|
|
}); |
|
|
} else { |
|
|
resolve({ |
|
|
success: true, |
|
|
image: obj.image, |
|
|
}); |
|
|
} |
|
|
} |
|
|
); |
|
|
}); |
|
|
} catch (error) { |
|
|
return { |
|
|
success: false, |
|
|
error: error instanceof Error ? error.message : 'Unknown error', |
|
|
}; |
|
|
} |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export const downloadGif = (dataUrl: string, filename: string = 'trace-replay.gif') => { |
|
|
const link = document.createElement('a'); |
|
|
link.href = dataUrl; |
|
|
link.download = filename; |
|
|
document.body.appendChild(link); |
|
|
link.click(); |
|
|
document.body.removeChild(link); |
|
|
}; |
|
|
|