Compare commits
2 commits
60e664db52
...
8a870a1710
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a870a1710 | |||
| 9720b671e2 |
9 changed files with 254 additions and 0 deletions
|
|
@ -39,10 +39,12 @@
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@immich/sdk": "^2.5.6",
|
||||||
"@prisma/adapter-better-sqlite3": "^7.4.2",
|
"@prisma/adapter-better-sqlite3": "^7.4.2",
|
||||||
"@prisma/client": "^7.4.2",
|
"@prisma/client": "^7.4.2",
|
||||||
"@sveltejs/adapter-node": "^5.5.3",
|
"@sveltejs/adapter-node": "^5.5.3",
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@types/js-yaml": "^4.0.9",
|
||||||
|
"canvas-dither": "^1.0.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"feed": "^4.2.2",
|
"feed": "^4.2.2",
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@ importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@immich/sdk':
|
||||||
|
specifier: ^2.5.6
|
||||||
|
version: 2.5.6
|
||||||
'@prisma/adapter-better-sqlite3':
|
'@prisma/adapter-better-sqlite3':
|
||||||
specifier: ^7.4.2
|
specifier: ^7.4.2
|
||||||
version: 7.4.2
|
version: 7.4.2
|
||||||
|
|
@ -20,6 +23,9 @@ importers:
|
||||||
'@types/js-yaml':
|
'@types/js-yaml':
|
||||||
specifier: ^4.0.9
|
specifier: ^4.0.9
|
||||||
version: 4.0.9
|
version: 4.0.9
|
||||||
|
canvas-dither:
|
||||||
|
specifier: ^1.0.1
|
||||||
|
version: 1.0.1
|
||||||
date-fns:
|
date-fns:
|
||||||
specifier: ^4.1.0
|
specifier: ^4.1.0
|
||||||
version: 4.1.0
|
version: 4.1.0
|
||||||
|
|
@ -386,6 +392,9 @@ packages:
|
||||||
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
|
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
|
||||||
engines: {node: '>=18.18'}
|
engines: {node: '>=18.18'}
|
||||||
|
|
||||||
|
'@immich/sdk@2.5.6':
|
||||||
|
resolution: {integrity: sha512-u6o0rNBsTbAkl/6vz3Z63A/yNkLYeuKiHKoJgLuvRqTpPahgxAFcfDeN8rLd0Ya1kM5I2c610/VDcvItzzVcWg==}
|
||||||
|
|
||||||
'@jridgewell/gen-mapping@0.3.13':
|
'@jridgewell/gen-mapping@0.3.13':
|
||||||
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||||
|
|
||||||
|
|
@ -406,6 +415,9 @@ packages:
|
||||||
resolution: {integrity: sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==}
|
resolution: {integrity: sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
|
'@oazapfts/runtime@1.2.0':
|
||||||
|
resolution: {integrity: sha512-fi7dp7dNayyh/vzqhf0ZdoPfC7tJvYfjaE8MBL1yR+iIsH7cFoqHt+DV70VU49OMCqLc7wQa+yVJcSmIRnV4wA==}
|
||||||
|
|
||||||
'@parcel/watcher-android-arm64@2.5.6':
|
'@parcel/watcher-android-arm64@2.5.6':
|
||||||
resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==}
|
resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==}
|
||||||
engines: {node: '>= 10.0.0'}
|
engines: {node: '>= 10.0.0'}
|
||||||
|
|
@ -1001,6 +1013,9 @@ packages:
|
||||||
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
|
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
canvas-dither@1.0.1:
|
||||||
|
resolution: {integrity: sha512-wT1RnV2y9SauibcsfMMCagQTnXmyQq2nJXuX5tTEvo0sXm1vVbJ2r8QxSC3fRQMY3ZgZVq1j54cOvmO4u+Lu/A==}
|
||||||
|
|
||||||
ccount@2.0.1:
|
ccount@2.0.1:
|
||||||
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
|
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
|
||||||
|
|
||||||
|
|
@ -2539,6 +2554,10 @@ snapshots:
|
||||||
|
|
||||||
'@humanwhocodes/retry@0.4.3': {}
|
'@humanwhocodes/retry@0.4.3': {}
|
||||||
|
|
||||||
|
'@immich/sdk@2.5.6':
|
||||||
|
dependencies:
|
||||||
|
'@oazapfts/runtime': 1.2.0
|
||||||
|
|
||||||
'@jridgewell/gen-mapping@0.3.13':
|
'@jridgewell/gen-mapping@0.3.13':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
@ -2563,6 +2582,8 @@ snapshots:
|
||||||
chevrotain: 10.5.0
|
chevrotain: 10.5.0
|
||||||
lilconfig: 2.1.0
|
lilconfig: 2.1.0
|
||||||
|
|
||||||
|
'@oazapfts/runtime@1.2.0': {}
|
||||||
|
|
||||||
'@parcel/watcher-android-arm64@2.5.6':
|
'@parcel/watcher-android-arm64@2.5.6':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -3150,6 +3171,8 @@ snapshots:
|
||||||
|
|
||||||
callsites@3.1.0: {}
|
callsites@3.1.0: {}
|
||||||
|
|
||||||
|
canvas-dither@1.0.1: {}
|
||||||
|
|
||||||
ccount@2.0.1: {}
|
ccount@2.0.1: {}
|
||||||
|
|
||||||
chai@5.3.3:
|
chai@5.3.3:
|
||||||
|
|
|
||||||
29
src/routes/dither/+layout.svelte
Normal file
29
src/routes/dither/+layout.svelte
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<script>
|
||||||
|
const { children } = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Dithering Tool | thomaswilson.xyz</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<main class="page-dither">
|
||||||
|
<h1 class="page-dither__title">Image Dither Tool</h1>
|
||||||
|
<p class="page-dither__about">
|
||||||
|
In-browser, privacy respecting image dithering tool.
|
||||||
|
</p>
|
||||||
|
{@render children()}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page-dither {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100dvh;
|
||||||
|
background: var(--gray-1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-dither__title {
|
||||||
|
font-size: 3rem;
|
||||||
|
font-family: sans-serif;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
158
src/routes/dither/+page.svelte
Normal file
158
src/routes/dither/+page.svelte
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import Dither from 'canvas-dither'
|
||||||
|
import ErrorMessage from './ErrorMessage.svelte';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
const DITHERING_ALGORITHMS = {
|
||||||
|
atkinson: (imageData) => Dither.atkinson(imageData),
|
||||||
|
bayer: (imageData) => Dither.bayer(imageData, 170),
|
||||||
|
floydSteinberg: (imageData) => Dither.floydsteinberg(imageData),
|
||||||
|
threshold: (imageData) => Dither.threshold(imageData, 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
let canvas: HTMLCanvasElement;
|
||||||
|
let offscreenCanvas: OffscreenCanvas;
|
||||||
|
let ctx = $derived(canvas.getContext('2d'))
|
||||||
|
let offscreenCtx: OffscreenCanvasRenderingContext2D;
|
||||||
|
let imgObjectUrl = $state('')
|
||||||
|
let errorMessage = $state('')
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
offscreenCanvas = new OffscreenCanvas(1000, 1000)
|
||||||
|
offscreenCtx = offscreenCanvas.getContext('2d')
|
||||||
|
})
|
||||||
|
|
||||||
|
const doesCtxExist = () => {
|
||||||
|
if (!ctx) {
|
||||||
|
errorMessage = "Problem finding context for on-screen canvas"
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleImageChange = async (event: any) => {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
if (!file) {
|
||||||
|
errorMessage = "No file selected, try again"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
imgObjectUrl = URL.createObjectURL(file);
|
||||||
|
const image = await createImage(imgObjectUrl);
|
||||||
|
|
||||||
|
// Change the sizes of canvases
|
||||||
|
canvas.width = image.width
|
||||||
|
canvas.height = image.height
|
||||||
|
|
||||||
|
offscreenCanvas.height = image.height
|
||||||
|
offscreenCanvas.width = image.width
|
||||||
|
|
||||||
|
// Now draw the images
|
||||||
|
drawImage(image)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createImage(objectUrl: string): Promise<HTMLImageElement> {
|
||||||
|
return new Promise<HTMLImageElement>((resolve) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
if (!ctx) return;
|
||||||
|
resolve(img);
|
||||||
|
}
|
||||||
|
img.src = objectUrl;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearCanvases = () => {
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
offscreenCtx.clearRect(0, 0, offscreenCanvas.width, offscreenCanvas.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
const drawImage = (theImage: HTMLImageElement) => {
|
||||||
|
if (!doesCtxExist()) return
|
||||||
|
clearCanvases()
|
||||||
|
ctx.drawImage(theImage, 0, 0, canvas.width, canvas.height);
|
||||||
|
offscreenCtx.drawImage(theImage, offscreenCanvas.width, offscreenCanvas.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDitheringAlgorithm(algorithm: (imageData: ImageData) => ImageData) {
|
||||||
|
return async () => {
|
||||||
|
const freshImage = await createImage(imgObjectUrl);
|
||||||
|
drawImage(freshImage)
|
||||||
|
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||||
|
const ditheredImageData = algorithm(imageData);
|
||||||
|
ctx.putImageData(ditheredImageData, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveImage() {
|
||||||
|
if (!canvas) return;
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.download = 'dithered-image.png';
|
||||||
|
link.href = canvas.toDataURL();
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ErrorMessage message={errorMessage} />
|
||||||
|
|
||||||
|
<h2 class="page-dither__subtitle">1: Select a file</h2>
|
||||||
|
|
||||||
|
<form class="file-form">
|
||||||
|
<label for="file"> Select a file<br />
|
||||||
|
<input type="file" name="file" id="file" onchange={handleImageChange} accept="image/*" >
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h2 class="page-dither__subtitle">2: Select a dithering algorithm</h2>
|
||||||
|
|
||||||
|
<ul class="algorithms">
|
||||||
|
{#each Object.entries(DITHERING_ALGORITHMS) as [key, value]}
|
||||||
|
<li class="algorithms__item">
|
||||||
|
<button class="algorithms__button" onclick={applyDitheringAlgorithm(value)}>{key}</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="canvas-container">
|
||||||
|
<canvas id="photo-canvas" height="250" bind:this={canvas} onload={() => ctx = canvas.getContext('2d')}></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<h2 class="page-dither__subtitle">
|
||||||
|
3: Save the file
|
||||||
|
</h2>
|
||||||
|
<button onclick={saveImage}>Save Image</button>
|
||||||
|
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.algorithms {
|
||||||
|
list-style: none;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.algorithms__button {
|
||||||
|
box-shadow: 5px 2px blue;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-container {
|
||||||
|
display: flex;
|
||||||
|
width: auto;
|
||||||
|
padding: 14px;
|
||||||
|
height: min-content
|
||||||
|
}
|
||||||
|
|
||||||
|
#photo-canvas {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
</style>
|
||||||
9
src/routes/dither/Editor.svelte
Normal file
9
src/routes/dither/Editor.svelte
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Photon } from '@silvia-odwyer/photon-node'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
photon: Photon
|
||||||
|
}
|
||||||
|
|
||||||
|
const { photon }: Props = $props()
|
||||||
|
</script>
|
||||||
18
src/routes/dither/ErrorMessage.svelte
Normal file
18
src/routes/dither/ErrorMessage.svelte
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<script lang="ts">
|
||||||
|
const { errorMessage } = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if errorMessage}
|
||||||
|
<div class="error-message" aria-live="polite">
|
||||||
|
<p>{errorMessage}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.error-message {
|
||||||
|
border: 2px solid red;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
7
src/routes/photo-posts/new/+page.server.ts
Normal file
7
src/routes/photo-posts/new/+page.server.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import type { PageServerLoad } from "./$types.js";
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async({}) => {
|
||||||
|
return {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/routes/photo-posts/new/+page.svelte
Normal file
1
src/routes/photo-posts/new/+page.svelte
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<h1>Create a new photo post</h1>
|
||||||
7
src/routes/photo-posts/new/page.server.ts
Normal file
7
src/routes/photo-posts/new/page.server.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import type { PageServerLoad } from "./$types.js";
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async({}) => {
|
||||||
|
return {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue