Fara-BrowserUse / src /services /gifGenerator.ts
VyoJ's picture
Upload 78 files
7fcdb70 verified
import gifshot from 'gifshot';
export interface GifGenerationOptions {
images: string[];
interval?: number; // Duration of each frame in seconds
gifWidth?: number;
gifHeight?: number;
quality?: number;
}
export interface GifGenerationResult {
success: boolean;
image?: string; // GIF data URL
error?: string;
}
/**
* Add step counter to an image
* @param imageSrc Image source (base64 or URL)
* @param stepNumber Step number
* @param totalSteps Total number of steps
* @param width Image width
* @param height Image height
* @returns Promise resolved with modified image in base64
*/
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;
}
// Draw the image
ctx.drawImage(img, 0, 0, width, height);
// Configure counter style
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;
// Use actual text metrics for better vertical centering
const actualHeight = textMetrics.actualBoundingBoxAscent + textMetrics.actualBoundingBoxDescent;
// Calculate box dimensions
const boxWidth = textWidth + padding * 2;
const boxHeight = actualHeight + padding * 2;
// Position at bottom right with margin
const margin = Math.max(8, Math.floor(height * 0.015));
const boxX = width - boxWidth - margin;
const boxY = height - boxHeight - margin;
// Draw semi-transparent rounded rectangle for readability
ctx.fillStyle = 'rgba(255, 255, 255, 0.85)';
const borderRadius = 4;
ctx.beginPath();
ctx.roundRect(boxX, boxY, boxWidth, boxHeight, borderRadius);
ctx.fill();
// Draw black text centered in the box
ctx.fillStyle = '#000000';
ctx.textAlign = 'center';
ctx.textBaseline = 'alphabetic';
// Position text precisely using actual bounding box metrics
const textX = boxX + boxWidth / 2;
const textY = boxY + padding + textMetrics.actualBoundingBoxAscent;
ctx.fillText(text, textX, textY);
// Convert canvas to base64
resolve(canvas.toDataURL('image/png'));
};
img.onerror = () => {
reject(new Error('Failed to load image'));
};
img.src = imageSrc;
});
};
/**
* Get the dimensions of an image
* @param imageSrc Image source (base64 or URL)
* @returns Promise resolved with image dimensions
*/
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;
});
};
/**
* Generate a GIF from a list of images (base64 or URLs)
* @param options GIF generation options
* @returns Promise resolved with generation result
*/
export const generateGif = async (
options: GifGenerationOptions
): Promise<GifGenerationResult> => {
const {
images,
interval = 1.5, // 1.5 seconds per frame by default
gifWidth,
gifHeight,
quality = 10,
} = options;
if (!images || images.length === 0) {
return {
success: false,
error: 'No images provided to generate GIF',
};
}
try {
// Get dimensions from the first image if not specified
let width = gifWidth;
let height = gifHeight;
if (!width || !height) {
const dimensions = await getImageDimensions(images[0]);
width = width || dimensions.width;
height = height || dimensions.height;
}
// Add counter to each image
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',
};
}
};
/**
* Download a GIF (data URL) with a filename
* @param dataUrl GIF data URL
* @param filename Filename to download
*/
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);
};