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