| 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);
|
| };
|
|
|