Add GIF exporter for images

Closes #2679
This commit is contained in:
JannisX11 2025-03-21 23:42:01 +01:00
parent 03ab1fc2d1
commit 8a46f2a2dc
3 changed files with 123 additions and 8 deletions

View File

@ -81,7 +81,7 @@ export class Codec extends EventSystem {
let opts_in_project = Project.export_options[codec.id];
for (let form_id in this.export_options) {
if (!Condition(this.export_options[form_id].condition)) continue;
// if (!Condition(this.export_options[form_id].condition)) continue;
form[form_id] = {};
for (let key in this.export_options[form_id]) {
form[form_id][key] = this.export_options[form_id][key];

View File

@ -92,22 +92,135 @@ let codec = new Codec('image', {
png: 'PNG',
jpeg: 'JPEG',
webp: 'WebP',
gif: 'GIF',
}},
quality: {type: 'range', label: 'codec.image.quality', value: 1, min: 0, max: 1, step: 0.05, editable_range_label: true, condition: (result) => result?.format != 'png'}
alpha_channel: {type: 'checkbox', label: 'codec.image.alpha_channel', condition: (result) => result?.format == 'gif', value: true},
animation_fps: {type: 'number', label: 'codec.image.animation_fps', condition: (result) => result?.format == 'gif', value: settings.texture_fps.value},
quality: {type: 'range', label: 'codec.image.quality', value: 1, min: 0, max: 1, step: 0.05, editable_range_label: true, condition: (result) => result && ['jpeg', 'webp'].includes(result.format)}
},
compile(options) {
async compile(options) {
options = Object.assign(this.getExportOptions(), options);
let texture = Texture.getDefault();
if (!texture) return;
let encoding = 'image/'+(options.format??'png');
let data_url = texture.canvas.toDataURL(encoding, options.quality);
return data_url;
if (options.format == 'gif') {
let gif = GIFEnc.GIFEncoder();
let has_transparency = options.alpha_channel ?? true;
let i = 0;
let format = 'rgb565';
let prio_color_accuracy = false;
function quantize(data) {
let palette = has_transparency ? [[0, 0, 0]] : [];
let counter = has_transparency ? [100] : [];
for (let i = 0; i < data.length; i += 4) {
if (data[i+3] < 127) {
continue;
}
let r = data[i];
let g = data[i+1];
let b = data[i+2];
let match = palette.findIndex((color, i) => color[0] == r && color[1] == g && color[2] == b && (i != 0 || !has_transparency));
if (match == -1) {
palette.push([r, g, b])
counter.push(1)
} else {
counter[match] += 1;
}
if (!prio_color_accuracy && palette.length > 256) break;
}
let threshold = 4;
while (palette.length > 256 && prio_color_accuracy) {
counter.forEachReverse((count, index) => {
if (index == 0) return;
if (count < threshold) {
palette.splice(index, 1);
counter.splice(index, 1);
}
});
threshold *= 1.5;
if (threshold > 50) break;
}
return palette;
}
function applyPalette(data, palette) {
let array = new Uint8Array(data.length / 4);
for (let i = 0; i < array.length; i++) {
if (data[i*4+3] < 127) {
continue;
}
let r = data[i*4];
let g = data[i*4+1];
let b = data[i*4+2];
let match = palette.findIndex((color, i) => color[0] == r && color[1] == g && color[2] == b && (i != 0 || !has_transparency));
if (match == -1 && prio_color_accuracy) {
let closest = palette.filter((color, i) => Math.epsilon(color[0], r, 6) && Math.epsilon(color[1], g, 6) && Math.epsilon(color[2], b, 6) && (i != 0 || !has_transparency));
if (!closest.length) {
closest = palette.filter((color, i) => Math.epsilon(color[0], r, 24) && Math.epsilon(color[1], g, 24) && Math.epsilon(color[2], b, 128) && (i != 0 || !has_transparency));
}
if (!closest.length) {
closest = palette.filter((color, i) => Math.epsilon(color[0], r, 24) && Math.epsilon(color[1], g, 24) && Math.epsilon(color[2], b, 128) && (i != 0 || !has_transparency));
}
if (!closest.length) {
closest = palette.filter((color, i) => Math.epsilon(color[0], r, 64) && Math.epsilon(color[1], g, 64) && Math.epsilon(color[2], b, 128) && (i != 0 || !has_transparency));
}
if (!closest.length) {
closest = palette.slice();
}
closest.sort((color_a, color_b) => {
let diff_a = Math.pow(color_a[0] + r, 2) + Math.pow(color_a[1] + g, 2) + Math.pow(color_a[2] + b, 2);
let diff_b = Math.pow(color_b[0] + r, 2) + Math.pow(color_b[1] + g, 2) + Math.pow(color_b[2] + b, 2);
return diff_a - diff_b;
})
if (closest[0]) {
match = palette.indexOf(closest[0]);
}
}
if (match != -1) array[i] = match;
}
return array;
}
let width = texture.width;
let height = texture.display_height;
let interval = 1000 / options.animation_fps;
let {ctx} = texture;
for (let frame = 0; frame < texture.frameCount; frame++) {
let data = ctx.getImageData(0, frame * height, width, height).data;
let palette = quantize(data, 256, {format, oneBitAlpha: true, clearAlphaThreshold: 127});
let index;
if (palette.length > 256) {
// Built-in methods
palette = GIFEnc.quantize(data, 256, {format, oneBitAlpha: true, clearAlphaThreshold: 127});
index = GIFEnc.applyPalette(data, palette, format);
} else {
// Direct flicker-free color mapping
index = applyPalette(data, palette, format);
}
gif.writeFrame(index, width, height, { palette, delay: interval, transparent: has_transparency });
i++;
await new Promise(resolve => setTimeout(resolve, 0));
}
gif.finish();
let buffer = gif.bytesView();
let blob = new Blob([buffer], {type: 'image/gif'});
var reader = new FileReader();
return await new Promise((resolve, reject) => {
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(blob);
})
} else {
let encoding = 'image/'+(options.format??'png');
let data_url = texture.canvas.toDataURL(encoding, options.quality);
return data_url;
}
},
async export() {
let options = await this.promptExportOptions();
if (options === null) return;
let content = this.compile();
let content = await this.compile();
Blockbench.export({
resource_id: 'image',
type: 'Image',

View File

@ -2332,6 +2332,8 @@
"codec.common.embed_textures": "Embed Textures",
"codec.common.format": "Format",
"codec.image.quality": "Quality",
"codec.image.alpha_channel": "Alpha Channel",
"codec.image.animation_fps": "Animation FPS",
"preview.center_camera": "Center Camera",