/**
* GIF Encoder Module for Offscreen Document
*
* Handles GIF encoding using the gifenc library in the offscreen document context.
* This module provides frame-by-frame GIF encoding with palette quantization.
*/
import { GIFEncoder, quantize, applyPalette } from 'gifenc';
import { MessageTarget, OFFSCREEN_MESSAGE_TYPES } from '@/common/message-types';
// ============================================================================
// Types
// ============================================================================
interface GifEncoderState {
encoder: ReturnType<typeof GIFEncoder> | null;
width: number;
height: number;
frameCount: number;
isInitialized: boolean;
}
interface GifAddFrameMessage {
target: MessageTarget;
type: typeof OFFSCREEN_MESSAGE_TYPES.GIF_ADD_FRAME;
imageData: number[];
width: number;
height: number;
delay: number;
maxColors?: number;
}
interface GifFinishMessage {
target: MessageTarget;
type: typeof OFFSCREEN_MESSAGE_TYPES.GIF_FINISH;
}
interface GifResetMessage {
target: MessageTarget;
type: typeof OFFSCREEN_MESSAGE_TYPES.GIF_RESET;
}
type GifMessage = GifAddFrameMessage | GifFinishMessage | GifResetMessage;
interface GifMessageResponse {
success: boolean;
error?: string;
frameCount?: number;
gifData?: number[];
byteLength?: number;
}
// ============================================================================
// State
// ============================================================================
const state: GifEncoderState = {
encoder: null,
width: 0,
height: 0,
frameCount: 0,
isInitialized: false,
};
// ============================================================================
// Handlers
// ============================================================================
function initializeEncoder(width: number, height: number): void {
state.encoder = GIFEncoder();
state.width = width;
state.height = height;
state.frameCount = 0;
state.isInitialized = true;
}
function addFrame(
imageData: Uint8ClampedArray,
width: number,
height: number,
delay: number,
maxColors: number = 256,
): void {
// Initialize encoder on first frame
if (!state.isInitialized || state.width !== width || state.height !== height) {
initializeEncoder(width, height);
}
if (!state.encoder) {
throw new Error('GIF encoder not initialized');
}
// Quantize colors to create palette
const palette = quantize(imageData, maxColors, { format: 'rgb444' });
// Map pixels to palette indices
const indexedPixels = applyPalette(imageData, palette, 'rgb444');
// Write frame to encoder
state.encoder.writeFrame(indexedPixels, width, height, {
palette,
delay,
dispose: 2, // Restore to background color
});
state.frameCount++;
}
function finishEncoding(): Uint8Array {
if (!state.encoder) {
throw new Error('GIF encoder not initialized');
}
state.encoder.finish();
const bytes = state.encoder.bytes();
// Reset state after finishing
resetEncoder();
return bytes;
}
function resetEncoder(): void {
if (state.encoder) {
state.encoder.reset();
}
state.encoder = null;
state.width = 0;
state.height = 0;
state.frameCount = 0;
state.isInitialized = false;
}
// ============================================================================
// Message Handler
// ============================================================================
function isGifMessage(message: unknown): message is GifMessage {
if (!message || typeof message !== 'object') return false;
const msg = message as Record<string, unknown>;
if (msg.target !== MessageTarget.Offscreen) return false;
const gifTypes = [
OFFSCREEN_MESSAGE_TYPES.GIF_ADD_FRAME,
OFFSCREEN_MESSAGE_TYPES.GIF_FINISH,
OFFSCREEN_MESSAGE_TYPES.GIF_RESET,
];
return gifTypes.includes(msg.type as string);
}
export function handleGifMessage(
message: unknown,
sendResponse: (response: GifMessageResponse) => void,
): boolean {
if (!isGifMessage(message)) {
return false;
}
try {
switch (message.type) {
case OFFSCREEN_MESSAGE_TYPES.GIF_ADD_FRAME: {
const { imageData, width, height, delay, maxColors } = message;
const clampedData = new Uint8ClampedArray(imageData);
addFrame(clampedData, width, height, delay, maxColors);
sendResponse({
success: true,
frameCount: state.frameCount,
});
break;
}
case OFFSCREEN_MESSAGE_TYPES.GIF_FINISH: {
const gifBytes = finishEncoding();
sendResponse({
success: true,
gifData: Array.from(gifBytes),
byteLength: gifBytes.byteLength,
});
break;
}
case OFFSCREEN_MESSAGE_TYPES.GIF_RESET: {
resetEncoder();
sendResponse({ success: true });
break;
}
default:
sendResponse({ success: false, error: `Unknown GIF message type` });
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error('GIF encoder error:', errorMessage);
sendResponse({ success: false, error: errorMessage });
}
return true;
}
console.log('GIF encoder module loaded');