[in progress] a color utility i'm making for my pixel art painting process www.val.town/x/jennschiffer/pixelstats
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

init

+449
+1
.gitignore
··· 1 + .DS_Store
+25
README.md
··· 1 + # pixelstats 2 + 3 + upload an image less than 80px wide/high and it will give some info about its 4 + size, along with a table distribution of colors and their counts in the image. 5 + this is to help me plan the colors i need and potential edits to the references 6 + i use in a pixel art painting. xoxo [j$](https://jennschiffer.com) 7 + 8 + ## dev notes 9 + 10 + i am literally only posting this to sync across computers lol, there is no reason why anyone should want to run this development deno server-backed application so please don't take this as a production-ready environment thanks luv u, that being said if you need to run it locally then........,,,,,, 11 + 12 + ``` 13 + deno task dev 14 + ``` 15 + 16 + 17 + ``` 18 + .+------+ +------+ +------+ +------+ +------+. 19 + .' | .'| /| /| | | |\ |\ |`. | `. 20 + +---+--+' | +-+----+ | +------+ | +----+-+ | `+--+---+ 21 + | | | | | | | | | | | | | | | | | | 22 + | ,+--+---+ | +----+-+ +------+ +-+----+ | +---+--+ | 23 + |.' | .' |/ |/ | | \| \| `. | `. | 24 + +------+' +------+ +------+ +------+ `+------+ 25 + ```
+10
deno.json
··· 1 + { 2 + "tasks": { 3 + "dev": "deno run --allow-net --allow-read --watch main.ts" 4 + }, 5 + "imports": { 6 + "@oak/oak": "jsr:@oak/oak@^17.2.0", 7 + "@std/path": "jsr:@std/path@^1.1.4", 8 + "@unpic/pixels": "jsr:@unpic/pixels@^1.2.3" 9 + } 10 + }
+89
deno.lock
··· 1 + { 2 + "version": "5", 3 + "specifiers": { 4 + "jsr:@oak/commons@1": "1.0.1", 5 + "jsr:@oak/oak@^17.2.0": "17.2.0", 6 + "jsr:@std/assert@1": "1.0.19", 7 + "jsr:@std/bytes@1": "1.0.6", 8 + "jsr:@std/crypto@1": "1.0.5", 9 + "jsr:@std/encoding@1": "1.0.10", 10 + "jsr:@std/encoding@^1.0.10": "1.0.10", 11 + "jsr:@std/http@1": "1.0.25", 12 + "jsr:@std/internal@^1.0.12": "1.0.12", 13 + "jsr:@std/media-types@1": "1.1.0", 14 + "jsr:@std/path@1": "1.1.4", 15 + "jsr:@std/path@^1.1.4": "1.1.4", 16 + "jsr:@unpic/pixels@^1.2.3": "1.2.3", 17 + "npm:path-to-regexp@^6.3.0": "6.3.0" 18 + }, 19 + "jsr": { 20 + "@oak/commons@1.0.1": { 21 + "integrity": "889ff210f0b4292591721be07244ecb1b5c118742f5273c70cf30d7cd4184d0c", 22 + "dependencies": [ 23 + "jsr:@std/assert", 24 + "jsr:@std/bytes", 25 + "jsr:@std/crypto", 26 + "jsr:@std/encoding@1", 27 + "jsr:@std/http", 28 + "jsr:@std/media-types" 29 + ] 30 + }, 31 + "@oak/oak@17.2.0": { 32 + "integrity": "938537a92fc7922a46a9984696c65fb189c9baad164416ac3e336768a9ff0cd1", 33 + "dependencies": [ 34 + "jsr:@oak/commons", 35 + "jsr:@std/assert", 36 + "jsr:@std/bytes", 37 + "jsr:@std/http", 38 + "jsr:@std/media-types", 39 + "jsr:@std/path@1", 40 + "npm:path-to-regexp" 41 + ] 42 + }, 43 + "@std/assert@1.0.19": { 44 + "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e" 45 + }, 46 + "@std/bytes@1.0.6": { 47 + "integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a" 48 + }, 49 + "@std/crypto@1.0.5": { 50 + "integrity": "0dcfbb319fe0bba1bd3af904ceb4f948cde1b92979ec1614528380ed308a3b40" 51 + }, 52 + "@std/encoding@1.0.10": { 53 + "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1" 54 + }, 55 + "@std/http@1.0.25": { 56 + "integrity": "577b4252290af1097132812b339fffdd55fb0f4aeb98ff11bdbf67998aa17193", 57 + "dependencies": [ 58 + "jsr:@std/encoding@^1.0.10" 59 + ] 60 + }, 61 + "@std/internal@1.0.12": { 62 + "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" 63 + }, 64 + "@std/media-types@1.1.0": { 65 + "integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4" 66 + }, 67 + "@std/path@1.1.4": { 68 + "integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5", 69 + "dependencies": [ 70 + "jsr:@std/internal" 71 + ] 72 + }, 73 + "@unpic/pixels@1.2.3": { 74 + "integrity": "7282e7fe77b6efaf345382f1443ca08f6494103c258ded90f7aade1f221df63c" 75 + } 76 + }, 77 + "npm": { 78 + "path-to-regexp@6.3.0": { 79 + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==" 80 + } 81 + }, 82 + "workspace": { 83 + "dependencies": [ 84 + "jsr:@oak/oak@^17.2.0", 85 + "jsr:@std/path@^1.1.4", 86 + "jsr:@unpic/pixels@^1.2.3" 87 + ] 88 + } 89 + }
+118
main.ts
··· 1 + import * as path from "@std/path"; 2 + import { Application, Router } from "@oak/oak"; 3 + import { getPixels } from "@unpic/pixels"; 4 + 5 + const app = new Application(); 6 + 7 + /** FILES **/ 8 + const moduleDir = path.dirname(path.fromFileUrl(import.meta.url)); 9 + const publicDir = path.join(moduleDir, "public"); 10 + 11 + function getPublicFile(...filePath: string[]): Promise < Uint8Array > { 12 + return Deno.readFile(path.join(publicDir, ...filePath)); 13 + } 14 + 15 + /** ROUTES **/ 16 + const router = new Router(); 17 + 18 + // index 19 + router.get("/", async (ctx, next) => { 20 + ctx.response.body = await getPublicFile("index.html"); 21 + ctx.response.type = "text/html"; 22 + await next(); 23 + }); 24 + 25 + // process 26 + router.post("/process-pixels", async (ctx, next) => { 27 + const file = (await ctx.request.body.formData()).get("image"); 28 + 29 + if (file instanceof File) { 30 + 31 + try { 32 + // create image from file uploaded 33 + const uploadedFileBuffer = await file.arrayBuffer(); 34 + const { width, height, data } = await getPixels(uploadedFileBuffer); 35 + const colors = getPixelColors(data); 36 + 37 + const processed = JSON.stringify({ 38 + message: "success: pixels processed", 39 + fileName: file.name, 40 + fileSize: file.size, 41 + fileType: file.type, 42 + width, 43 + height, 44 + data, 45 + colors, 46 + }); 47 + 48 + ctx.response.status = 200; 49 + ctx.response.body = { processed } 50 + 51 + } catch (err) { 52 + ctx.response.status = 400; 53 + ctx.response.body = { message: 'Error: Failed to create getPixels' } 54 + } 55 + } else { 56 + ctx.response.status = 400; 57 + ctx.response.body = { message: 'Error: File not uploaded' } 58 + } 59 + await next(); 60 + }) 61 + 62 + // routes & static files 63 + app.use(router.routes()); 64 + app.use(router.allowedMethods()); 65 + app.use(async (context, next) => { 66 + const root = `${Deno.cwd()}/public` 67 + try { 68 + await context.send({ root }) 69 + } catch { 70 + next() 71 + } 72 + }) 73 + 74 + 75 + /** CONSOLE/LISTEN **/ 76 + function printStartupMessage({ hostname, port, secure }: { 77 + hostname: string; 78 + port: number; 79 + secure ? : boolean; 80 + }): void { 81 + const address = new URL( 82 + `http${secure ? "s" : ""}://${ 83 + hostname === "0.0.0.0" ? "localhost" : hostname 84 + }:${port}/`, 85 + ).href; 86 + console.log(`Listening at ${address}`); 87 + } 88 + app.addEventListener("listen", printStartupMessage); 89 + await app.listen({ port: 8000 }); 90 + 91 + 92 + /** HELPER FUNCTIONS */ 93 + // generate pixel colors json from image buffer 94 + function getPixelColors(data: Uint8Array): object { 95 + let i = 0; 96 + const pixelsArray = []; 97 + 98 + while (i < data.length) { 99 + pixelsArray.push([data[i], data[i + 1], data[i + 2], data[i + 3]].join()); 100 + i = i + 4; 101 + } 102 + 103 + // eg. 2400 colors for a 60 x 40 image 104 + const colorsDistribution = {}; 105 + for (const pixel of pixelsArray) { 106 + if (colorsDistribution[pixel]) { 107 + colorsDistribution[pixel] += 1; 108 + } else { 109 + colorsDistribution[pixel] = 1; 110 + } 111 + } 112 + 113 + const colorsDistributionSorted = Object.fromEntries( 114 + Object.entries(colorsDistribution).sort(([, a], [, b]) => b - a), 115 + ); 116 + 117 + return { pixelsArray, colorsDistribution, colorsDistributionSorted }; 118 + }
+34
public/index.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + 4 + <head> 5 + <meta charset="UTF-8"> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 7 + <title>pixelstats</title> 8 + <link rel="icon" href="https://jennschiffer.com/_public/img/favicon.png"> 9 + <link rel="stylesheet" href="/style.css"> 10 + <script src="/script.js" defer></script> 11 + </head> 12 + 13 + <body> 14 + <h1>pixelstats</h1> 15 + 16 + <p>a utility for for tiny <span id="size-text"></span> pixel art images</p> 17 + 18 + <form> 19 + <label id="dropload"> 20 + drop images here or click to upload 21 + <input type="file" id="file-input" multiple accept="image/*" /> 22 + </label> 23 + 24 + <ul id="preview"></ul> 25 + 26 + <div id="stats"></div> 27 + 28 + <button id="clear">clear</button> 29 + </form> 30 + 31 + <footer>made by <a href="https://jennschiffer.com">j$</a></footer> 32 + </body> 33 + 34 + </html>
+124
public/script.js
··· 1 + const MAX_PIXELS = 80; 2 + const sizeText = document.getElementById("size-text"); 3 + sizeText.innerHTML = "(< " + MAX_PIXELS + "px)"; 4 + const dropload = document.getElementById("dropload"); 5 + 6 + // drop event 7 + dropload.addEventListener("drop", dropHandler); 8 + window.addEventListener("drop", (e) => { 9 + if ([...e.dataTransfer.items].some((item) => item.kind === "file")) { 10 + e.preventDefault(); 11 + } 12 + }); 13 + 14 + // dragover event 15 + dropload.addEventListener("dragover", (e) => { 16 + const fileItems = [...e.dataTransfer.items].filter( 17 + (item) => item.kind === "file", 18 + ); 19 + if (fileItems.length > 0) { 20 + e.preventDefault(); 21 + if (fileItems.some((item) => item.type.startsWith("image/"))) { 22 + e.dataTransfer.dropEffect = "copy"; 23 + } else { 24 + e.dataTransfer.dropEffect = "none"; 25 + } 26 + } 27 + }); 28 + window.addEventListener("dragover", (e) => { 29 + const fileItems = [...e.dataTransfer.items].filter( 30 + (item) => item.kind === "file", 31 + ); 32 + if (fileItems.length > 0) { 33 + e.preventDefault(); 34 + if (!dropload.contains(e.target)) { 35 + e.dataTransfer.dropEffect = "none"; 36 + } 37 + } 38 + }); 39 + 40 + const preview = document.getElementById("preview"); 41 + 42 + function displayImages(files) { 43 + for (const file of files) { 44 + if (file.type.startsWith("image/")) { 45 + const li = document.createElement("li"); 46 + const img = document.createElement("img"); 47 + img.src = URL.createObjectURL(file); 48 + img.onload = function() { 49 + // don't allow images bigger than max 50 + if (this.width <= MAX_PIXELS && this.height <= MAX_PIXELS) { 51 + img.alt = file.name; 52 + li.classList.add('clickable'); 53 + li.appendChild(img); 54 + li.appendChild(document.createTextNode(" click to get pixelstats")); 55 + li.addEventListener("click", (e) => { 56 + getPixelStats(e, file) 57 + }); 58 + } else { 59 + li.appendChild(document.createTextNode('file \"' + file.name + '\" is bigger than ' + MAX_PIXELS + 'px')); 60 + } 61 + preview.appendChild(li); 62 + } 63 + } 64 + } 65 + } 66 + 67 + function dropHandler(e) { 68 + e.preventDefault(); 69 + const files = [...e.dataTransfer.items] 70 + .map((item) => item.getAsFile()) 71 + .filter((file) => file); 72 + displayImages(files); 73 + } 74 + 75 + const fileInput = document.getElementById("file-input"); 76 + fileInput.addEventListener("change", (e) => { 77 + displayImages(e.target.files); 78 + }); 79 + 80 + // clear button 81 + const clearButton = document.getElementById("clear"); 82 + clearButton.addEventListener("click", () => { 83 + for (const img of preview.querySelectorAll("img")) { 84 + URL.revokeObjectURL(img.src); 85 + } 86 + preview.textContent = ""; 87 + }); 88 + 89 + // show pixel stats 90 + const stats = document.getElementById("stats"); 91 + 92 + function getPixelStats(e, file) { 93 + stats.innerHTML = ""; 94 + const formData = new FormData(); 95 + formData.append('image', file); 96 + const element = e.target; 97 + 98 + fetch('/process-pixels', { 99 + method: 'POST', 100 + body: formData 101 + }).then( 102 + response => response.json() 103 + ).then( 104 + success => { 105 + const data = JSON.parse(success.processed); 106 + console.log(data); 107 + let colorTable = '<table>'; 108 + for (const [color, count] of Object.entries(data.colors.colorsDistributionSorted)) { 109 + colorTable += '<tr><td style="width:50px;background-color:rgba(' + color + ')" data-color=' + color + ' title="rgba(' + color + 110 + ')"> </td><td data-color=' + count + '>' + count + ' (' + (count / (data.width * data.height) * 100).toFixed(3) + '%)</tr>'; 111 + } 112 + colorTable += '</table>'; 113 + stats.innerHTML = '<h3>file stats</h3><table><tr>' + 114 + '<th>name</th><td>' + data.fileName + 115 + '</td></tr><tr><th>size</th><td>' + data.fileSize + 116 + '</td></tr><tr><th>type</th><td>' + data.fileType + 117 + '</td></tr><tr><th>width</th><td>' + data.width + 118 + '</td></tr><tr><th>height</th><td>' + data.height + 119 + '</td></tr></table><h3>pixel color distribution</h3>' + colorTable; 120 + } 121 + ).catch( 122 + error => console.log(error) 123 + ); 124 + };
+48
public/style.css
··· 1 + * { 2 + box-sizing: border-box; 3 + font-family: monospace; 4 + } 5 + 6 + body { 7 + max-width: 400px; 8 + margin: 1em; 9 + } 10 + 11 + label#dropload { 12 + display: block; 13 + width: 500px; 14 + max-width: 100%; 15 + padding: 2em; 16 + margin: 1em 0; 17 + border: 1px dotted black; 18 + cursor: pointer; 19 + } 20 + 21 + #dropload input { 22 + display: none; 23 + } 24 + 25 + #preview { 26 + margin: 1em 0; 27 + padding: 0; 28 + } 29 + 30 + #preview li { 31 + margin: 1em 0; 32 + list-style: none; 33 + } 34 + 35 + #preview li.clickable { 36 + background-color: rgba(0, 0, 255, .5); 37 + color: white; 38 + padding: .2em; 39 + } 40 + 41 + #clear { 42 + margin: 2em 0; 43 + } 44 + 45 + footer { 46 + margin: 1em 0; 47 + text-align: right; 48 + }