diff options
feat: figured i'd revisit this code i wrote as a shitpost, it seems quite decent actually
| -rw-r--r-- | src/lib/test/canvas/CanvasCopy.svelte | 148 | ||||
| -rw-r--r-- | src/lib/test/canvas/CanvasCopyLib.ts | 113 | ||||
| -rw-r--r-- | src/lib/test/canvas/CanvasImg.svelte | 56 | ||||
| -rw-r--r-- | src/lib/test/canvas/detect-canvas-block.ts | 46 | ||||
| -rw-r--r-- | src/routes/playground/canvas/+page.svelte | 54 |
5 files changed, 417 insertions, 0 deletions
diff --git a/src/lib/test/canvas/CanvasCopy.svelte b/src/lib/test/canvas/CanvasCopy.svelte new file mode 100644 index 0000000..7795116 --- /dev/null +++ b/src/lib/test/canvas/CanvasCopy.svelte @@ -0,0 +1,148 @@ +<!-- Small test for making copy-pasting images harder (without putting any major technical restrictions in there, and keeping the site itself working if it errors) --> + +<script lang="ts"> + import { onDestroy, tick } from 'svelte'; + import { canvascopy, shuffleInPlace } from './CanvasCopyLib'; + + let { + src, + alt, + loading = 'lazy', + width, + height, + artifacts = 'fix', + useBg = false, + }: { + src: string; + alt: string; + width?: number; + height?: number; + loading?: 'eager' | 'lazy' | undefined | null; + artifacts?: 'fix' | 'keep'; + useBg?: boolean; + } = $props(); + let blobUrl = $state(undefined as undefined | string); + let additionalOverlayedBlobs = $state([] as string[]); + let additionalOverlayedBlobsDone = $state(false); + // svelte-ignore state_referenced_locally + let lastLoadedSrc = $state(src); + + const blobify = ( + image: HTMLImageElement, + artifacts: 'fix' | 'keep' = 'fix', + ) => { + firstLoad = false; + if (image.src.startsWith('blob:') || image.src === blobUrl) { + // The image is likely a partial of the image. Tell the browser to reload the original + lastLoadedSrc = ''; + } else { + console.debug('[blobify] Entering blobify'); + if (blobUrl) URL.revokeObjectURL(blobUrl); + if (additionalOverlayedBlobs.length) { + for (const blobUrl of additionalOverlayedBlobs) + URL.revokeObjectURL(blobUrl); + additionalOverlayedBlobs.length = 0; + } + additionalOverlayedBlobsDone = false; + + // Need to update lastLoaded to prevent unneeded refreshes + console.debug('[blobify] Set lastload to', src); + const lastLoad = lastLoadedSrc; + lastLoadedSrc = src; + + let nextPerf = 0; + canvascopy( + image, + artifacts, + () => blobUrl, + (blob) => (blobUrl = blob), + tick, + (blob) => additionalOverlayedBlobs.push(blob), + () => shuffleInPlace(additionalOverlayedBlobs).shift()!, + () => { + const p = performance.now(); + if (p >= nextPerf) { + nextPerf = p + 10; + return new Promise((rs) => requestAnimationFrame(() => rs(void 0))); + } + }, + ) + .then(() => { + additionalOverlayedBlobsDone = true; + }) + .catch((e) => { + lastLoadedSrc = lastLoad; + throw e; + }); + } + }; + + let imgloaded = $state(false); + let image = $state(null as null | HTMLImageElement); + + $effect(() => { + if (lastLoadedSrc !== src && blobUrl) { + URL.revokeObjectURL(blobUrl); + blobUrl = undefined; + imgloaded = false; + console.debug('[effect] Revoking blobURL due to src change'); + } + }); + let firstLoad = true; + $effect(() => { + if (imgloaded && image) { + console.debug('[effect][imgload] Calling blobify. State:', { + imgloaded, + image, + artifacts, + }); + if (firstLoad) { + requestIdleCallback(() => blobify(image!, artifacts)); + firstLoad = false; + } else requestAnimationFrame(() => blobify(image!, artifacts)); + } + }); + + onDestroy(() => { + if (blobUrl) URL.revokeObjectURL(blobUrl); + }); +</script> + +<div + class="relative max-w-max max-h-max block" + aria-label={additionalOverlayedBlobsDone ? alt : undefined} + role={additionalOverlayedBlobsDone ? 'img' : undefined} +> + <img + class="select-none" + {loading} + src={blobUrl ?? src} + alt={additionalOverlayedBlobsDone ? undefined : alt} + {width} + {height} + onload={() => (blobUrl !== undefined ? void 0 : (imgloaded = true))} + onloadstart={() => (blobUrl !== undefined ? void 0 : (imgloaded = false))} + bind:this={image} + /> + {#if additionalOverlayedBlobsDone} + {#each additionalOverlayedBlobs + .map( (v, i, a) => (useBg ? (i % 2 === 0 ? ([v, a[i + 1]] as [string, string | undefined]) : undefined!) : ([v] as const)), ) + .filter((v) => v !== undefined) as [blob1, blob2]} + <img + src={blob1} + {loading} + alt="" + class="blob-img" + style={blob2 ? `background-image: url(${JSON.stringify(blob2)});}` : ''} + /> + {/each} + {/if} +</div> + +<style lang="postcss"> + @reference "tailwindcss"; + + .blob-img { + @apply absolute top-0 left-0 h-full w-full bg-contain select-none; + } +</style> diff --git a/src/lib/test/canvas/CanvasCopyLib.ts b/src/lib/test/canvas/CanvasCopyLib.ts new file mode 100644 index 0000000..96d4e3d --- /dev/null +++ b/src/lib/test/canvas/CanvasCopyLib.ts @@ -0,0 +1,113 @@ +import { detect2dCanvasBlockOnCanvas } from './detect-canvas-block'; + +export const shuffleInPlace = <T extends any[]>(a: T): T => { + a.sort(() => Math.random() - 0.5); + return a; +}; +export const canvascopy = async ( + image: HTMLImageElement, + artifacts: 'fix' | 'keep' = 'fix', + getBlobURL: () => string | undefined, + setBlobURL: (url: string | undefined) => void, + tick: () => Promise<void>, + newBlob: (blob: string) => void, + getAndRemoveRandomBlob: () => string, + betweenSegments: undefined | ((ourPromise: Promise<string>) => Promise<void> | void) +) => { + console.debug('[canvascopy] Entering canvascopy'); + const canvas = document.createElement('canvas'); + const canvasOpts = { + desynchronized: true, + alpha: true, + willReadFrequently: true, + // change to unorm8 to use 8-bit colours (will break HDR images) + colorType: 'float16' as const, + // change to srgb if colours look off + colorSpace: 'display-p3' as const, + } as const; + const e = detect2dCanvasBlockOnCanvas(canvas, false, { + ...canvasOpts, + willReadFrequently: false, + }); + if (e) throw e; + console.debug('[canvascopy] Canvas OK'); + try { + // See https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/naturalHeight + const canvasWidth = (canvas.width = image.naturalWidth); + const canvasHeight = (canvas.height = image.naturalHeight); + + const ctx = canvas.getContext('2d', canvasOpts)!; + + const doIntermittentBlob = true; + + if (doIntermittentBlob) { + ctx.drawImage(image, 0, 0, canvasWidth, canvasHeight); + // First, convert to blob, make sure that goes well + setBlobURL(URL.createObjectURL( + await new Promise<Blob>((rs, rj) => + canvas.toBlob((value) => + value ? rs(value) : rj(new Error('No blob')), + ), + ), + )); + + await tick(); + } + + // TODO: For GNOME Web, we need to check if drawImage is implemented + + const oldBlob = doIntermittentBlob ? getBlobURL() : void 0; + + ctx.clearRect(0, 0, canvasWidth, canvasHeight); + + const segmentPromises = [] as Promise<void>[]; + + const xSegments = 8, ySegments = 4; + const xSegmentWidth = canvasWidth / xSegments, ySegmentHeight = canvasHeight / ySegments; + const pad = artifacts === 'fix' ? 4 : 0; + for (let x = 0; x < xSegments; x++) + for (let y = 0; y < ySegments; y++) { + const padLeft = x === 0 ? 0 : pad; + const padRight = x === xSegments - 1 ? 0 : pad; + const padTop = y === 0 ? 0 : pad; + const padBottom = y === ySegments - 1 ? 0 : pad; + const xStart = x * xSegmentWidth - padLeft, + xWidth = xSegmentWidth + padLeft + padRight, + yStart = y * ySegmentHeight - padTop, + yHeight = ySegmentHeight + padTop + padBottom; + ctx.drawImage( + image, + xStart, + yStart, + xWidth, + yHeight, + xStart, + yStart, + xWidth, + yHeight, + ); + const segmentPromise = new Promise<string>((rs, rj) => + canvas.toBlob((value) => + value + ? rs(URL.createObjectURL(value)) + : rj(new Error('No blob')), + ), + ); + segmentPromises.push(segmentPromise.then(v => newBlob(v))); + console.debug('[canvascopy] Push overlayed blob'); + if (betweenSegments) await betweenSegments(segmentPromise) + ctx.clearRect(xStart, yStart, xWidth, yHeight); + } + + await Promise.all(segmentPromises) + + if (doIntermittentBlob) await tick(); + + setBlobURL(getAndRemoveRandomBlob()); + if (doIntermittentBlob) URL.revokeObjectURL(oldBlob!); + + console.debug('[canvascopy] Done!'); + } catch (error) { + console.error('[canvascopy] Failed to get canvas data:', error); + } +}; diff --git a/src/lib/test/canvas/CanvasImg.svelte b/src/lib/test/canvas/CanvasImg.svelte new file mode 100644 index 0000000..ef9c870 --- /dev/null +++ b/src/lib/test/canvas/CanvasImg.svelte @@ -0,0 +1,56 @@ +<script lang="ts"> + import { onDestroy } from 'svelte'; + import { detect2dCanvasBlockOnCanvas } from './detect-canvas-block'; + + let { src, alt }: { src: string; alt: string } = $props(); + let blobUrl = $state(undefined as undefined | string); + + const blobify = async (image: HTMLImageElement) => { + if (!image.src.startsWith('blob:')) { + const canvas = document.createElement('canvas'); + if (blobUrl) URL.revokeObjectURL(blobUrl); + const canvasOpts = { + desynchronized: true, + alpha: true, + willReadFrequently: false, + // change to unorm8 to use 8-bit colours (will break HDR images) + colorType: 'float16' as const, + // change to srgb if colours look off + colorSpace: 'display-p3' as const, + } as const; + const e = detect2dCanvasBlockOnCanvas(canvas, false, canvasOpts); + if (e) throw e; + try { + // See https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/naturalHeight + const cw = (canvas.width = image.naturalWidth); + const ch = (canvas.height = image.naturalHeight); + + const ctx = canvas.getContext('2d', canvasOpts)!; + ctx.drawImage(image, 0, 0, cw, ch); + + blobUrl = URL.createObjectURL( + await new Promise<Blob>((rs, rj) => + canvas.toBlob((value) => + value ? rs(value) : rj(new Error('No blob')), + ), + ), + ); + } catch (error) { + console.error('Failed to get canvas data:', error); + } + } + }; + + onDestroy(() => { + if (blobUrl) URL.revokeObjectURL(blobUrl); + }); +</script> + +<img + class="pointer-events-none" + loading="lazy" + src={blobUrl ?? src} + {alt} + onload={(e) => + blobify((e.currentTarget ?? e.target) as unknown as HTMLImageElement)} +/> diff --git a/src/lib/test/canvas/detect-canvas-block.ts b/src/lib/test/canvas/detect-canvas-block.ts new file mode 100644 index 0000000..4557213 --- /dev/null +++ b/src/lib/test/canvas/detect-canvas-block.ts @@ -0,0 +1,46 @@ +export const detect2dCanvasBlockOnCanvas = (canvas: HTMLCanvasElement, resetDimensions = true, canvasOpts: CanvasRenderingContext2D | CanvasRenderingContext2DSettings = { + desynchronized: true, + alpha: true, + willReadFrequently: false, + // @ts-ignore type is incomplete + colorType: 'float16' as const, + colorSpace: 'display-p3' as const, +}): null | Error => { + const dimensions = resetDimensions ? [canvas.width, canvas.height] as const : [0, 0] as const; + const [w, h] = [255, 255]; + const checks = 32; + const ctx = canvasOpts instanceof CanvasRenderingContext2D && canvasOpts && canvasOpts.canvas.width >= 256 && canvasOpts.canvas.height >= 256 ? canvasOpts : (() => { + [canvas.width, canvas.height] = [w, h]; + return canvas.getContext('2d', canvasOpts) as CanvasRenderingContext2D | null + })(); + if (ctx) { + try { + // make sure we have canvas support + ctx.fillStyle = '#0099ff'; + ctx.fillRect(0, 0, w, h); + for (let i = 0; i < checks; i++) { + const data = ctx.getImageData(i % w, i % h, 1, 1).data; + if ( + data[0] !== 0 || + data[1] !== 153 || + data[2] !== 255 || + data[3] !== 255 + ) { + if (resetDimensions) [canvas.width, canvas.height] = dimensions; + return new Error("Canvas Data doesn't match written data."); + } + } + ctx.clearRect(0, 0, w, h); + } catch (error) { + if (resetDimensions) [canvas.width, canvas.height] = dimensions; + return new Error('No canvas support', { + cause: error + }); + } + } else { + if (resetDimensions) [canvas.width, canvas.height] = dimensions; + return new Error('No canvas support: ctx is undefined.'); + } + return null; +} +export const checkFor2dCanvasSupport = () => detect2dCanvasBlockOnCanvas(document.createElement('canvas'), false) diff --git a/src/routes/playground/canvas/+page.svelte b/src/routes/playground/canvas/+page.svelte new file mode 100644 index 0000000..04127c1 --- /dev/null +++ b/src/routes/playground/canvas/+page.svelte @@ -0,0 +1,54 @@ +<script> + import CanvasCopy from '$/lib/test/canvas/CanvasCopy.svelte'; + import CanvasImg from '$/lib/test/canvas/CanvasImg.svelte'; + import { resolve } from '$app/paths'; + let fixPaddingArtifacts = $state(false); + let useBg = $state(true); + let src = $state(resolve('/') + 'estrogen-half.png'); + let alt = $state('Favicon'); +</script> + +<h1 class="text-3xl">Canvas Playground</h1> + +<p> + Here's some fucking around with the HTML canvas element, initially mostly to + do a funny bit of obscuring images as found in the DOM. +</p> + +<h2 class="text-xl">Plain Img</h2> +<p>Normal image tag.</p> + +<div class="rounded-2xl"><img {src} {alt} /></div> + +<h2 class="text-xl">CanvasImg</h2> +<p> + Simple <code>canvas</code>-based copying of the contents and converting to + blob. Falls back to source image. +</p> +<div class="rounded-2xl"><CanvasImg {src} {alt} /></div> + +<h2 class="text-xl">CanvasCopy</h2> +<p> + A more involved technique, copying sections of the canvas source and rendering + them in separate image elements, still falling back to source image. +</p> +<p> + Single-pixel artifacts on high-dpi and non-1:1 scaled images can be solved by + creating an overlapping pixel on each edge. This, however, messes up partial + transparency.<br /> + <label + ><input type="checkbox" bind:checked={fixPaddingArtifacts} /> Fix Artifacts</label + > + <label + ><input type="checkbox" bind:checked={useBg} /> Use background-image to reduce + element count</label + > +</p> +<div class="rounded-2xl"> + <CanvasCopy + {src} + {alt} + artifacts={fixPaddingArtifacts ? 'fix' : 'keep'} + {useBg} + /> +</div> |