Skip to content

Commit

Permalink
Added GIF-animation option (#20)
Browse files Browse the repository at this point in the history
  • Loading branch information
HerrZatacke authored Jan 27, 2025
1 parent 2764302 commit 60d05c4
Show file tree
Hide file tree
Showing 19 changed files with 588 additions and 195 deletions.
14 changes: 13 additions & 1 deletion frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,10 @@
<h1>Pico Game Boy Printer</h1>
<span class="indicator"></span>
<div class="buttons">
<button id="select_all_btn" disabled><span>Select All</span></button>
<button id="select_all_btn"><span>Select All</span></button>
<button id="delete_selected_btn" disabled><span>Delete</span></button>
<button id="average_selected_btn" disabled><span>Average</span></button>
<button id="gif_selected_btn" disabled><span>Animate</span></button>
<label class="select">
<select id="download_size">
<option value="1">Scale: 1x</option>
Expand All @@ -35,6 +36,17 @@ <h1>Pico Game Boy Printer</h1>
<option value="8">Scale: 8x</option>
</select>
</label>
<label class="select">
<select id="download_fps">
<option value="1">FPS: 1x</option>
<option value="2">FPS: 2x</option>
<option value="4">FPS: 4x</option>
<option value="8">FPS: 8x</option>
<option value="12">FPS: 12x</option>
<option value="18">FPS: 18x</option>
<option value="24">FPS: 24x</option>
</select>
</label>
</div>
</header>
<div id="gallery" class="gallery"></div>
Expand Down
28 changes: 28 additions & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"dev": "vite --host",
"build": "tsc && vite build"
},
"devDependencies": {
"@types/chunk": "^0.0.2",
"@types/omggif": "^1.0.5",
"chunk": "^0.0.3",
"ofetch": "^1.4.1",
"omggif": "^1.0.10",
"typescript": "~5.6.2",
"vite": "^6.0.5"
}
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ export const TILE_SIZE = 0x10;
export const TILE_HEIGHT = 8;
export const TILE_WIDTH = 8;

export const MAX_POLL_DELAY = 2000;
export const MAX_POLL_DELAY = 1500;
export const BASIC_POLL_DELAY = 10;

export const LOCALSTORAGE_SCALE_KEY = 'pico-printer-save-scale';
export const LOCALSTORAGE_FPS_KEY = 'pico-printer-save-fps';
25 changes: 25 additions & 0 deletions frontend/src/functions/canvas/getScaledGif.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { ofetch } from 'ofetch';
import { GifReader } from 'omggif';
import { LOCALSTORAGE_SCALE_KEY } from '../../consts.ts';
import { imageDatasToBlob } from './imageDatasToBlob.ts';
import { scaleImageData } from './scaleImageData.ts';

export const getScaledGif = async (url: string): Promise<Blob> => {
const scale = parseInt(localStorage.getItem(LOCALSTORAGE_SCALE_KEY) || '1', 10);

const gifBlob = await ofetch<Blob>(url);
const gifBuffer = await gifBlob.arrayBuffer();
const intArray = new Uint8Array(gifBuffer);
const reader = new GifReader(intArray as Buffer);

const info = reader.frameInfo(0);
const fps = Math.round(100 / info.delay);

const frames: ImageData[] = new Array(reader.numFrames()).fill(0).map((_, k) => {
const image = new ImageData(info.width, info.height);
reader.decodeAndBlitFrameRGBA(k, image.data as any);
return scaleImageData(scale, image);
});

return imageDatasToBlob(frames, fps);
}
29 changes: 29 additions & 0 deletions frontend/src/functions/canvas/getScaledcanvas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
export const getScaledCanvas = (
imageSource: HTMLImageElement | HTMLCanvasElement,
scaleFactor: number
): HTMLCanvasElement => {
// Create a new canvas for the scaled output
const scaledCanvas = document.createElement("canvas");
const scaledWidth = imageSource.width * scaleFactor;
const scaledHeight = imageSource.height * scaleFactor;

scaledCanvas.width = scaledWidth;
scaledCanvas.height = scaledHeight;

const scaledContext = scaledCanvas.getContext("2d");
if (!scaledContext) {
throw new Error("Failed to get 2D context from scaled canvas.");
}

// Disable image smoothing for nearest-neighbor scaling
scaledContext.imageSmoothingEnabled = false;

// Scale the source canvas and draw to the new canvas
scaledContext.drawImage(
imageSource,
0, 0, imageSource.width, imageSource.height,
0, 0, scaledWidth, scaledHeight
);

return scaledCanvas;
}
45 changes: 45 additions & 0 deletions frontend/src/functions/canvas/imageDatasToBlob.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import chunk from 'chunk';
import { GifWriter } from 'omggif';

export interface GifFrameData {
palette: number[],
pixels: number[],
}

export const imageDatasToBlob = (frames: ImageData[], fps: number): Blob => {
const buf: number[] = [];
const gifWriter: GifWriter = new GifWriter(buf, frames[0].width, frames[0].height, { loop: 0xffff });

for (const frame of frames) {
const {
palette,
pixels,
} = chunk(frame.data, 4).reduce((acc: GifFrameData, [r, g, b]): GifFrameData => {
// eslint-disable-next-line no-bitwise
const color: number = (r << 16) + (g << 8) + b;
let colorIndex: number = acc.palette.findIndex((c) => c === color);
if (colorIndex === -1) {
colorIndex = acc.palette.length;
acc.palette.push(color);
}

acc.pixels.push(colorIndex);

return acc;
}, { pixels: [], palette: [] });

gifWriter.addFrame(0, 0, frame.width, frame.height, pixels, {
delay: Math.round(100 / fps),
palette,
});
}

const bufferSize = gifWriter.end();

const file = new Blob(
[new Uint8Array(buf.slice(0, bufferSize)).buffer],
{ type: 'image/gif' },
);

return file;
}
13 changes: 13 additions & 0 deletions frontend/src/functions/canvas/scaleImageData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { getScaledCanvas } from './getScaledcanvas.ts';

export const scaleImageData = (scale: number, sourceImageData: ImageData): ImageData => {
const canvas = document.createElement('canvas');
canvas.width = sourceImageData.width;
canvas.height = sourceImageData.height;
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;

ctx.putImageData(sourceImageData, 0, 0);
const result = getScaledCanvas(canvas, scale);
const resultCtx = result.getContext('2d') as CanvasRenderingContext2D;
return resultCtx.getImageData(0, 0, result.width, result.height);
}
10 changes: 2 additions & 8 deletions frontend/src/functions/decoding/downloadDataToImageData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@ import {
COMMAND_TRANSFER,
PRINTER_WIDTH,
} from '../../consts.ts';
import type { DownloadData, DownloadDataRaw } from '../storage/database.ts';
import { DataType } from '../storage/database.ts';
import type { DownloadDataRaw } from '../storage/database.ts';

import { decode } from './decode.ts';
import { render } from './render.ts';
Expand All @@ -18,12 +17,7 @@ const resetCanvas = (canvas: HTMLCanvasElement) => {
}


export const downloadDataToImageData = async (downloadData: DownloadData): Promise<ImageData[]> => {

if (downloadData.type === DataType.IMAGE_DATA) {
return [downloadData.data as ImageData];
}

export const downloadDataToImageData = async (downloadData: DownloadDataRaw): Promise<ImageData[]> => {
const dlData = downloadData as DownloadDataRaw;

const canvas = document.createElement('canvas');
Expand Down
93 changes: 58 additions & 35 deletions frontend/src/functions/gallery/addImageDataToGallery.ts
Original file line number Diff line number Diff line change
@@ -1,52 +1,75 @@
import { updateButtons } from './buttons.ts';
import { downloadImage } from '../saveImage.ts';
import { updateSelectionOrder } from './selectionOrder.ts';

const gallery = document.getElementById("gallery") as HTMLDivElement;

export const addImageDataToGallery = (imageData: ImageData, timestamp: number): boolean => {
if (imageData.height * imageData.width > 1) {
const canvas = document.createElement('canvas') as HTMLCanvasElement;
canvas.width = imageData.width;
canvas.height = imageData.height;
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
ctx.putImageData(imageData, 0, 0)
const createGalleryItem = (imgSrc: string, timestamp: number, isFinal: boolean) => {
const imageContainer = document.createElement('div');
imageContainer.classList.add('gallery-image');
imageContainer.dataset.timestamp = timestamp.toString(10);

const imageContainer = document.createElement("label");
imageContainer.classList.add("gallery-image");
if (isFinal) {
imageContainer.classList.add('final');
}

const img = new Image();
img.src = canvas.toDataURL();
imageContainer.appendChild(img);
const img = new Image();
img.src = imgSrc;

imageContainer.dataset.timestamp = timestamp.toString(10);
const label = document.createElement('label');
label.appendChild(img);

const input = document.createElement("input");
input.setAttribute("type", "checkbox");
imageContainer.appendChild(label);

input.addEventListener("change", function() {
if (input.checked) {
imageContainer.classList.add('marked-for-action');
} else {
imageContainer.classList.remove('marked-for-action');
}
const input = document.createElement('input');
input.setAttribute("type", "checkbox");

updateButtons();
});
input.addEventListener("change", function() {
if (input.checked) {
imageContainer.classList.add('marked-for-action');
} else {
imageContainer.classList.remove('marked-for-action');
}

imageContainer.appendChild(input);
updateButtons();
updateSelectionOrder(imageContainer);
});

const btn = document.createElement("button");
btn.innerHTML = "<span>Save</span>";
btn.addEventListener("click", function () {
downloadImage(img);
});
imageContainer.appendChild(btn);
label.appendChild(input);

gallery.appendChild(imageContainer);
updateButtons();
const btn = document.createElement("button");
btn.innerHTML = "<span>Save</span>";
btn.addEventListener("click", function () {
downloadImage(img, isFinal);
});
imageContainer.appendChild(btn);

return true;
}
gallery.appendChild(imageContainer);
updateButtons();
}

export const addImageDataToGallery = async (imageData: ImageData, timestamp: number): Promise<void> => (
new Promise((resolve) => {

if (imageData.height * imageData.width > 1) {
const canvas = document.createElement('canvas') as HTMLCanvasElement;
canvas.width = imageData.width;
canvas.height = imageData.height;
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
ctx.putImageData(imageData, 0, 0)
canvas.toBlob((blob) => {
if (blob) {
const url = URL.createObjectURL(blob);
createGalleryItem(url, timestamp, false);
}

resolve();
});
}
})
);

return false;
export const addBlobToGallery = async (blob: Blob, timestamp: number): Promise<void> => {
const url = URL.createObjectURL(blob);
createGalleryItem(url, timestamp, true);
}
Loading

0 comments on commit 60d05c4

Please sign in to comment.