diff --git a/README.md b/README.md
index a995e22..d8d6f28 100644
--- a/README.md
+++ b/README.md
@@ -82,6 +82,6 @@ Webserver will be available at http://192.168.7.1/
Frontend code development requires node.js (>=20)
* Navigate to the `frontend` folder.
* run `npm install` to install all dependencies
-* run `npm run dev` to start a local dev server on [127.0.0.1:3000](http://127.0.0.1:3000/). The server also does proxy the `/list.json`, `/status.json` and `/download` endpoints from a Pico which must be connected to the same machine.
+* run `npm run dev` to start a local dev server on [127.0.0.1:3000](http://127.0.0.1:3000/). The server also does proxy the `/status.json` and `/download` endpoints from a Pico which must be connected to the same machine.
* run `npm run build` to build the static files (html/css/js). Files will be built to `./fs`
* When building the rom file locally, also run `./regen-fsdata.sh`
diff --git a/frontend/index.html b/frontend/index.html
index 0a8373c..7067084 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -28,6 +28,7 @@
Pico Game Boy Printer
Delete
Average
Animate
+ RGB
Scale: 1x
diff --git a/frontend/src/functions/canvas/imageDatasToBlob.ts b/frontend/src/functions/canvas/imageDatasToBlob.ts
index 8a5748c..a5d0411 100644
--- a/frontend/src/functions/canvas/imageDatasToBlob.ts
+++ b/frontend/src/functions/canvas/imageDatasToBlob.ts
@@ -10,6 +10,8 @@ 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 });
+ let frameCount = 0;
+
for (const frame of frames) {
const {
palette,
@@ -23,15 +25,33 @@ export const imageDatasToBlob = (frames: ImageData[], fps: number): Blob => {
acc.palette.push(color);
}
+ if (colorIndex > 256) {
+ colorIndex = 0;
+ }
+
acc.pixels.push(colorIndex);
return acc;
}, { pixels: [], palette: [] });
- gifWriter.addFrame(0, 0, frame.width, frame.height, pixels, {
- delay: Math.round(100 / fps),
- palette,
- });
+ const p = [
+ ...palette,
+ ...(new Array(256).fill(0)),
+ ].slice(0, 256);
+
+ if (palette.length <= 256) {
+ frameCount += 1;
+ gifWriter.addFrame(0, 0, frame.width, frame.height, pixels, {
+ delay: Math.round(100 / fps),
+ palette: p,
+ });
+ }
+ }
+
+ if (frameCount !== frames.length) {
+ const msg = 'Some frames in your image contain more than 256 colors, which makes creating a GIF impossible';
+ alert(msg);
+ throw new Error(msg);
}
const bufferSize = gifWriter.end();
diff --git a/frontend/src/functions/gallery/buttons.ts b/frontend/src/functions/gallery/buttons.ts
index e6b0d5a..6f66e4d 100644
--- a/frontend/src/functions/gallery/buttons.ts
+++ b/frontend/src/functions/gallery/buttons.ts
@@ -1,3 +1,4 @@
+import chunk from 'chunk';
import { LOCALSTORAGE_FPS_KEY, LOCALSTORAGE_SCALE_KEY } from '../../consts.ts';
import { imageDatasToBlob } from '../canvas/imageDatasToBlob.ts';
import { DataType, DbAccess } from '../storage/database.ts';
@@ -8,6 +9,7 @@ const deleteSelectedBtn = document.getElementById("delete_selected_btn") as HTML
const selectAllBtn = document.getElementById("select_all_btn") as HTMLButtonElement;
const averageSelectedBtn = document.getElementById("average_selected_btn") as HTMLButtonElement;
const gifSelectedBtn = document.getElementById("gif_selected_btn") as HTMLButtonElement;
+const rgbSelectedBtn = document.getElementById("rgb_selected_btn") as HTMLButtonElement;
const scaleSelect = document.getElementById("download_size") as HTMLSelectElement;
const fpsSelect = document.getElementById("download_fps") as HTMLSelectElement;
@@ -18,6 +20,7 @@ export const updateButtons = () => {
deleteSelectedBtn.disabled = numSelectedItems < 1;
averageSelectedBtn.disabled = numSelectedItems < 2 || numSelectedItemsFinal !== 0;
gifSelectedBtn.disabled = numSelectedItems < 2 || numSelectedItemsFinal !== 0;
+ rgbSelectedBtn.disabled = numSelectedItems !== 3 || numSelectedItemsFinal !== 0;
scaleSelect.value = localStorage.getItem(LOCALSTORAGE_SCALE_KEY) || '1';
fpsSelect.value = localStorage.getItem(LOCALSTORAGE_FPS_KEY) || '12';
@@ -191,6 +194,61 @@ export const initButtons = (store: DbAccess) => {
});
});
+ rgbSelectedBtn.addEventListener('click', async () => {
+ const items = [...gallery.querySelectorAll('.marked-for-action')] as HTMLDivElement[];
+
+ if (items.length !== 3) {
+ return;
+ }
+
+ items.sort(sortBySelectionOrder((gallery.children.length + 1).toString(10)));
+
+ const images = items.map((item) => item.querySelector('img')) as HTMLImageElement[];
+
+ const dimensions = getCommonSize(images);
+
+ if (!dimensions) {
+ alert("Image dimensions must be the same to create a RGB image");
+ return;
+ }
+
+ const [pixelsR, pixelsG, pixelsB]: number[][][] = images.map((imageSource): number[][] => {
+ const canvas = document.createElement('canvas');
+ const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
+ canvas.width = dimensions.width;
+ canvas.height = dimensions.height;
+ ctx.drawImage(imageSource, 0, 0);
+ const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
+ return chunk(imageData.data, 4);
+ });
+
+ const pixels = new Array(dimensions.width * dimensions.height)
+ .fill(null)
+ .map((_, pixelIndex) => {
+ const [rr, rg, rb] = pixelsR[pixelIndex];
+ const [gr, gg, gb] = pixelsG[pixelIndex];
+ const [br, bg, bb] = pixelsB[pixelIndex];
+
+ const r = Math.floor((rr + rg + rb) / 3);
+ const g = Math.floor((gr + gg + gb) / 3);
+ const b = Math.floor((br + bg + bb) / 3);
+
+ return [r, g, b, 255];
+ })
+ .flat(1);
+
+ const rgbImageData = new ImageData(new Uint8ClampedArray(pixels), dimensions.width, dimensions.height);
+
+ store.add({
+ type: DataType.IMAGE_DATA,
+ timestamp: Date.now(),
+ data: rgbImageData,
+ });
+
+ unselectAll();
+ });
+
+
scaleSelect.addEventListener('change', () => {
const scale = parseInt(scaleSelect.value || '0', 10) || 1;
localStorage.setItem(LOCALSTORAGE_SCALE_KEY, scale.toString(10));
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 28c6114..caf5a2a 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -1,10 +1,27 @@
+import * as fs from 'fs';
+import * as path from 'path';
import { defineConfig } from 'vite';
import { $fetch } from 'ofetch';
+const input = fs.readdirSync(__dirname)
+ .reduce((acc: string[], file) => {
+ const filePath = path.join(__dirname, file);
+ const stats = fs.statSync(filePath);
+
+ if (!stats.isDirectory() && path.extname(file) === ".html") {
+ return [...acc, path.resolve(filePath)];
+ }
+
+ return acc;
+ }, []);
+
export default defineConfig({
build: {
outDir: '../fs/',
emptyOutDir: true,
+ rollupOptions: {
+ input,
+ }
},
plugins: [
{