cross-image 0.4.2 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/esm/src/formats/gif.d.ts +1 -0
- package/esm/src/formats/gif.js +19 -2
- package/esm/src/utils/gif_encoder.d.ts +13 -4
- package/esm/src/utils/gif_encoder.js +100 -97
- package/esm/src/utils/tiff_deflate.d.ts +0 -1
- package/esm/src/utils/tiff_deflate.js +0 -1
- package/package.json +1 -1
- package/script/src/formats/gif.d.ts +1 -0
- package/script/src/formats/gif.js +19 -2
- package/script/src/utils/gif_encoder.d.ts +13 -4
- package/script/src/utils/gif_encoder.js +100 -97
- package/script/src/utils/tiff_deflate.d.ts +0 -1
- package/script/src/utils/tiff_deflate.js +0 -1
package/esm/src/formats/gif.d.ts
CHANGED
|
@@ -48,6 +48,7 @@ export declare class GIFFormat implements ImageFormat {
|
|
|
48
48
|
* Encode multi-frame image data to animated GIF
|
|
49
49
|
*/
|
|
50
50
|
encodeFrames(imageData: MultiFrameImageData, options?: GIFEncoderOptions): Promise<Uint8Array>;
|
|
51
|
+
private mapDisposalMethodToNumber;
|
|
51
52
|
private mapDisposalMethod;
|
|
52
53
|
private decodeUsingRuntime;
|
|
53
54
|
private readDataSubBlocks;
|
package/esm/src/formats/gif.js
CHANGED
|
@@ -243,12 +243,29 @@ export class GIFFormat {
|
|
|
243
243
|
}
|
|
244
244
|
const encoder = new GIFEncoder(imageData.width, imageData.height);
|
|
245
245
|
for (const frame of imageData.frames) {
|
|
246
|
-
// Get delay from metadata (default to 100ms if not set)
|
|
247
246
|
const delay = frame.frameMetadata?.delay ?? 100;
|
|
248
|
-
encoder.addFrame(frame.data, delay
|
|
247
|
+
encoder.addFrame(frame.data, delay, {
|
|
248
|
+
left: frame.frameMetadata?.left ?? 0,
|
|
249
|
+
top: frame.frameMetadata?.top ?? 0,
|
|
250
|
+
width: frame.width,
|
|
251
|
+
height: frame.height,
|
|
252
|
+
disposal: this.mapDisposalMethodToNumber(frame.frameMetadata?.disposal),
|
|
253
|
+
});
|
|
249
254
|
}
|
|
250
255
|
return Promise.resolve(encoder.encode(options));
|
|
251
256
|
}
|
|
257
|
+
mapDisposalMethodToNumber(disposal) {
|
|
258
|
+
switch (disposal) {
|
|
259
|
+
case "none":
|
|
260
|
+
return 1;
|
|
261
|
+
case "background":
|
|
262
|
+
return 2;
|
|
263
|
+
case "previous":
|
|
264
|
+
return 3;
|
|
265
|
+
default:
|
|
266
|
+
return 0;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
252
269
|
mapDisposalMethod(disposal) {
|
|
253
270
|
switch (disposal) {
|
|
254
271
|
case 0:
|
|
@@ -3,11 +3,20 @@
|
|
|
3
3
|
* Supports GIF89a format with LZW compression
|
|
4
4
|
*/
|
|
5
5
|
export declare class GIFEncoder {
|
|
6
|
-
private
|
|
7
|
-
private
|
|
6
|
+
private canvasWidth;
|
|
7
|
+
private canvasHeight;
|
|
8
8
|
private frames;
|
|
9
9
|
constructor(width: number, height: number, data?: Uint8Array);
|
|
10
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Add a frame with optional metadata for partial frame support
|
|
12
|
+
*/
|
|
13
|
+
addFrame(data: Uint8Array, delay?: number, options?: {
|
|
14
|
+
left?: number;
|
|
15
|
+
top?: number;
|
|
16
|
+
width?: number;
|
|
17
|
+
height?: number;
|
|
18
|
+
disposal?: number;
|
|
19
|
+
}): void;
|
|
11
20
|
private writeBytes;
|
|
12
21
|
private writeUint16LE;
|
|
13
22
|
private writeString;
|
|
@@ -20,7 +29,7 @@ export declare class GIFEncoder {
|
|
|
20
29
|
*/
|
|
21
30
|
private quantizeChannel;
|
|
22
31
|
/**
|
|
23
|
-
* Quantize RGBA image to 256 colors
|
|
32
|
+
* Quantize RGBA image to 256 colors with transparency support
|
|
24
33
|
*/
|
|
25
34
|
private quantize;
|
|
26
35
|
private nextPowerOf2;
|
|
@@ -5,13 +5,13 @@
|
|
|
5
5
|
import { LZWEncoder } from "./lzw.js";
|
|
6
6
|
export class GIFEncoder {
|
|
7
7
|
constructor(width, height, data) {
|
|
8
|
-
Object.defineProperty(this, "
|
|
8
|
+
Object.defineProperty(this, "canvasWidth", {
|
|
9
9
|
enumerable: true,
|
|
10
10
|
configurable: true,
|
|
11
11
|
writable: true,
|
|
12
12
|
value: void 0
|
|
13
13
|
});
|
|
14
|
-
Object.defineProperty(this, "
|
|
14
|
+
Object.defineProperty(this, "canvasHeight", {
|
|
15
15
|
enumerable: true,
|
|
16
16
|
configurable: true,
|
|
17
17
|
writable: true,
|
|
@@ -23,14 +23,28 @@ export class GIFEncoder {
|
|
|
23
23
|
writable: true,
|
|
24
24
|
value: []
|
|
25
25
|
});
|
|
26
|
-
this.
|
|
27
|
-
this.
|
|
26
|
+
this.canvasWidth = width;
|
|
27
|
+
this.canvasHeight = height;
|
|
28
28
|
if (data) {
|
|
29
29
|
this.addFrame(data);
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Add a frame with optional metadata for partial frame support
|
|
34
|
+
*/
|
|
35
|
+
addFrame(data, delay = 0, options) {
|
|
36
|
+
const pixelCount = data.length / 4;
|
|
37
|
+
const frameWidth = options?.width ?? this.canvasWidth;
|
|
38
|
+
const frameHeight = options?.height ?? Math.ceil(pixelCount / frameWidth);
|
|
39
|
+
this.frames.push({
|
|
40
|
+
data,
|
|
41
|
+
delay,
|
|
42
|
+
left: options?.left ?? 0,
|
|
43
|
+
top: options?.top ?? 0,
|
|
44
|
+
width: frameWidth,
|
|
45
|
+
height: frameHeight,
|
|
46
|
+
disposal: options?.disposal ?? 0,
|
|
47
|
+
});
|
|
34
48
|
}
|
|
35
49
|
writeBytes(output, bytes) {
|
|
36
50
|
output.push(...bytes);
|
|
@@ -57,49 +71,59 @@ export class GIFEncoder {
|
|
|
57
71
|
return Math.round(Math.round(value * levels / 255) * step);
|
|
58
72
|
}
|
|
59
73
|
/**
|
|
60
|
-
* Quantize RGBA image to 256 colors
|
|
74
|
+
* Quantize RGBA image to 256 colors with transparency support
|
|
61
75
|
*/
|
|
62
|
-
quantize(data) {
|
|
63
|
-
//
|
|
76
|
+
quantize(data, frameWidth, frameHeight) {
|
|
77
|
+
// Check for transparent pixels (alpha < 128)
|
|
78
|
+
let hasTransparency = false;
|
|
79
|
+
for (let i = 3; i < data.length; i += 4) {
|
|
80
|
+
if (data[i] < 128) {
|
|
81
|
+
hasTransparency = true;
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
64
85
|
const colorMap = new Map();
|
|
65
86
|
const colors = [];
|
|
66
|
-
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
87
|
+
const transparentIndex = 0;
|
|
88
|
+
// Reserve index 0 for transparency if needed
|
|
89
|
+
if (hasTransparency) {
|
|
90
|
+
colors.push({ r: 0, g: 0, b: 0 });
|
|
91
|
+
}
|
|
92
|
+
// Color quantization parameters
|
|
93
|
+
const RG_LEVELS = 7;
|
|
94
|
+
const B_LEVELS = 3;
|
|
95
|
+
const rgStep = 255 / RG_LEVELS;
|
|
96
|
+
const bStep = 255 / B_LEVELS;
|
|
97
|
+
const maxColors = hasTransparency ? 255 : 256;
|
|
98
|
+
// Collect unique colors from opaque pixels only
|
|
73
99
|
for (let i = 0; i < data.length; i += 4) {
|
|
100
|
+
if (data[i + 3] < 128)
|
|
101
|
+
continue; // Skip transparent
|
|
74
102
|
const r = data[i];
|
|
75
103
|
const g = data[i + 1];
|
|
76
104
|
const b = data[i + 2];
|
|
77
105
|
const key = `${r},${g},${b}`;
|
|
78
|
-
if (!colorMap.has(key) && colors.length <
|
|
106
|
+
if (!colorMap.has(key) && colors.length < maxColors) {
|
|
79
107
|
colorMap.set(key, colors.length);
|
|
80
108
|
colors.push({ r, g, b });
|
|
81
109
|
}
|
|
82
110
|
}
|
|
83
|
-
// Track if color reduction was applied
|
|
84
111
|
let useColorReduction = false;
|
|
85
|
-
// If
|
|
86
|
-
if (colors.length >=
|
|
87
|
-
// Downsample colors to 256 by reducing color depth
|
|
112
|
+
// If too many colors, reduce color depth
|
|
113
|
+
if (colors.length >= maxColors) {
|
|
88
114
|
colorMap.clear();
|
|
89
|
-
colors.length = 0;
|
|
115
|
+
colors.length = hasTransparency ? 1 : 0;
|
|
90
116
|
useColorReduction = true;
|
|
91
117
|
for (let i = 0; i < data.length; i += 4) {
|
|
92
|
-
|
|
93
|
-
|
|
118
|
+
if (data[i + 3] < 128)
|
|
119
|
+
continue;
|
|
94
120
|
const r = this.quantizeChannel(data[i], RG_LEVELS, rgStep);
|
|
95
121
|
const g = this.quantizeChannel(data[i + 1], RG_LEVELS, rgStep);
|
|
96
122
|
const b = this.quantizeChannel(data[i + 2], B_LEVELS, bStep);
|
|
97
123
|
const key = `${r},${g},${b}`;
|
|
98
|
-
if (!colorMap.has(key)) {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
colors.push({ r, g, b });
|
|
102
|
-
}
|
|
124
|
+
if (!colorMap.has(key) && colors.length < maxColors) {
|
|
125
|
+
colorMap.set(key, colors.length);
|
|
126
|
+
colors.push({ r, g, b });
|
|
103
127
|
}
|
|
104
128
|
}
|
|
105
129
|
}
|
|
@@ -115,28 +139,31 @@ export class GIFEncoder {
|
|
|
115
139
|
palette[i * 3 + 1] = colors[i].g;
|
|
116
140
|
palette[i * 3 + 2] = colors[i].b;
|
|
117
141
|
}
|
|
118
|
-
// Create indexed data
|
|
119
|
-
const indexed = new Uint8Array(
|
|
120
|
-
for (let i = 0, j = 0; i < data.length; i += 4, j++) {
|
|
142
|
+
// Create indexed data using frame dimensions
|
|
143
|
+
const indexed = new Uint8Array(frameWidth * frameHeight);
|
|
144
|
+
for (let i = 0, j = 0; i < data.length && j < indexed.length; i += 4, j++) {
|
|
145
|
+
// Handle transparent pixels
|
|
146
|
+
if (data[i + 3] < 128) {
|
|
147
|
+
indexed[j] = transparentIndex;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
121
150
|
let r = data[i];
|
|
122
151
|
let g = data[i + 1];
|
|
123
152
|
let b = data[i + 2];
|
|
124
|
-
// Apply color reduction if it was used for building the palette
|
|
125
153
|
if (useColorReduction) {
|
|
126
154
|
r = this.quantizeChannel(r, RG_LEVELS, rgStep);
|
|
127
155
|
g = this.quantizeChannel(g, RG_LEVELS, rgStep);
|
|
128
156
|
b = this.quantizeChannel(b, B_LEVELS, bStep);
|
|
129
157
|
}
|
|
130
158
|
const key = `${r},${g},${b}`;
|
|
131
|
-
// Try fast O(1) lookup first
|
|
132
159
|
if (colorMap.has(key)) {
|
|
133
160
|
indexed[j] = colorMap.get(key);
|
|
134
161
|
}
|
|
135
162
|
else {
|
|
136
|
-
//
|
|
163
|
+
// Find closest color
|
|
137
164
|
let minDist = Infinity;
|
|
138
|
-
let bestIdx = 0;
|
|
139
|
-
for (let k = 0; k < colors.length; k++) {
|
|
165
|
+
let bestIdx = hasTransparency ? 1 : 0;
|
|
166
|
+
for (let k = hasTransparency ? 1 : 0; k < colors.length; k++) {
|
|
140
167
|
const dr = r - colors[k].r;
|
|
141
168
|
const dg = g - colors[k].g;
|
|
142
169
|
const db = b - colors[k].b;
|
|
@@ -149,7 +176,7 @@ export class GIFEncoder {
|
|
|
149
176
|
indexed[j] = bestIdx;
|
|
150
177
|
}
|
|
151
178
|
}
|
|
152
|
-
return { palette, indexed };
|
|
179
|
+
return { palette, indexed, hasTransparency, transparentIndex };
|
|
153
180
|
}
|
|
154
181
|
nextPowerOf2(n) {
|
|
155
182
|
let power = 1;
|
|
@@ -170,46 +197,34 @@ export class GIFEncoder {
|
|
|
170
197
|
throw new Error("No frames to encode");
|
|
171
198
|
}
|
|
172
199
|
const output = [];
|
|
173
|
-
// Get loop count from options (default to 0 = infinite)
|
|
174
200
|
const loopCount = options?.loop ?? 0;
|
|
175
201
|
// Quantize first frame for Global Color Table
|
|
176
202
|
const firstFrame = this.frames[0];
|
|
177
|
-
const
|
|
203
|
+
const firstResult = this.quantize(firstFrame.data, firstFrame.width, firstFrame.height);
|
|
204
|
+
const globalPalette = firstResult.palette;
|
|
178
205
|
const paletteSize = globalPalette.length / 3;
|
|
179
206
|
const bitsPerColor = this.getBitsPerColor(paletteSize);
|
|
180
207
|
// Header
|
|
181
208
|
this.writeString(output, "GIF89a");
|
|
182
209
|
// Logical Screen Descriptor
|
|
183
|
-
this.writeUint16LE(output, this.
|
|
184
|
-
this.writeUint16LE(output, this.
|
|
185
|
-
// Packed field:
|
|
186
|
-
// - Global Color Table Flag (1 bit): 1
|
|
187
|
-
// - Color Resolution (3 bits): bitsPerColor - 1
|
|
188
|
-
// - Sort Flag (1 bit): 0
|
|
189
|
-
// - Size of Global Color Table (3 bits): bitsPerColor - 1
|
|
210
|
+
this.writeUint16LE(output, this.canvasWidth);
|
|
211
|
+
this.writeUint16LE(output, this.canvasHeight);
|
|
190
212
|
const packed = 0x80 | ((bitsPerColor - 1) << 4) | (bitsPerColor - 1);
|
|
191
213
|
output.push(packed);
|
|
192
|
-
// Background Color Index
|
|
193
|
-
output.push(0);
|
|
194
|
-
// Pixel Aspect Ratio
|
|
195
|
-
output.push(0);
|
|
214
|
+
output.push(0); // Background Color Index
|
|
215
|
+
output.push(0); // Pixel Aspect Ratio
|
|
196
216
|
// Global Color Table
|
|
197
|
-
// The GCT size is 2^(n+1) where n is the value in the packed field
|
|
198
|
-
// So we need to write that many colors, padding if necessary
|
|
199
217
|
const gctSize = 1 << bitsPerColor;
|
|
200
218
|
const paddedPalette = new Uint8Array(gctSize * 3);
|
|
201
219
|
paddedPalette.set(globalPalette);
|
|
202
220
|
this.writeBytes(output, paddedPalette);
|
|
203
221
|
// Netscape Application Extension (Looping)
|
|
204
222
|
if (this.frames.length > 1) {
|
|
205
|
-
output.push(0x21);
|
|
206
|
-
output.push(0xff); // Application Extension Label
|
|
207
|
-
output.push(11); // Block Size
|
|
223
|
+
output.push(0x21, 0xff, 11);
|
|
208
224
|
this.writeString(output, "NETSCAPE2.0");
|
|
209
|
-
output.push(3);
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
output.push(0); // Block Terminator
|
|
225
|
+
output.push(3, 1);
|
|
226
|
+
this.writeUint16LE(output, loopCount);
|
|
227
|
+
output.push(0);
|
|
213
228
|
}
|
|
214
229
|
// Encode frames
|
|
215
230
|
for (let i = 0; i < this.frames.length; i++) {
|
|
@@ -218,67 +233,56 @@ export class GIFEncoder {
|
|
|
218
233
|
let useLocalPalette = false;
|
|
219
234
|
let localPalette = null;
|
|
220
235
|
let localBitsPerColor = bitsPerColor;
|
|
236
|
+
let hasTransparency;
|
|
237
|
+
let transparentIndex;
|
|
221
238
|
if (i === 0) {
|
|
222
|
-
indexed =
|
|
239
|
+
indexed = firstResult.indexed;
|
|
240
|
+
hasTransparency = firstResult.hasTransparency;
|
|
241
|
+
transparentIndex = firstResult.transparentIndex;
|
|
223
242
|
}
|
|
224
243
|
else {
|
|
225
|
-
|
|
226
|
-
// For simplicity, we use a Local Color Table for each frame to ensure colors are correct
|
|
227
|
-
const result = this.quantize(frame.data);
|
|
244
|
+
const result = this.quantize(frame.data, frame.width, frame.height);
|
|
228
245
|
indexed = result.indexed;
|
|
229
246
|
localPalette = result.palette;
|
|
230
247
|
useLocalPalette = true;
|
|
231
|
-
|
|
232
|
-
|
|
248
|
+
hasTransparency = result.hasTransparency;
|
|
249
|
+
transparentIndex = result.transparentIndex;
|
|
250
|
+
localBitsPerColor = this.getBitsPerColor(localPalette.length / 3);
|
|
233
251
|
}
|
|
234
252
|
// Graphic Control Extension
|
|
235
|
-
output.push(0x21);
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
//
|
|
241
|
-
// User Input Flag (1 bit): 0
|
|
242
|
-
// Transparent Color Flag (1 bit): 0
|
|
243
|
-
output.push(0x08); // Disposal method 2 (Restore to background)
|
|
244
|
-
// Delay Time (1/100ths of a second)
|
|
245
|
-
// Default to 10 (100ms) if not specified
|
|
253
|
+
output.push(0x21, 0xf9, 4);
|
|
254
|
+
// Packed: disposal method + transparency flag
|
|
255
|
+
const disposal = frame.disposal & 0x07;
|
|
256
|
+
const gcePacked = (disposal << 2) | (hasTransparency ? 1 : 0);
|
|
257
|
+
output.push(gcePacked);
|
|
258
|
+
// Delay time
|
|
246
259
|
const delay = frame.delay > 0 ? Math.round(frame.delay / 10) : 10;
|
|
247
260
|
this.writeUint16LE(output, delay);
|
|
248
|
-
// Transparent
|
|
249
|
-
output.push(0);
|
|
261
|
+
// Transparent color index
|
|
262
|
+
output.push(hasTransparency ? transparentIndex : 0);
|
|
250
263
|
output.push(0); // Block Terminator
|
|
251
264
|
// Image Descriptor
|
|
252
|
-
output.push(0x2c);
|
|
253
|
-
this.writeUint16LE(output,
|
|
254
|
-
this.writeUint16LE(output,
|
|
255
|
-
this.writeUint16LE(output,
|
|
256
|
-
this.writeUint16LE(output,
|
|
257
|
-
// Packed Field
|
|
265
|
+
output.push(0x2c);
|
|
266
|
+
this.writeUint16LE(output, frame.left);
|
|
267
|
+
this.writeUint16LE(output, frame.top);
|
|
268
|
+
this.writeUint16LE(output, frame.width);
|
|
269
|
+
this.writeUint16LE(output, frame.height);
|
|
258
270
|
if (useLocalPalette && localPalette) {
|
|
259
|
-
// LCT Flag: 1
|
|
260
|
-
// Interlace: 0
|
|
261
|
-
// Sort: 0
|
|
262
|
-
// Reserved: 0
|
|
263
|
-
// Size of LCT: localBitsPerColor - 1
|
|
264
271
|
const lctPacked = 0x80 | (localBitsPerColor - 1);
|
|
265
272
|
output.push(lctPacked);
|
|
266
|
-
// Write Local Color Table
|
|
267
273
|
const lctSize = 1 << localBitsPerColor;
|
|
268
274
|
const paddedLct = new Uint8Array(lctSize * 3);
|
|
269
275
|
paddedLct.set(localPalette);
|
|
270
276
|
this.writeBytes(output, paddedLct);
|
|
271
277
|
}
|
|
272
278
|
else {
|
|
273
|
-
output.push(0);
|
|
279
|
+
output.push(0);
|
|
274
280
|
}
|
|
275
|
-
// LZW
|
|
281
|
+
// LZW compression
|
|
276
282
|
const minCodeSize = Math.max(2, useLocalPalette ? localBitsPerColor : bitsPerColor);
|
|
277
283
|
output.push(minCodeSize);
|
|
278
|
-
// Compress image data with LZW
|
|
279
284
|
const encoder = new LZWEncoder(minCodeSize);
|
|
280
285
|
const compressed = encoder.compress(indexed);
|
|
281
|
-
// Write compressed data in sub-blocks (max 255 bytes per block)
|
|
282
286
|
for (let k = 0; k < compressed.length; k += 255) {
|
|
283
287
|
const blockSize = Math.min(255, compressed.length - k);
|
|
284
288
|
output.push(blockSize);
|
|
@@ -288,8 +292,7 @@ export class GIFEncoder {
|
|
|
288
292
|
}
|
|
289
293
|
output.push(0); // Block Terminator
|
|
290
294
|
}
|
|
291
|
-
// Trailer
|
|
292
|
-
output.push(0x3b);
|
|
295
|
+
output.push(0x3b); // Trailer
|
|
293
296
|
return new Uint8Array(output);
|
|
294
297
|
}
|
|
295
298
|
}
|
package/package.json
CHANGED
|
@@ -48,6 +48,7 @@ export declare class GIFFormat implements ImageFormat {
|
|
|
48
48
|
* Encode multi-frame image data to animated GIF
|
|
49
49
|
*/
|
|
50
50
|
encodeFrames(imageData: MultiFrameImageData, options?: GIFEncoderOptions): Promise<Uint8Array>;
|
|
51
|
+
private mapDisposalMethodToNumber;
|
|
51
52
|
private mapDisposalMethod;
|
|
52
53
|
private decodeUsingRuntime;
|
|
53
54
|
private readDataSubBlocks;
|
|
@@ -246,12 +246,29 @@ class GIFFormat {
|
|
|
246
246
|
}
|
|
247
247
|
const encoder = new gif_encoder_js_1.GIFEncoder(imageData.width, imageData.height);
|
|
248
248
|
for (const frame of imageData.frames) {
|
|
249
|
-
// Get delay from metadata (default to 100ms if not set)
|
|
250
249
|
const delay = frame.frameMetadata?.delay ?? 100;
|
|
251
|
-
encoder.addFrame(frame.data, delay
|
|
250
|
+
encoder.addFrame(frame.data, delay, {
|
|
251
|
+
left: frame.frameMetadata?.left ?? 0,
|
|
252
|
+
top: frame.frameMetadata?.top ?? 0,
|
|
253
|
+
width: frame.width,
|
|
254
|
+
height: frame.height,
|
|
255
|
+
disposal: this.mapDisposalMethodToNumber(frame.frameMetadata?.disposal),
|
|
256
|
+
});
|
|
252
257
|
}
|
|
253
258
|
return Promise.resolve(encoder.encode(options));
|
|
254
259
|
}
|
|
260
|
+
mapDisposalMethodToNumber(disposal) {
|
|
261
|
+
switch (disposal) {
|
|
262
|
+
case "none":
|
|
263
|
+
return 1;
|
|
264
|
+
case "background":
|
|
265
|
+
return 2;
|
|
266
|
+
case "previous":
|
|
267
|
+
return 3;
|
|
268
|
+
default:
|
|
269
|
+
return 0;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
255
272
|
mapDisposalMethod(disposal) {
|
|
256
273
|
switch (disposal) {
|
|
257
274
|
case 0:
|
|
@@ -3,11 +3,20 @@
|
|
|
3
3
|
* Supports GIF89a format with LZW compression
|
|
4
4
|
*/
|
|
5
5
|
export declare class GIFEncoder {
|
|
6
|
-
private
|
|
7
|
-
private
|
|
6
|
+
private canvasWidth;
|
|
7
|
+
private canvasHeight;
|
|
8
8
|
private frames;
|
|
9
9
|
constructor(width: number, height: number, data?: Uint8Array);
|
|
10
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Add a frame with optional metadata for partial frame support
|
|
12
|
+
*/
|
|
13
|
+
addFrame(data: Uint8Array, delay?: number, options?: {
|
|
14
|
+
left?: number;
|
|
15
|
+
top?: number;
|
|
16
|
+
width?: number;
|
|
17
|
+
height?: number;
|
|
18
|
+
disposal?: number;
|
|
19
|
+
}): void;
|
|
11
20
|
private writeBytes;
|
|
12
21
|
private writeUint16LE;
|
|
13
22
|
private writeString;
|
|
@@ -20,7 +29,7 @@ export declare class GIFEncoder {
|
|
|
20
29
|
*/
|
|
21
30
|
private quantizeChannel;
|
|
22
31
|
/**
|
|
23
|
-
* Quantize RGBA image to 256 colors
|
|
32
|
+
* Quantize RGBA image to 256 colors with transparency support
|
|
24
33
|
*/
|
|
25
34
|
private quantize;
|
|
26
35
|
private nextPowerOf2;
|
|
@@ -8,13 +8,13 @@ exports.GIFEncoder = void 0;
|
|
|
8
8
|
const lzw_js_1 = require("./lzw.js");
|
|
9
9
|
class GIFEncoder {
|
|
10
10
|
constructor(width, height, data) {
|
|
11
|
-
Object.defineProperty(this, "
|
|
11
|
+
Object.defineProperty(this, "canvasWidth", {
|
|
12
12
|
enumerable: true,
|
|
13
13
|
configurable: true,
|
|
14
14
|
writable: true,
|
|
15
15
|
value: void 0
|
|
16
16
|
});
|
|
17
|
-
Object.defineProperty(this, "
|
|
17
|
+
Object.defineProperty(this, "canvasHeight", {
|
|
18
18
|
enumerable: true,
|
|
19
19
|
configurable: true,
|
|
20
20
|
writable: true,
|
|
@@ -26,14 +26,28 @@ class GIFEncoder {
|
|
|
26
26
|
writable: true,
|
|
27
27
|
value: []
|
|
28
28
|
});
|
|
29
|
-
this.
|
|
30
|
-
this.
|
|
29
|
+
this.canvasWidth = width;
|
|
30
|
+
this.canvasHeight = height;
|
|
31
31
|
if (data) {
|
|
32
32
|
this.addFrame(data);
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Add a frame with optional metadata for partial frame support
|
|
37
|
+
*/
|
|
38
|
+
addFrame(data, delay = 0, options) {
|
|
39
|
+
const pixelCount = data.length / 4;
|
|
40
|
+
const frameWidth = options?.width ?? this.canvasWidth;
|
|
41
|
+
const frameHeight = options?.height ?? Math.ceil(pixelCount / frameWidth);
|
|
42
|
+
this.frames.push({
|
|
43
|
+
data,
|
|
44
|
+
delay,
|
|
45
|
+
left: options?.left ?? 0,
|
|
46
|
+
top: options?.top ?? 0,
|
|
47
|
+
width: frameWidth,
|
|
48
|
+
height: frameHeight,
|
|
49
|
+
disposal: options?.disposal ?? 0,
|
|
50
|
+
});
|
|
37
51
|
}
|
|
38
52
|
writeBytes(output, bytes) {
|
|
39
53
|
output.push(...bytes);
|
|
@@ -60,49 +74,59 @@ class GIFEncoder {
|
|
|
60
74
|
return Math.round(Math.round(value * levels / 255) * step);
|
|
61
75
|
}
|
|
62
76
|
/**
|
|
63
|
-
* Quantize RGBA image to 256 colors
|
|
77
|
+
* Quantize RGBA image to 256 colors with transparency support
|
|
64
78
|
*/
|
|
65
|
-
quantize(data) {
|
|
66
|
-
//
|
|
79
|
+
quantize(data, frameWidth, frameHeight) {
|
|
80
|
+
// Check for transparent pixels (alpha < 128)
|
|
81
|
+
let hasTransparency = false;
|
|
82
|
+
for (let i = 3; i < data.length; i += 4) {
|
|
83
|
+
if (data[i] < 128) {
|
|
84
|
+
hasTransparency = true;
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
67
88
|
const colorMap = new Map();
|
|
68
89
|
const colors = [];
|
|
69
|
-
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
90
|
+
const transparentIndex = 0;
|
|
91
|
+
// Reserve index 0 for transparency if needed
|
|
92
|
+
if (hasTransparency) {
|
|
93
|
+
colors.push({ r: 0, g: 0, b: 0 });
|
|
94
|
+
}
|
|
95
|
+
// Color quantization parameters
|
|
96
|
+
const RG_LEVELS = 7;
|
|
97
|
+
const B_LEVELS = 3;
|
|
98
|
+
const rgStep = 255 / RG_LEVELS;
|
|
99
|
+
const bStep = 255 / B_LEVELS;
|
|
100
|
+
const maxColors = hasTransparency ? 255 : 256;
|
|
101
|
+
// Collect unique colors from opaque pixels only
|
|
76
102
|
for (let i = 0; i < data.length; i += 4) {
|
|
103
|
+
if (data[i + 3] < 128)
|
|
104
|
+
continue; // Skip transparent
|
|
77
105
|
const r = data[i];
|
|
78
106
|
const g = data[i + 1];
|
|
79
107
|
const b = data[i + 2];
|
|
80
108
|
const key = `${r},${g},${b}`;
|
|
81
|
-
if (!colorMap.has(key) && colors.length <
|
|
109
|
+
if (!colorMap.has(key) && colors.length < maxColors) {
|
|
82
110
|
colorMap.set(key, colors.length);
|
|
83
111
|
colors.push({ r, g, b });
|
|
84
112
|
}
|
|
85
113
|
}
|
|
86
|
-
// Track if color reduction was applied
|
|
87
114
|
let useColorReduction = false;
|
|
88
|
-
// If
|
|
89
|
-
if (colors.length >=
|
|
90
|
-
// Downsample colors to 256 by reducing color depth
|
|
115
|
+
// If too many colors, reduce color depth
|
|
116
|
+
if (colors.length >= maxColors) {
|
|
91
117
|
colorMap.clear();
|
|
92
|
-
colors.length = 0;
|
|
118
|
+
colors.length = hasTransparency ? 1 : 0;
|
|
93
119
|
useColorReduction = true;
|
|
94
120
|
for (let i = 0; i < data.length; i += 4) {
|
|
95
|
-
|
|
96
|
-
|
|
121
|
+
if (data[i + 3] < 128)
|
|
122
|
+
continue;
|
|
97
123
|
const r = this.quantizeChannel(data[i], RG_LEVELS, rgStep);
|
|
98
124
|
const g = this.quantizeChannel(data[i + 1], RG_LEVELS, rgStep);
|
|
99
125
|
const b = this.quantizeChannel(data[i + 2], B_LEVELS, bStep);
|
|
100
126
|
const key = `${r},${g},${b}`;
|
|
101
|
-
if (!colorMap.has(key)) {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
colors.push({ r, g, b });
|
|
105
|
-
}
|
|
127
|
+
if (!colorMap.has(key) && colors.length < maxColors) {
|
|
128
|
+
colorMap.set(key, colors.length);
|
|
129
|
+
colors.push({ r, g, b });
|
|
106
130
|
}
|
|
107
131
|
}
|
|
108
132
|
}
|
|
@@ -118,28 +142,31 @@ class GIFEncoder {
|
|
|
118
142
|
palette[i * 3 + 1] = colors[i].g;
|
|
119
143
|
palette[i * 3 + 2] = colors[i].b;
|
|
120
144
|
}
|
|
121
|
-
// Create indexed data
|
|
122
|
-
const indexed = new Uint8Array(
|
|
123
|
-
for (let i = 0, j = 0; i < data.length; i += 4, j++) {
|
|
145
|
+
// Create indexed data using frame dimensions
|
|
146
|
+
const indexed = new Uint8Array(frameWidth * frameHeight);
|
|
147
|
+
for (let i = 0, j = 0; i < data.length && j < indexed.length; i += 4, j++) {
|
|
148
|
+
// Handle transparent pixels
|
|
149
|
+
if (data[i + 3] < 128) {
|
|
150
|
+
indexed[j] = transparentIndex;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
124
153
|
let r = data[i];
|
|
125
154
|
let g = data[i + 1];
|
|
126
155
|
let b = data[i + 2];
|
|
127
|
-
// Apply color reduction if it was used for building the palette
|
|
128
156
|
if (useColorReduction) {
|
|
129
157
|
r = this.quantizeChannel(r, RG_LEVELS, rgStep);
|
|
130
158
|
g = this.quantizeChannel(g, RG_LEVELS, rgStep);
|
|
131
159
|
b = this.quantizeChannel(b, B_LEVELS, bStep);
|
|
132
160
|
}
|
|
133
161
|
const key = `${r},${g},${b}`;
|
|
134
|
-
// Try fast O(1) lookup first
|
|
135
162
|
if (colorMap.has(key)) {
|
|
136
163
|
indexed[j] = colorMap.get(key);
|
|
137
164
|
}
|
|
138
165
|
else {
|
|
139
|
-
//
|
|
166
|
+
// Find closest color
|
|
140
167
|
let minDist = Infinity;
|
|
141
|
-
let bestIdx = 0;
|
|
142
|
-
for (let k = 0; k < colors.length; k++) {
|
|
168
|
+
let bestIdx = hasTransparency ? 1 : 0;
|
|
169
|
+
for (let k = hasTransparency ? 1 : 0; k < colors.length; k++) {
|
|
143
170
|
const dr = r - colors[k].r;
|
|
144
171
|
const dg = g - colors[k].g;
|
|
145
172
|
const db = b - colors[k].b;
|
|
@@ -152,7 +179,7 @@ class GIFEncoder {
|
|
|
152
179
|
indexed[j] = bestIdx;
|
|
153
180
|
}
|
|
154
181
|
}
|
|
155
|
-
return { palette, indexed };
|
|
182
|
+
return { palette, indexed, hasTransparency, transparentIndex };
|
|
156
183
|
}
|
|
157
184
|
nextPowerOf2(n) {
|
|
158
185
|
let power = 1;
|
|
@@ -173,46 +200,34 @@ class GIFEncoder {
|
|
|
173
200
|
throw new Error("No frames to encode");
|
|
174
201
|
}
|
|
175
202
|
const output = [];
|
|
176
|
-
// Get loop count from options (default to 0 = infinite)
|
|
177
203
|
const loopCount = options?.loop ?? 0;
|
|
178
204
|
// Quantize first frame for Global Color Table
|
|
179
205
|
const firstFrame = this.frames[0];
|
|
180
|
-
const
|
|
206
|
+
const firstResult = this.quantize(firstFrame.data, firstFrame.width, firstFrame.height);
|
|
207
|
+
const globalPalette = firstResult.palette;
|
|
181
208
|
const paletteSize = globalPalette.length / 3;
|
|
182
209
|
const bitsPerColor = this.getBitsPerColor(paletteSize);
|
|
183
210
|
// Header
|
|
184
211
|
this.writeString(output, "GIF89a");
|
|
185
212
|
// Logical Screen Descriptor
|
|
186
|
-
this.writeUint16LE(output, this.
|
|
187
|
-
this.writeUint16LE(output, this.
|
|
188
|
-
// Packed field:
|
|
189
|
-
// - Global Color Table Flag (1 bit): 1
|
|
190
|
-
// - Color Resolution (3 bits): bitsPerColor - 1
|
|
191
|
-
// - Sort Flag (1 bit): 0
|
|
192
|
-
// - Size of Global Color Table (3 bits): bitsPerColor - 1
|
|
213
|
+
this.writeUint16LE(output, this.canvasWidth);
|
|
214
|
+
this.writeUint16LE(output, this.canvasHeight);
|
|
193
215
|
const packed = 0x80 | ((bitsPerColor - 1) << 4) | (bitsPerColor - 1);
|
|
194
216
|
output.push(packed);
|
|
195
|
-
// Background Color Index
|
|
196
|
-
output.push(0);
|
|
197
|
-
// Pixel Aspect Ratio
|
|
198
|
-
output.push(0);
|
|
217
|
+
output.push(0); // Background Color Index
|
|
218
|
+
output.push(0); // Pixel Aspect Ratio
|
|
199
219
|
// Global Color Table
|
|
200
|
-
// The GCT size is 2^(n+1) where n is the value in the packed field
|
|
201
|
-
// So we need to write that many colors, padding if necessary
|
|
202
220
|
const gctSize = 1 << bitsPerColor;
|
|
203
221
|
const paddedPalette = new Uint8Array(gctSize * 3);
|
|
204
222
|
paddedPalette.set(globalPalette);
|
|
205
223
|
this.writeBytes(output, paddedPalette);
|
|
206
224
|
// Netscape Application Extension (Looping)
|
|
207
225
|
if (this.frames.length > 1) {
|
|
208
|
-
output.push(0x21);
|
|
209
|
-
output.push(0xff); // Application Extension Label
|
|
210
|
-
output.push(11); // Block Size
|
|
226
|
+
output.push(0x21, 0xff, 11);
|
|
211
227
|
this.writeString(output, "NETSCAPE2.0");
|
|
212
|
-
output.push(3);
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
output.push(0); // Block Terminator
|
|
228
|
+
output.push(3, 1);
|
|
229
|
+
this.writeUint16LE(output, loopCount);
|
|
230
|
+
output.push(0);
|
|
216
231
|
}
|
|
217
232
|
// Encode frames
|
|
218
233
|
for (let i = 0; i < this.frames.length; i++) {
|
|
@@ -221,67 +236,56 @@ class GIFEncoder {
|
|
|
221
236
|
let useLocalPalette = false;
|
|
222
237
|
let localPalette = null;
|
|
223
238
|
let localBitsPerColor = bitsPerColor;
|
|
239
|
+
let hasTransparency;
|
|
240
|
+
let transparentIndex;
|
|
224
241
|
if (i === 0) {
|
|
225
|
-
indexed =
|
|
242
|
+
indexed = firstResult.indexed;
|
|
243
|
+
hasTransparency = firstResult.hasTransparency;
|
|
244
|
+
transparentIndex = firstResult.transparentIndex;
|
|
226
245
|
}
|
|
227
246
|
else {
|
|
228
|
-
|
|
229
|
-
// For simplicity, we use a Local Color Table for each frame to ensure colors are correct
|
|
230
|
-
const result = this.quantize(frame.data);
|
|
247
|
+
const result = this.quantize(frame.data, frame.width, frame.height);
|
|
231
248
|
indexed = result.indexed;
|
|
232
249
|
localPalette = result.palette;
|
|
233
250
|
useLocalPalette = true;
|
|
234
|
-
|
|
235
|
-
|
|
251
|
+
hasTransparency = result.hasTransparency;
|
|
252
|
+
transparentIndex = result.transparentIndex;
|
|
253
|
+
localBitsPerColor = this.getBitsPerColor(localPalette.length / 3);
|
|
236
254
|
}
|
|
237
255
|
// Graphic Control Extension
|
|
238
|
-
output.push(0x21);
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
//
|
|
244
|
-
// User Input Flag (1 bit): 0
|
|
245
|
-
// Transparent Color Flag (1 bit): 0
|
|
246
|
-
output.push(0x08); // Disposal method 2 (Restore to background)
|
|
247
|
-
// Delay Time (1/100ths of a second)
|
|
248
|
-
// Default to 10 (100ms) if not specified
|
|
256
|
+
output.push(0x21, 0xf9, 4);
|
|
257
|
+
// Packed: disposal method + transparency flag
|
|
258
|
+
const disposal = frame.disposal & 0x07;
|
|
259
|
+
const gcePacked = (disposal << 2) | (hasTransparency ? 1 : 0);
|
|
260
|
+
output.push(gcePacked);
|
|
261
|
+
// Delay time
|
|
249
262
|
const delay = frame.delay > 0 ? Math.round(frame.delay / 10) : 10;
|
|
250
263
|
this.writeUint16LE(output, delay);
|
|
251
|
-
// Transparent
|
|
252
|
-
output.push(0);
|
|
264
|
+
// Transparent color index
|
|
265
|
+
output.push(hasTransparency ? transparentIndex : 0);
|
|
253
266
|
output.push(0); // Block Terminator
|
|
254
267
|
// Image Descriptor
|
|
255
|
-
output.push(0x2c);
|
|
256
|
-
this.writeUint16LE(output,
|
|
257
|
-
this.writeUint16LE(output,
|
|
258
|
-
this.writeUint16LE(output,
|
|
259
|
-
this.writeUint16LE(output,
|
|
260
|
-
// Packed Field
|
|
268
|
+
output.push(0x2c);
|
|
269
|
+
this.writeUint16LE(output, frame.left);
|
|
270
|
+
this.writeUint16LE(output, frame.top);
|
|
271
|
+
this.writeUint16LE(output, frame.width);
|
|
272
|
+
this.writeUint16LE(output, frame.height);
|
|
261
273
|
if (useLocalPalette && localPalette) {
|
|
262
|
-
// LCT Flag: 1
|
|
263
|
-
// Interlace: 0
|
|
264
|
-
// Sort: 0
|
|
265
|
-
// Reserved: 0
|
|
266
|
-
// Size of LCT: localBitsPerColor - 1
|
|
267
274
|
const lctPacked = 0x80 | (localBitsPerColor - 1);
|
|
268
275
|
output.push(lctPacked);
|
|
269
|
-
// Write Local Color Table
|
|
270
276
|
const lctSize = 1 << localBitsPerColor;
|
|
271
277
|
const paddedLct = new Uint8Array(lctSize * 3);
|
|
272
278
|
paddedLct.set(localPalette);
|
|
273
279
|
this.writeBytes(output, paddedLct);
|
|
274
280
|
}
|
|
275
281
|
else {
|
|
276
|
-
output.push(0);
|
|
282
|
+
output.push(0);
|
|
277
283
|
}
|
|
278
|
-
// LZW
|
|
284
|
+
// LZW compression
|
|
279
285
|
const minCodeSize = Math.max(2, useLocalPalette ? localBitsPerColor : bitsPerColor);
|
|
280
286
|
output.push(minCodeSize);
|
|
281
|
-
// Compress image data with LZW
|
|
282
287
|
const encoder = new lzw_js_1.LZWEncoder(minCodeSize);
|
|
283
288
|
const compressed = encoder.compress(indexed);
|
|
284
|
-
// Write compressed data in sub-blocks (max 255 bytes per block)
|
|
285
289
|
for (let k = 0; k < compressed.length; k += 255) {
|
|
286
290
|
const blockSize = Math.min(255, compressed.length - k);
|
|
287
291
|
output.push(blockSize);
|
|
@@ -291,8 +295,7 @@ class GIFEncoder {
|
|
|
291
295
|
}
|
|
292
296
|
output.push(0); // Block Terminator
|
|
293
297
|
}
|
|
294
|
-
// Trailer
|
|
295
|
-
output.push(0x3b);
|
|
298
|
+
output.push(0x3b); // Trailer
|
|
296
299
|
return new Uint8Array(output);
|
|
297
300
|
}
|
|
298
301
|
}
|