File size: 6,010 Bytes
7fcdb70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
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);
};