diff --git a/.gitignore b/.gitignore index 844c980..e526b54 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ next-env.d.ts npm-debug.log* yarn-debug.log* yarn-error.log* +.plans \ No newline at end of file diff --git a/README.md b/README.md index 8ddabba..2500537 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,10 @@ illustrations from your pictures. All the processing is made locally, your images never leave your computer and you are free to use them as you please. +The platform-neutral, incremental generator is documented in +[PINTR core](lib/pintr-core/README.md). It is currently a local module; this +refactor does not create or publish a package. + [![pintr](public/thumbnail.jpg)](https://javier.xyz/pintr/) ## Examples diff --git a/app/globals.css b/app/globals.css index f3e2909..37a5629 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,7 +1,25 @@ +@font-face { + font-family: 'Brutalita'; + src: url('https://brutalita.com/font/Brutalita-Regular.woff2') format('woff2'); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Brutalita'; + src: url('https://brutalita.com/font/Brutalita-SemiBold.woff2') + format('woff2'); + font-weight: 600; + font-style: normal; + font-display: swap; +} + body { touch-action: pan-y; margin: 0; - padding: 0.25rem 0 2rem; + padding: 0.25rem 0 3rem; + letter-spacing: -0.01em; } .skip-link { @@ -34,11 +52,10 @@ a:focus { body, html, button { - font-family: var(--font-brutalita), Courier, monospace, sans-serif; - letter-spacing: -1px; + font-family: 'Brutalita', Courier, monospace, sans-serif; font-weight: 400; font-style: normal; - font-size: 24px; + font-size: 22.4px; } h1 { @@ -77,7 +94,8 @@ button { background: #ecf0f1; white-space: nowrap; color: #000; - font-size: 1rem; + font-size: var(--font-size-small); + font-weight: var(--font-weight-small); } button:hover { cursor: pointer; @@ -123,6 +141,14 @@ body.-dragging::after { :root { --sizeh: calc(1080px / 2); --sizew: calc(1080px / 2); + + /* normal text */ + --font-size: 1rem; + --font-weight: 400; + + /* small text: 4/5 of normal, medium weight */ + --font-size-small: 1rem; + --font-weight-small: 600; } .inline { @@ -174,6 +200,9 @@ canvas, #srcImg { max-width: calc(100vw - 32px); max-height: calc(100vw - 32px); + /* The box follows the inline aspect-ratio of whichever image is shown, which + is not the drawing's once an aspect ratio is picked. */ + height: auto; } canvas, @@ -245,6 +274,35 @@ input[type='range']:focus-visible { outline: 2px solid #3498db; outline-offset: 2px; } +select { + margin-left: 8px; + flex: 1; + width: 180px; + appearance: none; + -webkit-appearance: none; + background-color: #95a5a6; + /* `appearance: none` drops the native dropdown marker, so draw one back: a + solid black triangle, matching the square slider thumbs. */ + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 6'%3E%3Cpath d='M0 0h10L5 6z' fill='%23000'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 8px center; + background-size: 10px 6px; + color: #000; + border: none; + border-radius: 0; + height: 32px; + padding: 0 26px 0 8px; + font: inherit; + font-size: var(--font-size-small); +} +select:hover { + cursor: pointer; +} +select:focus-visible { + outline: 2px solid #3498db; + outline-offset: 2px; +} + input[type='range'].toggle { width: 48px; } @@ -300,6 +358,42 @@ input[type='range'].toggle::-moz-range-thumb { margin: 16px; } +.vignette-options--container { + display: none; +} + +/* An open folder: the toggle row is the tab and the panel below is the sheet + hanging off it, both tinted a step darker than the page and outlined with + inset shadows rather than borders. The tab is pulled down over the sheet's + top line by a negative bottom margin, so nothing inside the tab's own box + moves and its label sits exactly where it does when the folder is closed. */ +.folder.-open { + --folder-tint: #dfe4e6; +} + +.folder.-open > .folder--tab { + position: relative; + z-index: 1; + width: fit-content; + margin-bottom: -1px; + background: var(--folder-tint); + /* left + top, then right — no bottom, so the tab opens into the sheet. */ + box-shadow: + inset 1px 1px #000, + inset -1px 0 #000; +} + +.folder.-open > .folder--sheet { + background: var(--folder-tint); + box-shadow: + inset 1px 1px #000, + inset -1px -1px #000; +} + +.srcimage-container { + cursor: pointer; +} + ul, ol, li, @@ -307,3 +401,74 @@ p { margin: 0; padding: 0; } + +html { + color-scheme: light; +} + +.doc-header h1 { + line-height: 1.2; + padding: 8px 0; +} +.doc-header .doc-back { + margin: 0 0 0 16px; + font-size: 0.7rem; +} + +.doc { + max-width: 42rem; + padding: 0 16px 32px; + line-height: 1.5; + font-size: 0.8rem; + overflow-wrap: anywhere; +} +.doc h2 { + font-weight: 400; + font-size: 1rem; + margin: 32px 0 8px; +} +.doc h3 { + font-weight: 400; + font-size: 0.85rem; + color: #34495e; + margin: 24px 0 4px; +} +.doc p, +.doc ul, +.doc ol { + margin: 0 0 12px; +} +.doc ul, +.doc ol { + padding-left: 1.4em; + list-style-position: outside; +} +.doc li { + margin-bottom: 4px; +} +.doc strong { + color: #34495e; +} +.doc .meta { + color: #555; + font-size: 0.7rem; +} + +/* A control whose option has nothing to act on yet — the extend toggle with no + padding and no aspect ratio change. */ +.input-container.-disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.input-container.-disabled input[type='range'] { + pointer-events: none; +} + +/* Explains a download's format next to its button, in the muted register the + instructions use. */ +.export-note { + margin: 8px 16px 8px 0; + color: #555; + line-height: 1.4; +} diff --git a/app/icon.svg b/app/icon.svg new file mode 100644 index 0000000..bfcfa2f --- /dev/null +++ b/app/icon.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx index f01aeb2..05004fa 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,15 +1,6 @@ import type { Metadata, Viewport } from 'next'; -import localFont from 'next/font/local'; -import { GoogleAnalytics } from '@next/third-parties/google'; - import './globals.css'; -const brutalita = localFont({ - src: '../public/fonts/Brutalita-Regular.woff2', - display: 'swap', - variable: '--font-brutalita', -}); - const TITLE = 'PINTR - Create plotter-like line drawings from your images'; const DESCRIPTION = 'Create plotter-like line drawings from your images.'; @@ -60,9 +51,11 @@ export default function RootLayout({ children: React.ReactNode; }) { return ( - + + + + {children} - ); } diff --git a/app/page.tsx b/app/page.tsx index 79fb3ae..f2d3563 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,16 +1,27 @@ 'use client'; -import { useEffect, useRef } from 'react'; +import { useEffect } from 'react'; +import { GoogleAnalytics } from '@next/third-parties/google'; -export default function Page() { - const started = useRef(false); +import SiteFooter from './site-footer'; +export default function Page() { useEffect(() => { - if (started.current) return; - started.current = true; - // The DOM is now mounted, so main.ts's top-level querySelector/listener - // wiring (and final startNewDrawing()) runs against the rendered elements. - import('../main'); + let disposed = false; + let cleanup: (() => void) | undefined; + + // Initialize after React has mounted the DOM. The module itself is cached by + // Next.js, so setup must run on every mount (including client-side back + // navigation from Privacy or Support). + import('../main').then(({ initializePintrApp }) => { + if (disposed) return; + cleanup = initializePintrApp(); + }); + + return () => { + disposed = true; + cleanup?.(); + }; }, []); return ( @@ -62,9 +73,10 @@ export default function Page() { type="range" data-start-drawing id="lines" + step="1" min="0" max="100" - defaultValue="50" + defaultValue="42" /> @@ -74,6 +86,7 @@ export default function Page() { type="range" data-start-drawing id="contrast" + step="10" min="0" max="100" defaultValue="50" @@ -86,9 +99,10 @@ export default function Page() { type="range" data-start-drawing id="definition" + step="10" min="0" max="100" - defaultValue="50" + defaultValue="55" /> @@ -111,43 +125,15 @@ export default function Page() { type="range" data-start-drawing id="strokeWidth" - step="0.25" - min="0.5" - max="5" - defaultValue="1.5" + step="1" + min="0" + max="100" + defaultValue="35" /> - - -
- - -
- -
-
    -
  1. - Select an image — images with a face, white or light backgrounds - and good contrast work better. -
  2. -
  3. Play with the controls.
  4. -
  5. Download. Your pictures never leave your computer.
  6. -
-

- More information and source available on{' '} - Github. -

-
- - -
- +
- + + +
+
+ +
+
+ + +
+
+ +
+
+
+
+
+
+ + +
+
+ +
+
+
+ + +
+
+ +
+
+ + +
+
+ +
+
+ + +
+
+
+
+
Smoothing only applies to "single line" drawings.
+ +
+ +

+ x,y rows · · + origin top left +

+
+ +
+ + + + {/* Only affects the downloads, so it lives with the download buttons. */} +
+ + +
+
+ +
+
    +
  1. + Select an image — images with a face, white or light backgrounds + and good contrast work better. +
  2. +
  3. Play with the controls.
  4. +
  5. Download. Your pictures never leave your computer.
  6. +
+

+ {'More information and source available on '} + Github. +

+
+ + + ); } diff --git a/app/privacy/page.tsx b/app/privacy/page.tsx new file mode 100644 index 0000000..2817918 --- /dev/null +++ b/app/privacy/page.tsx @@ -0,0 +1,224 @@ +import type { Metadata } from 'next'; +import Link from 'next/link'; + +import SiteFooter from '../site-footer'; + +const TITLE = 'PINTR Privacy Policy'; +const DESCRIPTION = + 'How the PINTR app for iPhone and iPad, and this website, handle your data.'; + +export const metadata: Metadata = { + title: TITLE, + description: DESCRIPTION, + alternates: { + canonical: 'https://javier.xyz/pintr/privacy', + }, + openGraph: { + title: TITLE, + description: DESCRIPTION, + url: 'https://javier.xyz/pintr/privacy', + siteName: 'PINTR', + locale: 'en_US', + type: 'website', + }, + twitter: { + card: 'summary_large_image', + title: TITLE, + description: DESCRIPTION, + site: '@javierbyte', + creator: '@javierbyte', + }, +}; + +export default function PrivacyPage() { + return ( + <> + + Skip to content + +
+

PINTR Privacy Policy

+

+ Back to PINTR +

+
+ +
+

Last updated: August 29, 2026.

+ +

+ PINTR turns a photo into a line drawing. The app for iPhone and iPad + does all of that work on your device. It has no account, no server, + and nothing to sign in to, so there is no personal information for it + to collect, store, or share. +

+ +

+ This policy covers two separate things: the PINTR app{' '} + you install from the App Store, and this website at + javier.xyz/pintr. They are described separately below because they do + not work the same way. +

+ +

The PINTR app for iPhone and iPad

+ +

+ The app collects no data. Its bundled privacy + manifest declares no collected data types and no tracking, which is + why its App Store listing shows{' '} + Data Not Collected. +

+ +
    +
  • No account, sign-in, or profile.
  • +
  • No analytics, crash reporting, or usage measurement.
  • +
  • No advertising, and no tracking across apps or websites.
  • +
  • No third-party SDKs.
  • +
  • + No network requests. The drawing engine, the preview, and the + exports are all computed on your device, and the app keeps working + with no connection at all. +
  • +
  • Nothing is sold or shared with anyone.
  • +
+ +

Photos, camera, and files

+ +

+ The app can only see an image after you choose it yourself, through + the Add Image menu: +

+ +
    +
  • + Choose Photo opens the system photo picker. The + picker runs outside the app and hands over only the single item you + pick, so the app never gets access to your photo library. +
  • +
  • + Take Photo opens the camera. iOS asks for camera + permission the first time, for the reason shown in the prompt: + "Take a photo to turn it into a PINTR drawing." The photo + goes straight into the drawing and is not saved anywhere by PINTR. +
  • +
  • + Open File… and drag-and-drop read the one image + file you select. +
  • +
+ +

+ Whatever the source, the image is prepared and drawn entirely on your + device. It is never uploaded, and it is not copied anywhere outside + the app. Choosing another image, or closing the app, discards the + previous one. +

+ +

Exported drawings

+ +

+ When you export a drawing, PINTR writes a PNG or SVG file into + app-owned temporary storage on your device. That file leaves the app + only when you send it somewhere yourself with the system share sheet + — to Files, Photos, Mail, or another app of your choosing. PINTR does + not upload it, and has no say in what the destination app does with + it afterwards. +

+ +

+ Temporary export files are removed on a later launch of the app, so + save or share an export you want to keep rather than relying on that + temporary copy. +

+ +

Data retention and deletion

+ +

+ There is no server-side user data to retain or delete, because the + app sends nothing anywhere. Everything stays under your control on + your device: +

+ +
    +
  • + The image currently loaded in the app is discarded when you replace + it or close the app. +
  • +
  • + Exports you shared live in whichever app or folder you sent them to, + and are deleted there — in Files, Photos, or the destination app. +
  • +
  • + Temporary exports the app still holds are cleared on a later launch, + and deleting the app removes them along with everything else it + stores. +
  • +
  • + Camera permission can be changed or revoked at any time in Settings + → Privacy & Security → Camera, or in Settings → PINTR. +
  • +
+ +

This website

+ +

+ This section is about javier.xyz/pintr only. None of it applies to the + app, which contacts no servers at all. +

+ +
    +
  • + The browser version of PINTR draws your image locally in your + browser. Images are never uploaded to a server. +
  • +
  • + The site is hosted by Vercel, which + processes ordinary request data such as IP address and browser user + agent in order to deliver the pages and keep the service secure. +
  • +
  • + The drawing tool page uses Google Analytics (Google LLC) to count + visits. +
  • +
  • + + This privacy page and the support page load no analytics and set + no cookies. + +
  • +
  • + The Brutalita typeface is loaded from brutalita.com, so that origin + sees your browser’s font request. +
  • +
+ +

Children

+ +

+ PINTR is not directed at children, and because it collects no data it + collects nothing from children either. +

+ +

Changes to this policy

+ +

+ If the app or this site ever changes how it handles data, this page is + updated and the date at the top changes with it. This address stays + stable, because released versions of the app link to it. +

+ +

Contact

+ +

+ Privacy questions go to{' '} + pintr@javier.xyz. The app and + this site are made by Javier Bórquez ( + javier.xyz). See also the{' '} + support page. +

+
+ + + + ); +} diff --git a/app/site-footer.tsx b/app/site-footer.tsx new file mode 100644 index 0000000..b469b91 --- /dev/null +++ b/app/site-footer.tsx @@ -0,0 +1,9 @@ +export default function SiteFooter() { + return ( + + ); +} diff --git a/app/support/page.tsx b/app/support/page.tsx new file mode 100644 index 0000000..5b6802e --- /dev/null +++ b/app/support/page.tsx @@ -0,0 +1,244 @@ +import type { Metadata } from 'next'; +import Link from 'next/link'; + +import SiteFooter from '../site-footer'; + +const TITLE = 'PINTR Support'; +const DESCRIPTION = + 'Help, contact, and troubleshooting for the PINTR app for iPhone and iPad.'; + +export const metadata: Metadata = { + title: TITLE, + description: DESCRIPTION, + alternates: { + canonical: 'https://javier.xyz/pintr/support', + }, + openGraph: { + title: TITLE, + description: DESCRIPTION, + url: 'https://javier.xyz/pintr/support', + siteName: 'PINTR', + locale: 'en_US', + type: 'website', + }, + twitter: { + card: 'summary_large_image', + title: TITLE, + description: DESCRIPTION, + site: '@javierbyte', + creator: '@javierbyte', + }, +}; + +export default function SupportPage() { + return ( + <> + + Skip to content + +
+

PINTR Support

+

+ Back to PINTR +

+
+ +
+

Last updated: August 29, 2026.

+ +

+ PINTR turns a photo into a plotter-like line drawing on your iPhone or + iPad. Everything runs on the device. Read the{' '} + + privacy policy + {' '} + for how it handles your photos and exports. +

+ +

Contact

+ +

+ Email pintr@javier.xyz with + questions, bugs, or feature requests. It helps to include your device + model, your iOS or iPadOS version, and the app version shown at the + bottom of About PINTR inside the app. +

+ +

Requirements

+ +
    +
  • iPhone or iPad running iOS or iPadOS 26 or later.
  • +
  • + No account and no internet connection. After installation PINTR + works entirely offline. +
  • +
+ +

Getting started

+ +
    +
  1. + Tap Add Image and pick a source:{' '} + Choose Photo for the photo library,{' '} + Take Photo for the camera, or{' '} + Open File… for an image file. On iPad you can also + drag an image into the app. +
  2. +
  3. + The drawing starts immediately and fills in line by line while you + watch. +
  4. +
  5. + Adjust the controls. Every change redraws right away. +
  6. +
  7. + Tap Export to share the result as PNG or SVG. +
  8. +
+ +

+ Photos with a clear subject, good contrast, and a light background + give the strongest drawings. +

+ +

Controls

+ +
    +
  • + Lines — how many strokes the drawing uses. More + lines mean more detail and a longer draw. +
  • +
  • + Contrast — how strongly light and dark areas are + separated before drawing. +
  • +
  • + Definition — how closely the strokes follow fine + detail rather than broad shapes. +
  • +
  • + Stroke width — the thickness of each line. +
  • +
  • + Single continuous line — draws the whole image + without lifting the pen, the classic plotter look. +
  • +
  • + New variation — picks a new seed, giving a + different drawing of the same photo. The current seed is shown next + to it. +
  • +
+ +

+ Pinch or use a trackpad to zoom, drag to pan, and double-tap to reset + the view. Fit Artwork returns the drawing to the + screen if you lose it while zoomed in. +

+ +

Exporting PNG and SVG

+ +
    +
  • + Share PNG exports the drawing as an image, at the + size of the prepared artwork. +
  • +
  • + Share SVG exports vector paths, for plotters, + cutters, and editors like Illustrator, Affinity, Figma, or Inkscape. +
  • +
  • + Both open the system share sheet, from which you can save to Files + or Photos, send the file, or open it in another app. +
  • +
  • + Exports are written to temporary storage inside the app and are + cleared on a later launch, so save or send the file rather than + relying on that temporary copy. +
  • +
+ +

Permissions

+ +

+ Photos and files. Choosing a photo or opening a file + goes through the system picker, which hands PINTR only the item you + selected. No separate permission is requested, and PINTR never gets + access to your whole library. +

+ +

+ Camera. The first time you use{' '} + Take Photo, iOS asks for camera permission. If you + declined earlier and want it back, open Settings → PINTR and turn on + Camera, or go to Settings → Privacy & Security → Camera and enable + PINTR there. Return to the app and choose{' '} + Take Photo again. Everything else in the app keeps + working without camera access. +

+ +

Troubleshooting

+ +

"The selected item is not a readable image."

+

+ The file is not an image PINTR can open, or it is not an image at all + — a PDF, a video, or a document with an image-like name. Try a JPEG, + PNG, or HEIC file instead. +

+ +

"PINTR could not prepare the selected image."

+

+ The file is an image, but its contents could not be decoded — usually + a partially downloaded or corrupted file, or an unusual camera raw + format. Open it in Photos first, export or duplicate it as a JPEG, and + import that copy. +

+ +

The drawing looks blank, faint, or too dark

+

+ Raise Lines for more coverage and adjust{' '} + Contrast. Photos that are very dark, very flat, or + busy from edge to edge give the engine little to follow; a portrait or + a subject against a light background works far better. +

+ +

Drawing feels slow

+

+ A high line count is simply a lot of work. The image appears + progressively and stays interactive while it draws; lower{' '} + Lines for a faster result, and use{' '} + Stop to end a draw you no longer want. +

+ +

An export failed

+

+ Free up storage space on the device and try again. If it keeps + failing, note what you were exporting and email{' '} + pintr@javier.xyz. +

+ +

Privacy

+ +

+ Your photos and drawings stay on your device. PINTR has no account, no + analytics, and no network service. The full{' '} + + privacy policy + {' '} + explains what that means for Photos, the camera, files, and exports. +

+ +

PINTR in a browser

+ +

+ There is also a free web version of PINTR at{' '} + javier.xyz/pintr, and the drawing engine is open + source on{' '} + GitHub. +

+
+ + + + ); +} diff --git a/lib/PINTR.ts b/lib/PINTR.ts index 900fc4f..a18555c 100644 --- a/lib/PINTR.ts +++ b/lib/PINTR.ts @@ -1,32 +1,34 @@ -import { imageToRawData, ctxToRGBGrayscaleMatrix } from 'canvas-image-utils'; +import { imageToRawData } from 'canvas-image-utils'; import Draw from './draw'; -import { canvasDataToGrayscale } from './canvasDataToGrayscale'; -import { scanLine } from './scan'; -import { intRnd, tweenValue } from './utils'; +import { createPintr } from './pintr-core'; +import type { + PintrConfig as CoreConfig, + PintrLine, + PintrSession, +} from './pintr-core'; +import { preparePintrImage } from './pintr-core/utils'; const DRAW_SIZE = 1080; export type Coord = [number, number]; - -export type PintrConfig = { - contrast: number; - definition: number; - singleLine: boolean; - strokeWidth: number; -}; +export type PintrConfig = CoreConfig; type Session = { - singleLine: boolean; - tweenDefinition: number; - updateSampleRate: number; - plusColor: string; - minusColor: string; + pintr: PintrSession; strokeWidth: number; target: number; - drawnCount: number; }; +function toCoords(line: PintrLine): [Coord, Coord] { + return [ + [line[0][0], line[0][1]], + [line[1][0], line[1][1]], + ]; +} + +// Browser compatibility around the platform-neutral generator. The app still +// owns image URLs, frame pacing, and visible Canvas strokes; core only picks lines. export async function pinterCreator( imgSrc: string, { @@ -45,15 +47,7 @@ export async function pinterCreator( }) => void; } ) { - // Per-instance drawing state, scoped to this closure so two instances never - // collide (a superseded one may still be resolving a requestAnimationFrame). const canvasSrc = document.createElement('canvas'); - let S: Uint8Array[] = []; - let COORDS: [Coord, Coord][] = []; - let cursor: Coord = [0, 0]; - let running = false; - let stopped = false; - const data = await imageToRawData(imgSrc, { size: DRAW_SIZE, canvas: canvasSrc, @@ -61,202 +55,101 @@ export async function pinterCreator( }); const WIDTH = data.width; const HEIGHT = data.height; - - canvasSrc.width = WIDTH; - canvasSrc.height = HEIGHT; - const srcCtx: CanvasRenderingContext2D | null = canvasSrc.getContext('2d', { - willReadFrequently: true, + const image = preparePintrImage({ + width: WIDTH, + height: HEIGHT, + rgba: data.data, }); - if (!srcCtx) { - throw new Error("Failed to initiate 'CanvasRenderingContext2D'"); - } - - let pencilSrc = Draw(srcCtx); - const canvasDraw = canvasDrawEl || document.createElement('canvas'); canvasDraw.width = WIDTH; canvasDraw.height = HEIGHT; - const srcImgEl: HTMLImageElement | null = document.querySelector('#srcImg'); const drawCtx: CanvasRenderingContext2D | null = canvasDraw.getContext('2d'); - if (!srcImgEl) { - throw new Error('Failed to initiate srcImgEl'); - } - if (!drawCtx) { - throw new Error('Failed to initiate CanvasRenderingContext2D'); - } + if (!drawCtx) throw new Error('Failed to initiate CanvasRenderingContext2D'); - // Non-null aliases: the narrowing above doesn't carry into the nested closures. - const srcContext: CanvasRenderingContext2D = srcCtx; const drawContext: CanvasRenderingContext2D = drawCtx; + onLoad({ width: WIDTH, height: HEIGHT }); - srcImgEl.style.aspectRatio = String(WIDTH / HEIGHT); - - let pencilDraw = Draw(drawCtx); - onLoad({ - width: WIDTH, - height: HEIGHT, - }); - - const { canvasData } = canvasDataToGrayscale(data); - + let pencilDraw = Draw(drawContext); let session: Session | null = null; - - function drawSequenceLine(currentSession: Session) { - let from = cursor; - - // if no `singleLine` hop to different points to find a new cursor - let toFrom = currentSession.singleLine ? 0 : currentSession.tweenDefinition; - while (toFrom--) { - let tmpFrom: Coord = [intRnd(WIDTH), intRnd(HEIGHT)]; - if (S[from[0]][from[1]] > S[tmpFrom[0]][tmpFrom[1]]) { - from = tmpFrom; - } - } - - // now we look at different places to expand - let remainingCursorsToExplore = intRnd( - currentSession.tweenDefinition, - currentSession.tweenDefinition * 2 - ); - let to: Coord = [intRnd(WIDTH), intRnd(HEIGHT)]; - let light = 255; - while (remainingCursorsToExplore--) { - let tmpTo: Coord = [intRnd(WIDTH), intRnd(HEIGHT)]; - const tmpLight = scanLine(from, tmpTo, S); - if (tmpLight <= light) { - light = tmpLight; - to = tmpTo; - } - } - light = scanLine(from, to, S); - - COORDS.push([from, to]); - - pencilDraw.lineBuffer(from, to); - pencilSrc.lineBuffer(from, to); - cursor = to; - } + let running = false; + let stopped = false; // Draws lines toward `session.target` within a single ~15ms time budget, then // resolves on the next animation frame so the canvas can paint between batches. + // Requesting one core line at a time keeps the target exact even when Definition + // is slow. function drawBatch(currentSession: Session) { return new Promise((resolve) => { const time = Date.now(); - const startCount = currentSession.drawnCount; + const coords: [Coord, Coord][] = []; while ( Date.now() < time + 15 && - currentSession.drawnCount < currentSession.target + currentSession.pintr.lineCount < currentSession.target ) { - // here we put the changes back to the src and update our matrix - if (currentSession.drawnCount % currentSession.updateSampleRate === 0) { - pencilSrc.stroke({ - color: currentSession.minusColor, - width: currentSession.strokeWidth * 1.5, - }); - S = ctxToRGBGrayscaleMatrix(srcContext); - } - drawSequenceLine(currentSession); - currentSession.drawnCount++; + const line = currentSession.pintr.next(1).lines[0]; + const coord = toCoords(line); + coords.push(coord); + pencilDraw.lineBuffer(coord[0], coord[1]); } pencilDraw.stroke({ - color: currentSession.plusColor, - width: currentSession.strokeWidth * 1, + color: 'rgba(0, 0, 0, 255)', + width: currentSession.strokeWidth, }); - const newCoords = COORDS.slice(startCount, currentSession.drawnCount); - const done = currentSession.drawnCount >= currentSession.target; - onProgress && onProgress({ coords: newCoords, done }); + const done = currentSession.pintr.lineCount >= currentSession.target; + if (coords.length || done) onProgress && onProgress({ coords, done }); window.requestAnimationFrame(() => resolve()); }); } // Keeps batching until the drawn count reaches the current target, then idles; - // raising the target via `requestLines` restarts it. The `running` flag keeps - // a single loop alive, and it reads the live `session` each iteration so a - // `start()` that swaps the session mid-flight is picked up once a batch resolves. + // raising the target via `requestLines` restarts it. The core session stays + // warm while idle, so it continues without replaying the existing drawing. + // Reading `session` each iteration also picks up a replacement made by start(). async function pump() { if (running) return; running = true; - while (!stopped && session && session.drawnCount < session.target) { + while ( + !stopped && + session && + session.pintr.lineCount < session.target + ) { await drawBatch(session); } running = false; } - // (Re)initialise a drawing session. Resets all drawing state and captures the - // config-derived values, but draws nothing until `requestLines` is called. + // Start a fresh generator for this image and config. The core owns only the + // state needed to choose future lines; the app collects the returned coords. function start(config: PintrConfig) { - const { singleLine, contrast, definition, strokeWidth } = config; - - COORDS = []; - - const tweenDefinition = Math.round( - tweenValue(definition, [ - [0, 3], - [50, 15], - [100, 75], - ]) - ); - - const plusColor = `rgba(0, 0, 0, 255)`; - const minusColor = `rgba(255, 255, 255, ${ - (100 - - Math.round( - tweenValue(contrast, [ - [0, 20], - [50, 67], - [100, 90], - ]) - )) / - 100 - })`; - - const updateSampleRate = 100 - Math.floor(tweenDefinition / 2); - - // Clear the canvas and start a fresh pencil so no partial sub-path from a - // previous session on this instance carries over. drawContext.clearRect(0, 0, WIDTH, HEIGHT); - pencilDraw = Draw(drawContext); - - srcContext.putImageData(canvasData, 0, 0); - pencilSrc = Draw(srcContext); - S = ctxToRGBGrayscaleMatrix(srcContext); - - cursor = [Math.floor(WIDTH / 2), Math.floor(HEIGHT / 2)]; - + pencilDraw = Draw(drawContext, config.singleLine); session = { - singleLine, - tweenDefinition, - updateSampleRate, - plusColor, - minusColor, - strokeWidth, + pintr: createPintr({ image, config }), + strokeWidth: config.strokeWidth, target: 0, - drawnCount: 0, }; } - // Set the target line count for the current session. Returns immediately; the - // pump loop catches up and streams the new lines via `onProgress`. Raising the - // target draws more (continuing from the last line); lowering it below the - // drawn count simply halts the loop — already-drawn lines are kept (the drawn - // count never decreases) so raising it again resumes instantly. + // Set the target line count and return immediately. Lower targets simply stop + // generation; raising the target later continues the same in-memory session. function requestLines(targetCount: number) { - if (!session) { - throw new Error('PINTR: call start() before requestLines()'); + if (!session) throw new Error('PINTR: call start() before requestLines()'); + if (!Number.isInteger(targetCount) || targetCount < 0) { + throw new Error('PINTR: target line count must be a non-negative integer'); } + session.target = targetCount; if (!running) pump(); } - // Permanently halt this instance so its pump stops drawing — called when a new - // image takes over the canvas. + // Permanently halt this instance when a new image takes over the canvas. function stop() { stopped = true; } diff --git a/lib/canvasDataToGrayscale.ts b/lib/canvasDataToGrayscale.ts deleted file mode 100644 index 74cd8b2..0000000 --- a/lib/canvasDataToGrayscale.ts +++ /dev/null @@ -1,51 +0,0 @@ -const RED_PREFERENCE = 0.1; -const WEIGHTS = { - r: 0.299 + RED_PREFERENCE, - g: 0.587 + RED_PREFERENCE * -0.5, - b: 0.114 + RED_PREFERENCE * -0.5, -}; - -export function canvasDataToGrayscale(canvasData: ImageData): { - canvasData: ImageData; - averageLightness: number; -} { - let pixels = canvasData.data; - - let minLightness = Infinity; - let maxLightness = 0; - let averageLightness = 0; - - for (let i = 0; i < pixels.length; i += 4) { - let lightness = - pixels[i] * WEIGHTS.r + - pixels[i + 1] * WEIGHTS.g + - pixels[i + 2] * WEIGHTS.b; - - if (pixels[i + 3] < 128) { - lightness = 255; - } - - minLightness = Math.min(minLightness, lightness); - maxLightness = Math.max(maxLightness, lightness); - averageLightness += lightness; - } - averageLightness = Math.round(averageLightness / (pixels.length / 4)); - - minLightness += 32; - maxLightness -= 32; - - const contrast = 255 / (maxLightness - minLightness); - - for (let i = 0; i < pixels.length; i += 4) { - let lightness = - pixels[i] * WEIGHTS.r + - pixels[i + 1] * WEIGHTS.g + - pixels[i + 2] * WEIGHTS.b; - - pixels[i] = Math.round(lightness * contrast) - minLightness; - pixels[i + 1] = Math.round(lightness * contrast) - minLightness; - pixels[i + 2] = Math.round(lightness * contrast) - minLightness; - } - - return { canvasData, averageLightness }; -} diff --git a/lib/coords-txt.ts b/lib/coords-txt.ts new file mode 100644 index 0000000..2cbd7c5 --- /dev/null +++ b/lib/coords-txt.ts @@ -0,0 +1,55 @@ +import type { Coord } from './PINTR'; + +// Plain-text geometry export. The other two exports are pictures; this one is +// the raw path, so it can be converted to G-code, HPGL, or whatever else the +// user's machine wants without parsing an SVG. +// +// The file is nothing but coordinates — no header, no comments — so it drops +// straight into any parser. The one thing a reader cannot infer from the rows, +// the canvas the coordinates are measured against, rides in the filename. + +function samePoint(a: Coord, b: Coord): boolean { + return a[0] === b[0] && a[1] === b[1]; +} + +/** + * Segments arrive as independent pairs, but single-line mode emits a chain + * where each segment starts exactly where the previous one ended. Splitting on + * that discontinuity recovers the connected paths without needing to know which + * mode produced them: a chain collapses to one path, disjoint segments stay as + * one two-point path each. + */ +export function segmentsToPaths(coords: [Coord, Coord][]): Coord[][] { + const paths: Coord[][] = []; + let current: Coord[] | null = null; + let previousEnd: Coord | null = null; + + for (const [from, to] of coords) { + if (!current || !previousEnd || !samePoint(from, previousEnd)) { + current = [from]; + paths.push(current); + } + current.push(to); + previousEnd = to; + } + + return paths; +} + +/** `PINTR-1080x1350.txt` — the pixel space the coordinates are measured in. */ +export function coordsFileName(size: [number, number]) { + return `PINTR-${size[0]}x${size[1]}.txt`; +} + +/** + * One `x,y` per row; a blank line starts a new path (pen up, travel, pen down). + * Unlike the SVG renderers, which take only the start of each pair and so drop + * the drawing's final endpoint, every generated point is written out. + */ +export function generateCoordsTxt(coords: [Coord, Coord][]) { + const body = segmentsToPaths(coords) + .map((path) => path.map((point) => `${point[0]},${point[1]}`).join('\n')) + .join('\n\n'); + + return body ? `${body}\n` : ''; +} diff --git a/lib/draw.ts b/lib/draw.ts index 532356d..338e7ba 100644 --- a/lib/draw.ts +++ b/lib/draw.ts @@ -1,9 +1,14 @@ -export default function Draw(ctx: CanvasRenderingContext2D) { +export default function Draw( + ctx: CanvasRenderingContext2D, + singleLine = false +) { ctx.beginPath(); + let hasPoint = false; function lineBuffer(from: [number, number], to: [number, number]) { - ctx.moveTo(from[0], from[1]); + if (!singleLine || !hasPoint) ctx.moveTo(from[0], from[1]); ctx.lineTo(to[0], to[1]); + hasPoint = true; } function stroke( @@ -12,9 +17,11 @@ export default function Draw(ctx: CanvasRenderingContext2D) { const { color = '#000', width = 1 } = ops; ctx.lineWidth = width; + ctx.lineJoin = 'round'; ctx.strokeStyle = color; ctx.stroke(); ctx.beginPath(); + hasPoint = false; } return { diff --git a/lib/pintr-core/README.md b/lib/pintr-core/README.md new file mode 100644 index 0000000..1cc14c8 --- /dev/null +++ b/lib/pintr-core/README.md @@ -0,0 +1,128 @@ +# PINTR core + +```ts +import { createPintr } from './lib/pintr-core'; +import { preparePintrImage } from './lib/pintr-core/utils'; + +const image = preparePintrImage({ width, height, rgba }); +const pintr = createPintr({ + image, + config: { + contrast: 50, + definition: 50, + singleLine: true, + strokeWidth: 1.5, + }, + seed: 1234, +}); + +const first = pintr.next(128); +draw(first.lines); + +// Nothing runs between calls. Calling next() continues at line 128. +const second = pintr.next(128); +draw(second.lines); +``` + +The implementation is a local module for now; no package is created by this +extraction. A future package can expose the same two entry points as +`pintr-core` and `pintr-core/utils` without changing the API. + +## Core API + +The core receives prepared grayscale pixels and returns coordinates. It has no +DOM, Canvas, decoder, renderer, timer, or platform dependency. + +```ts +createPintr({ image, config, seed? }): PintrSession; + +session.next(count): PintrBatch; +``` + +`next(count)` synchronously generates exactly that many lines. The session is +naturally paused as soon as the call returns; call it again whenever the host is +ready. Its working image, cursor, and random generator stay warm between calls, +and batch boundaries do not affect the generated coordinates. + +The core only returns new lines. A host that needs the complete drawing owns +that list and appends each batch to it. This keeps generation state small and +lets a browser draw lines immediately without storing them twice. + +The input image is: + +```ts +type PintrImage = { + width: number; + height: number; + // Column-major: gray[x * height + y], where 0 is black and 255 is white. + gray: Uint8Array; +}; +``` + +PINTR copies the working pixels, so a prepared image can be reused to create +independent sessions. Supplying a uint32 `seed` makes a run reproducible. + +## Host scheduling + +A browser or React Native host should request small batches from its own frame +loop: + +```ts +function frame() { + const remaining = targetLineCount - pintr.lineCount; + if (remaining <= 0) return; + + draw(pintr.next(Math.min(32, remaining)).lines); + requestAnimationFrame(frame); +} +``` + +A CLI can use larger batches or generate its whole target in one call: + +```ts +const lines = []; + +while (pintr.lineCount < targetLineCount) { + const batch = pintr.next( + Math.min(1000, targetLineCount - pintr.lineCount) + ); + lines.push(...batch.lines); +} + +writeCoordinates(lines); +``` + +## Optional image utilities + +`./lib/pintr-core/utils` contains pure, non-mutating helpers that are not needed +by the generator itself: + +```ts +preparePintrImage(rgbaImage): PintrImage; +resizeRgbaImage(rgbaImage, { longestSide, allowUpscale? }): RgbaImage; +applyVignette(rgbaImage, options): RgbaImage; +frameRgbaImage(rgbaImage, { aspectRatio, padding, extend? }): RgbaImage; +extendRgbaImage(rgbaImage, { canvasWidth, canvasHeight, offsetX, offsetY }): RgbaImage; +``` + +`frameRgbaImage` places the image, contained, on an opaque white canvas of the +requested aspect ratio (`width / height`, or `null` to keep the source's). +`padding` is a percentage of the canvas short side and insets the picture within +that canvas, so the requested ratio always decides the output dimensions. With +`extend`, the space around the picture is filled by `extendRgbaImage` instead of +staying white. + +`extendRgbaImage` continues a picture outward over the canvas around it. Each +pixel outside averages the picture pixels within its own distance of the nearest +picture pixel, weighted so the nearest ones count for most. The fill leaves the +edge sharp, grows blurrier the further it travels, and settles on the picture's +overall tone far away from it — a diffusion outward rather than the edge pixel +smearing. The weight comes from averaging four nested boxes, each half the radius +of the one before, which counts the near pixels repeatedly; boxes rather than +discs let a summed-area table answer any radius in constant time, so the pass +stays linear in the canvas. + +`RgbaImage.rgba` is row-major, unpremultiplied RGBA in a `Uint8Array` or +`Uint8ClampedArray`. Image decoding remains the host's responsibility: Canvas +in a browser, the application's native image library in React Native, or a Node +image library in a CLI. diff --git a/lib/pintr-core/createPintr.ts b/lib/pintr-core/createPintr.ts new file mode 100644 index 0000000..2dff4d2 --- /dev/null +++ b/lib/pintr-core/createPintr.ts @@ -0,0 +1,181 @@ +import { tweenValue } from './math'; +import { createRandom } from './random'; +import { scanLine } from './scan'; +import { createSource } from './source'; +import type { + PintrBatch, + PintrConfig, + PintrImage, + PintrLine, + PintrPoint, + PintrSession, +} from './types'; +import { PintrError } from './types'; + +const MAX_PIXELS = 100_000_000; + +function validateImage(image: PintrImage) { + if (!Number.isInteger(image.width) || !Number.isInteger(image.height)) { + throw new PintrError('image dimensions must be integers'); + } + if (image.width <= 0 || image.height <= 0) { + throw new PintrError('image dimensions must be positive'); + } + if (image.width * image.height > MAX_PIXELS) { + throw new PintrError('image is too large'); + } + if (!(image.gray instanceof Uint8Array)) { + throw new PintrError('image.gray must be a Uint8Array'); + } + if (image.gray.length !== image.width * image.height) { + throw new PintrError('image.gray has the wrong length'); + } +} + +function validateConfig(config: PintrConfig) { + if (!Number.isFinite(config.contrast) || config.contrast < 0 || config.contrast > 100) { + throw new PintrError('contrast must be between 0 and 100'); + } + if ( + !Number.isFinite(config.definition) || + config.definition < 0 || + config.definition > 100 + ) { + throw new PintrError('definition must be between 0 and 100'); + } + if (typeof config.singleLine !== 'boolean') { + throw new PintrError('singleLine must be a boolean'); + } + if (!Number.isFinite(config.strokeWidth) || config.strokeWidth <= 0) { + throw new PintrError('strokeWidth must be greater than zero'); + } +} + +export function createPintr(input: { + image: PintrImage; + config: PintrConfig; + seed?: number; +}): PintrSession { + const { image } = input; + const seed = + input.seed === undefined + ? Math.floor(Math.random() * 0x100000000) + : input.seed; + + validateImage(image); + validateConfig(input.config); + if (!Number.isInteger(seed) || seed < 0 || seed > 0xffffffff) { + throw new PintrError('seed must be a uint32'); + } + + // Everything needed to continue a drawing stays in this closure. Calling + // next() later resumes from these values without replaying earlier lines. + const config = { ...input.config }; + const tweenDefinition = Math.round( + tweenValue(config.definition, [ + [0, 3], + [50, 15], + [100, 75], + ]) + ); + const feedbackAlpha = + (100 - + Math.round( + tweenValue(config.contrast, [ + [0, 20], + [50, 67], + [100, 90], + ]) + )) / + 100; + const updateSampleRate = 100 - Math.floor(tweenDefinition / 2); + const source = createSource(image.width, image.height, image.gray); + const random = createRandom(seed); + let pendingLines: PintrLine[] = []; + let cursor: PintrPoint = [ + Math.floor(image.width / 2), + Math.floor(image.height / 2), + ]; + let generatedLineCount = 0; + + function drawSequenceLine() { + let from = cursor; + + // if no `singleLine` hop to different points to find a new cursor + let toFrom = config.singleLine ? 0 : tweenDefinition; + while (toFrom--) { + const tmpFrom: PintrPoint = [ + random.int(image.width), + random.int(image.height), + ]; + if ( + source.gray[from[0] * image.height + from[1]] > + source.gray[tmpFrom[0] * image.height + tmpFrom[1]] + ) { + from = tmpFrom; + } + } + + // now we look at different places to expand + let remainingCursorsToExplore = random.int( + tweenDefinition, + tweenDefinition * 2 + ); + let to: PintrPoint = [random.int(image.width), random.int(image.height)]; + let light = 255; + while (remainingCursorsToExplore--) { + const tmpTo: PintrPoint = [ + random.int(image.width), + random.int(image.height), + ]; + const tmpLight = scanLine(from, tmpTo, source.gray, image.height); + if (tmpLight <= light) { + light = tmpLight; + to = tmpTo; + } + } + light = scanLine(from, to, source.gray, image.height); + + const line: PintrLine = [from, to]; + pendingLines.push(line); + cursor = line[1]; + + return line; + } + + function next(lineCount: number): PintrBatch { + if (!Number.isInteger(lineCount) || lineCount <= 0) { + throw new PintrError('next() lineCount must be a positive integer'); + } + + const startLine = generatedLineCount; + const batch: PintrLine[] = []; + + for (let i = 0; i < lineCount; i++) { + // Keep this before the next line: exactly R generated lines remain pending + // until line R asks the source to refresh. + if (generatedLineCount % updateSampleRate === 0) { + source.erase(pendingLines, config.strokeWidth * 1.5, feedbackAlpha); + pendingLines = []; + } + batch.push(drawSequenceLine()); + generatedLineCount++; + } + + return { + startLine, + endLine: generatedLineCount, + lines: batch, + }; + } + + return { + width: image.width, + height: image.height, + seed, + get lineCount() { + return generatedLineCount; + }, + next, + }; +} diff --git a/lib/pintr-core/index.ts b/lib/pintr-core/index.ts new file mode 100644 index 0000000..f6a50db --- /dev/null +++ b/lib/pintr-core/index.ts @@ -0,0 +1,10 @@ +export { createPintr } from './createPintr'; +export { PintrError } from './types'; +export type { + PintrBatch, + PintrConfig, + PintrImage, + PintrLine, + PintrPoint, + PintrSession, +} from './types'; diff --git a/lib/pintr-core/math.ts b/lib/pintr-core/math.ts new file mode 100644 index 0000000..48582b5 --- /dev/null +++ b/lib/pintr-core/math.ts @@ -0,0 +1,29 @@ +export function tweenValue( + value: number, + tweens: readonly (readonly [number, number])[] +): number { + const sortedTweens = [...tweens].sort((a, b) => a[0] - b[0]); + + if ( + value < sortedTweens[0][0] || + value > sortedTweens[sortedTweens.length - 1][0] + ) { + return value; + } + + for (let i = 0; i < sortedTweens.length; i++) { + const tween = sortedTweens[i]; + + if (tween[0] === value) return tween[1]; + + if (tween[0] > value) { + const previousTween = sortedTweens[i - 1]; + const range = tween[0] - previousTween[0]; + const progress = (value - previousTween[0]) / range; + + return progress * tween[1] + (1 - progress) * previousTween[1]; + } + } + + return value; +} diff --git a/lib/pintr-core/random.ts b/lib/pintr-core/random.ts new file mode 100644 index 0000000..b7c9e72 --- /dev/null +++ b/lib/pintr-core/random.ts @@ -0,0 +1,26 @@ +const STEP = 0x6d2b79f5; + +// A seed makes a drawing reproducible without changing how the generator asks +// for random integers. +export function createRandom(seed: number) { + let state = seed >>> 0; + + function next() { + state = (state + STEP) >>> 0; + let value = state; + value = Math.imul(value ^ (value >>> 15), value | 1); + value ^= value + Math.imul(value ^ (value >>> 7), value | 61); + + return ((value ^ (value >>> 14)) >>> 0) / 4294967296; + } + + function int(start: number, end?: number) { + if (end === undefined) return Math.floor(next() * start); + + return ( + Math.min(start, end) + Math.floor(next() * Math.abs(start - end)) + ); + } + + return { int }; +} diff --git a/lib/scan.ts b/lib/pintr-core/scan.ts similarity index 70% rename from lib/scan.ts rename to lib/pintr-core/scan.ts index 36cbc56..c06eccf 100644 --- a/lib/scan.ts +++ b/lib/pintr-core/scan.ts @@ -1,6 +1,11 @@ -import type { Coord } from './PINTR'; +import type { PintrPoint } from './types'; -export function scanLine(from: Coord, to: Coord, matrix: Uint8Array[]) { +export function scanLine( + from: PintrPoint, + to: PintrPoint, + gray: Uint8Array, + height: number +) { let total = 0; const x = from[0]; @@ -16,7 +21,7 @@ export function scanLine(from: Coord, to: Coord, matrix: Uint8Array[]) { for (let i = 0; i < precision; i++) { const scanX = Math.round(x + stepX * i); const scanY = Math.round(y + stepY * i); - total += matrix[scanX][scanY]; + total += gray[scanX * height + scanY]; } return Math.round(total / precision); diff --git a/lib/pintr-core/source.ts b/lib/pintr-core/source.ts new file mode 100644 index 0000000..d3676dc --- /dev/null +++ b/lib/pintr-core/source.ts @@ -0,0 +1,117 @@ +import type { PintrLine } from './types'; + +const SAMPLES = 4; +const SAMPLE_COUNT = SAMPLES * SAMPLES; +const EPSILON = 1e-9; + +function countBits16(value: number) { + value -= (value >>> 1) & 0x5555; + value = (value & 0x3333) + ((value >>> 2) & 0x3333); + value = (value + (value >>> 4)) & 0x0f0f; + value += value >>> 8; + return value & 0x1f; +} + +// The browser strokes every buffered segment as one path, so overlaps are a +// union. A bit per sub-pixel keeps that union instead of blending twice. +export function createSource( + width: number, + height: number, + initialGray: Uint8Array +) { + const gray = new Uint8Array(initialGray); + const coverage = new Uint16Array(width * height); + const touched: number[] = []; + + function coverLine(line: PintrLine, strokeWidth: number) { + const x1 = line[0][0]; + const y1 = line[0][1]; + const x2 = line[1][0]; + const y2 = line[1][1]; + const dx = x2 - x1; + const dy = y2 - y1; + const length = Math.hypot(dx, dy); + + // A zero-length subpath with a butt cap paints nothing. + if (!length) return; + + const half = strokeWidth / 2; + const nx = (-dy / length) * half; + const ny = (dx / length) * half; + const xs = [x1 + nx, x2 + nx, x2 - nx, x1 - nx]; + const ys = [y1 + ny, y2 + ny, y2 - ny, y1 - ny]; + + const minY = Math.max(0, Math.floor(Math.min(...ys) - 1)); + const maxY = Math.min(height - 1, Math.ceil(Math.max(...ys) + 1)); + + for (let y = minY; y <= maxY; y++) { + for (let sampleY = 0; sampleY < SAMPLES; sampleY++) { + const py = y + (sampleY + 0.5) / SAMPLES; + let left = Infinity; + let right = -Infinity; + + for (let edge = 0; edge < 4; edge++) { + const nextEdge = (edge + 1) % 4; + const ay = ys[edge]; + const by = ys[nextEdge]; + + if (!((ay <= py && by > py) || (by <= py && ay > py))) continue; + + const progress = (py - ay) / (by - ay); + const px = xs[edge] + progress * (xs[nextEdge] - xs[edge]); + left = Math.min(left, px); + right = Math.max(right, px); + } + + if (left === Infinity) continue; + + const minX = Math.max(0, Math.floor(left) - 1); + const maxX = Math.min(width - 1, Math.ceil(right) + 1); + + for (let x = minX; x <= maxX; x++) { + let bits = 0; + + for (let sampleX = 0; sampleX < SAMPLES; sampleX++) { + const px = x + (sampleX + 0.5) / SAMPLES; + if (px >= left - EPSILON && px <= right + EPSILON) { + bits |= 1 << (sampleY * SAMPLES + sampleX); + } + } + + if (!bits) continue; + + const pixel = y * width + x; + if (!coverage[pixel]) touched.push(pixel); + coverage[pixel] |= bits; + } + } + } + } + + function erase( + lines: readonly PintrLine[], + strokeWidth: number, + alpha: number + ) { + if (!lines.length || alpha <= 0 || strokeWidth <= 0) return; + + for (const line of lines) coverLine(line, strokeWidth); + + for (const pixel of touched) { + const x = pixel % width; + const y = Math.floor(pixel / width); + const grayIndex = x * height + y; + const amount = (countBits16(coverage[pixel]) / SAMPLE_COUNT) * alpha; + const current = gray[grayIndex]; + gray[grayIndex] = Math.round(current + (255 - current) * amount); + coverage[pixel] = 0; + } + + touched.length = 0; + } + + return { + gray, + erase, + }; +} diff --git a/lib/pintr-core/types.ts b/lib/pintr-core/types.ts new file mode 100644 index 0000000..9269d7c --- /dev/null +++ b/lib/pintr-core/types.ts @@ -0,0 +1,38 @@ +export type PintrPoint = [x: number, y: number]; +export type PintrLine = [from: PintrPoint, to: PintrPoint]; + +// The core starts after image preparation. Keeping this shape small means a +// browser, native app, or CLI only has to agree on pixels — never on decoders. +export type PintrImage = { + width: number; + height: number; + gray: Uint8Array; +}; + +export type PintrConfig = { + contrast: number; + definition: number; + singleLine: boolean; + strokeWidth: number; +}; + +export type PintrBatch = { + startLine: number; + endLine: number; + lines: PintrLine[]; +}; + +export type PintrSession = { + width: number; + height: number; + seed: number; + lineCount: number; + next(lineCount: number): PintrBatch; +}; + +export class PintrError extends Error { + constructor(message: string) { + super(`PINTR: ${message}`); + this.name = 'PintrError'; + } +} diff --git a/lib/pintr-core/utils/applyVignette.ts b/lib/pintr-core/utils/applyVignette.ts new file mode 100644 index 0000000..e063aa8 --- /dev/null +++ b/lib/pintr-core/utils/applyVignette.ts @@ -0,0 +1,52 @@ +import { clamp, validateRgbaImage } from './shared'; +import type { RgbaImage } from './types'; + +export type VignetteOptions = { + vignetteDistance: number; + vignetteHardness: number; + vignetteSquare: boolean; +}; + +export function applyVignette( + image: RgbaImage, + { vignetteDistance, vignetteHardness, vignetteSquare }: VignetteOptions +): RgbaImage { + validateRgbaImage(image); + const { width: W, height: H } = image; + const rgba = new Uint8ClampedArray(image.rgba); + const SHORT_SIDE = Math.min(W, H); + const cx = W / 2; + const cy = H / 2; + + // distance in px; 0 = strongest (fade at the inscribed edge), raising it pushes + // the fade outward toward the corners until it disappears. + const VD = (clamp(vignetteDistance, 0, 100) / 100) * (SHORT_SIDE / 2); + const hardness = clamp(vignetteHardness, 0, 100) / 100; + const outer = VD + SHORT_SIDE / 2; + const inner = outer - (1 - hardness) * SHORT_SIDE; + const denom = Math.max(1e-6, outer - inner); + const hx = vignetteSquare ? SHORT_SIDE / 2 : W / 2; + const hy = vignetteSquare ? SHORT_SIDE / 2 : H / 2; + + for (let y = 0; y < H; y++) { + for (let x = 0; x < W; x++) { + const d = + (SHORT_SIDE / 2) * Math.hypot((x - cx) / hx, (y - cy) / hy); + + let alpha: number; + if (d <= inner) alpha = 1; + else if (d >= outer) alpha = 0; + else alpha = (outer - d) / denom; + + if (alpha >= 1) continue; + + const i = (y * W + x) * 4; + const white = (1 - alpha) * 255; + rgba[i] = rgba[i] * alpha + white; + rgba[i + 1] = rgba[i + 1] * alpha + white; + rgba[i + 2] = rgba[i + 2] * alpha + white; + } + } + + return { width: W, height: H, rgba }; +} diff --git a/lib/pintr-core/utils/extendRgbaImage.ts b/lib/pintr-core/utils/extendRgbaImage.ts new file mode 100644 index 0000000..cb56f26 --- /dev/null +++ b/lib/pintr-core/utils/extendRgbaImage.ts @@ -0,0 +1,183 @@ +import { PintrError } from '../types'; +import { clamp, toUint8Clamp, validateRgbaImage } from './shared'; +import type { RgbaImage } from './types'; + +export type ExtendOptions = { + // The canvas the picture is being placed on, and where it sits inside it. + canvasWidth: number; + canvasHeight: number; + offsetX: number; + offsetY: number; +}; + +// How many nested boxes the weighted average blends, each half the radius of the +// one before. Four octaves put roughly eighty times more weight on a pixel +// against the picture than on one at the far edge of the window, while still +// giving every scale a comparable share of the total — so the fill keeps the +// structure it is nearest to and stays self-similar as the radius grows. +const LEVELS = 4; + +// Fill everything around a picture with a plausible continuation of it. +// +// For a pixel outside the picture, the nearest picture pixel is just a clamp of +// its coordinates — the picture is always an axis-aligned rectangle, so no +// distance transform is needed. The colour written is then an average of the +// picture pixels within that same distance of the nearest pixel, weighted so the +// ones closest to it count for most: touching the edge it is the edge pixel +// itself, and the further out it travels the wider it averages, until the far +// corners settle on the picture's overall tone. It reads as the picture +// diffusing outward rather than the edge pixel smearing. +// +// The weighting is what nested boxes buy. A single box would let a pixel at the +// rim of the window pull as hard as one against the picture, which flattens the +// structure the fill is supposed to continue; averaging boxes of shrinking +// radius counts the near ones repeatedly, so weight falls off with distance. +// Boxes rather than discs are what make it affordable: a summed-area table +// answers any box in four lookups, so the pass stays linear in the canvas +// however large the radii grow. +export function extendRgbaImage( + picture: RgbaImage, + { canvasWidth, canvasHeight, offsetX, offsetY }: ExtendOptions +): RgbaImage { + validateRgbaImage(picture); + if (!Number.isInteger(canvasWidth) || !Number.isInteger(canvasHeight)) { + throw new PintrError('extend canvas dimensions must be integers'); + } + if (canvasWidth <= 0 || canvasHeight <= 0) { + throw new PintrError('extend canvas dimensions must be positive'); + } + if (!Number.isInteger(offsetX) || !Number.isInteger(offsetY)) { + throw new PintrError('extend offsets must be integers'); + } + + const { width, height } = picture; + const source = picture.rgba; + + // A row and a column of zeros ahead of the picture so the four-corner lookup + // needs no bounds tests. The largest entry is width * height * 255, which + // overflows Uint32 past ~16.8 MP, so the prefix sums are held as Float64 — + // exact for integers well beyond the 100 MP validateRgbaImage allows. + const satWidth = width + 1; + const sat = new Float64Array(satWidth * (height + 1) * 4); + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const pixel = (y * width + x) * 4; + const current = ((y + 1) * satWidth + x + 1) * 4; + const above = (y * satWidth + x + 1) * 4; + const before = ((y + 1) * satWidth + x) * 4; + const diagonal = (y * satWidth + x) * 4; + + for (let channel = 0; channel < 4; channel++) { + sat[current + channel] = + source[pixel + channel] + + sat[above + channel] + + sat[before + channel] - + sat[diagonal + channel]; + } + } + } + + const spare = new Float64Array(4); + + // Mean of the box of the given whole radius around a picture pixel, clipped to + // the picture — outside it there is nothing to average. + function boxMean( + centreX: number, + centreY: number, + radius: number, + target: Float64Array + ) { + const x0 = Math.max(0, centreX - radius); + const y0 = Math.max(0, centreY - radius); + const x1 = Math.min(width - 1, centreX + radius); + const y1 = Math.min(height - 1, centreY + radius); + const count = (x1 - x0 + 1) * (y1 - y0 + 1); + + const topLeft = (y0 * satWidth + x0) * 4; + const topRight = (y0 * satWidth + x1 + 1) * 4; + const bottomLeft = ((y1 + 1) * satWidth + x0) * 4; + const bottomRight = ((y1 + 1) * satWidth + x1 + 1) * 4; + + for (let channel = 0; channel < 4; channel++) { + target[channel] = + (sat[bottomRight + channel] - + sat[topRight + channel] - + sat[bottomLeft + channel] + + sat[topLeft + channel]) / + count; + } + } + + // The same for a fractional radius. Blending the two whole radii keeps every + // box continuous with the distance that chose it; rounding instead makes the + // contours of that rounding — rounded rectangles around the picture — visible + // as bands wherever the picture is smooth. + function boxMeanAt( + centreX: number, + centreY: number, + radius: number, + target: Float64Array + ) { + const whole = Math.floor(radius); + const fraction = radius - whole; + + boxMean(centreX, centreY, whole, target); + if (fraction > 0) { + boxMean(centreX, centreY, whole + 1, spare); + for (let channel = 0; channel < 4; channel++) { + target[channel] += (spare[channel] - target[channel]) * fraction; + } + } + } + + const rgba = new Uint8ClampedArray(canvasWidth * canvasHeight * 4); + const level = new Float64Array(4); + const weighted = new Float64Array(4); + + for (let y = 0; y < canvasHeight; y++) { + const sourceY = y - offsetY; + const sampleY = clamp(sourceY, 0, height - 1); + const dy = sourceY - sampleY; + + for (let x = 0; x < canvasWidth; x++) { + const sourceX = x - offsetX; + const sampleX = clamp(sourceX, 0, width - 1); + const dx = sourceX - sampleX; + const target = (y * canvasWidth + x) * 4; + + // The clamp moved nothing, so this pixel is the picture: copy it verbatim + // rather than averaging over it. + if (dx === 0 && dy === 0) { + const pixel = (sampleY * width + sampleX) * 4; + rgba[target] = source[pixel]; + rgba[target + 1] = source[pixel + 1]; + rgba[target + 2] = source[pixel + 2]; + rgba[target + 3] = source[pixel + 3]; + continue; + } + + weighted[0] = 0; + weighted[1] = 0; + weighted[2] = 0; + weighted[3] = 0; + + let radius = Math.sqrt(dx * dx + dy * dy); + for (let step = 0; step < LEVELS; step++) { + boxMeanAt(sampleX, sampleY, radius, level); + weighted[0] += level[0]; + weighted[1] += level[1]; + weighted[2] += level[2]; + weighted[3] += level[3]; + radius /= 2; + } + + rgba[target] = toUint8Clamp(weighted[0] / LEVELS); + rgba[target + 1] = toUint8Clamp(weighted[1] / LEVELS); + rgba[target + 2] = toUint8Clamp(weighted[2] / LEVELS); + rgba[target + 3] = toUint8Clamp(weighted[3] / LEVELS); + } + } + + return { width: canvasWidth, height: canvasHeight, rgba }; +} diff --git a/lib/pintr-core/utils/frameRgbaImage.ts b/lib/pintr-core/utils/frameRgbaImage.ts new file mode 100644 index 0000000..53f067e --- /dev/null +++ b/lib/pintr-core/utils/frameRgbaImage.ts @@ -0,0 +1,90 @@ +import { PintrError } from '../types'; +import { extendRgbaImage } from './extendRgbaImage'; +import { resizeRgbaImage } from './resizeRgbaImage'; +import { clamp, validateRgbaImage } from './shared'; +import type { RgbaImage } from './types'; + +export type FrameOptions = { + // width / height of the output canvas; null keeps the source ratio. + aspectRatio: number | null; + // 0-100, percent of the canvas short side, inset on each of the four sides. + padding: number; + // Fill the space around the picture with a blurred continuation of it + // instead of leaving it white. + extend?: boolean; +}; + +// Place the image, contained (never cropped, never stretched), on an opaque +// white canvas of the requested aspect ratio. The ratio alone decides the output +// dimensions: padding insets the picture inside that canvas instead of growing +// it, so the ratio stays exact. White is what PINTR reads as empty, so the +// letterbox and margin simply produce no lines — unless `extend` diffuses the +// picture into them, which makes the drawing reach the frame. +export function frameRgbaImage( + image: RgbaImage, + { aspectRatio, padding, extend = false }: FrameOptions +): RgbaImage { + validateRgbaImage(image); + if (aspectRatio !== null && (!Number.isFinite(aspectRatio) || aspectRatio <= 0)) { + throw new PintrError('frame aspectRatio must be a positive number or null'); + } + if (!Number.isFinite(padding) || padding < 0 || padding >= 50) { + throw new PintrError('frame padding must be between 0 and 50'); + } + + const longest = Math.max(image.width, image.height); + const ratio = aspectRatio ?? image.width / image.height; + const canvasWidth = ratio >= 1 ? longest : Math.max(1, Math.round(longest * ratio)); + const canvasHeight = ratio >= 1 ? Math.max(1, Math.round(longest / ratio)) : longest; + + const pad = Math.round( + (clamp(padding, 0, 100) / 100) * Math.min(canvasWidth, canvasHeight) + ); + const innerWidth = Math.max(1, canvasWidth - pad * 2); + const innerHeight = Math.max(1, canvasHeight - pad * 2); + + // Contain preserves the source ratio, so the scaled longest side is exactly + // what resizeRgbaImage takes — no second resampler needed here. + const scale = Math.min(innerWidth / image.width, innerHeight / image.height); + const scaled = + scale >= 1 + ? image + : resizeRgbaImage(image, { + longestSide: Math.max(1, Math.round(longest * scale)), + }); + + const offsetX = Math.round((canvasWidth - scaled.width) / 2); + const offsetY = Math.round((canvasHeight - scaled.height) / 2); + + if (extend) { + return extendRgbaImage(scaled, { + canvasWidth, + canvasHeight, + offsetX, + offsetY, + }); + } + + // Walk the canvas rather than the picture: everything the picture does not + // cover keeps the white the buffer was filled with. + const rgba = new Uint8ClampedArray(canvasWidth * canvasHeight * 4).fill(255); + + for (let y = 0; y < canvasHeight; y++) { + const sourceY = y - offsetY; + if (sourceY < 0 || sourceY >= scaled.height) continue; + + for (let x = 0; x < canvasWidth; x++) { + const sourceX = x - offsetX; + if (sourceX < 0 || sourceX >= scaled.width) continue; + + const source = (sourceY * scaled.width + sourceX) * 4; + const target = (y * canvasWidth + x) * 4; + rgba[target] = scaled.rgba[source]; + rgba[target + 1] = scaled.rgba[source + 1]; + rgba[target + 2] = scaled.rgba[source + 2]; + rgba[target + 3] = scaled.rgba[source + 3]; + } + } + + return { width: canvasWidth, height: canvasHeight, rgba }; +} diff --git a/lib/pintr-core/utils/index.ts b/lib/pintr-core/utils/index.ts new file mode 100644 index 0000000..509f259 --- /dev/null +++ b/lib/pintr-core/utils/index.ts @@ -0,0 +1,9 @@ +export { applyVignette } from './applyVignette'; +export type { VignetteOptions } from './applyVignette'; +export { extendRgbaImage } from './extendRgbaImage'; +export type { ExtendOptions } from './extendRgbaImage'; +export { frameRgbaImage } from './frameRgbaImage'; +export type { FrameOptions } from './frameRgbaImage'; +export { preparePintrImage } from './preparePintrImage'; +export { resizeRgbaImage } from './resizeRgbaImage'; +export type { RgbaImage } from './types'; diff --git a/lib/pintr-core/utils/preparePintrImage.ts b/lib/pintr-core/utils/preparePintrImage.ts new file mode 100644 index 0000000..51d3234 --- /dev/null +++ b/lib/pintr-core/utils/preparePintrImage.ts @@ -0,0 +1,60 @@ +import type { PintrImage } from '../types'; +import { toUint8Clamp, validateRgbaImage } from './shared'; +import type { RgbaImage } from './types'; + +const RED_PREFERENCE = 0.1; +const WEIGHTS = { + r: 0.299 + RED_PREFERENCE, + g: 0.587 + RED_PREFERENCE * -0.5, + b: 0.114 + RED_PREFERENCE * -0.5, +}; + +export function preparePintrImage(image: RgbaImage): PintrImage { + validateRgbaImage(image); + const pixels = image.rgba; + let minLightness = Infinity; + let maxLightness = 0; + + for (let i = 0; i < pixels.length; i += 4) { + let lightness = + pixels[i] * WEIGHTS.r + + pixels[i + 1] * WEIGHTS.g + + pixels[i + 2] * WEIGHTS.b; + + // Keep the current split behavior: transparent pixels affect the min/max as + // white here, while the RGB normalization below still uses their RGB bytes. + if (pixels[i + 3] < 128) lightness = 255; + + minLightness = Math.min(minLightness, lightness); + maxLightness = Math.max(maxLightness, lightness); + } + + minLightness += 32; + maxLightness -= 32; + const contrast = 255 / (maxLightness - minLightness); + const gray = new Uint8Array(image.width * image.height); + + for (let y = 0; y < image.height; y++) { + for (let x = 0; x < image.width; x++) { + const rgbaIndex = (y * image.width + x) * 4; + const lightness = + pixels[rgbaIndex] * WEIGHTS.r + + pixels[rgbaIndex + 1] * WEIGHTS.g + + pixels[rgbaIndex + 2] * WEIGHTS.b; + const normalized = toUint8Clamp( + Math.round(lightness * contrast) - minLightness + ); + const alpha = pixels[rgbaIndex + 3]; + const composited = + alpha === 255 + ? normalized + : Math.round( + normalized * (alpha / 255) + 255 * (1 - alpha / 255) + ); + + gray[x * image.height + y] = composited; + } + } + + return { width: image.width, height: image.height, gray }; +} diff --git a/lib/pintr-core/utils/resizeRgbaImage.ts b/lib/pintr-core/utils/resizeRgbaImage.ts new file mode 100644 index 0000000..fc9769c --- /dev/null +++ b/lib/pintr-core/utils/resizeRgbaImage.ts @@ -0,0 +1,120 @@ +import { PintrError } from '../types'; +import { toUint8Clamp, validateRgbaImage } from './shared'; +import type { RgbaImage } from './types'; + +export function resizeRgbaImage( + image: RgbaImage, + { + longestSide, + allowUpscale = false, + }: { longestSide: number; allowUpscale?: boolean } +): RgbaImage { + validateRgbaImage(image); + if (!Number.isInteger(longestSide) || longestSide <= 0) { + throw new PintrError('resize longestSide must be a positive integer'); + } + + const scale = longestSide / Math.max(image.width, image.height); + if (!allowUpscale && scale >= 1) { + return { + width: image.width, + height: image.height, + rgba: new Uint8ClampedArray(image.rgba), + }; + } + + const width = Math.max(1, Math.round(image.width * scale)); + const height = Math.max(1, Math.round(image.height * scale)); + const rgba = new Uint8ClampedArray(width * height * 4); + + if (scale >= 1) { + // Enlarging: every output pixel sits between four source pixels, so pixel- + // centre bilinear is the right filter and is deterministic. + for (let y = 0; y < height; y++) { + const sourceY = (y + 0.5) / scale - 0.5; + const y0 = Math.max(0, Math.floor(sourceY)); + const y1 = Math.min(image.height - 1, y0 + 1); + const fy = Math.max(0, sourceY - y0); + + for (let x = 0; x < width; x++) { + const sourceX = (x + 0.5) / scale - 0.5; + const x0 = Math.max(0, Math.floor(sourceX)); + const x1 = Math.min(image.width - 1, x0 + 1); + const fx = Math.max(0, sourceX - x0); + const target = (y * width + x) * 4; + + for (let channel = 0; channel < 4; channel++) { + const topLeft = image.rgba[(y0 * image.width + x0) * 4 + channel]; + const topRight = image.rgba[(y0 * image.width + x1) * 4 + channel]; + const bottomLeft = image.rgba[(y1 * image.width + x0) * 4 + channel]; + const bottomRight = image.rgba[(y1 * image.width + x1) * 4 + channel]; + const top = topLeft * (1 - fx) + topRight * fx; + const bottom = bottomLeft * (1 - fx) + bottomRight * fx; + rgba[target + channel] = toUint8Clamp(top * (1 - fy) + bottom * fy); + } + } + } + + return { width, height, rgba }; + } + + // Reducing: average the whole source rectangle each output pixel covers, + // weighting the partial pixels at its edges. Bilinear only ever reads a 2x2 + // neighbourhood, so past ~0.5x it drops most of the source and aliases — + // and PINTR traces that aliasing into real lines. Framing reaches 0.28x at + // padding 25 with a tall ratio, well inside the range that matters. + const spanX = image.width / width; + const spanY = image.height / height; + + for (let y = 0; y < height; y++) { + const top = y * spanY; + const bottom = (y + 1) * spanY; + const firstRow = Math.floor(top); + const lastRow = Math.min(image.height - 1, Math.ceil(bottom) - 1); + + for (let x = 0; x < width; x++) { + const left = x * spanX; + const right = (x + 1) * spanX; + const firstColumn = Math.floor(left); + const lastColumn = Math.min(image.width - 1, Math.ceil(right) - 1); + + let red = 0; + let green = 0; + let blue = 0; + let alpha = 0; + let total = 0; + + for (let sourceY = firstRow; sourceY <= lastRow; sourceY++) { + const weightY = + Math.min(sourceY + 1, bottom) - Math.max(sourceY, top); + if (weightY <= 0) continue; + + for ( + let sourceX = firstColumn; + sourceX <= lastColumn; + sourceX++ + ) { + const weightX = + Math.min(sourceX + 1, right) - Math.max(sourceX, left); + if (weightX <= 0) continue; + + const weight = weightX * weightY; + const source = (sourceY * image.width + sourceX) * 4; + red += image.rgba[source] * weight; + green += image.rgba[source + 1] * weight; + blue += image.rgba[source + 2] * weight; + alpha += image.rgba[source + 3] * weight; + total += weight; + } + } + + const target = (y * width + x) * 4; + rgba[target] = toUint8Clamp(red / total); + rgba[target + 1] = toUint8Clamp(green / total); + rgba[target + 2] = toUint8Clamp(blue / total); + rgba[target + 3] = toUint8Clamp(alpha / total); + } + } + + return { width, height, rgba }; +} diff --git a/lib/pintr-core/utils/shared.ts b/lib/pintr-core/utils/shared.ts new file mode 100644 index 0000000..a7d749a --- /dev/null +++ b/lib/pintr-core/utils/shared.ts @@ -0,0 +1,44 @@ +import type { RgbaImage } from './types'; +import { PintrError } from '../types'; + +const MAX_PIXELS = 100_000_000; + +export function validateRgbaImage(image: RgbaImage) { + if (!Number.isInteger(image.width) || !Number.isInteger(image.height)) { + throw new PintrError('RGBA image dimensions must be integers'); + } + if (image.width <= 0 || image.height <= 0) { + throw new PintrError('RGBA image dimensions must be positive'); + } + if (image.width * image.height > MAX_PIXELS) { + throw new PintrError('RGBA image is too large'); + } + if ( + !(image.rgba instanceof Uint8Array) && + !(image.rgba instanceof Uint8ClampedArray) + ) { + throw new PintrError('RGBA pixels must be a Uint8Array'); + } + if (image.rgba.length !== image.width * image.height * 4) { + throw new PintrError('RGBA image has the wrong byte length'); + } +} + +export function clamp(value: number, start: number, end: number) { + const min = Math.min(start, end); + const max = Math.max(start, end); + return Math.min(Math.max(value, min), max); +} + +// ImageData writes use ToUint8Clamp rather than Uint8Array's modulo behavior. +// Keeping it explicit makes browser and CLI byte buffers prepare identically. +export function toUint8Clamp(value: number) { + if (Number.isNaN(value) || value <= 0) return 0; + if (value >= 255) return 255; + + const floor = Math.floor(value); + const fraction = value - floor; + if (fraction > 0.5) return floor + 1; + if (fraction < 0.5) return floor; + return floor % 2 ? floor + 1 : floor; +} diff --git a/lib/pintr-core/utils/types.ts b/lib/pintr-core/utils/types.ts new file mode 100644 index 0000000..845686d --- /dev/null +++ b/lib/pintr-core/utils/types.ts @@ -0,0 +1,5 @@ +export type RgbaImage = { + width: number; + height: number; + rgba: Uint8Array | Uint8ClampedArray; +}; diff --git a/lib/preprocess.ts b/lib/preprocess.ts new file mode 100644 index 0000000..9cb6a89 --- /dev/null +++ b/lib/preprocess.ts @@ -0,0 +1,133 @@ +import { applyVignette, frameRgbaImage } from './pintr-core/utils'; +import type { RgbaImage } from './pintr-core/utils'; + +// Cap the working canvas at the size PINTR itself draws at, so the per-pixel +// pass stays fast, the resulting data URL small, and padding percentages mean +// the same thing whatever the source resolution. +const MAX_SIDE = 1080; + +export type SourceConfig = { + // '' keeps the source ratio; otherwise 'W:H'. + aspectRatio: string; + // 0-100, percent of the canvas short side. + padding: number; + extend: boolean; + vignette: boolean; + vignetteDistance: number; + vignetteHardness: number; + vignetteSquare: boolean; +}; + +export type PreprocessedImage = { + src: string; + width: number; + height: number; +}; + +// Decoding an image is the slow part, so keep the last decoded source canvas +// around — dragging a pre-processing slider re-runs the pixel pass but not the decode. +let cache: { src: string; canvas: HTMLCanvasElement } | null = null; + +function loadImageEl(src: string): Promise { + return new Promise((resolve, reject) => { + const img = new window.Image(); + img.onload = () => resolve(img); + img.onerror = reject; + img.src = src; + }); +} + +async function getSourceCanvas(imgSrc: string): Promise { + if (cache && cache.src === imgSrc) return cache.canvas; + + const img = await loadImageEl(imgSrc); + const ratio = Math.min(1, MAX_SIDE / Math.max(img.width, img.height)); + const w = Math.max(1, Math.round(img.width * ratio)); + const h = Math.max(1, Math.round(img.height * ratio)); + + const canvas = document.createElement('canvas'); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error("Failed to 'getContext2d'"); + ctx.drawImage(img, 0, 0, w, h); + + cache = { src: imgSrc, canvas }; + return canvas; +} + +// 'W:H' -> W / H. Anything unparseable keeps the source ratio. +function parseAspectRatio(value: string): number | null { + const [w, h] = value.split(':').map(Number); + if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return null; + return w / h; +} + +function isIdentity(config: SourceConfig): boolean { + return ( + !config.vignette && + parseAspectRatio(config.aspectRatio) === null && + config.padding === 0 + ); +} + +// Apply the source options in the order the picture demands. The vignette always +// belongs on top of whatever counts as the picture: extending makes that the +// whole framed canvas, so it runs last; without extend the frame is only white +// margin, which the vignette must neither be centred on nor scaled against, so +// it runs on the picture first. +export function processSourceImage( + image: RgbaImage, + config: SourceConfig +): RgbaImage { + const vignette = (input: RgbaImage) => + config.vignette ? applyVignette(input, config) : input; + const frame = (input: RgbaImage) => + frameRgbaImage(input, { + aspectRatio: parseAspectRatio(config.aspectRatio), + padding: config.padding, + extend: config.extend, + }); + + return config.extend ? vignette(frame(image)) : frame(vignette(image)); +} + +// Produce the image actually fed to PINTR. With every source option at its +// default this is a passthrough so the rest of the pipeline is byte-for-byte +// unchanged. +export async function preprocessImage( + imgSrc: string, + config: SourceConfig +): Promise { + const srcCanvas = await getSourceCanvas(imgSrc); + + if (isIdentity(config)) { + return { src: imgSrc, width: srcCanvas.width, height: srcCanvas.height }; + } + + const srcCtx = srcCanvas.getContext('2d'); + if (!srcCtx) throw new Error("Failed to 'getContext2d'"); + const source = srcCtx.getImageData(0, 0, srcCanvas.width, srcCanvas.height); + + const image = processSourceImage( + { width: source.width, height: source.height, rgba: source.data }, + config + ); + + const canvas = document.createElement('canvas'); + canvas.width = image.width; + canvas.height = image.height; + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error("Failed to 'getContext2d'"); + ctx.putImageData( + new ImageData( + new Uint8ClampedArray(image.rgba), + image.width, + image.height + ), + 0, + 0 + ); + + return { src: canvas.toDataURL(), width: image.width, height: image.height }; +} diff --git a/lib/renderCoords.ts b/lib/renderCoords.ts index 0fffb06..8f86753 100644 --- a/lib/renderCoords.ts +++ b/lib/renderCoords.ts @@ -17,21 +17,26 @@ export function renderCoordsToCanvas( ctx.clearRect(0, 0, width, height); ctx.beginPath(); - if (singleLine && smoothingAmount > 0) { - const points = coords.map((coord) => coord[0]); - if (points.length) { - const segments = smoothControlPoints(points, smoothingAmount); - ctx.moveTo(points[0][0], points[0][1]); - for (const s of segments) { - ctx.bezierCurveTo( - s.cps[0], - s.cps[1], - s.cpe[0], - s.cpe[1], - s.point[0], - s.point[1] - ); + if (singleLine) { + if (smoothingAmount > 0) { + const points = coords.map((coord) => coord[0]); + if (points.length) { + const segments = smoothControlPoints(points, smoothingAmount); + ctx.moveTo(points[0][0], points[0][1]); + for (const s of segments) { + ctx.bezierCurveTo( + s.cps[0], + s.cps[1], + s.cpe[0], + s.cpe[1], + s.point[0], + s.point[1] + ); + } } + } else if (coords.length) { + ctx.moveTo(coords[0][0][0], coords[0][0][1]); + for (const [, to] of coords) ctx.lineTo(to[0], to[1]); } } else { for (const [from, to] of coords) { @@ -41,6 +46,7 @@ export function renderCoordsToCanvas( } ctx.lineWidth = strokeWidth; + ctx.lineJoin = 'round'; ctx.strokeStyle = '#000'; ctx.stroke(); } diff --git a/lib/smooth-svg.ts b/lib/smooth-svg.ts index 30ff5f3..39127ea 100644 --- a/lib/smooth-svg.ts +++ b/lib/smooth-svg.ts @@ -84,7 +84,7 @@ export function generateSmoothSvg( return ` + }" xmlns="http://www.w3.org/2000/svg" stroke="black" stroke-linejoin="round"> ${background}${path} `; diff --git a/lib/svg.ts b/lib/svg.ts index c25d14f..66f72bd 100644 --- a/lib/svg.ts +++ b/lib/svg.ts @@ -22,7 +22,7 @@ function generateMultiLineSvg( ) { return ` + }" xmlns="http://www.w3.org/2000/svg" stroke="black" stroke-width="${strokeWidth}" stroke-linejoin="round"> ${backgroundRect(size, whiteBackground)}${coords .map((coord) => { return ``; @@ -46,7 +46,7 @@ function generateSinglePolySvg( ) { return ` + }" xmlns="http://www.w3.org/2000/svg" stroke-linejoin="round"> ${backgroundRect(size, whiteBackground)} diff --git a/main.ts b/main.ts index f2a6c0a..fd9a47f 100644 --- a/main.ts +++ b/main.ts @@ -1,36 +1,92 @@ import { pinterCreator } from './lib/PINTR'; import { generateSvg } from './lib/svg'; +import { coordsFileName, generateCoordsTxt } from './lib/coords-txt'; import { renderCoordsToCanvas } from './lib/renderCoords'; +import { preprocessImage } from './lib/preprocess'; import { clamp, debounce, throttle } from './lib/utils'; import type { Coord } from './lib/PINTR'; +import type { SourceConfig } from './lib/preprocess'; -export type configType = { +// The pipeline has three independent layers, and the config mirrors them so the +// "what needs redoing?" question is answered by which group changed rather than +// by a hand-maintained list of field comparisons. + +// Only affects the image handed to the algorithm: re-run pre-processing, reload. +export type { SourceConfig }; + +// Affects the algorithm but not the source image: restart the PINTR session. +// Structurally identical to the core's PintrConfig, so it is passed straight in. +export type GenerationConfig = { contrast: number; definition: number; - lines: number; singleLine: boolean; strokeWidth: number; +}; + +// Independent of both: never regenerates, only repaints (or trims the buffer). +export type RenderConfig = { + lines: number; smoothingAmount: number; - advancedOptions: boolean; transparentBackground: boolean; }; +export type configType = { + source: SourceConfig; + generation: GenerationConfig; + render: RenderConfig; + advancedOptions: boolean; +}; + +// Stroke width uses the same shape as Lines: the slider is a plain 0-100 track +// and the curve lives here, so the fine widths people actually use occupy most +// of the travel while the thick end stays reachable. +const STROKE_MIN = 0.5; +const STROKE_MAX = 20; +const STROKE_POWER = 2; +// Mirrors the #strokeWidth input's defaultValue; lands on 3.62px. Heavier than +// the 1.5px this used to default to — and because strokeWidth also sets the +// feedback erase radius, it changes which lines get picked, not just their +// weight. +const STROKE_SLIDER_DEFAULT = 40; + +function strokeWidthFromSlider(value: number): number { + const t = clamp(value, 0, 100) / 100; + const width = + STROKE_MIN + (STROKE_MAX - STROKE_MIN) * Math.pow(t, STROKE_POWER); + return Math.round(width * 100) / 100; +} + const DEFAULT_IMG = '/pintr/test.jpg'; let CONFIG: configType = { - contrast: 50, - definition: 50, - lines: 50, - singleLine: true, - strokeWidth: 1, - smoothingAmount: 0, + source: { + aspectRatio: '', + padding: 0, + extend: false, + vignette: false, + vignetteDistance: 50, + vignetteHardness: 50, + vignetteSquare: false, + }, + generation: { + contrast: 50, + definition: 50, + singleLine: true, + strokeWidth: strokeWidthFromSlider(STROKE_SLIDER_DEFAULT), + }, + render: { + lines: 50, + smoothingAmount: 0, + transparentBackground: true, + }, advancedOptions: false, - transparentBackground: true, }; +type Size = { width: number; height: number }; + let GLOBAL: { currentImgSrc: string; // `buffer` holds every line ever drawn for the current image + generation @@ -43,6 +99,14 @@ let GLOBAL: { height: number; // Bumped on every new image so a superseded instance's late callbacks are ignored. drawToken: number; + // The pre-processed image actually fed to PINTR (equals currentImgSrc when + // every source option is at its default), plus whether the Source preview + // shows it. Both sizes are kept so the preview can use the right aspect for + // whichever of the two it is displaying. + processedImgSrc: string; + showPreprocessed: boolean; + sourceSize: Size; + processedSize: Size; } = { currentImgSrc: DEFAULT_IMG, coords: [], @@ -51,6 +115,10 @@ let GLOBAL: { width: 512, height: 512, drawToken: 0, + processedImgSrc: DEFAULT_IMG, + showPreprocessed: false, + sourceSize: { width: 512, height: 512 }, + processedSize: { width: 512, height: 512 }, }; // The single PINTR instance for the current image. Generation-param changes @@ -64,6 +132,12 @@ function getDrawCtx(): CanvasRenderingContext2D | null { return canvasDrawEl ? canvasDrawEl.getContext('2d') : null; } +// Renderers straddle two layers: the stroke geometry comes from the generation +// config, the styling from the render config. +function renderOptions() { + return { ...CONFIG.generation, ...CONFIG.render }; +} + // The Lines slider (0-100) maps directly to a target line count via a power // curve — no image-lightness or stroke-width scaling, so the slider value alone // decides how many lines are drawn. Left end is exactly LINES_MIN, right end is @@ -75,7 +149,7 @@ const LINES_POWER = 2; function desiredLineCount(): number { if (!PINTR) return 0; - const t = clamp(CONFIG.lines, 0, 100) / 100; + const t = clamp(CONFIG.render.lines, 0, 100) / 100; return Math.round( LINES_MIN + (LINES_MAX - LINES_MIN) * Math.pow(t, LINES_POWER) ); @@ -86,8 +160,13 @@ function desiredLineCount(): number { // displayed lines, not a live in-progress draw). function showFromBuffer(count: number) { GLOBAL.coords = GLOBAL.buffer.slice(0, count); + repaint(); +} + +// Repaint what is already generated. Nothing here touches PINTR. +function repaint() { const ctx = getDrawCtx(); - if (ctx) renderCoordsToCanvas(ctx, GLOBAL.coords, CONFIG); + if (ctx) renderCoordsToCanvas(ctx, GLOBAL.coords, renderOptions()); } // Fresh drawing: reset the buffer and (re)start PINTR with the current @@ -98,12 +177,7 @@ function restartDrawing() { GLOBAL.coords = []; const desired = desiredLineCount(); GLOBAL.displayedCount = desired; - PINTR.start({ - contrast: CONFIG.contrast, - definition: CONFIG.definition, - singleLine: CONFIG.singleLine, - strokeWidth: CONFIG.strokeWidth, - }); + PINTR.start(CONFIG.generation); PINTR.requestLines(desired); } @@ -128,16 +202,54 @@ function applyLines() { const ctx = getDrawCtx(); if (ctx) renderCoordsToCanvas(ctx, GLOBAL.buffer, { - ...CONFIG, + ...renderOptions(), smoothingAmount: 0, }); PINTR.requestLines(desired); } } -// Create a PINTR instance for a new image and kick off the first drawing. +// The coordinate export is in the drawing's own pixel space, which the aspect +// ratio and padding options change. Show the live extent next to the button so +// the numbers in the file are never a guess. +function updateCoordsSize() { + const coordsSizeEl = document.querySelector('#coordsSize'); + if (coordsSizeEl) { + coordsSizeEl.textContent = `${GLOBAL.width}\u00d7${GLOBAL.height}`; + } +} + +// Show the source preview, honouring the Source / Pre-processing toggle. Each of +// the two images has its own aspect, which the aspect ratio option can make very +// different from the other's. +function updateSourcePreview() { + const srcImgEl: HTMLElement | null = document.querySelector('#srcImg'); + if (srcImgEl) { + const src = GLOBAL.showPreprocessed + ? GLOBAL.processedImgSrc + : GLOBAL.currentImgSrc; + const size = GLOBAL.showPreprocessed + ? GLOBAL.processedSize + : GLOBAL.sourceSize; + srcImgEl.style.backgroundImage = `url("${src}")`; + srcImgEl.style.aspectRatio = String(size.width / size.height); + } + + const labelEl: HTMLElement | null = document.querySelector( + '.srcimage-container .label' + ); + if (labelEl) { + labelEl.textContent = GLOBAL.showPreprocessed + ? 'Source - Pre processing' + : 'Source'; + } +} + +// Create a PINTR instance for a new image and kick off the first drawing. The +// image is run through source pre-processing first; PINTR draws the result. async function loadImage(imgSrc: string) { const myToken = ++GLOBAL.drawToken; + GLOBAL.currentImgSrc = imgSrc; // Stop the previous instance so it doesn't keep drawing to the shared canvas. if (PINTR) PINTR.stop(); @@ -149,7 +261,13 @@ async function loadImage(imgSrc: string) { throw new Error(); } - const instance = await pinterCreator(imgSrc, { + const processed = await preprocessImage(imgSrc, CONFIG.source); + // A newer image started while this one was pre-processing — discard it. + if (myToken !== GLOBAL.drawToken) return; + GLOBAL.processedImgSrc = processed.src; + GLOBAL.processedSize = { width: processed.width, height: processed.height }; + + const instance = await pinterCreator(processed.src, { canvasDrawEl, onLoad({ width, height }) { if (myToken !== GLOBAL.drawToken) return; @@ -158,6 +276,7 @@ async function loadImage(imgSrc: string) { GLOBAL.width = width; GLOBAL.height = height; + updateCoordsSize(); }, onProgress({ coords, done }) { if (myToken !== GLOBAL.drawToken) return; @@ -166,12 +285,9 @@ async function loadImage(imgSrc: string) { const shown = Math.min(GLOBAL.buffer.length, GLOBAL.displayedCount); GLOBAL.coords = GLOBAL.buffer.slice(0, shown); - // The live draw is straight lines; once it reaches the target, redraw as a - // smooth curve so the smoothing slider is visible on screen. - if (done && CONFIG.smoothingAmount > 0 && CONFIG.singleLine) { - const ctx = canvasDrawEl.getContext('2d'); - if (ctx) renderCoordsToCanvas(ctx, GLOBAL.coords, CONFIG); - } + // Repaint the completed drawing as one connected path in single-line mode + // so canvas joins match the SVG (and apply smoothing when requested). + if (done && CONFIG.generation.singleLine) repaint(); }, }); @@ -182,10 +298,7 @@ async function loadImage(imgSrc: string) { } PINTR = instance; - const srcImgEl: HTMLImageElement | null = document.querySelector('#srcImg'); - if (srcImgEl) { - srcImgEl.style.backgroundImage = `url("${imgSrc}")`; - } + updateSourcePreview(); restartDrawing(); } @@ -204,14 +317,26 @@ function readFile(evt: Event) { const FR = new FileReader(); FR.addEventListener('load', function (e) { if (!e || !e.target) return; - GLOBAL.currentImgSrc = String(e.target.result); - loadImage(String(e.target.result)); + selectImage(String(e.target.result)); }); FR.readAsDataURL(file); } } +// Adopt a newly chosen image: measure it (the Source preview needs its real +// aspect) and draw it. +function selectImage(imgSrc: string) { + const probe = new window.Image(); + probe.onload = () => { + GLOBAL.sourceSize = { width: probe.width, height: probe.height }; + updateSourcePreview(); + }; + probe.src = imgSrc; + + loadImage(imgSrc); +} + function getInputNumber(selector: string): number { const inputEl: HTMLInputElement | null = document.querySelector(selector); return inputEl ? Number(inputEl.value) : 0; @@ -222,58 +347,115 @@ function getInputBoolean(selector: string): boolean { return inputEl ? Boolean(Number(inputEl.value)) : false; } +function getInputString(selector: string): string { + const inputEl: HTMLSelectElement | HTMLInputElement | null = + document.querySelector(selector); + return inputEl ? inputEl.value : ''; +} + function readConfigFromInputs(): configType { return { - lines: getInputNumber('#lines'), - singleLine: getInputBoolean('#singleLine'), - contrast: getInputNumber('#contrast'), - definition: getInputNumber('#definition'), - strokeWidth: getInputNumber('#strokeWidth'), - smoothingAmount: getInputNumber('#smoothingAmount'), + source: { + aspectRatio: getInputString('#aspectRatio'), + padding: getInputNumber('#padding'), + extend: getInputBoolean('#extendImage'), + vignette: getInputBoolean('#vignette'), + vignetteDistance: getInputNumber('#vignetteDistance'), + vignetteHardness: getInputNumber('#vignetteHardness'), + vignetteSquare: getInputBoolean('#vignetteSquare'), + }, + generation: { + contrast: getInputNumber('#contrast'), + definition: getInputNumber('#definition'), + singleLine: getInputBoolean('#singleLine'), + strokeWidth: strokeWidthFromSlider(getInputNumber('#strokeWidth')), + }, + render: { + lines: getInputNumber('#lines'), + smoothingAmount: getInputNumber('#smoothingAmount'), + transparentBackground: getInputBoolean('#transparentBackground'), + }, advancedOptions: getInputBoolean('#advancedOptions'), - transparentBackground: getInputBoolean('#transparentBackground'), }; } -// A control changed. Generation params (definition/contrast/single line/stroke -// width) restart the drawing; lines is incremental; the rest are UI/export -// only and need no redraw. +function changed(a: T, b: T): boolean { + return (Object.keys(a) as (keyof T)[]).some((key) => a[key] !== b[key]); +} + +// Shows or hides an optional panel. A panel that is half of a folder also marks +// its wrapper, so the panel and its toggle row draw as one open folder. +function syncOptionalPanel(panelSelector: string, open: boolean) { + const panelEl = document.querySelector(panelSelector) as HTMLElement | null; + if (!panelEl) return; + + panelEl.style.display = open ? 'block' : 'none'; + panelEl.closest('.folder')?.classList.toggle('-open', open); +} + +function syncOptionalSections() { + syncOptionalPanel('.advanced-options--container', CONFIG.advancedOptions); + syncOptionalPanel('.vignette-options--container', CONFIG.source.vignette); + + syncExtendImage(); + updateSmoothingWarning(); +} + +// Extending only fills margin the frame adds around the picture, so with no +// padding and the original aspect ratio there is nothing for it to do: disable +// the toggle instead of leaving it inert. +function syncExtendImage() { + const extendImageEl = document.querySelector( + '#extendImage' + ) as HTMLInputElement | null; + if (!extendImageEl) return; + + const framed = CONFIG.source.padding > 0 || CONFIG.source.aspectRatio !== ''; + extendImageEl.disabled = !framed; + extendImageEl + .closest('.input-container') + ?.classList.toggle('-disabled', !framed); +} + +// A control changed. Which layer it belongs to decides how much work to redo: +// source options rebuild the image PINTR draws, generation options restart the +// session, and render options only touch what is already generated. function onControlChange() { const prev = CONFIG; CONFIG = readConfigFromInputs(); - const advancedOptionsContainerEl = document.querySelector( - '.advanced-options--container' - ) as HTMLElement; - advancedOptionsContainerEl.style.display = CONFIG.advancedOptions - ? 'block' - : 'none'; - - updateSmoothingWarning(); + syncOptionalSections(); - const generationChanged = - CONFIG.definition !== prev.definition || - CONFIG.contrast !== prev.contrast || - CONFIG.singleLine !== prev.singleLine || - CONFIG.strokeWidth !== prev.strokeWidth; + // Opening Advanced switches the Source preview to the pre-processed image, + // since that is what the advanced options act on. Clicking the preview still + // flips it back. + if (CONFIG.advancedOptions && !prev.advancedOptions) { + GLOBAL.showPreprocessed = true; + updateSourcePreview(); + } - if (generationChanged) { + if (changed(CONFIG.source, prev.source)) { + loadImage(GLOBAL.currentImgSrc); + } else if (changed(CONFIG.generation, prev.generation)) { restartDrawing(); - } else if (CONFIG.lines !== prev.lines) { + } else if (CONFIG.render.lines !== prev.render.lines) { applyLines(); + } else { + repaint(); } } // Smoothing only applies to single-line drawings; warn when it won't take effect. function updateSmoothingWarning() { - const smoothingAmount = getInputNumber('#smoothingAmount'); - const singleLine = getInputBoolean('#singleLine'); - const smoothSvgContainerWarningEl = document.querySelector( '.experimental--smooth-svg--container--warning' - ) as HTMLElement; - smoothSvgContainerWarningEl.style.display = - smoothingAmount > 0 && !singleLine ? 'block' : 'none'; + ) as HTMLElement | null; + if (smoothSvgContainerWarningEl) { + smoothSvgContainerWarningEl.style.display = + CONFIG.render.smoothingAmount > 0 && !CONFIG.generation.singleLine + ? 'block' + : 'none'; + } } let count = 0; @@ -291,8 +473,7 @@ function onDrop(ev: DragEvent) { return; } - GLOBAL.currentImgSrc = String(e.target.result); - loadImage(String(e.target.result)); + selectImage(String(e.target.result)); document.body.classList.remove('-dragging'); count = 0; }); @@ -327,124 +508,182 @@ function onDragLeave(evt: DragEvent) { } } -// ADD LISTENERS -const appEl = document.querySelector('.app') as HTMLElement; -appEl.addEventListener('drop', onDrop); -appEl.addEventListener('dragover', onDragOver); -appEl.addEventListener('dragenter', onDragEnter); -appEl.addEventListener('dragleave', onDragLeave); - -const inputImageFileEl = document.querySelector( - '#inputImageFile' -) as HTMLInputElement; - -inputImageFileEl.addEventListener('change', readFile); - -const inputImageButtonEl = document.querySelector( - '#inputImageButton' -) as HTMLElement; -inputImageButtonEl.addEventListener('click', () => { - inputImageFileEl.click(); -}); - // React to each control as fast as it's cheap to. Lines only trims or extends // the already-computed line buffer, so it's effectively free: track the slider -// live (`input`) with a tiny throttle. Contrast/definition regenerate the whole -// drawing, so they react only on release (`change`) with a heavier throttle; -// everything else keeps the default. -const HEAVY_CONTROLS = new Set(['contrast', 'definition']); - -document - .querySelectorAll('[data-start-drawing]') - .forEach((input) => { - if (input.id === 'lines') { - // Throttle (not debounce) so it tracks the slider live during the drag - // instead of only firing on release. - input.addEventListener('input', throttle(onControlChange, 16)); - } else { - const delay = HEAVY_CONTROLS.has(input.id) ? 120 : 32; - input.addEventListener('change', debounce(onControlChange, delay)); - } +// live (`input`) with a tiny throttle. Smoothing only repaints, so it can track +// live too. Controls that rerun the pixel pass or regenerate the whole drawing +// react only on release (`change`) with a heavier throttle. +const LIVE_CONTROLS = new Set(['lines', 'smoothingAmount']); +const HEAVY_CONTROLS = new Set([ + 'contrast', + 'definition', + 'padding', + 'vignetteDistance', + 'vignetteHardness', +]); + +/** + * Wire the drawing UI to the currently mounted page. Next.js keeps this module + * cached across client-side navigation, so initialization cannot happen as a + * top-level side effect: Privacy/Support navigation replaces all of these DOM + * nodes while retaining the module instance. + */ +export function initializePintrApp(): () => void { + const abortController = new AbortController(); + const { signal } = abortController; + count = 0; + + const appEl = document.querySelector('.app') as HTMLElement; + appEl.addEventListener('drop', onDrop, { signal }); + appEl.addEventListener('dragover', onDragOver, { signal }); + appEl.addEventListener('dragenter', onDragEnter, { signal }); + appEl.addEventListener('dragleave', onDragLeave, { signal }); + + const inputImageFileEl = document.querySelector( + '#inputImageFile' + ) as HTMLInputElement; + inputImageFileEl.addEventListener('change', readFile, { signal }); + + const inputImageButtonEl = document.querySelector( + '#inputImageButton' + ) as HTMLElement; + inputImageButtonEl.addEventListener('click', () => inputImageFileEl.click(), { + signal, }); -// Toggles are 0/1 range inputs; let a tap/click anywhere flip them instead of -// requiring the user to drag the thumb to the other end. -document - .querySelectorAll('input[type="range"].toggle') - .forEach((toggle) => { - toggle.addEventListener('pointerdown', (evt) => { - evt.preventDefault(); - toggle.value = Number(toggle.value) ? '0' : '1'; - toggle.dispatchEvent(new Event('change', { bubbles: true })); + document + .querySelectorAll('[data-start-drawing]') + .forEach((input) => { + if (LIVE_CONTROLS.has(input.id)) { + // Throttle (not debounce) so it tracks the slider live during the drag + // instead of only firing on release. + input.addEventListener('input', throttle(onControlChange, 16), { + signal, + }); + } else { + const delay = HEAVY_CONTROLS.has(input.id) ? 120 : 32; + input.addEventListener('change', debounce(onControlChange, delay), { + signal, + }); + } }); - }); -const smoothingAmountEl = document.querySelector( - '#smoothingAmount' -) as HTMLInputElement; -smoothingAmountEl.addEventListener( - 'input', - throttle(() => { - CONFIG.smoothingAmount = getInputNumber('#smoothingAmount'); - updateSmoothingWarning(); - - // Redraw the existing drawing (smooth or straight) without regenerating lines. - const canvasDrawEl = document.querySelector( - 'canvas#draw' - ) as HTMLCanvasElement; - const ctx = canvasDrawEl.getContext('2d'); - if (ctx) renderCoordsToCanvas(ctx, GLOBAL.coords, CONFIG); - }, 32) -); - -const downloadEl = document.querySelector('#download') as HTMLButtonElement; -downloadEl.addEventListener('click', () => { - const link = document.createElement('a'); - const canvasDrawEl = document.querySelector( - 'canvas#draw' - ) as HTMLCanvasElement; - - link.download = 'PINTR.png'; - - if (CONFIG.advancedOptions && !CONFIG.transparentBackground) { - const tmpCanvas = document.createElement('canvas'); - tmpCanvas.width = canvasDrawEl.width; - tmpCanvas.height = canvasDrawEl.height; - const tmpCtx = tmpCanvas.getContext('2d') as CanvasRenderingContext2D; - tmpCtx.fillStyle = '#fff'; - tmpCtx.fillRect(0, 0, tmpCanvas.width, tmpCanvas.height); - tmpCtx.drawImage(canvasDrawEl, 0, 0); - link.href = tmpCanvas.toDataURL(); - } else { - link.href = canvasDrawEl.toDataURL(); - } + // Toggles are 0/1 range inputs; let a tap/click anywhere flip them instead of + // requiring the user to drag the thumb to the other end. + document + .querySelectorAll('input[type="range"].toggle') + .forEach((toggle) => { + toggle.addEventListener( + 'pointerdown', + (evt) => { + evt.preventDefault(); + toggle.value = Number(toggle.value) ? '0' : '1'; + toggle.dispatchEvent(new Event('change', { bubbles: true })); + }, + { signal } + ); + }); - link.click(); -}); - -const downloadSvgEl = document.querySelector( - '#downloadSvg' -) as HTMLButtonElement; -downloadSvgEl.addEventListener('click', () => { - const link = document.createElement('a'); - link.download = 'PINTR.svg'; - const svgData = generateSvg(GLOBAL.coords, { - ...CONFIG, - size: [GLOBAL.width, GLOBAL.height], - whiteBackground: CONFIG.advancedOptions && !CONFIG.transparentBackground, - }); - const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' }); - const svgUrl = URL.createObjectURL(svgBlob); - link.href = svgUrl; - link.click(); - setTimeout(() => URL.revokeObjectURL(svgUrl), 1000); -}); - -// Initial bootstrap: sync CONFIG/UI from the inputs' default values, then load -// the default image and start drawing. -CONFIG = readConfigFromInputs(); -( - document.querySelector('.advanced-options--container') as HTMLElement -).style.display = CONFIG.advancedOptions ? 'block' : 'none'; -updateSmoothingWarning(); -loadImage(GLOBAL.currentImgSrc); + // Click the Source preview to toggle between the original image and the + // pre-processed image that PINTR actually draws. + const srcImageContainerEl = document.querySelector( + '.srcimage-container' + ) as HTMLElement | null; + srcImageContainerEl?.addEventListener( + 'click', + () => { + GLOBAL.showPreprocessed = !GLOBAL.showPreprocessed; + updateSourcePreview(); + }, + { signal } + ); + + const downloadEl = document.querySelector('#download') as HTMLButtonElement; + downloadEl.addEventListener( + 'click', + () => { + const link = document.createElement('a'); + const canvasDrawEl = document.querySelector( + 'canvas#draw' + ) as HTMLCanvasElement; + + link.download = 'PINTR.png'; + + if (!CONFIG.render.transparentBackground) { + const tmpCanvas = document.createElement('canvas'); + tmpCanvas.width = canvasDrawEl.width; + tmpCanvas.height = canvasDrawEl.height; + const tmpCtx = tmpCanvas.getContext('2d') as CanvasRenderingContext2D; + tmpCtx.fillStyle = '#fff'; + tmpCtx.fillRect(0, 0, tmpCanvas.width, tmpCanvas.height); + tmpCtx.drawImage(canvasDrawEl, 0, 0); + link.href = tmpCanvas.toDataURL(); + } else { + link.href = canvasDrawEl.toDataURL(); + } + + link.click(); + }, + { signal } + ); + + const downloadSvgEl = document.querySelector( + '#downloadSvg' + ) as HTMLButtonElement; + downloadSvgEl.addEventListener( + 'click', + () => { + const link = document.createElement('a'); + link.download = 'PINTR.svg'; + const svgData = generateSvg(GLOBAL.coords, { + ...renderOptions(), + size: [GLOBAL.width, GLOBAL.height], + whiteBackground: !CONFIG.render.transparentBackground, + }); + const svgBlob = new Blob([svgData], { + type: 'image/svg+xml;charset=utf-8', + }); + const svgUrl = URL.createObjectURL(svgBlob); + link.href = svgUrl; + link.click(); + setTimeout(() => URL.revokeObjectURL(svgUrl), 1000); + }, + { signal } + ); + + const downloadTxtEl = document.querySelector( + '#downloadTxt' + ) as HTMLButtonElement; + downloadTxtEl.addEventListener( + 'click', + () => { + const link = document.createElement('a'); + link.download = coordsFileName([GLOBAL.width, GLOBAL.height]); + const txtData = generateCoordsTxt(GLOBAL.coords); + const txtBlob = new Blob([txtData], { type: 'text/plain;charset=utf-8' }); + const txtUrl = URL.createObjectURL(txtBlob); + link.href = txtUrl; + link.click(); + setTimeout(() => URL.revokeObjectURL(txtUrl), 1000); + }, + { signal } + ); + + // Sync CONFIG/UI from the fresh inputs, then attach the drawing engine to the + // newly mounted canvas. Keep the selected image across client navigation. + CONFIG = readConfigFromInputs(); + syncOptionalSections(); + selectImage(GLOBAL.currentImgSrc); + + return () => { + abortController.abort(); + document.body.classList.remove('-dragging'); + count = 0; + + // Invalidate async image work and stop callbacks from drawing into the + // detached canvas after navigation. + GLOBAL.drawToken++; + PINTR?.stop(); + PINTR = null; + }; +} diff --git a/package.json b/package.json index e7b14ee..c37562a 100644 --- a/package.json +++ b/package.json @@ -10,18 +10,18 @@ "type-check": "tsc --noEmit -p tsconfig.json" }, "devDependencies": { - "@types/node": "26.0.1", - "@types/react": "^19.2.17", - "@types/react-dom": "^19.2.3", - "eslint": "^10.5.0", - "typescript": "^6.0.3" + "@types/node": "26.4.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.5", + "eslint": "^10.9.1", + "typescript": "^7.0.2" }, "dependencies": { - "@next/third-parties": "^16.2.9", + "@next/third-parties": "^16.3.4", "canvas-image-utils": "^2.5.0", - "next": "^16.2.9", - "react": "^19.2.7", - "react-dom": "^19.2.7" + "next": "^16.3.4", + "react": "^19.2.8", + "react-dom": "^19.2.8" }, "author": "Javier Bórquez ", "license": "BSD-3-Clause" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f85409f..28333aa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,44 +9,44 @@ importers: .: dependencies: '@next/third-parties': - specifier: ^16.2.9 - version: 16.2.9(next@16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + specifier: ^16.3.4 + version: 16.3.4(next@16.3.4(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) canvas-image-utils: specifier: ^2.5.0 version: 2.5.0 next: - specifier: ^16.2.9 - version: 16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + specifier: ^16.3.4 + version: 16.3.4(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: - specifier: ^19.2.7 - version: 19.2.7 + specifier: ^19.2.8 + version: 19.2.8 react-dom: - specifier: ^19.2.7 - version: 19.2.7(react@19.2.7) + specifier: ^19.2.8 + version: 19.2.8(react@19.2.8) devDependencies: '@types/node': - specifier: 26.0.1 - version: 26.0.1 + specifier: 26.4.0 + version: 26.4.0 '@types/react': - specifier: ^19.2.17 - version: 19.2.17 + specifier: ^19.2.18 + version: 19.2.18 '@types/react-dom': - specifier: ^19.2.3 - version: 19.2.3(@types/react@19.2.17) + specifier: ^19.2.5 + version: 19.2.5(@types/react@19.2.18) eslint: - specifier: ^10.5.0 - version: 10.5.0 + specifier: ^10.9.1 + version: 10.9.1 typescript: - specifier: ^6.0.3 - version: 6.0.3 + specifier: ^7.0.2 + version: 7.0.2 packages: - '@emnapi/runtime@1.11.1': - resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} - '@eslint-community/eslint-utils@4.9.1': - resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 @@ -59,8 +59,8 @@ packages: resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/config-helpers@0.6.0': - resolution: {integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==} + '@eslint/config-helpers@0.7.0': + resolution: {integrity: sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} '@eslint/core@1.2.1': @@ -99,218 +99,227 @@ packages: resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} - '@img/sharp-darwin-arm64@0.34.5': - resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-darwin-arm64@0.35.4': + resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.34.5': - resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-darwin-x64@0.35.4': + resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [darwin] - '@img/sharp-libvips-darwin-arm64@1.2.4': - resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + '@img/sharp-freebsd-wasm32@0.35.4': + resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.3': + resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.2.4': - resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + '@img/sharp-libvips-darwin-x64@1.3.3': + resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.2.4': - resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + '@img/sharp-libvips-linux-arm64@1.3.3': + resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-arm@1.2.4': - resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + '@img/sharp-libvips-linux-arm@1.3.3': + resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-ppc64@1.2.4': - resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + '@img/sharp-libvips-linux-ppc64@1.3.3': + resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-riscv64@1.2.4': - resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + '@img/sharp-libvips-linux-riscv64@1.3.3': + resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-s390x@1.2.4': - resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + '@img/sharp-libvips-linux-s390x@1.3.3': + resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-x64@1.2.4': - resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + '@img/sharp-libvips-linux-x64@1.3.3': + resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-linux-arm64@0.34.5': - resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-arm64@0.35.4': + resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-linux-arm@0.34.5': - resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-arm@0.35.4': + resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} + engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-linux-ppc64@0.34.5': - resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-ppc64@0.35.4': + resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} + engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-linux-riscv64@0.34.5': - resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-riscv64@0.35.4': + resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} + engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-linux-s390x@0.34.5': - resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-s390x@0.35.4': + resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} + engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-linux-x64@0.34.5': - resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-x64@0.35.4': + resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-linuxmusl-arm64@0.34.5': - resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linuxmusl-arm64@0.35.4': + resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-linuxmusl-x64@0.34.5': - resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linuxmusl-x64@0.35.4': + resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-wasm32@0.34.5': - resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-wasm32@0.35.4': + resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.4': + resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} + engines: {node: '>=20.9.0'} cpu: [wasm32] - '@img/sharp-win32-arm64@0.34.5': - resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-win32-arm64@0.35.4': + resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] - '@img/sharp-win32-ia32@0.34.5': - resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-win32-ia32@0.35.4': + resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} + engines: {node: ^20.9.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.34.5': - resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-win32-x64@0.35.4': + resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [win32] - '@next/env@16.2.9': - resolution: {integrity: sha512-ki5VxxXfzD/9TDe13wyeTKIjQTAwBVpnr8KhRDUr8ltMUq1/NBpWNT5tiPoxiGl+PHM4X2ahSOiPk6iAimIzPg==} + '@next/env@16.3.4': + resolution: {integrity: sha512-cjWZnUUa6jZq2kFaNe/ZyJdZonOZ/QoN0Zka2nz/FLOrfx14pQuM9c5RaSVkWMqgdt4ksgPAMWPyHSs/CyV48Q==} - '@next/swc-darwin-arm64@16.2.9': - resolution: {integrity: sha512-HkfxNYUCmcct0Xsqib5KxqMSHV4AHJq857BNRchyBDs4YS19aHzVfn1kDuBYKqLLQBjXgnkIsjV2Kd4d2wzYhw==} + '@next/swc-darwin-arm64@16.3.4': + resolution: {integrity: sha512-iBr3I5LZNk5/bgl5//iTgD2tcym14MX0Xo7fD//u9dYAEgGzza1y9oywluPtf74YnOswVdH1908aK9xVz7zQTw==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.2.9': - resolution: {integrity: sha512-7IAtK4MeybpqRV9GRABWEhJ62mOS+rzWOzOTFie4cSEtm12xsoOMJRcECoZx3FHPzFAqN/IJtHqWAFOLfl152w==} + '@next/swc-darwin-x64@16.3.4': + resolution: {integrity: sha512-2dpiSyl2Jw/NrBPaU2MAKGSa+2MR82pJIn4Sm5Rjr+gxAeuh0z158Su3Z2O8zn7UNNq+ej4bToed6RcRN/Lydg==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.2.9': - resolution: {integrity: sha512-hBD75iWpUtkL9SmQmcRhmLomn9jgkPzCEkbOcLgHymPEKzv+6ONy13RRiIEz/iEObjkS2Jlb5gYS2XGoS3X4rw==} + '@next/swc-linux-arm64-gnu@16.3.4': + resolution: {integrity: sha512-+t+U8HZT+fApePCS5h89CSH3datz29MkzyfCn+6fpsZBG/oiEOhINcb9rtkv6sdpToLGFn2e6146NzaKCXkqrA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@16.2.9': - resolution: {integrity: sha512-qZTI3pf9SGc/obr8NkQAekBxmp1QK+kVm+VAf3BALLfFAj+1kUhkTxmrWpVos9R/UYIA8AWX2p6cGI5WdwzVUA==} + '@next/swc-linux-arm64-musl@16.3.4': + resolution: {integrity: sha512-mx03GNs1ocQA5JQ4FxDMmIsNkdrZh8cuezKCrId28e5/gIPU/l7Kcy2+vmCCzdjnnmXJy+iOAu+7K0QppO6Urg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@16.2.9': - resolution: {integrity: sha512-xm0HfRNX+UkH4R3c18ynswjj5o5uEj/7iI9p9omdtTSIsRCzQqkGMA+10nzJ4EHnYC3as65IMhbbl5fWRUWHYg==} + '@next/swc-linux-x64-gnu@16.3.4': + resolution: {integrity: sha512-YIhGY6fSMfha52bnVxnzc9zaVBzJg+cqQTOD8tXIBSx4fuv0pVMxQTE0PaS59YhnMOiYiG09IMwxJAf/CFm/Dw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@16.2.9': - resolution: {integrity: sha512-QumimHkGEG6vM3PfEDWKyKen03NcqLOkeKB1EfcPe7VxzmEiCa4jNnMyBn/US5zcd/VE1CI+O8Ovb3lfjVHfGw==} + '@next/swc-linux-x64-musl@16.3.4': + resolution: {integrity: sha512-+eaaX6axpDb0yF1GCpiERe6njplvdC+nks/fKfcHu3XPGRrald8P3/X7yv7QLdjA51knnxwl9pxdIJsg+w1L+Q==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@16.2.9': - resolution: {integrity: sha512-hzQpKZvw8rAwI6A2uQh6SacCSvNAXaIkPNsWwzqqfRiIMiXMfH936skDhz1OO6KpvdKkJrgHHtqQOq5PIXOvdQ==} + '@next/swc-win32-arm64-msvc@16.3.4': + resolution: {integrity: sha512-0jcXW7Xs/uzICrmgV3MhDYDeRy++1CqnpDIerlPIqYO4bhzB4WNbX/aRnQclustsAyTkFKB0z6rbcjmNg5tR8A==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.2.9': - resolution: {integrity: sha512-qr2VL3Ce5QrwgO2yh1ujSBawrimjVKX8FGF/cOynmdYKJY0BdHpGVNIRK1tqONB10Vkm25Ub1BD2bkjWs4+96w==} + '@next/swc-win32-x64-msvc@16.3.4': + resolution: {integrity: sha512-vvBzwu1pYQCp92maZCFCIw/XgOTMR5tur9GjakwIo2cmwRTMKajRZZDS9+e4KsUZWKu1E007WUeAFXRRjZeuzw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@next/third-parties@16.2.9': - resolution: {integrity: sha512-JZPGQRN8eQs2WMfBXOf6Zjrma+JIN0KyA24MvmIa3bUI4BwTaJ1UlxmYVc5ri6l30LQ8fPv6Kmx20447hCltrg==} + '@next/third-parties@16.3.4': + resolution: {integrity: sha512-DjyyOoBHPHYFgQ0l9VXJFWMeO5FvnFP+bXM9ZC0eGYluVwpNIvMjDPhDhM/VlhK+VK423G+8LMD+y/xwEKAi+w==} peerDependencies: next: ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0-beta.0 react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 - '@swc/helpers@0.5.15': - resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} '@types/esrecurse@4.3.1': resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} @@ -321,24 +330,144 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/node@26.0.1': - resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==} + '@types/node@26.4.0': + resolution: {integrity: sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==} - '@types/react-dom@19.2.3': - resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + '@types/react-dom@19.2.5': + resolution: {integrity: sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==} peerDependencies: '@types/react': ^19.2.0 - '@types/react@19.2.17': - resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + '@types/react@19.2.18': + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn@8.17.0: - resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} engines: {node: '>=0.4.0'} hasBin: true @@ -349,17 +478,17 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - baseline-browser-mapping@2.10.40: - resolution: {integrity: sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==} + baseline-browser-mapping@2.11.20: + resolution: {integrity: sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==} engines: {node: '>=6.0.0'} hasBin: true - brace-expansion@5.0.6: - resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} - engines: {node: 18 || 20 || >=22} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} - caniuse-lite@1.0.30001799: - resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} + caniuse-lite@1.0.30001810: + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} canvas-image-utils@2.5.0: resolution: {integrity: sha512-BMtKO3/uvdOfY42wbOYwgWZl3oIwgM1WJ6nPNA8EnGa6E2uhCcKxBKIkpYqDpkzoLDFY9oXk6V5OX5m4FbRJfg==} @@ -406,8 +535,8 @@ packages: resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@10.5.0: - resolution: {integrity: sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==} + eslint@10.9.1: + resolution: {integrity: sha512-9VaAkDURekixUQJy0oJYl2DcN6oKMfxay7XzaGYAWQwsb6qfKf+x76R2k1L8kb1boc+FyCAaTA9GmiKaaiaF+A==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} hasBin: true peerDependencies: @@ -457,8 +586,8 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} - flatted@3.4.2: - resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} @@ -503,23 +632,23 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} - minimatch@10.2.5: - resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nanoid@3.3.15: - resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - next@16.2.9: - resolution: {integrity: sha512-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww==} + next@16.3.4: + resolution: {integrity: sha512-/Ztf6CeRH+ejEXUrYtqI4gkS66eFIHuSwqi60RgcpWKodxFZx2/dqVCMKBwILfAHXQ+F1b1vAudgj3mnxqtoIA==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -562,8 +691,8 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - postcss@8.4.31: - resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + postcss@8.5.23: + resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} engines: {node: ^10 || ^12 || >=14} prelude-ls@1.2.1: @@ -574,13 +703,13 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - react-dom@19.2.7: - resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} peerDependencies: - react: ^19.2.7 + react: ^19.2.8 - react@19.2.7: - resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} engines: {node: '>=0.10.0'} scheduler@0.27.0: @@ -591,9 +720,14 @@ packages: engines: {node: '>=10'} hasBin: true - sharp@0.34.5: - resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + sharp@0.35.4: + resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} @@ -630,9 +764,9 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - typescript@6.0.3: - resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} - engines: {node: '>=14.17'} + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} hasBin: true undici-types@8.3.0: @@ -656,14 +790,14 @@ packages: snapshots: - '@emnapi/runtime@1.11.1': + '@emnapi/runtime@1.11.3': dependencies: tslib: 2.8.1 optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@10.5.0)': + '@eslint-community/eslint-utils@4.10.1(eslint@10.9.1)': dependencies: - eslint: 10.5.0 + eslint: 10.9.1 eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -672,11 +806,11 @@ snapshots: dependencies: '@eslint/object-schema': 3.0.5 debug: 4.4.3 - minimatch: 10.2.5 + minimatch: 10.2.6 transitivePeerDependencies: - supports-color - '@eslint/config-helpers@0.6.0': + '@eslint/config-helpers@0.7.0': dependencies: '@eslint/core': 1.2.1 @@ -710,133 +844,143 @@ snapshots: '@img/colour@1.1.0': optional: true - '@img/sharp-darwin-arm64@0.34.5': + '@img/sharp-darwin-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-arm64': 1.3.3 optional: true - '@img/sharp-darwin-x64@0.34.5': + '@img/sharp-darwin-x64@0.35.4': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.3.3 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.4': + dependencies: + '@img/sharp-wasm32': 0.35.4 optional: true - '@img/sharp-libvips-darwin-arm64@1.2.4': + '@img/sharp-libvips-darwin-arm64@1.3.3': optional: true - '@img/sharp-libvips-darwin-x64@1.2.4': + '@img/sharp-libvips-darwin-x64@1.3.3': optional: true - '@img/sharp-libvips-linux-arm64@1.2.4': + '@img/sharp-libvips-linux-arm64@1.3.3': optional: true - '@img/sharp-libvips-linux-arm@1.2.4': + '@img/sharp-libvips-linux-arm@1.3.3': optional: true - '@img/sharp-libvips-linux-ppc64@1.2.4': + '@img/sharp-libvips-linux-ppc64@1.3.3': optional: true - '@img/sharp-libvips-linux-riscv64@1.2.4': + '@img/sharp-libvips-linux-riscv64@1.3.3': optional: true - '@img/sharp-libvips-linux-s390x@1.2.4': + '@img/sharp-libvips-linux-s390x@1.3.3': optional: true - '@img/sharp-libvips-linux-x64@1.2.4': + '@img/sharp-libvips-linux-x64@1.3.3': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.2.4': + '@img/sharp-libvips-linuxmusl-x64@1.3.3': optional: true - '@img/sharp-linux-arm64@0.34.5': + '@img/sharp-linux-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.3.3 optional: true - '@img/sharp-linux-arm@0.34.5': + '@img/sharp-linux-arm@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.3.3 optional: true - '@img/sharp-linux-ppc64@0.34.5': + '@img/sharp-linux-ppc64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.3.3 optional: true - '@img/sharp-linux-riscv64@0.34.5': + '@img/sharp-linux-riscv64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.3.3 optional: true - '@img/sharp-linux-s390x@0.34.5': + '@img/sharp-linux-s390x@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.3.3 optional: true - '@img/sharp-linux-x64@0.34.5': + '@img/sharp-linux-x64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.3.3 optional: true - '@img/sharp-linuxmusl-arm64@0.34.5': + '@img/sharp-linuxmusl-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 optional: true - '@img/sharp-linuxmusl-x64@0.34.5': + '@img/sharp-linuxmusl-x64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + optional: true + + '@img/sharp-wasm32@0.35.4': + dependencies: + '@emnapi/runtime': 1.11.3 optional: true - '@img/sharp-wasm32@0.34.5': + '@img/sharp-webcontainers-wasm32@0.35.4': dependencies: - '@emnapi/runtime': 1.11.1 + '@img/sharp-wasm32': 0.35.4 optional: true - '@img/sharp-win32-arm64@0.34.5': + '@img/sharp-win32-arm64@0.35.4': optional: true - '@img/sharp-win32-ia32@0.34.5': + '@img/sharp-win32-ia32@0.35.4': optional: true - '@img/sharp-win32-x64@0.34.5': + '@img/sharp-win32-x64@0.35.4': optional: true - '@next/env@16.2.9': {} + '@next/env@16.3.4': {} - '@next/swc-darwin-arm64@16.2.9': + '@next/swc-darwin-arm64@16.3.4': optional: true - '@next/swc-darwin-x64@16.2.9': + '@next/swc-darwin-x64@16.3.4': optional: true - '@next/swc-linux-arm64-gnu@16.2.9': + '@next/swc-linux-arm64-gnu@16.3.4': optional: true - '@next/swc-linux-arm64-musl@16.2.9': + '@next/swc-linux-arm64-musl@16.3.4': optional: true - '@next/swc-linux-x64-gnu@16.2.9': + '@next/swc-linux-x64-gnu@16.3.4': optional: true - '@next/swc-linux-x64-musl@16.2.9': + '@next/swc-linux-x64-musl@16.3.4': optional: true - '@next/swc-win32-arm64-msvc@16.2.9': + '@next/swc-win32-arm64-msvc@16.3.4': optional: true - '@next/swc-win32-x64-msvc@16.2.9': + '@next/swc-win32-x64-msvc@16.3.4': optional: true - '@next/third-parties@16.2.9(next@16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)': + '@next/third-parties@16.3.4(next@16.3.4(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)': dependencies: - next: 16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - react: 19.2.7 + next: 16.3.4(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 third-party-capital: 1.0.20 - '@swc/helpers@0.5.15': + '@swc/helpers@0.5.23': dependencies: tslib: 2.8.1 @@ -846,23 +990,83 @@ snapshots: '@types/json-schema@7.0.15': {} - '@types/node@26.0.1': + '@types/node@26.4.0': dependencies: undici-types: 8.3.0 - '@types/react-dom@19.2.3(@types/react@19.2.17)': + '@types/react-dom@19.2.5(@types/react@19.2.18)': dependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@types/react@19.2.17': + '@types/react@19.2.18': dependencies: csstype: 3.2.3 - acorn-jsx@5.3.2(acorn@8.17.0): + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + acorn-jsx@5.3.2(acorn@8.18.0): dependencies: - acorn: 8.17.0 + acorn: 8.18.0 - acorn@8.17.0: {} + acorn@8.18.0: {} ajv@6.15.0: dependencies: @@ -873,13 +1077,13 @@ snapshots: balanced-match@4.0.4: {} - baseline-browser-mapping@2.10.40: {} + baseline-browser-mapping@2.11.20: {} - brace-expansion@5.0.6: + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 - caniuse-lite@1.0.30001799: {} + caniuse-lite@1.0.30001810: {} canvas-image-utils@2.5.0: {} @@ -915,12 +1119,12 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.5.0: + eslint@10.9.1: dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.1) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.23.5 - '@eslint/config-helpers': 0.6.0 + '@eslint/config-helpers': 0.7.0 '@eslint/core': 1.2.1 '@eslint/plugin-kit': 0.7.2 '@humanfs/node': 0.16.8 @@ -944,7 +1148,7 @@ snapshots: imurmurhash: 0.1.4 is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 - minimatch: 10.2.5 + minimatch: 10.2.6 natural-compare: 1.4.0 optionator: 0.9.4 transitivePeerDependencies: @@ -952,8 +1156,8 @@ snapshots: espree@11.2.0: dependencies: - acorn: 8.17.0 - acorn-jsx: 5.3.2(acorn@8.17.0) + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) eslint-visitor-keys: 5.0.1 esquery@1.7.0: @@ -985,10 +1189,10 @@ snapshots: flat-cache@4.0.1: dependencies: - flatted: 3.4.2 + flatted: 3.4.4 keyv: 4.5.4 - flatted@3.4.2: {} + flatted@3.4.4: {} glob-parent@6.0.2: dependencies: @@ -1025,38 +1229,39 @@ snapshots: dependencies: p-locate: 5.0.0 - minimatch@10.2.5: + minimatch@10.2.6: dependencies: - brace-expansion: 5.0.6 + brace-expansion: 5.0.9 ms@2.1.3: {} - nanoid@3.3.15: {} + nanoid@3.3.18: {} natural-compare@1.4.0: {} - next@16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + next@16.3.4(@types/node@26.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: - '@next/env': 16.2.9 - '@swc/helpers': 0.5.15 - baseline-browser-mapping: 2.10.40 - caniuse-lite: 1.0.30001799 - postcss: 8.4.31 - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) - styled-jsx: 5.1.6(react@19.2.7) + '@next/env': 16.3.4 + '@swc/helpers': 0.5.23 + baseline-browser-mapping: 2.11.20 + caniuse-lite: 1.0.30001810 + postcss: 8.5.23 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + styled-jsx: 5.1.6(react@19.2.8) optionalDependencies: - '@next/swc-darwin-arm64': 16.2.9 - '@next/swc-darwin-x64': 16.2.9 - '@next/swc-linux-arm64-gnu': 16.2.9 - '@next/swc-linux-arm64-musl': 16.2.9 - '@next/swc-linux-x64-gnu': 16.2.9 - '@next/swc-linux-x64-musl': 16.2.9 - '@next/swc-win32-arm64-msvc': 16.2.9 - '@next/swc-win32-x64-msvc': 16.2.9 - sharp: 0.34.5 + '@next/swc-darwin-arm64': 16.3.4 + '@next/swc-darwin-x64': 16.3.4 + '@next/swc-linux-arm64-gnu': 16.3.4 + '@next/swc-linux-arm64-musl': 16.3.4 + '@next/swc-linux-x64-gnu': 16.3.4 + '@next/swc-linux-x64-musl': 16.3.4 + '@next/swc-win32-arm64-msvc': 16.3.4 + '@next/swc-win32-x64-msvc': 16.3.4 + sharp: 0.35.4(@types/node@26.4.0) transitivePeerDependencies: - '@babel/core' + - '@types/node' - babel-plugin-macros optionator@0.9.4: @@ -1082,9 +1287,9 @@ snapshots: picocolors@1.1.1: {} - postcss@8.4.31: + postcss@8.5.23: dependencies: - nanoid: 3.3.15 + nanoid: 3.3.18 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -1092,48 +1297,50 @@ snapshots: punycode@2.3.1: {} - react-dom@19.2.7(react@19.2.7): + react-dom@19.2.8(react@19.2.8): dependencies: - react: 19.2.7 + react: 19.2.8 scheduler: 0.27.0 - react@19.2.7: {} + react@19.2.8: {} scheduler@0.27.0: {} semver@7.8.5: optional: true - sharp@0.34.5: + sharp@0.35.4(@types/node@26.4.0): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 semver: 7.8.5 optionalDependencies: - '@img/sharp-darwin-arm64': 0.34.5 - '@img/sharp-darwin-x64': 0.34.5 - '@img/sharp-libvips-darwin-arm64': 1.2.4 - '@img/sharp-libvips-darwin-x64': 1.2.4 - '@img/sharp-libvips-linux-arm': 1.2.4 - '@img/sharp-libvips-linux-arm64': 1.2.4 - '@img/sharp-libvips-linux-ppc64': 1.2.4 - '@img/sharp-libvips-linux-riscv64': 1.2.4 - '@img/sharp-libvips-linux-s390x': 1.2.4 - '@img/sharp-libvips-linux-x64': 1.2.4 - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - '@img/sharp-linux-arm': 0.34.5 - '@img/sharp-linux-arm64': 0.34.5 - '@img/sharp-linux-ppc64': 0.34.5 - '@img/sharp-linux-riscv64': 0.34.5 - '@img/sharp-linux-s390x': 0.34.5 - '@img/sharp-linux-x64': 0.34.5 - '@img/sharp-linuxmusl-arm64': 0.34.5 - '@img/sharp-linuxmusl-x64': 0.34.5 - '@img/sharp-wasm32': 0.34.5 - '@img/sharp-win32-arm64': 0.34.5 - '@img/sharp-win32-ia32': 0.34.5 - '@img/sharp-win32-x64': 0.34.5 + '@img/sharp-darwin-arm64': 0.35.4 + '@img/sharp-darwin-x64': 0.35.4 + '@img/sharp-freebsd-wasm32': 0.35.4 + '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-linux-arm': 0.35.4 + '@img/sharp-linux-arm64': 0.35.4 + '@img/sharp-linux-ppc64': 0.35.4 + '@img/sharp-linux-riscv64': 0.35.4 + '@img/sharp-linux-s390x': 0.35.4 + '@img/sharp-linux-x64': 0.35.4 + '@img/sharp-linuxmusl-arm64': 0.35.4 + '@img/sharp-linuxmusl-x64': 0.35.4 + '@img/sharp-webcontainers-wasm32': 0.35.4 + '@img/sharp-win32-arm64': 0.35.4 + '@img/sharp-win32-ia32': 0.35.4 + '@img/sharp-win32-x64': 0.35.4 + '@types/node': 26.4.0 optional: true shebang-command@2.0.0: @@ -1144,10 +1351,10 @@ snapshots: source-map-js@1.2.1: {} - styled-jsx@5.1.6(react@19.2.7): + styled-jsx@5.1.6(react@19.2.8): dependencies: client-only: 0.0.1 - react: 19.2.7 + react: 19.2.8 third-party-capital@1.0.20: {} @@ -1157,7 +1364,28 @@ snapshots: dependencies: prelude-ls: 1.2.1 - typescript@6.0.3: {} + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 undici-types@8.3.0: {} diff --git a/public/fonts/Brutalita-Medium.woff2 b/public/fonts/Brutalita-Medium.woff2 new file mode 100644 index 0000000..75694a7 Binary files /dev/null and b/public/fonts/Brutalita-Medium.woff2 differ diff --git a/public/fonts/Brutalita-Regular.woff2 b/public/fonts/Brutalita-Regular.woff2 index 2df5cfd..39e8d77 100644 Binary files a/public/fonts/Brutalita-Regular.woff2 and b/public/fonts/Brutalita-Regular.woff2 differ