Refactor application
- Use contexts for large-scope states - Use OffscreenCanvas for rendering instead of hidden canvas element
This commit is contained in:
Generated
+312
-705
File diff suppressed because it is too large
Load Diff
+8
-9
@@ -10,25 +10,24 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@fontsource-variable/ibm-plex-sans": "^5.2.5",
|
"@fontsource-variable/ibm-plex-sans": "^5.2.5",
|
||||||
"@iconify/json": "^2.2.321",
|
"@iconify/json": "^2.2.323",
|
||||||
"@lucide/svelte": "^0.485.0",
|
"@lucide/svelte": "^0.487.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||||
"@tsconfig/svelte": "^5.0.4",
|
"@tsconfig/svelte": "^5.0.4",
|
||||||
"@types/node": "^22.13.14",
|
"@types/node": "^22.13.17",
|
||||||
"@types/w3c-web-hid": "^1.0.6",
|
"@types/w3c-web-hid": "^1.0.6",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"bits-ui": "^1.3.15",
|
"bits-ui": "^1.3.16",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"mode-watcher": "^0.5.1",
|
"mode-watcher": "^0.5.1",
|
||||||
"rgbquant": "^1.1.2",
|
"svelte": "^5.25.6",
|
||||||
"svelte": "^5.25.2",
|
|
||||||
"svelte-sonner": "^0.3.28",
|
"svelte-sonner": "^0.3.28",
|
||||||
"tailwind-merge": "^3.0.2",
|
"tailwind-merge": "^3.1.0",
|
||||||
"tailwind-variants": "^1.0.0",
|
"tailwind-variants": "^1.0.0",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"typescript": "~5.7.2",
|
"typescript": "^5.8.2",
|
||||||
"unplugin-icons": "^22.1.0",
|
"unplugin-icons": "^22.1.0",
|
||||||
"vite": "^6.2.0"
|
"vite": "^6.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
<svg version="1.1" width="256" height="256" xmlns="http://www.w3.org/2000/svg">
|
<svg version="1.1" width="256" height="256" xmlns="http://www.w3.org/2000/svg">
|
||||||
<polygon points="0,0 255,0 0,255" fill="#afbabc" />
|
<polygon points="0,0 256,0 0,256" fill="#afbabc" />
|
||||||
<polygon points="255,255 255,0 0,255" fill="#182435" />
|
<polygon points="256,256 256,0 0,256" fill="#182435" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 199 B After Width: | Height: | Size: 199 B |
+4
-5
@@ -2,9 +2,9 @@
|
|||||||
import { ModeWatcher } from 'mode-watcher'
|
import { ModeWatcher } from 'mode-watcher'
|
||||||
import { Toaster } from 'svelte-sonner'
|
import { Toaster } from 'svelte-sonner'
|
||||||
|
|
||||||
import Footer from './Footer.svelte'
|
import Footer from '$lib/layouts/Footer.svelte'
|
||||||
import Main from './Main.svelte'
|
import Main from '$lib/layouts/Main.svelte'
|
||||||
import Unsupported from './Unsupported.svelte'
|
import Unsupported from '$lib/layouts/Unsupported.svelte'
|
||||||
|
|
||||||
const unsupported = navigator.hid === undefined
|
const unsupported = navigator.hid === undefined
|
||||||
</script>
|
</script>
|
||||||
@@ -14,8 +14,7 @@
|
|||||||
|
|
||||||
<div class="max-w-screen-xl min-h-screen m-auto p-8 flex flex-col gap-4">
|
<div class="max-w-screen-xl min-h-screen m-auto p-8 flex flex-col gap-4">
|
||||||
{#if unsupported}
|
{#if unsupported}
|
||||||
<Unsupported />
|
<Unsupported class="grow" />
|
||||||
<div class="grow"></div>
|
|
||||||
{:else}
|
{:else}
|
||||||
<Main class="grow" />
|
<Main class="grow" />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { HTMLAttributes } from 'svelte/elements'
|
|
||||||
import { cn } from '$lib/utils'
|
|
||||||
|
|
||||||
import { Separator } from '$lib/components/ui/separator'
|
|
||||||
|
|
||||||
import ConnectSection from './connect/ConnectSection.svelte'
|
|
||||||
import EditSection from './edit/EditSection.svelte'
|
|
||||||
import WriteSection from './write/WriteSection.svelte'
|
|
||||||
|
|
||||||
interface Props extends HTMLAttributes<HTMLElement> {}
|
|
||||||
|
|
||||||
const { class: classNames, ...restProps }: Props = $props()
|
|
||||||
|
|
||||||
let device: HIDDevice | null = $state(null)
|
|
||||||
let bitmap: number[] | null = $state(null)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<main class={cn('w-full flex flex-col gap-4', classNames)} {...restProps}>
|
|
||||||
<ConnectSection
|
|
||||||
{device}
|
|
||||||
onchange={v => {
|
|
||||||
device = v
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<EditSection
|
|
||||||
class="grow"
|
|
||||||
onchange={v => {
|
|
||||||
bitmap = v
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<WriteSection {device} {bitmap} />
|
|
||||||
</main>
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import IconPending from '~icons/material-symbols/pending'
|
|
||||||
import IconCheckCircle from '~icons/material-symbols/check-circle'
|
|
||||||
|
|
||||||
import { onMount } from 'svelte'
|
|
||||||
import { toast } from 'svelte-sonner'
|
|
||||||
import ConnectButton from './ConnectButton.svelte'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
device: HIDDevice | null
|
|
||||||
onchange: (device: HIDDevice | null) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const { device, onchange }: Props = $props()
|
|
||||||
|
|
||||||
const hid = navigator.hid
|
|
||||||
|
|
||||||
function isInkclip(dev: HIDDevice) {
|
|
||||||
return dev.vendorId == 0xc0de && dev.productId == 0xcafe
|
|
||||||
}
|
|
||||||
|
|
||||||
async function tryGetPairedDevice() {
|
|
||||||
const dev = (await hid.getDevices()).find(isInkclip)
|
|
||||||
if (dev !== undefined) onchange(dev)
|
|
||||||
}
|
|
||||||
|
|
||||||
hid.addEventListener('connect', e => {
|
|
||||||
if (isInkclip(e.device) && device === null) {
|
|
||||||
toast.info('Device connected')
|
|
||||||
onchange(e.device)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
hid.addEventListener('disconnect', e => {
|
|
||||||
if (device === e.device) {
|
|
||||||
toast.info('Device disconnected')
|
|
||||||
onchange(null)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
tryGetPairedDevice()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<section class="flex items-center gap-2 max-lg:flex-col max-lg:items-stretch">
|
|
||||||
<div class="grow">
|
|
||||||
<h1 class="font-semibold text-xl/8">Connect to a device</h1>
|
|
||||||
|
|
||||||
<div class="text-sm">
|
|
||||||
{#if device === null}
|
|
||||||
<IconPending class="inline" /> Not connected to any device yet. Plug in your device, and click on the button to select
|
|
||||||
it.
|
|
||||||
{:else}
|
|
||||||
<IconCheckCircle class="inline" /> Successfully conected to device. If you want to, you can connect to another device
|
|
||||||
instead.
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ConnectButton onconnect={onchange} {device} />
|
|
||||||
</section>
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { Transform, withTransform } from '$lib/image/transform'
|
|
||||||
import { DEFAULT_DITHERING_KERNEL, Quantizer, type DitheringKernel } from '$lib/image/quantizer'
|
|
||||||
import { Scaler, type ScaleMode } from '$lib/image/scaler'
|
|
||||||
|
|
||||||
import type { HTMLAttributes } from 'svelte/elements'
|
|
||||||
import { cn } from '$lib/utils'
|
|
||||||
|
|
||||||
import { Separator } from '$lib/components/ui/separator'
|
|
||||||
|
|
||||||
import PreviewSection from './preview/PreviewSection.svelte'
|
|
||||||
import ControlsSection from './controls/ControlsSection.svelte'
|
|
||||||
|
|
||||||
const scaler = new Scaler(200, 200)
|
|
||||||
|
|
||||||
interface Props extends Omit<HTMLAttributes<HTMLDivElement>, 'onchange'> {
|
|
||||||
onchange: (bitmap: number[] | null) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const { onchange, class: classNames, ...restProps }: Props = $props()
|
|
||||||
|
|
||||||
const mobileMediaQuery = matchMedia('(max-width: 1024px)')
|
|
||||||
let mobile = $state(mobileMediaQuery.matches)
|
|
||||||
const separatorOrientation = $derived(mobile ? 'horizontal' : 'vertical')
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
mobileMediaQuery.addEventListener('change', self => {
|
|
||||||
mobile = self.matches
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
let imageCanvasEl: HTMLCanvasElement
|
|
||||||
|
|
||||||
let imageBitmap: ImageBitmap | null = $state(null)
|
|
||||||
|
|
||||||
let scaleMode: ScaleMode = $state('fit')
|
|
||||||
let transform: Transform = $state(new Transform())
|
|
||||||
let backgroundColor: number = $state(255)
|
|
||||||
let ditheringKernel: DitheringKernel | null = $state(DEFAULT_DITHERING_KERNEL)
|
|
||||||
let contrast = $state(0)
|
|
||||||
let bias = $state(0)
|
|
||||||
|
|
||||||
let renderedBitmap: number[] | null = $state(null)
|
|
||||||
|
|
||||||
const quantizer = $derived(
|
|
||||||
new Quantizer({
|
|
||||||
ditheringKernel,
|
|
||||||
contrast,
|
|
||||||
bias,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
async function renderPattern() {
|
|
||||||
if (imageBitmap === null) {
|
|
||||||
renderedBitmap = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const ctx = imageCanvasEl.getContext('2d', {
|
|
||||||
willReadFrequently: true,
|
|
||||||
})!
|
|
||||||
|
|
||||||
withTransform(ctx, transform, () => {
|
|
||||||
const nonNullBitmap = imageBitmap!
|
|
||||||
|
|
||||||
ctx.fillStyle = `rgb(${backgroundColor} ${backgroundColor} ${backgroundColor})`
|
|
||||||
ctx.fillRect(0, 0, 200, 200)
|
|
||||||
const { dx, dy, dWidth, dHeight } = scaler.scale(nonNullBitmap, scaleMode)
|
|
||||||
ctx.drawImage(nonNullBitmap, dx, dy, dWidth, dHeight)
|
|
||||||
})
|
|
||||||
|
|
||||||
const quantizedData = quantizer.reduce(ctx)
|
|
||||||
renderedBitmap = quantizedData
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
renderPattern()
|
|
||||||
})
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
onchange(renderedBitmap)
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class={cn('flex max-lg:flex-col gap-4', classNames)} {...restProps}>
|
|
||||||
<canvas class="hidden" width={200} height={200} bind:this={imageCanvasEl}></canvas>
|
|
||||||
|
|
||||||
<PreviewSection
|
|
||||||
bitmap={renderedBitmap}
|
|
||||||
onchange={v => {
|
|
||||||
imageBitmap = v
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Separator orientation={separatorOrientation} />
|
|
||||||
|
|
||||||
<ControlsSection
|
|
||||||
class="grow"
|
|
||||||
{imageBitmap}
|
|
||||||
bind:scaleMode
|
|
||||||
bind:transform
|
|
||||||
bind:backgroundColor
|
|
||||||
bind:ditheringKernel
|
|
||||||
bind:contrast
|
|
||||||
bind:bias
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { Label } from '$lib/components/ui/label'
|
|
||||||
import { Slider } from '$lib/components/ui/slider'
|
|
||||||
import Infotip from '$lib/Infotip.svelte'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
backgroundColor: number
|
|
||||||
}
|
|
||||||
|
|
||||||
let { backgroundColor = $bindable() }: Props = $props()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-4">
|
|
||||||
<Label for="background-color-input" class="multimodal">
|
|
||||||
<div>
|
|
||||||
Background Color
|
|
||||||
<span class="font-normal text-muted-foreground"> = {backgroundColor} </span>
|
|
||||||
</div>
|
|
||||||
<Infotip>The color used for transparent pixels.</Infotip>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Slider
|
|
||||||
type="single"
|
|
||||||
value={backgroundColor}
|
|
||||||
onValueCommit={v => {
|
|
||||||
backgroundColor = v
|
|
||||||
}}
|
|
||||||
min={0}
|
|
||||||
max={255}
|
|
||||||
step={1}
|
|
||||||
id="background-color-input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { DEFAULT_DITHERING_KERNEL, type DitheringKernel } from '$lib/image/quantizer'
|
|
||||||
import DitheringKernelDropdown from './DitheringKernelDropdown.svelte'
|
|
||||||
import DitherSwitch from './DitherSwitch.svelte'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
ditheringKernel: DitheringKernel | null
|
|
||||||
}
|
|
||||||
|
|
||||||
let { ditheringKernel = $bindable() }: Props = $props()
|
|
||||||
let lastDitheringKernel: DitheringKernel = $state(ditheringKernel ?? DEFAULT_DITHERING_KERNEL)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="flex gap-4">
|
|
||||||
<DitherSwitch
|
|
||||||
checked={ditheringKernel !== null}
|
|
||||||
onCheckedChange={c => {
|
|
||||||
if (c) {
|
|
||||||
ditheringKernel = lastDitheringKernel
|
|
||||||
} else {
|
|
||||||
ditheringKernel = null
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DitheringKernelDropdown
|
|
||||||
class={ditheringKernel !== null ? [] : ['invisible']}
|
|
||||||
value={ditheringKernel ?? DEFAULT_DITHERING_KERNEL}
|
|
||||||
onchange={v => {
|
|
||||||
ditheringKernel = lastDitheringKernel = v
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { ScaleMode } from '$lib/image/scaler'
|
|
||||||
|
|
||||||
import { Label } from '$lib/components/ui/label'
|
|
||||||
import * as ToggleGroup from '$lib/components/ui/toggle-group'
|
|
||||||
import * as Tooltip from '$lib/components/ui/tooltip'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
scaleMode: ScaleMode
|
|
||||||
}
|
|
||||||
|
|
||||||
let { scaleMode = $bindable() }: Props = $props()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<Label for="scale-mode-input">Scaling method</Label>
|
|
||||||
<Tooltip.Provider delayDuration={0} disableHoverableContent>
|
|
||||||
<ToggleGroup.Root
|
|
||||||
id="scale-mode-input"
|
|
||||||
type="single"
|
|
||||||
bind:value={
|
|
||||||
() => scaleMode,
|
|
||||||
v => {
|
|
||||||
if (v.length !== 0) scaleMode = v as ScaleMode
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Tooltip.Root>
|
|
||||||
<Tooltip.Trigger>
|
|
||||||
<ToggleGroup.Item value="fit">Fit</ToggleGroup.Item>
|
|
||||||
</Tooltip.Trigger>
|
|
||||||
<Tooltip.Content>
|
|
||||||
Fit the entire image onto the display. <br />
|
|
||||||
May introduce letterboxing.
|
|
||||||
</Tooltip.Content>
|
|
||||||
</Tooltip.Root>
|
|
||||||
|
|
||||||
<Tooltip.Root>
|
|
||||||
<Tooltip.Trigger>
|
|
||||||
<ToggleGroup.Item value="crop">Crop</ToggleGroup.Item>
|
|
||||||
</Tooltip.Trigger>
|
|
||||||
<Tooltip.Content>Fill the display and crop out-of-frame parts of the image.</Tooltip.Content>
|
|
||||||
</Tooltip.Root>
|
|
||||||
|
|
||||||
<Tooltip.Root>
|
|
||||||
<Tooltip.Trigger>
|
|
||||||
<ToggleGroup.Item value="distort">Distort</ToggleGroup.Item>
|
|
||||||
</Tooltip.Trigger>
|
|
||||||
<Tooltip.Content>
|
|
||||||
Stretch the image to fill the display. <br />
|
|
||||||
Distorts the aspect ratio.
|
|
||||||
</Tooltip.Content>
|
|
||||||
</Tooltip.Root>
|
|
||||||
</ToggleGroup.Root>
|
|
||||||
</Tooltip.Provider>
|
|
||||||
</div>
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { Input } from '$lib/components/ui/input'
|
|
||||||
import { toast } from 'svelte-sonner'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
onchange: (imageBitmap: ImageBitmap | null) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const { onchange }: Props = $props()
|
|
||||||
|
|
||||||
let fileList: FileList | undefined = $state()
|
|
||||||
|
|
||||||
async function updateImageBitmap() {
|
|
||||||
if (fileList === undefined || fileList.length < 1) {
|
|
||||||
onchange(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const imageFile = fileList[0]
|
|
||||||
try {
|
|
||||||
onchange(await createImageBitmap(imageFile))
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(`Error loading image file: ${e}`)
|
|
||||||
onchange(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
updateImageBitmap()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Input type="file" id="image-file" accept="image/*" bind:files={fileList} />
|
|
||||||
@@ -2,12 +2,15 @@
|
|||||||
import IconInfo from '~icons/material-symbols/info'
|
import IconInfo from '~icons/material-symbols/info'
|
||||||
|
|
||||||
import * as Popover from '$lib/components/ui/popover'
|
import * as Popover from '$lib/components/ui/popover'
|
||||||
|
import { Label } from '$lib/components/ui/label'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Popover.Root>
|
<Popover.Root>
|
||||||
<Popover.Trigger>
|
<Label>
|
||||||
<IconInfo class="text-muted-foreground text-sm" />
|
<Popover.Trigger>
|
||||||
</Popover.Trigger>
|
<IconInfo class="text-muted-foreground inline" />
|
||||||
|
</Popover.Trigger>
|
||||||
|
</Label>
|
||||||
|
|
||||||
<Popover.Content class="text-sm">
|
<Popover.Content class="text-sm">
|
||||||
<slot />
|
<slot />
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Slider as SliderPrimitive, type WithoutChildrenOrChild } from "bits-ui";
|
import { Slider as SliderPrimitive, type WithoutChildrenOrChild } from 'bits-ui'
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from '$lib/utils.js'
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
value = $bindable(),
|
value = $bindable(),
|
||||||
orientation = "horizontal",
|
orientation = 'horizontal',
|
||||||
class: className,
|
class: className,
|
||||||
...restProps
|
...restProps
|
||||||
}: WithoutChildrenOrChild<SliderPrimitive.RootProps> = $props();
|
}: WithoutChildrenOrChild<SliderPrimitive.RootProps> = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -16,29 +16,29 @@ Discriminated Unions + Destructing (required for bindable) do not
|
|||||||
get along, so we shut typescript up by casting `value` to `never`.
|
get along, so we shut typescript up by casting `value` to `never`.
|
||||||
-->
|
-->
|
||||||
<SliderPrimitive.Root
|
<SliderPrimitive.Root
|
||||||
bind:ref
|
bind:ref
|
||||||
bind:value={value as never}
|
bind:value={value as never}
|
||||||
{orientation}
|
{orientation}
|
||||||
class={cn(
|
class={cn(
|
||||||
"relative flex touch-none select-none items-center data-[orientation='vertical']:h-full data-[orientation='vertical']:min-h-44 data-[orientation='horizontal']:w-full data-[orientation='vertical']:w-auto data-[orientation='vertical']:flex-col",
|
"relative flex touch-none select-none items-center data-[orientation='vertical']:h-full data-[orientation='vertical']:min-h-44 data-[orientation='horizontal']:w-full data-[orientation='vertical']:w-auto data-[orientation='vertical']:flex-col",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{#snippet children({ thumbs })}
|
{#snippet children({ thumbs })}
|
||||||
<span
|
<span
|
||||||
data-orientation={orientation}
|
data-orientation={orientation}
|
||||||
class="bg-secondary relative grow overflow-hidden rounded-full data-[orientation='horizontal']:h-2 data-[orientation='vertical']:h-full data-[orientation='horizontal']:w-full data-[orientation='vertical']:w-2"
|
class="bg-secondary relative grow overflow-hidden rounded-full data-[orientation='horizontal']:h-2 data-[orientation='vertical']:h-full data-[orientation='horizontal']:w-full data-[orientation='vertical']:w-2"
|
||||||
>
|
>
|
||||||
<SliderPrimitive.Range
|
<SliderPrimitive.Range
|
||||||
class="bg-primary absolute data-[orientation='horizontal']:h-full data-[orientation='vertical']:w-full"
|
class="bg-primary absolute data-[orientation='horizontal']:h-full data-[orientation='vertical']:w-full"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
{#each thumbs as thumb (thumb)}
|
{#each thumbs as thumb (thumb)}
|
||||||
<SliderPrimitive.Thumb
|
<SliderPrimitive.Thumb
|
||||||
index={thumb}
|
index={thumb}
|
||||||
class="border-primary bg-background ring-offset-background focus-visible:ring-ring block size-5 rounded-full border-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
|
class="border-primary bg-background ring-offset-background focus-visible:ring-ring block size-5 rounded-full border-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</SliderPrimitive.Root>
|
</SliderPrimitive.Root>
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { getContext, setContext } from 'svelte'
|
||||||
|
import type { ConversionConfig } from './config.svelte'
|
||||||
|
import { Quantizer } from '$lib/image/quantizer'
|
||||||
|
import { withTransform } from '$lib/image/transform'
|
||||||
|
import { Scaler } from '$lib/image/scaler'
|
||||||
|
|
||||||
|
const scaler = new Scaler(200, 200)
|
||||||
|
|
||||||
|
export interface BitmapContext {
|
||||||
|
image: ImageBitmap | null
|
||||||
|
rendered: number[] | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BitmapContextToken = Symbol('bitmap')
|
||||||
|
|
||||||
|
export function getBitmapContext(): BitmapContext {
|
||||||
|
return getContext(BitmapContextToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBitmapContext(config: ConversionConfig): BitmapContext {
|
||||||
|
const ctx: BitmapContext = $state({
|
||||||
|
image: null,
|
||||||
|
rendered: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const canvas = new OffscreenCanvas(200, 200)
|
||||||
|
|
||||||
|
const quantizer = $derived(
|
||||||
|
new Quantizer({
|
||||||
|
ditheringKernel: config.ditheringKernel,
|
||||||
|
contrast: config.contrast,
|
||||||
|
bias: config.bias,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const bitmap = ctx.image
|
||||||
|
|
||||||
|
if (bitmap === null) {
|
||||||
|
ctx.rendered = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvasCtx = canvas.getContext('2d', {
|
||||||
|
willReadFrequently: true,
|
||||||
|
})!
|
||||||
|
|
||||||
|
withTransform(canvasCtx, config.transform, () => {
|
||||||
|
const nonNullBitmap = bitmap
|
||||||
|
|
||||||
|
const bg = config.backgroundColor
|
||||||
|
canvasCtx.fillStyle = `rgb(${bg} ${bg} ${bg})`
|
||||||
|
canvasCtx.fillRect(0, 0, 200, 200)
|
||||||
|
const { dx, dy, dWidth, dHeight } = scaler[config.scaleMode](nonNullBitmap)
|
||||||
|
canvasCtx.drawImage(nonNullBitmap, dx, dy, dWidth, dHeight)
|
||||||
|
})
|
||||||
|
|
||||||
|
const quantizedData = quantizer.reduce(canvasCtx)
|
||||||
|
ctx.rendered = quantizedData
|
||||||
|
})
|
||||||
|
|
||||||
|
return setContext(BitmapContextToken, ctx)
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { DEFAULT_DITHERING_KERNEL, type DitheringKernel } from '$lib/image/quantizer'
|
||||||
|
import type { ScaleMode } from '$lib/image/scaler'
|
||||||
|
import { Transform } from '$lib/image/transform'
|
||||||
|
import { getContext, setContext } from 'svelte'
|
||||||
|
|
||||||
|
export interface ConversionConfig {
|
||||||
|
scaleMode: ScaleMode
|
||||||
|
transform: Transform
|
||||||
|
backgroundColor: number
|
||||||
|
ditheringKernel: DitheringKernel | null
|
||||||
|
contrast: number
|
||||||
|
bias: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConversionConfigToken = Symbol('conversion-config')
|
||||||
|
|
||||||
|
export function getConversionConfig(): ConversionConfig {
|
||||||
|
return getContext(ConversionConfigToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createConversionConfig() {
|
||||||
|
const config = $state<ConversionConfig>({
|
||||||
|
scaleMode: 'fit',
|
||||||
|
transform: new Transform(),
|
||||||
|
backgroundColor: 255,
|
||||||
|
ditheringKernel: DEFAULT_DITHERING_KERNEL,
|
||||||
|
contrast: 0,
|
||||||
|
bias: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
return setContext(ConversionConfigToken, config)
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { getContext, setContext } from 'svelte'
|
||||||
|
import { toast } from 'svelte-sonner'
|
||||||
|
|
||||||
|
function isInkclip(dev: HIDDevice) {
|
||||||
|
return dev.vendorId == 0xc0de && dev.productId == 0xcafe
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceContext {
|
||||||
|
device: HIDDevice | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const DeviceContextToken = Symbol('device')
|
||||||
|
|
||||||
|
export function getDeviceContext(): DeviceContext {
|
||||||
|
return getContext(DeviceContextToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDeviceContext(): DeviceContext {
|
||||||
|
const ctx: DeviceContext = $state({ device: null })
|
||||||
|
|
||||||
|
navigator.hid.getDevices().then(devices => {
|
||||||
|
const dev = devices.find(isInkclip)
|
||||||
|
if (dev !== undefined) ctx.device = dev
|
||||||
|
})
|
||||||
|
|
||||||
|
navigator.hid.addEventListener('connect', e => {
|
||||||
|
if (isInkclip(e.device) && ctx.device === null) {
|
||||||
|
toast.info('Device connected')
|
||||||
|
ctx.device = e.device
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
navigator.hid.addEventListener('disconnect', e => {
|
||||||
|
if (ctx.device === e.device) {
|
||||||
|
toast.info('Device disconnected')
|
||||||
|
ctx.device = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return setContext(DeviceContextToken, ctx)
|
||||||
|
}
|
||||||
@@ -1,22 +1,10 @@
|
|||||||
import RgbQuant from 'rgbquant'
|
import RgbQuant from '$lib/vendor/rgbquant'
|
||||||
import type { DitheringKernel, RgbQuantImage } from 'rgbquant'
|
import type { DitheringKernel, RgbQuantImage } from '$lib/vendor/rgbquant'
|
||||||
|
|
||||||
export type { DitheringKernel, RgbQuantImage } from 'rgbquant'
|
export type { DitheringKernel, RgbQuantImage } from '$lib/vendor/rgbquant'
|
||||||
|
|
||||||
export const DEFAULT_DITHERING_KERNEL: DitheringKernel = 'FloydSteinberg'
|
export const DEFAULT_DITHERING_KERNEL: DitheringKernel = 'FloydSteinberg'
|
||||||
|
|
||||||
export const ditheringKernels: Record<DitheringKernel, string> = {
|
|
||||||
FloydSteinberg: 'Floyd-Steinberg',
|
|
||||||
FalseFloydSteinberg: 'False Floyd-Steinberg',
|
|
||||||
Stucki: 'Stucki',
|
|
||||||
Atkinson: 'Atkinson',
|
|
||||||
Jarvis: 'Jarvis',
|
|
||||||
Burkes: 'Burkes',
|
|
||||||
Sierra: 'Sierra',
|
|
||||||
TwoSierra: '2-Row Sierra',
|
|
||||||
SierraLite: 'Sierra Lite',
|
|
||||||
}
|
|
||||||
|
|
||||||
export type QuantizerOptions = {
|
export type QuantizerOptions = {
|
||||||
ditheringKernel: DitheringKernel | null
|
ditheringKernel: DitheringKernel | null
|
||||||
contrast: number
|
contrast: number
|
||||||
|
|||||||
Vendored
-51
@@ -1,51 +0,0 @@
|
|||||||
declare module 'rgbquant' {
|
|
||||||
type DitheringKernel =
|
|
||||||
| 'FloydSteinberg'
|
|
||||||
| 'FalseFloydSteinberg'
|
|
||||||
| 'Stucki'
|
|
||||||
| 'Atkinson'
|
|
||||||
| 'Jarvis'
|
|
||||||
| 'Burkes'
|
|
||||||
| 'Sierra'
|
|
||||||
| 'TwoSierra'
|
|
||||||
| 'SierraLite'
|
|
||||||
|
|
||||||
type Rgb = [number, number, number]
|
|
||||||
|
|
||||||
type RgbQuantOptions = {
|
|
||||||
colors?: number
|
|
||||||
method?: 1 | 2
|
|
||||||
boxSize?: [number, number]
|
|
||||||
boxPxls?: number
|
|
||||||
initColors?: number
|
|
||||||
minHueCols?: number
|
|
||||||
dithKern?: DitheringKernel | null
|
|
||||||
dithDelta?: number
|
|
||||||
dithSerp?: boolean
|
|
||||||
palette?: Rgb[]
|
|
||||||
reIndex?: boolean
|
|
||||||
useCache?: boolean
|
|
||||||
cacheFreq?: number
|
|
||||||
colorDist?: 'euclidean' | 'manhattan'
|
|
||||||
}
|
|
||||||
|
|
||||||
type RgbQuantImage =
|
|
||||||
| HTMLImageElement
|
|
||||||
| HTMLCanvasElement
|
|
||||||
| CanvasRenderingContext2D
|
|
||||||
| ImageData
|
|
||||||
| Uint8Array
|
|
||||||
| Uint8ClampedArray
|
|
||||||
| Uint32Array
|
|
||||||
| number[]
|
|
||||||
|
|
||||||
export default class RgbQuant {
|
|
||||||
constructor(opts?: RgbQuantOptions)
|
|
||||||
sample(image: RgbQuantImage, width?: number): void
|
|
||||||
palette(tuples: true, noSort?: boolean = false): Rgb[]
|
|
||||||
palette(tuples: false, noSort?: boolean = false): Uint8Array
|
|
||||||
palette(): Uint8Array
|
|
||||||
reduce(image: RgbQuantImage, retType: 1, dithKern?: DitheringKernel | null, dithSerp?: boolean): Uint8Array
|
|
||||||
reduce(image: RgbQuantImage, retType: 2, dithKern?: DitheringKernel | null, dithSerp?: boolean): number[]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+3
-14
@@ -14,7 +14,7 @@ export class Scaler {
|
|||||||
this.canvasAspectRatio = canvasWidth / canvasHeight
|
this.canvasAspectRatio = canvasWidth / canvasHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
fitScale(image: ImageBitmap): DrawParameters {
|
fit(image: ImageBitmap): DrawParameters {
|
||||||
const { width, height } = image
|
const { width, height } = image
|
||||||
const aspectRatio = width / height
|
const aspectRatio = width / height
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ export class Scaler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cropScale(image: ImageBitmap): DrawParameters {
|
crop(image: ImageBitmap): DrawParameters {
|
||||||
const { width, height } = image
|
const { width, height } = image
|
||||||
const aspectRatio = width / height
|
const aspectRatio = width / height
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ export class Scaler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
distortScale(): DrawParameters {
|
distort(): DrawParameters {
|
||||||
return {
|
return {
|
||||||
dx: 0,
|
dx: 0,
|
||||||
dy: 0,
|
dy: 0,
|
||||||
@@ -72,15 +72,4 @@ export class Scaler {
|
|||||||
dHeight: this.canvasHeight,
|
dHeight: this.canvasHeight,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
scale(image: ImageBitmap, mode: ScaleMode) {
|
|
||||||
switch (mode) {
|
|
||||||
case 'crop':
|
|
||||||
return this.cropScale(image)
|
|
||||||
case 'fit':
|
|
||||||
return this.fitScale(image)
|
|
||||||
case 'distort':
|
|
||||||
return this.distortScale()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ export class Transform {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function withCtx<T>(ctx: CanvasRenderingContext2D, pre: () => void, action: () => T): T {
|
export type Context2D = CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D
|
||||||
|
|
||||||
|
export function withCtx<T>(ctx: Context2D, pre: () => void, action: () => T): T {
|
||||||
ctx.save()
|
ctx.save()
|
||||||
try {
|
try {
|
||||||
pre()
|
pre()
|
||||||
@@ -44,7 +46,7 @@ export function withCtx<T>(ctx: CanvasRenderingContext2D, pre: () => void, actio
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function withTransform<T>(ctx: CanvasRenderingContext2D, transform: Transform, action: () => T): T {
|
export function withTransform<T>(ctx: Context2D, transform: Transform, action: () => T): T {
|
||||||
return withCtx(
|
return withCtx(
|
||||||
ctx,
|
ctx,
|
||||||
() => {
|
() => {
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'svelte/elements'
|
||||||
|
import { cn } from '$lib/utils'
|
||||||
|
|
||||||
|
import { Separator } from '$lib/components/ui/separator'
|
||||||
|
|
||||||
|
import ConnectSection from '$lib/layouts/connect/ConnectSection.svelte'
|
||||||
|
import EditSection from '$lib/layouts/edit/EditSection.svelte'
|
||||||
|
import WriteSection from '$lib/layouts/write/WriteSection.svelte'
|
||||||
|
import { createDeviceContext } from '$lib/contexts/device.svelte'
|
||||||
|
import { createConversionConfig } from '$lib/contexts/config.svelte'
|
||||||
|
import { createBitmapContext } from '$lib/contexts/bitmap.svelte'
|
||||||
|
|
||||||
|
interface Props extends HTMLAttributes<HTMLElement> {}
|
||||||
|
|
||||||
|
const { class: classNames, ...restProps }: Props = $props()
|
||||||
|
|
||||||
|
createDeviceContext()
|
||||||
|
const config = createConversionConfig()
|
||||||
|
createBitmapContext(config)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main class={cn('w-full flex flex-col gap-4', classNames)} {...restProps}>
|
||||||
|
<ConnectSection />
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<EditSection class="grow" />
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<WriteSection />
|
||||||
|
</main>
|
||||||
@@ -1,7 +1,24 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import IconDisabledByDefault from '~icons/material-symbols/disabled-by-default'
|
||||||
|
|
||||||
|
import type { HTMLAttributes } from 'svelte/elements'
|
||||||
|
import { cn } from '$lib/utils'
|
||||||
|
|
||||||
import * as AlertDialog from '$lib/components/ui/alert-dialog'
|
import * as AlertDialog from '$lib/components/ui/alert-dialog'
|
||||||
|
|
||||||
|
interface Props extends HTMLAttributes<HTMLElement> {}
|
||||||
|
|
||||||
|
const { class: classNames, ...restProps }: Props = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<main
|
||||||
|
class={cn('flex items-center justify-center gap-2 font-medium text-xl text-muted-foreground', classNames)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
<IconDisabledByDefault />
|
||||||
|
<div>Not Supported</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
<AlertDialog.Root open>
|
<AlertDialog.Root open>
|
||||||
<AlertDialog.Portal>
|
<AlertDialog.Portal>
|
||||||
<AlertDialog.Overlay />
|
<AlertDialog.Overlay />
|
||||||
@@ -9,7 +26,7 @@
|
|||||||
<AlertDialog.Title>Browser not supported</AlertDialog.Title>
|
<AlertDialog.Title>Browser not supported</AlertDialog.Title>
|
||||||
|
|
||||||
<AlertDialog.Description>
|
<AlertDialog.Description>
|
||||||
<p class="mb-4">Write to Inkclip uses the WebHID API, which is not supported by your browser.</p>
|
<p class="mb-2">Write to Inkclip uses the WebHID API, which is not supported by your browser.</p>
|
||||||
<p>
|
<p>
|
||||||
We recommend using a Chromium-based browser, such as a recent version of Google Chrome, Microsoft Edge, Opera,
|
We recommend using a Chromium-based browser, such as a recent version of Google Chrome, Microsoft Edge, Opera,
|
||||||
or Arc.
|
or Arc.
|
||||||
@@ -1,17 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '$lib/components/ui/button'
|
import { Button } from '$lib/components/ui/button'
|
||||||
|
import { getDeviceContext } from '$lib/contexts/device.svelte'
|
||||||
|
|
||||||
const hid = navigator.hid
|
const deviceCtx = getDeviceContext()
|
||||||
|
|
||||||
interface Props {
|
|
||||||
onconnect: (device: HIDDevice) => void
|
|
||||||
device: HIDDevice | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const { onconnect, device }: Props = $props()
|
|
||||||
|
|
||||||
async function requestDevice() {
|
async function requestDevice() {
|
||||||
const devs = await hid.requestDevice({
|
const devs = await navigator.hid.requestDevice({
|
||||||
filters: [
|
filters: [
|
||||||
{
|
{
|
||||||
vendorId: 0xc0de,
|
vendorId: 0xc0de,
|
||||||
@@ -24,12 +18,12 @@
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
onconnect(devs[0])
|
deviceCtx.device = devs[0]
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Button variant={device === null ? 'default' : 'secondary'} onclick={requestDevice}>
|
<Button variant={deviceCtx.device === null ? 'default' : 'secondary'} onclick={requestDevice}>
|
||||||
{#if device === null}
|
{#if deviceCtx.device === null}
|
||||||
Select device
|
Select device
|
||||||
{:else}
|
{:else}
|
||||||
Connect to another device
|
Connect to another device
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import IconPending from '~icons/material-symbols/pending'
|
||||||
|
import IconCheckCircle from '~icons/material-symbols/check-circle'
|
||||||
|
|
||||||
|
import ConnectButton from './ConnectButton.svelte'
|
||||||
|
import { getDeviceContext } from '$lib/contexts/device.svelte'
|
||||||
|
|
||||||
|
const deviceCtx = getDeviceContext()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="flex items-center gap-2 max-lg:flex-col max-lg:items-stretch">
|
||||||
|
<div class="grow">
|
||||||
|
<h1 class="font-semibold text-xl/8">Connect to a device</h1>
|
||||||
|
|
||||||
|
<div class="text-sm">
|
||||||
|
{#if deviceCtx.device === null}
|
||||||
|
<IconPending class="inline" /> Not connected to any device yet. Plug in your device, and click on the button to select
|
||||||
|
it.
|
||||||
|
{:else}
|
||||||
|
<IconCheckCircle class="inline" /> Successfully conected to device. If you want to, you can connect to another device
|
||||||
|
instead.
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConnectButton />
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'svelte/elements'
|
||||||
|
import { cn } from '$lib/utils'
|
||||||
|
|
||||||
|
import { Separator } from '$lib/components/ui/separator'
|
||||||
|
|
||||||
|
import PreviewSection from './preview/PreviewSection.svelte'
|
||||||
|
import ControlsSection from './controls/ControlsSection.svelte'
|
||||||
|
import { createMediaQuery } from '$lib/utils/media.svelte'
|
||||||
|
|
||||||
|
interface Props extends HTMLAttributes<HTMLDivElement> {}
|
||||||
|
|
||||||
|
const { class: classNames, ...restProps }: Props = $props()
|
||||||
|
|
||||||
|
const mobileMediaQuery = createMediaQuery('(max-width: 1024px)')
|
||||||
|
const separatorOrientation = $derived(mobileMediaQuery.matches ? 'horizontal' : 'vertical')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={cn('flex max-lg:flex-col gap-4', classNames)} {...restProps}>
|
||||||
|
<PreviewSection />
|
||||||
|
|
||||||
|
<Separator orientation={separatorOrientation} />
|
||||||
|
|
||||||
|
<ControlsSection class="grow" />
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Label } from '$lib/components/ui/label'
|
||||||
|
import { Slider } from '$lib/components/ui/slider'
|
||||||
|
import Infotip from '$lib/components/Infotip.svelte'
|
||||||
|
import { getConversionConfig } from '$lib/contexts/config.svelte'
|
||||||
|
|
||||||
|
const config = getConversionConfig()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<Label id="background-color-input-label" class="multimodal">
|
||||||
|
Background Color
|
||||||
|
<span class="font-normal text-muted-foreground"> = {config.backgroundColor} </span>
|
||||||
|
<Infotip>The color used for transparent pixels.</Infotip>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Slider
|
||||||
|
type="single"
|
||||||
|
value={config.backgroundColor}
|
||||||
|
onValueCommit={v => {
|
||||||
|
config.backgroundColor = v
|
||||||
|
}}
|
||||||
|
min={0}
|
||||||
|
max={255}
|
||||||
|
step={1}
|
||||||
|
aria-labelledby="background-color-input-label"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
+25
-39
@@ -4,8 +4,7 @@
|
|||||||
import type { HTMLAttributes } from 'svelte/elements'
|
import type { HTMLAttributes } from 'svelte/elements'
|
||||||
import { cn } from '$lib/utils'
|
import { cn } from '$lib/utils'
|
||||||
|
|
||||||
import { DEFAULT_DITHERING_KERNEL, type DitheringKernel } from '$lib/image/quantizer'
|
import { DEFAULT_DITHERING_KERNEL } from '$lib/image/quantizer'
|
||||||
import type { ScaleMode } from '$lib/image/scaler'
|
|
||||||
import { Transform } from '$lib/image/transform'
|
import { Transform } from '$lib/image/transform'
|
||||||
|
|
||||||
import { Button } from '$lib/components/ui/button'
|
import { Button } from '$lib/components/ui/button'
|
||||||
@@ -18,43 +17,30 @@
|
|||||||
import DitherControls from './conversion/dither/DitherControls.svelte'
|
import DitherControls from './conversion/dither/DitherControls.svelte'
|
||||||
import ContrastSlider from './conversion/ContrastSlider.svelte'
|
import ContrastSlider from './conversion/ContrastSlider.svelte'
|
||||||
import BiasSlider from './conversion/BiasSlider.svelte'
|
import BiasSlider from './conversion/BiasSlider.svelte'
|
||||||
|
import { getBitmapContext } from '$lib/contexts/bitmap.svelte'
|
||||||
|
import { getConversionConfig } from '$lib/contexts/config.svelte'
|
||||||
|
|
||||||
interface Props extends HTMLAttributes<HTMLElement> {
|
interface Props extends HTMLAttributes<HTMLElement> {}
|
||||||
imageBitmap: ImageBitmap | null
|
|
||||||
scaleMode: ScaleMode
|
|
||||||
transform: Transform
|
|
||||||
backgroundColor: number
|
|
||||||
ditheringKernel: DitheringKernel | null
|
|
||||||
contrast: number
|
|
||||||
bias: number
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
const { class: className, ...restProps }: Props = $props()
|
||||||
imageBitmap,
|
|
||||||
scaleMode = $bindable(),
|
|
||||||
transform = $bindable(),
|
|
||||||
backgroundColor = $bindable(),
|
|
||||||
ditheringKernel = $bindable(),
|
|
||||||
contrast = $bindable(),
|
|
||||||
bias = $bindable(),
|
|
||||||
class: className,
|
|
||||||
...restProps
|
|
||||||
}: Props = $props()
|
|
||||||
|
|
||||||
const transformDisabled = $derived(imageBitmap === null)
|
const bitmapCtx = getBitmapContext()
|
||||||
|
const config = getConversionConfig()
|
||||||
|
|
||||||
|
const transformDisabled = $derived(bitmapCtx.image === null)
|
||||||
|
|
||||||
function imageNonSquare() {
|
function imageNonSquare() {
|
||||||
if (imageBitmap === null) return false
|
if (bitmapCtx.image === null) return false
|
||||||
return imageBitmap.height !== imageBitmap.width
|
return bitmapCtx.image.height !== bitmapCtx.image.width
|
||||||
}
|
}
|
||||||
|
|
||||||
function restoreDefaultImageSettings() {
|
function restoreDefaultImageSettings() {
|
||||||
scaleMode = 'fit'
|
config.scaleMode = 'fit'
|
||||||
transform = new Transform()
|
config.transform = new Transform()
|
||||||
backgroundColor = 255
|
config.backgroundColor = 255
|
||||||
ditheringKernel = DEFAULT_DITHERING_KERNEL
|
config.ditheringKernel = DEFAULT_DITHERING_KERNEL
|
||||||
contrast = 0
|
config.contrast = 0
|
||||||
bias = 0
|
config.bias = 0
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -67,26 +53,26 @@
|
|||||||
|
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
{#if imageNonSquare()}
|
{#if imageNonSquare()}
|
||||||
<ScaleModeToggleGroup bind:scaleMode />
|
<ScaleModeToggleGroup />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<TransformControls disabled={transformDisabled} bind:transform />
|
<TransformControls disabled={transformDisabled} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<BackgroundColorSlider bind:backgroundColor />
|
<BackgroundColorSlider />
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<DitherControls bind:ditheringKernel />
|
<DitherControls />
|
||||||
|
|
||||||
{#if ditheringKernel !== null}
|
{#if config.ditheringKernel !== null}
|
||||||
<ContrastSlider bind:contrast />
|
<ContrastSlider />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if ditheringKernel === null || contrast !== 0}
|
{#if config.ditheringKernel === null || config.contrast !== 0}
|
||||||
<BiasSlider bind:bias />
|
<BiasSlider />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
+6
-9
@@ -1,20 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Label } from '$lib/components/ui/label'
|
import { Label } from '$lib/components/ui/label'
|
||||||
import { Slider } from '$lib/components/ui/slider'
|
import { Slider } from '$lib/components/ui/slider'
|
||||||
import Infotip from '$lib/Infotip.svelte'
|
import Infotip from '$lib/components/Infotip.svelte'
|
||||||
|
import { getConversionConfig } from '$lib/contexts/config.svelte'
|
||||||
|
|
||||||
interface Props {
|
const config = getConversionConfig()
|
||||||
bias: number
|
|
||||||
}
|
|
||||||
|
|
||||||
let { bias = $bindable() }: Props = $props()
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<Label for="bias-input" class="multimodal">
|
<Label for="bias-input" class="multimodal">
|
||||||
<div>
|
<div>
|
||||||
Bias
|
Bias
|
||||||
<span class="font-normal text-muted-foreground"> = {Math.floor(bias * 100)}% </span>
|
<span class="font-normal text-muted-foreground"> = {Math.floor(config.bias * 100)}% </span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Infotip>
|
<Infotip>
|
||||||
@@ -24,9 +21,9 @@
|
|||||||
|
|
||||||
<Slider
|
<Slider
|
||||||
type="single"
|
type="single"
|
||||||
value={bias}
|
value={config.bias}
|
||||||
onValueCommit={v => {
|
onValueCommit={v => {
|
||||||
bias = v
|
config.bias = v
|
||||||
}}
|
}}
|
||||||
min={-1}
|
min={-1}
|
||||||
max={1}
|
max={1}
|
||||||
+6
-9
@@ -1,20 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Label } from '$lib/components/ui/label'
|
import { Label } from '$lib/components/ui/label'
|
||||||
import { Slider } from '$lib/components/ui/slider'
|
import { Slider } from '$lib/components/ui/slider'
|
||||||
import Infotip from '$lib/Infotip.svelte'
|
import Infotip from '$lib/components/Infotip.svelte'
|
||||||
|
import { getConversionConfig } from '$lib/contexts/config.svelte'
|
||||||
|
|
||||||
interface Props {
|
const config = getConversionConfig()
|
||||||
contrast: number
|
|
||||||
}
|
|
||||||
|
|
||||||
let { contrast = $bindable() }: Props = $props()
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<Label for="contrast-input" class="multimodal">
|
<Label for="contrast-input" class="multimodal">
|
||||||
<div>
|
<div>
|
||||||
Contrast
|
Contrast
|
||||||
<span class="font-normal text-muted-foreground"> = {Math.floor(contrast * 100)}% </span>
|
<span class="font-normal text-muted-foreground"> = {Math.floor(config.contrast * 100)}% </span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Infotip>
|
<Infotip>
|
||||||
@@ -24,9 +21,9 @@
|
|||||||
|
|
||||||
<Slider
|
<Slider
|
||||||
type="single"
|
type="single"
|
||||||
value={contrast}
|
value={config.contrast}
|
||||||
onValueCommit={v => {
|
onValueCommit={v => {
|
||||||
contrast = v
|
config.contrast = v
|
||||||
}}
|
}}
|
||||||
min={0}
|
min={0}
|
||||||
max={1}
|
max={1}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { DEFAULT_DITHERING_KERNEL, type DitheringKernel } from '$lib/image/quantizer'
|
||||||
|
import { getConversionConfig } from '$lib/contexts/config.svelte'
|
||||||
|
import DitheringKernelDropdown from './DitheringKernelDropdown.svelte'
|
||||||
|
import DitherSwitch from './DitherSwitch.svelte'
|
||||||
|
|
||||||
|
const config = getConversionConfig()
|
||||||
|
|
||||||
|
let lastDitheringKernel: DitheringKernel = $state(DEFAULT_DITHERING_KERNEL)
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (config.ditheringKernel === null) return
|
||||||
|
lastDitheringKernel = config.ditheringKernel
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<DitherSwitch
|
||||||
|
checked={config.ditheringKernel !== null}
|
||||||
|
onCheckedChange={c => {
|
||||||
|
if (c) {
|
||||||
|
config.ditheringKernel = lastDitheringKernel
|
||||||
|
} else {
|
||||||
|
config.ditheringKernel = null
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DitheringKernelDropdown
|
||||||
|
class={config.ditheringKernel !== null ? [] : ['invisible']}
|
||||||
|
value={lastDitheringKernel}
|
||||||
|
onchange={v => {
|
||||||
|
config.ditheringKernel = v
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Switch } from '$lib/components/ui/switch'
|
import { Switch } from '$lib/components/ui/switch'
|
||||||
import { Label } from '$lib/components/ui/label'
|
import { Label } from '$lib/components/ui/label'
|
||||||
import Infotip from '$lib/Infotip.svelte'
|
import Infotip from '$lib/components/Infotip.svelte'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
checked: boolean
|
checked: boolean
|
||||||
+14
-2
@@ -2,11 +2,11 @@
|
|||||||
import type { HTMLAttributes } from 'svelte/elements'
|
import type { HTMLAttributes } from 'svelte/elements'
|
||||||
import { cn } from '$lib/utils'
|
import { cn } from '$lib/utils'
|
||||||
|
|
||||||
import { ditheringKernels, type DitheringKernel } from '$lib/image/quantizer'
|
import { type DitheringKernel } from '$lib/image/quantizer'
|
||||||
|
|
||||||
import { Label } from '$lib/components/ui/label'
|
import { Label } from '$lib/components/ui/label'
|
||||||
import * as Select from '$lib/components/ui/select'
|
import * as Select from '$lib/components/ui/select'
|
||||||
import Infotip from '$lib/Infotip.svelte'
|
import Infotip from '$lib/components/Infotip.svelte'
|
||||||
|
|
||||||
interface Props extends Omit<HTMLAttributes<HTMLDivElement>, 'onchange'> {
|
interface Props extends Omit<HTMLAttributes<HTMLDivElement>, 'onchange'> {
|
||||||
value: DitheringKernel
|
value: DitheringKernel
|
||||||
@@ -14,6 +14,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let { value, onchange, class: classNames, ...restProps }: Props = $props()
|
let { value, onchange, class: classNames, ...restProps }: Props = $props()
|
||||||
|
|
||||||
|
const ditheringKernels: Record<DitheringKernel, string> = {
|
||||||
|
FloydSteinberg: 'Floyd-Steinberg',
|
||||||
|
FalseFloydSteinberg: 'False Floyd-Steinberg',
|
||||||
|
Stucki: 'Stucki',
|
||||||
|
Atkinson: 'Atkinson',
|
||||||
|
Jarvis: 'Jarvis',
|
||||||
|
Burkes: 'Burkes',
|
||||||
|
Sierra: 'Sierra',
|
||||||
|
TwoSierra: '2-Row Sierra',
|
||||||
|
SierraLite: 'Sierra Lite',
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class={cn('grow flex flex-col gap-2', classNames)} {...restProps}>
|
<div class={cn('grow flex flex-col gap-2', classNames)} {...restProps}>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ScaleMode } from '$lib/image/scaler'
|
||||||
|
|
||||||
|
import { Label } from '$lib/components/ui/label'
|
||||||
|
import * as ToggleGroup from '$lib/components/ui/toggle-group'
|
||||||
|
import * as Tooltip from '$lib/components/ui/tooltip'
|
||||||
|
import { getConversionConfig } from '$lib/contexts/config.svelte'
|
||||||
|
|
||||||
|
const config = getConversionConfig()
|
||||||
|
|
||||||
|
const scaleModes: Record<ScaleMode, { name: string; description: string }> = {
|
||||||
|
fit: {
|
||||||
|
name: 'Fit',
|
||||||
|
description: 'Fit the entire image onto the display. May introduce letterboxing.',
|
||||||
|
},
|
||||||
|
crop: {
|
||||||
|
name: 'Crop',
|
||||||
|
description: 'Fill the display and crop out-of-frame parts of the image.',
|
||||||
|
},
|
||||||
|
distort: {
|
||||||
|
name: 'Distort',
|
||||||
|
description: 'Stretch the image to fill the display. Distorts the aspect ratio.',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<Label for="scale-mode-input">Scaling method</Label>
|
||||||
|
<Tooltip.Provider delayDuration={0} disableHoverableContent>
|
||||||
|
<ToggleGroup.Root
|
||||||
|
id="scale-mode-input"
|
||||||
|
type="single"
|
||||||
|
bind:value={
|
||||||
|
() => config.scaleMode,
|
||||||
|
v => {
|
||||||
|
if (v.length !== 0) config.scaleMode = v as ScaleMode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{#each Object.entries(scaleModes) as [mode, { name, description }]}
|
||||||
|
<Tooltip.Root>
|
||||||
|
<Tooltip.Trigger>
|
||||||
|
<ToggleGroup.Item value={mode}>{name}</ToggleGroup.Item>
|
||||||
|
</Tooltip.Trigger>
|
||||||
|
<Tooltip.Content>
|
||||||
|
{description}
|
||||||
|
</Tooltip.Content>
|
||||||
|
</Tooltip.Root>
|
||||||
|
{/each}
|
||||||
|
</ToggleGroup.Root>
|
||||||
|
</Tooltip.Provider>
|
||||||
|
</div>
|
||||||
+8
-8
@@ -4,18 +4,18 @@
|
|||||||
import IconFlipHorizontal from '~icons/mdi/flip-horizontal'
|
import IconFlipHorizontal from '~icons/mdi/flip-horizontal'
|
||||||
import IconFlipVertical from '~icons/mdi/flip-Vertical'
|
import IconFlipVertical from '~icons/mdi/flip-Vertical'
|
||||||
|
|
||||||
import { Transform } from '$lib/image/transform'
|
|
||||||
|
|
||||||
import { Button } from '$lib/components/ui/button'
|
import { Button } from '$lib/components/ui/button'
|
||||||
import { Label } from '$lib/components/ui/label'
|
import { Label } from '$lib/components/ui/label'
|
||||||
import * as Tooltip from '$lib/components/ui/tooltip'
|
import * as Tooltip from '$lib/components/ui/tooltip'
|
||||||
|
import { getConversionConfig } from '$lib/contexts/config.svelte'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
transform: Transform
|
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
let { transform = $bindable(), disabled = false }: Props = $props()
|
const { disabled = false }: Props = $props()
|
||||||
|
|
||||||
|
const config = getConversionConfig()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
size="icon"
|
size="icon"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
transform = transform.cw()
|
config.transform = config.transform.cw()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconRotate90DegreesCw />
|
<IconRotate90DegreesCw />
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
size="icon"
|
size="icon"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
transform = transform.ccw()
|
config.transform = config.transform.ccw()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconRotate90DegreesCcw />
|
<IconRotate90DegreesCcw />
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
size="icon"
|
size="icon"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
transform = transform.h()
|
config.transform = config.transform.h()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconFlipHorizontal />
|
<IconFlipHorizontal />
|
||||||
@@ -81,7 +81,7 @@
|
|||||||
size="icon"
|
size="icon"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
transform = transform.v()
|
config.transform = config.transform.v()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconFlipVertical />
|
<IconFlipVertical />
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Input } from '$lib/components/ui/input'
|
||||||
|
import { getBitmapContext } from '$lib/contexts/bitmap.svelte'
|
||||||
|
import { toast } from 'svelte-sonner'
|
||||||
|
|
||||||
|
const bitmapCtx = getBitmapContext()
|
||||||
|
|
||||||
|
async function updateImageBitmap(fileList: FileList | null) {
|
||||||
|
if (fileList === null || fileList.length < 1) {
|
||||||
|
bitmapCtx.image = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageFile = fileList[0]
|
||||||
|
try {
|
||||||
|
bitmapCtx.image = await createImageBitmap(imageFile)
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(`Error loading image file: ${e}`)
|
||||||
|
bitmapCtx.image = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Input type="file" id="image-file" accept="image/*" onchange={e => updateImageBitmap(e.currentTarget.files)} />
|
||||||
+6
-9
@@ -1,16 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Label } from '$lib/components/ui/label'
|
import { Label } from '$lib/components/ui/label'
|
||||||
import { withCtx } from '$lib/image/transform'
|
import { withCtx } from '$lib/image/transform'
|
||||||
|
import { getBitmapContext } from '$lib/contexts/bitmap.svelte'
|
||||||
interface Props {
|
|
||||||
bitmap: number[] | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const { bitmap }: Props = $props()
|
|
||||||
|
|
||||||
let canvas2xEl: HTMLCanvasElement
|
let canvas2xEl: HTMLCanvasElement
|
||||||
let canvas1xEl: HTMLCanvasElement
|
let canvas1xEl: HTMLCanvasElement
|
||||||
|
|
||||||
|
const bitmapCtx = getBitmapContext()
|
||||||
|
|
||||||
function freshContext(el: HTMLCanvasElement) {
|
function freshContext(el: HTMLCanvasElement) {
|
||||||
const ctx = el.getContext('2d')!
|
const ctx = el.getContext('2d')!
|
||||||
ctx.clearRect(0, 0, 200, 200)
|
ctx.clearRect(0, 0, 200, 200)
|
||||||
@@ -39,10 +36,10 @@
|
|||||||
const context2x = freshContext(canvas2xEl)
|
const context2x = freshContext(canvas2xEl)
|
||||||
const context1x = freshContext(canvas1xEl)
|
const context1x = freshContext(canvas1xEl)
|
||||||
|
|
||||||
if (bitmap === null) return
|
if (bitmapCtx.rendered === null) return
|
||||||
|
|
||||||
drawQuantizedData(context2x, bitmap)
|
drawQuantizedData(context2x, bitmapCtx.rendered)
|
||||||
drawQuantizedData(context1x, bitmap)
|
drawQuantizedData(context1x, bitmapCtx.rendered)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
+2
-9
@@ -1,17 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import PreviewCanvases from './PreviewCanvases.svelte'
|
import PreviewCanvases from './PreviewCanvases.svelte'
|
||||||
import FileSelect from './FileSelect.svelte'
|
import FileSelect from './FileSelect.svelte'
|
||||||
|
|
||||||
interface Props {
|
|
||||||
bitmap: number[] | null
|
|
||||||
onchange: (imageBitmap: ImageBitmap | null) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const { bitmap, onchange }: Props = $props()
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="flex flex-col gap-4">
|
<section class="flex flex-col gap-4">
|
||||||
<h1 class="font-semibold text-xl/6">Choose an image</h1>
|
<h1 class="font-semibold text-xl/6">Choose an image</h1>
|
||||||
<FileSelect {onchange} />
|
<FileSelect />
|
||||||
<PreviewCanvases {bitmap} />
|
<PreviewCanvases />
|
||||||
</section>
|
</section>
|
||||||
@@ -1,24 +1,29 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '$lib/components/ui/button'
|
import { Button } from '$lib/components/ui/button'
|
||||||
|
import { getBitmapContext } from '$lib/contexts/bitmap.svelte'
|
||||||
|
import { getDeviceContext } from '$lib/contexts/device.svelte'
|
||||||
import { toast } from 'svelte-sonner'
|
import { toast } from 'svelte-sonner'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
device: HIDDevice | null
|
|
||||||
data: number[] | null
|
|
||||||
onprogress: (inPropgress: boolean) => void
|
onprogress: (inPropgress: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const { device, data, onprogress }: Props = $props()
|
const { onprogress }: Props = $props()
|
||||||
|
|
||||||
let inProgress = $state(false)
|
let inProgress = $state(false)
|
||||||
let disabled = $derived(device === null || data === null || inProgress)
|
|
||||||
let secondary = $derived(device === null)
|
const deviceCtx = getDeviceContext()
|
||||||
|
const bitmapCtx = getBitmapContext()
|
||||||
|
|
||||||
|
let disabled = $derived(deviceCtx.device === null || bitmapCtx.rendered === null || inProgress)
|
||||||
|
let secondary = $derived(deviceCtx.device === null)
|
||||||
|
|
||||||
async function connectAndWrite() {
|
async function connectAndWrite() {
|
||||||
if (device === null || data === null) return
|
if (deviceCtx.device === null || bitmapCtx.rendered === null) return
|
||||||
|
|
||||||
if (!device.opened) {
|
if (!deviceCtx.device.opened) {
|
||||||
try {
|
try {
|
||||||
await device.open()
|
await deviceCtx.device.open()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(`Unable to open device: ${e}`)
|
toast.error(`Unable to open device: ${e}`)
|
||||||
return
|
return
|
||||||
@@ -31,7 +36,7 @@
|
|||||||
let cell = 0x0
|
let cell = 0x0
|
||||||
for (let xStroll = 0; xStroll < 8; xStroll++) {
|
for (let xStroll = 0; xStroll < 8; xStroll++) {
|
||||||
const index = y * 200 + xStride * 8 + xStroll
|
const index = y * 200 + xStride * 8 + xStroll
|
||||||
cell |= data[index] << xStroll
|
cell |= bitmapCtx.rendered[index] << xStroll
|
||||||
}
|
}
|
||||||
buffer[y * 25 + xStride] = cell
|
buffer[y * 25 + xStride] = cell
|
||||||
}
|
}
|
||||||
@@ -40,7 +45,7 @@
|
|||||||
inProgress = true
|
inProgress = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await device.sendReport(0, buffer)
|
await deviceCtx.device.sendReport(0, buffer)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(`Error writing to device: ${e}`)
|
toast.error(`Error writing to device: ${e}`)
|
||||||
return
|
return
|
||||||
@@ -5,13 +5,11 @@
|
|||||||
import IconWarning from '~icons/material-symbols/warning'
|
import IconWarning from '~icons/material-symbols/warning'
|
||||||
|
|
||||||
import WriteButton from './WriteButton.svelte'
|
import WriteButton from './WriteButton.svelte'
|
||||||
|
import { getDeviceContext } from '$lib/contexts/device.svelte'
|
||||||
|
import { getBitmapContext } from '$lib/contexts/bitmap.svelte'
|
||||||
|
|
||||||
interface Props {
|
const deviceCtx = getDeviceContext()
|
||||||
device: HIDDevice | null
|
const bitmapCtx = getBitmapContext()
|
||||||
bitmap: number[] | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const { device, bitmap }: Props = $props()
|
|
||||||
|
|
||||||
let inProgress = $state(false)
|
let inProgress = $state(false)
|
||||||
</script>
|
</script>
|
||||||
@@ -21,9 +19,9 @@
|
|||||||
<h1 class="font-semibold text-xl/8">Write pattern to device</h1>
|
<h1 class="font-semibold text-xl/8">Write pattern to device</h1>
|
||||||
|
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
{#if device === null}
|
{#if deviceCtx.device === null}
|
||||||
<IconHelp class="inline" /> To start writing patterns to your device, connect it first.
|
<IconHelp class="inline" /> To start writing patterns to your device, connect it first.
|
||||||
{:else if bitmap === null}
|
{:else if bitmapCtx.image === null}
|
||||||
<IconPending class="inline" /> Select an image file in order to write it onto your device.
|
<IconPending class="inline" /> Select an image file in order to write it onto your device.
|
||||||
{:else if !inProgress}
|
{:else if !inProgress}
|
||||||
<IconArrowUploadProgress class="inline" /> Write the pattern onto your device if you have finished editing the image.
|
<IconArrowUploadProgress class="inline" /> Write the pattern onto your device if you have finished editing the image.
|
||||||
@@ -34,8 +32,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<WriteButton
|
<WriteButton
|
||||||
{device}
|
|
||||||
data={bitmap}
|
|
||||||
onprogress={v => {
|
onprogress={v => {
|
||||||
inProgress = v
|
inProgress = v
|
||||||
}}
|
}}
|
||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
import { type ClassValue, clsx } from "clsx";
|
import { type ClassValue, clsx } from 'clsx'
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export interface ReactiveMediaQuery {
|
||||||
|
readonly matches: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMediaQuery(query: string): ReactiveMediaQuery {
|
||||||
|
const queryList = matchMedia(query)
|
||||||
|
|
||||||
|
const queryState = $state({ matches: queryList.matches })
|
||||||
|
|
||||||
|
queryList.addEventListener('change', self => {
|
||||||
|
queryState.matches = self.matches
|
||||||
|
})
|
||||||
|
|
||||||
|
return queryState
|
||||||
|
}
|
||||||
Vendored
+50
@@ -0,0 +1,50 @@
|
|||||||
|
export type DitheringKernel =
|
||||||
|
| 'FloydSteinberg'
|
||||||
|
| 'FalseFloydSteinberg'
|
||||||
|
| 'Stucki'
|
||||||
|
| 'Atkinson'
|
||||||
|
| 'Jarvis'
|
||||||
|
| 'Burkes'
|
||||||
|
| 'Sierra'
|
||||||
|
| 'TwoSierra'
|
||||||
|
| 'SierraLite'
|
||||||
|
|
||||||
|
export type Rgb = [number, number, number]
|
||||||
|
|
||||||
|
export type RgbQuantOptions = {
|
||||||
|
colors?: number
|
||||||
|
method?: 1 | 2
|
||||||
|
boxSize?: [number, number]
|
||||||
|
boxPxls?: number
|
||||||
|
initColors?: number
|
||||||
|
minHueCols?: number
|
||||||
|
dithKern?: DitheringKernel | null
|
||||||
|
dithDelta?: number
|
||||||
|
dithSerp?: boolean
|
||||||
|
palette?: Rgb[]
|
||||||
|
reIndex?: boolean
|
||||||
|
useCache?: boolean
|
||||||
|
cacheFreq?: number
|
||||||
|
colorDist?: 'euclidean' | 'manhattan'
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RgbQuantImage =
|
||||||
|
| HTMLImageElement
|
||||||
|
| HTMLCanvasElement
|
||||||
|
| CanvasRenderingContext2D
|
||||||
|
| OffscreenCanvasRenderingContext2D
|
||||||
|
| ImageData
|
||||||
|
| Uint8Array
|
||||||
|
| Uint8ClampedArray
|
||||||
|
| Uint32Array
|
||||||
|
| number[]
|
||||||
|
|
||||||
|
export default class RgbQuant {
|
||||||
|
constructor(opts?: RgbQuantOptions)
|
||||||
|
sample(image: RgbQuantImage, width?: number): void
|
||||||
|
palette(tuples: true, noSort?: boolean = false): Rgb[]
|
||||||
|
palette(tuples: false, noSort?: boolean = false): Uint8Array
|
||||||
|
palette(): Uint8Array
|
||||||
|
reduce(image: RgbQuantImage, retType: 1, dithKern?: DitheringKernel | null, dithSerp?: boolean): Uint8Array
|
||||||
|
reduce(image: RgbQuantImage, retType: 2, dithKern?: DitheringKernel | null, dithSerp?: boolean): number[]
|
||||||
|
}
|
||||||
Vendored
+918
@@ -0,0 +1,918 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2015, Leon Sorokin
|
||||||
|
* All rights reserved. (MIT Licensed)
|
||||||
|
*
|
||||||
|
* RgbQuant.js - an image quantization lib
|
||||||
|
*/
|
||||||
|
|
||||||
|
function RgbQuant(opts) {
|
||||||
|
opts = opts || {}
|
||||||
|
|
||||||
|
// 1 = by global population, 2 = subregion population threshold
|
||||||
|
this.method = opts.method || 2
|
||||||
|
// desired final palette size
|
||||||
|
this.colors = opts.colors || 256
|
||||||
|
// # of highest-frequency colors to start with for palette reduction
|
||||||
|
this.initColors = opts.initColors || 4096
|
||||||
|
// color-distance threshold for initial reduction pass
|
||||||
|
this.initDist = opts.initDist || 0.01
|
||||||
|
// subsequent passes threshold
|
||||||
|
this.distIncr = opts.distIncr || 0.005
|
||||||
|
// palette grouping
|
||||||
|
this.hueGroups = opts.hueGroups || 10
|
||||||
|
this.satGroups = opts.satGroups || 10
|
||||||
|
this.lumGroups = opts.lumGroups || 10
|
||||||
|
// if > 0, enables hues stats and min-color retention per group
|
||||||
|
this.minHueCols = opts.minHueCols || 0
|
||||||
|
// HueStats instance
|
||||||
|
this.hueStats = this.minHueCols ? new HueStats(this.hueGroups, this.minHueCols) : null
|
||||||
|
|
||||||
|
// subregion partitioning box size
|
||||||
|
this.boxSize = opts.boxSize || [64, 64]
|
||||||
|
// number of same pixels required within box for histogram inclusion
|
||||||
|
this.boxPxls = opts.boxPxls || 2
|
||||||
|
// palette locked indicator
|
||||||
|
this.palLocked = false
|
||||||
|
// palette sort order
|
||||||
|
// this.sortPal = ['hue-','lum-','sat-'];
|
||||||
|
|
||||||
|
// dithering/error diffusion kernel name
|
||||||
|
this.dithKern = opts.dithKern || null
|
||||||
|
// dither serpentine pattern
|
||||||
|
this.dithSerp = opts.dithSerp || false
|
||||||
|
// minimum color difference (0-1) needed to dither
|
||||||
|
this.dithDelta = opts.dithDelta || 0
|
||||||
|
|
||||||
|
// accumulated histogram
|
||||||
|
this.histogram = {}
|
||||||
|
// palette - rgb triplets
|
||||||
|
this.idxrgb = opts.palette ? opts.palette.slice(0) : []
|
||||||
|
// palette - int32 vals
|
||||||
|
this.idxi32 = []
|
||||||
|
// reverse lookup {i32:idx}
|
||||||
|
this.i32idx = {}
|
||||||
|
// {i32:rgb}
|
||||||
|
this.i32rgb = {}
|
||||||
|
// enable color caching (also incurs overhead of cache misses and cache building)
|
||||||
|
this.useCache = opts.useCache !== false
|
||||||
|
// min color occurance count needed to qualify for caching
|
||||||
|
this.cacheFreq = opts.cacheFreq || 10
|
||||||
|
// allows pre-defined palettes to be re-indexed (enabling palette compacting and sorting)
|
||||||
|
this.reIndex = opts.reIndex || this.idxrgb.length == 0
|
||||||
|
// selection of color-distance equation
|
||||||
|
this.colorDist = opts.colorDist == 'manhattan' ? distManhattan : distEuclidean
|
||||||
|
|
||||||
|
// if pre-defined palette, build lookups
|
||||||
|
if (this.idxrgb.length > 0) {
|
||||||
|
var self = this
|
||||||
|
this.idxrgb.forEach(function (rgb, i) {
|
||||||
|
var i32 =
|
||||||
|
((255 << 24) | // alpha
|
||||||
|
(rgb[2] << 16) | // blue
|
||||||
|
(rgb[1] << 8) | // green
|
||||||
|
rgb[0]) >>> // red
|
||||||
|
0
|
||||||
|
|
||||||
|
self.idxi32[i] = i32
|
||||||
|
self.i32idx[i32] = i
|
||||||
|
self.i32rgb[i32] = rgb
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// gathers histogram info
|
||||||
|
RgbQuant.prototype.sample = function sample(img, width) {
|
||||||
|
if (this.palLocked) throw 'Cannot sample additional images, palette already assembled.'
|
||||||
|
|
||||||
|
var data = getImageData(img, width)
|
||||||
|
|
||||||
|
switch (this.method) {
|
||||||
|
case 1:
|
||||||
|
this.colorStats1D(data.buf32)
|
||||||
|
break
|
||||||
|
case 2:
|
||||||
|
this.colorStats2D(data.buf32, data.width)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// image quantizer
|
||||||
|
// todo: memoize colors here also
|
||||||
|
// @retType: 1 - Uint8Array (default), 2 - Indexed array, 3 - Match @img type (unimplemented, todo)
|
||||||
|
RgbQuant.prototype.reduce = function reduce(img, retType, dithKern, dithSerp) {
|
||||||
|
if (!this.palLocked) this.buildPal()
|
||||||
|
|
||||||
|
dithKern = dithKern || this.dithKern
|
||||||
|
dithSerp = typeof dithSerp != 'undefined' ? dithSerp : this.dithSerp
|
||||||
|
|
||||||
|
retType = retType || 1
|
||||||
|
|
||||||
|
// reduce w/dither
|
||||||
|
if (dithKern) var out32 = this.dither(img, dithKern, dithSerp)
|
||||||
|
else {
|
||||||
|
var data = getImageData(img),
|
||||||
|
buf32 = data.buf32,
|
||||||
|
len = buf32.length,
|
||||||
|
out32 = new Uint32Array(len)
|
||||||
|
|
||||||
|
for (var i = 0; i < len; i++) {
|
||||||
|
var i32 = buf32[i]
|
||||||
|
out32[i] = this.nearestColor(i32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (retType == 1) return new Uint8Array(out32.buffer)
|
||||||
|
|
||||||
|
if (retType == 2) {
|
||||||
|
var out = [],
|
||||||
|
len = out32.length
|
||||||
|
|
||||||
|
for (var i = 0; i < len; i++) {
|
||||||
|
var i32 = out32[i]
|
||||||
|
out[i] = this.i32idx[i32]
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// adapted from http://jsbin.com/iXofIji/2/edit by PAEz
|
||||||
|
RgbQuant.prototype.dither = function (img, kernel, serpentine) {
|
||||||
|
// http://www.tannerhelland.com/4660/dithering-eleven-algorithms-source-code/
|
||||||
|
var kernels = {
|
||||||
|
FloydSteinberg: [
|
||||||
|
[7 / 16, 1, 0],
|
||||||
|
[3 / 16, -1, 1],
|
||||||
|
[5 / 16, 0, 1],
|
||||||
|
[1 / 16, 1, 1],
|
||||||
|
],
|
||||||
|
FalseFloydSteinberg: [
|
||||||
|
[3 / 8, 1, 0],
|
||||||
|
[3 / 8, 0, 1],
|
||||||
|
[2 / 8, 1, 1],
|
||||||
|
],
|
||||||
|
Stucki: [
|
||||||
|
[8 / 42, 1, 0],
|
||||||
|
[4 / 42, 2, 0],
|
||||||
|
[2 / 42, -2, 1],
|
||||||
|
[4 / 42, -1, 1],
|
||||||
|
[8 / 42, 0, 1],
|
||||||
|
[4 / 42, 1, 1],
|
||||||
|
[2 / 42, 2, 1],
|
||||||
|
[1 / 42, -2, 2],
|
||||||
|
[2 / 42, -1, 2],
|
||||||
|
[4 / 42, 0, 2],
|
||||||
|
[2 / 42, 1, 2],
|
||||||
|
[1 / 42, 2, 2],
|
||||||
|
],
|
||||||
|
Atkinson: [
|
||||||
|
[1 / 8, 1, 0],
|
||||||
|
[1 / 8, 2, 0],
|
||||||
|
[1 / 8, -1, 1],
|
||||||
|
[1 / 8, 0, 1],
|
||||||
|
[1 / 8, 1, 1],
|
||||||
|
[1 / 8, 0, 2],
|
||||||
|
],
|
||||||
|
Jarvis: [
|
||||||
|
// Jarvis, Judice, and Ninke / JJN?
|
||||||
|
[7 / 48, 1, 0],
|
||||||
|
[5 / 48, 2, 0],
|
||||||
|
[3 / 48, -2, 1],
|
||||||
|
[5 / 48, -1, 1],
|
||||||
|
[7 / 48, 0, 1],
|
||||||
|
[5 / 48, 1, 1],
|
||||||
|
[3 / 48, 2, 1],
|
||||||
|
[1 / 48, -2, 2],
|
||||||
|
[3 / 48, -1, 2],
|
||||||
|
[5 / 48, 0, 2],
|
||||||
|
[3 / 48, 1, 2],
|
||||||
|
[1 / 48, 2, 2],
|
||||||
|
],
|
||||||
|
Burkes: [
|
||||||
|
[8 / 32, 1, 0],
|
||||||
|
[4 / 32, 2, 0],
|
||||||
|
[2 / 32, -2, 1],
|
||||||
|
[4 / 32, -1, 1],
|
||||||
|
[8 / 32, 0, 1],
|
||||||
|
[4 / 32, 1, 1],
|
||||||
|
[2 / 32, 2, 1],
|
||||||
|
],
|
||||||
|
Sierra: [
|
||||||
|
[5 / 32, 1, 0],
|
||||||
|
[3 / 32, 2, 0],
|
||||||
|
[2 / 32, -2, 1],
|
||||||
|
[4 / 32, -1, 1],
|
||||||
|
[5 / 32, 0, 1],
|
||||||
|
[4 / 32, 1, 1],
|
||||||
|
[2 / 32, 2, 1],
|
||||||
|
[2 / 32, -1, 2],
|
||||||
|
[3 / 32, 0, 2],
|
||||||
|
[2 / 32, 1, 2],
|
||||||
|
],
|
||||||
|
TwoSierra: [
|
||||||
|
[4 / 16, 1, 0],
|
||||||
|
[3 / 16, 2, 0],
|
||||||
|
[1 / 16, -2, 1],
|
||||||
|
[2 / 16, -1, 1],
|
||||||
|
[3 / 16, 0, 1],
|
||||||
|
[2 / 16, 1, 1],
|
||||||
|
[1 / 16, 2, 1],
|
||||||
|
],
|
||||||
|
SierraLite: [
|
||||||
|
[2 / 4, 1, 0],
|
||||||
|
[1 / 4, -1, 1],
|
||||||
|
[1 / 4, 0, 1],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!kernel || !kernels[kernel]) {
|
||||||
|
throw 'Unknown dithering kernel: ' + kernel
|
||||||
|
}
|
||||||
|
|
||||||
|
var ds = kernels[kernel]
|
||||||
|
|
||||||
|
var data = getImageData(img),
|
||||||
|
// buf8 = data.buf8,
|
||||||
|
buf32 = data.buf32,
|
||||||
|
width = data.width,
|
||||||
|
height = data.height,
|
||||||
|
len = buf32.length
|
||||||
|
|
||||||
|
var dir = serpentine ? -1 : 1
|
||||||
|
|
||||||
|
for (var y = 0; y < height; y++) {
|
||||||
|
if (serpentine) dir = dir * -1
|
||||||
|
|
||||||
|
var lni = y * width
|
||||||
|
|
||||||
|
for (var x = dir == 1 ? 0 : width - 1, xend = dir == 1 ? width : 0; x !== xend; x += dir) {
|
||||||
|
// Image pixel
|
||||||
|
var idx = lni + x,
|
||||||
|
i32 = buf32[idx],
|
||||||
|
r1 = i32 & 0xff,
|
||||||
|
g1 = (i32 & 0xff00) >> 8,
|
||||||
|
b1 = (i32 & 0xff0000) >> 16
|
||||||
|
|
||||||
|
// Reduced pixel
|
||||||
|
var i32x = this.nearestColor(i32),
|
||||||
|
r2 = i32x & 0xff,
|
||||||
|
g2 = (i32x & 0xff00) >> 8,
|
||||||
|
b2 = (i32x & 0xff0000) >> 16
|
||||||
|
|
||||||
|
buf32[idx] =
|
||||||
|
(255 << 24) | // alpha
|
||||||
|
(b2 << 16) | // blue
|
||||||
|
(g2 << 8) | // green
|
||||||
|
r2
|
||||||
|
|
||||||
|
// dithering strength
|
||||||
|
if (this.dithDelta) {
|
||||||
|
var dist = this.colorDist([r1, g1, b1], [r2, g2, b2])
|
||||||
|
if (dist < this.dithDelta) continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Component distance
|
||||||
|
var er = r1 - r2,
|
||||||
|
eg = g1 - g2,
|
||||||
|
eb = b1 - b2
|
||||||
|
|
||||||
|
for (var i = dir == 1 ? 0 : ds.length - 1, end = dir == 1 ? ds.length : 0; i !== end; i += dir) {
|
||||||
|
var x1 = ds[i][1] * dir,
|
||||||
|
y1 = ds[i][2]
|
||||||
|
|
||||||
|
var lni2 = y1 * width
|
||||||
|
|
||||||
|
if (x1 + x >= 0 && x1 + x < width && y1 + y >= 0 && y1 + y < height) {
|
||||||
|
var d = ds[i][0]
|
||||||
|
var idx2 = idx + (lni2 + x1)
|
||||||
|
|
||||||
|
var r3 = buf32[idx2] & 0xff,
|
||||||
|
g3 = (buf32[idx2] & 0xff00) >> 8,
|
||||||
|
b3 = (buf32[idx2] & 0xff0000) >> 16
|
||||||
|
|
||||||
|
var r4 = Math.max(0, Math.min(255, r3 + er * d)),
|
||||||
|
g4 = Math.max(0, Math.min(255, g3 + eg * d)),
|
||||||
|
b4 = Math.max(0, Math.min(255, b3 + eb * d))
|
||||||
|
|
||||||
|
buf32[idx2] =
|
||||||
|
(255 << 24) | // alpha
|
||||||
|
(b4 << 16) | // blue
|
||||||
|
(g4 << 8) | // green
|
||||||
|
r4 // red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf32
|
||||||
|
}
|
||||||
|
|
||||||
|
// reduces histogram to palette, remaps & memoizes reduced colors
|
||||||
|
RgbQuant.prototype.buildPal = function buildPal(noSort) {
|
||||||
|
if (this.palLocked || (this.idxrgb.length > 0 && this.idxrgb.length <= this.colors)) return
|
||||||
|
|
||||||
|
var histG = this.histogram,
|
||||||
|
sorted = sortedHashKeys(histG, true)
|
||||||
|
|
||||||
|
if (sorted.length == 0) throw 'Nothing has been sampled, palette cannot be built.'
|
||||||
|
|
||||||
|
switch (this.method) {
|
||||||
|
case 1:
|
||||||
|
var cols = this.initColors,
|
||||||
|
last = sorted[cols - 1],
|
||||||
|
freq = histG[last]
|
||||||
|
|
||||||
|
var idxi32 = sorted.slice(0, cols)
|
||||||
|
|
||||||
|
// add any cut off colors with same freq as last
|
||||||
|
var pos = cols,
|
||||||
|
len = sorted.length
|
||||||
|
while (pos < len && histG[sorted[pos]] == freq) idxi32.push(sorted[pos++])
|
||||||
|
|
||||||
|
// inject min huegroup colors
|
||||||
|
if (this.hueStats) this.hueStats.inject(idxi32)
|
||||||
|
|
||||||
|
break
|
||||||
|
case 2:
|
||||||
|
var idxi32 = sorted
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// int32-ify values
|
||||||
|
idxi32 = idxi32.map(function (v) {
|
||||||
|
return +v
|
||||||
|
})
|
||||||
|
|
||||||
|
this.reducePal(idxi32)
|
||||||
|
|
||||||
|
if (!noSort && this.reIndex) this.sortPal()
|
||||||
|
|
||||||
|
// build cache of top histogram colors
|
||||||
|
if (this.useCache) this.cacheHistogram(idxi32)
|
||||||
|
|
||||||
|
this.palLocked = true
|
||||||
|
}
|
||||||
|
|
||||||
|
RgbQuant.prototype.palette = function palette(tuples, noSort) {
|
||||||
|
this.buildPal(noSort)
|
||||||
|
return tuples ? this.idxrgb : new Uint8Array(new Uint32Array(this.idxi32).buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
RgbQuant.prototype.prunePal = function prunePal(keep) {
|
||||||
|
var i32
|
||||||
|
|
||||||
|
for (var j = 0; j < this.idxrgb.length; j++) {
|
||||||
|
if (!keep[j]) {
|
||||||
|
i32 = this.idxi32[j]
|
||||||
|
this.idxrgb[j] = null
|
||||||
|
this.idxi32[j] = null
|
||||||
|
delete this.i32idx[i32]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// compact
|
||||||
|
if (this.reIndex) {
|
||||||
|
var idxrgb = [],
|
||||||
|
idxi32 = [],
|
||||||
|
i32idx = {}
|
||||||
|
|
||||||
|
for (var j = 0, i = 0; j < this.idxrgb.length; j++) {
|
||||||
|
if (this.idxrgb[j]) {
|
||||||
|
i32 = this.idxi32[j]
|
||||||
|
idxrgb[i] = this.idxrgb[j]
|
||||||
|
i32idx[i32] = i
|
||||||
|
idxi32[i] = i32
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.idxrgb = idxrgb
|
||||||
|
this.idxi32 = idxi32
|
||||||
|
this.i32idx = i32idx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reduces similar colors from an importance-sorted Uint32 rgba array
|
||||||
|
RgbQuant.prototype.reducePal = function reducePal(idxi32) {
|
||||||
|
// if pre-defined palette's length exceeds target
|
||||||
|
if (this.idxrgb.length > this.colors) {
|
||||||
|
// quantize histogram to existing palette
|
||||||
|
var len = idxi32.length,
|
||||||
|
keep = {},
|
||||||
|
uniques = 0,
|
||||||
|
idx,
|
||||||
|
pruned = false
|
||||||
|
|
||||||
|
for (var i = 0; i < len; i++) {
|
||||||
|
// palette length reached, unset all remaining colors (sparse palette)
|
||||||
|
if (uniques == this.colors && !pruned) {
|
||||||
|
this.prunePal(keep)
|
||||||
|
pruned = true
|
||||||
|
}
|
||||||
|
|
||||||
|
idx = this.nearestIndex(idxi32[i])
|
||||||
|
|
||||||
|
if (uniques < this.colors && !keep[idx]) {
|
||||||
|
keep[idx] = true
|
||||||
|
uniques++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pruned) {
|
||||||
|
this.prunePal(keep)
|
||||||
|
pruned = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// reduce histogram to create initial palette
|
||||||
|
else {
|
||||||
|
// build full rgb palette
|
||||||
|
var idxrgb = idxi32.map(function (i32) {
|
||||||
|
return [i32 & 0xff, (i32 & 0xff00) >> 8, (i32 & 0xff0000) >> 16]
|
||||||
|
})
|
||||||
|
|
||||||
|
var len = idxrgb.length,
|
||||||
|
palLen = len,
|
||||||
|
thold = this.initDist
|
||||||
|
|
||||||
|
// palette already at or below desired length
|
||||||
|
if (palLen > this.colors) {
|
||||||
|
while (palLen > this.colors) {
|
||||||
|
var memDist = []
|
||||||
|
|
||||||
|
// iterate palette
|
||||||
|
for (var i = 0; i < len; i++) {
|
||||||
|
var pxi = idxrgb[i],
|
||||||
|
i32i = idxi32[i]
|
||||||
|
if (!pxi) continue
|
||||||
|
|
||||||
|
for (var j = i + 1; j < len; j++) {
|
||||||
|
var pxj = idxrgb[j],
|
||||||
|
i32j = idxi32[j]
|
||||||
|
if (!pxj) continue
|
||||||
|
|
||||||
|
var dist = this.colorDist(pxi, pxj)
|
||||||
|
|
||||||
|
if (dist < thold) {
|
||||||
|
// store index,rgb,dist
|
||||||
|
memDist.push([j, pxj, i32j, dist])
|
||||||
|
|
||||||
|
// kill squashed value
|
||||||
|
delete idxrgb[j]
|
||||||
|
palLen--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// palette reduction pass
|
||||||
|
// console.log("palette length: " + palLen);
|
||||||
|
|
||||||
|
// if palette is still much larger than target, increment by larger initDist
|
||||||
|
thold += palLen > this.colors * 3 ? this.initDist : this.distIncr
|
||||||
|
}
|
||||||
|
|
||||||
|
// if palette is over-reduced, re-add removed colors with largest distances from last round
|
||||||
|
if (palLen < this.colors) {
|
||||||
|
// sort descending
|
||||||
|
sort.call(memDist, function (a, b) {
|
||||||
|
return b[3] - a[3]
|
||||||
|
})
|
||||||
|
|
||||||
|
var k = 0
|
||||||
|
while (palLen < this.colors) {
|
||||||
|
// re-inject rgb into final palette
|
||||||
|
idxrgb[memDist[k][0]] = memDist[k][1]
|
||||||
|
|
||||||
|
palLen++
|
||||||
|
k++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var len = idxrgb.length
|
||||||
|
for (var i = 0; i < len; i++) {
|
||||||
|
if (!idxrgb[i]) continue
|
||||||
|
|
||||||
|
this.idxrgb.push(idxrgb[i])
|
||||||
|
this.idxi32.push(idxi32[i])
|
||||||
|
|
||||||
|
this.i32idx[idxi32[i]] = this.idxi32.length - 1
|
||||||
|
this.i32rgb[idxi32[i]] = idxrgb[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// global top-population
|
||||||
|
RgbQuant.prototype.colorStats1D = function colorStats1D(buf32) {
|
||||||
|
var histG = this.histogram,
|
||||||
|
num = 0,
|
||||||
|
col,
|
||||||
|
len = buf32.length
|
||||||
|
|
||||||
|
for (var i = 0; i < len; i++) {
|
||||||
|
col = buf32[i]
|
||||||
|
|
||||||
|
// skip transparent
|
||||||
|
if ((col & 0xff000000) >> 24 == 0) continue
|
||||||
|
|
||||||
|
// collect hue stats
|
||||||
|
if (this.hueStats) this.hueStats.check(col)
|
||||||
|
|
||||||
|
if (col in histG) histG[col]++
|
||||||
|
else histG[col] = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// population threshold within subregions
|
||||||
|
// FIXME: this can over-reduce (few/no colors same?), need a way to keep
|
||||||
|
// important colors that dont ever reach local thresholds (gradients?)
|
||||||
|
RgbQuant.prototype.colorStats2D = function colorStats2D(buf32, width) {
|
||||||
|
var boxW = this.boxSize[0],
|
||||||
|
boxH = this.boxSize[1],
|
||||||
|
area = boxW * boxH,
|
||||||
|
boxes = makeBoxes(width, buf32.length / width, boxW, boxH),
|
||||||
|
histG = this.histogram,
|
||||||
|
self = this
|
||||||
|
|
||||||
|
boxes.forEach(function (box) {
|
||||||
|
var effc = Math.max(Math.round((box.w * box.h) / area) * self.boxPxls, 2),
|
||||||
|
histL = {},
|
||||||
|
col
|
||||||
|
|
||||||
|
iterBox(box, width, function (i) {
|
||||||
|
col = buf32[i]
|
||||||
|
|
||||||
|
// skip transparent
|
||||||
|
if ((col & 0xff000000) >> 24 == 0) return
|
||||||
|
|
||||||
|
// collect hue stats
|
||||||
|
if (self.hueStats) self.hueStats.check(col)
|
||||||
|
|
||||||
|
if (col in histG) histG[col]++
|
||||||
|
else if (col in histL) {
|
||||||
|
if (++histL[col] >= effc) histG[col] = histL[col]
|
||||||
|
} else histL[col] = 1
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (this.hueStats) this.hueStats.inject(histG)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: group very low lum and very high lum colors
|
||||||
|
// TODO: pass custom sort order
|
||||||
|
RgbQuant.prototype.sortPal = function sortPal() {
|
||||||
|
var self = this
|
||||||
|
|
||||||
|
this.idxi32.sort(function (a, b) {
|
||||||
|
var idxA = self.i32idx[a],
|
||||||
|
idxB = self.i32idx[b],
|
||||||
|
rgbA = self.idxrgb[idxA],
|
||||||
|
rgbB = self.idxrgb[idxB]
|
||||||
|
|
||||||
|
var hslA = rgb2hsl(rgbA[0], rgbA[1], rgbA[2]),
|
||||||
|
hslB = rgb2hsl(rgbB[0], rgbB[1], rgbB[2])
|
||||||
|
|
||||||
|
// sort all grays + whites together
|
||||||
|
var hueA = rgbA[0] == rgbA[1] && rgbA[1] == rgbA[2] ? -1 : hueGroup(hslA.h, self.hueGroups)
|
||||||
|
var hueB = rgbB[0] == rgbB[1] && rgbB[1] == rgbB[2] ? -1 : hueGroup(hslB.h, self.hueGroups)
|
||||||
|
|
||||||
|
var hueDiff = hueB - hueA
|
||||||
|
if (hueDiff) return -hueDiff
|
||||||
|
|
||||||
|
var lumDiff = lumGroup(+hslB.l.toFixed(2)) - lumGroup(+hslA.l.toFixed(2))
|
||||||
|
if (lumDiff) return -lumDiff
|
||||||
|
|
||||||
|
var satDiff = satGroup(+hslB.s.toFixed(2)) - satGroup(+hslA.s.toFixed(2))
|
||||||
|
if (satDiff) return -satDiff
|
||||||
|
})
|
||||||
|
|
||||||
|
// sync idxrgb & i32idx
|
||||||
|
this.idxi32.forEach(function (i32, i) {
|
||||||
|
self.idxrgb[i] = self.i32rgb[i32]
|
||||||
|
self.i32idx[i32] = i
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TOTRY: use HUSL - http://boronine.com/husl/
|
||||||
|
RgbQuant.prototype.nearestColor = function nearestColor(i32) {
|
||||||
|
var idx = this.nearestIndex(i32)
|
||||||
|
return idx === null ? 0 : this.idxi32[idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
// TOTRY: use HUSL - http://boronine.com/husl/
|
||||||
|
RgbQuant.prototype.nearestIndex = function nearestIndex(i32) {
|
||||||
|
// alpha 0 returns null index
|
||||||
|
if ((i32 & 0xff000000) >> 24 == 0) return null
|
||||||
|
|
||||||
|
if (this.useCache && '' + i32 in this.i32idx) return this.i32idx[i32]
|
||||||
|
|
||||||
|
var min = 1000,
|
||||||
|
idx,
|
||||||
|
rgb = [i32 & 0xff, (i32 & 0xff00) >> 8, (i32 & 0xff0000) >> 16],
|
||||||
|
len = this.idxrgb.length
|
||||||
|
|
||||||
|
for (var i = 0; i < len; i++) {
|
||||||
|
if (!this.idxrgb[i]) continue // sparse palettes
|
||||||
|
|
||||||
|
var dist = this.colorDist(rgb, this.idxrgb[i])
|
||||||
|
|
||||||
|
if (dist < min) {
|
||||||
|
min = dist
|
||||||
|
idx = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return idx
|
||||||
|
}
|
||||||
|
|
||||||
|
RgbQuant.prototype.cacheHistogram = function cacheHistogram(idxi32) {
|
||||||
|
for (var i = 0, i32 = idxi32[i]; i < idxi32.length && this.histogram[i32] >= this.cacheFreq; i32 = idxi32[i++])
|
||||||
|
this.i32idx[i32] = this.nearestIndex(i32)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HueStats(numGroups, minCols) {
|
||||||
|
this.numGroups = numGroups
|
||||||
|
this.minCols = minCols
|
||||||
|
this.stats = {}
|
||||||
|
|
||||||
|
for (var i = -1; i < numGroups; i++) this.stats[i] = { num: 0, cols: [] }
|
||||||
|
|
||||||
|
this.groupsFull = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
HueStats.prototype.check = function checkHue(i32) {
|
||||||
|
if (this.groupsFull == this.numGroups + 1)
|
||||||
|
this.check = function () {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var r = i32 & 0xff,
|
||||||
|
g = (i32 & 0xff00) >> 8,
|
||||||
|
b = (i32 & 0xff0000) >> 16,
|
||||||
|
hg = r == g && g == b ? -1 : hueGroup(rgb2hsl(r, g, b).h, this.numGroups),
|
||||||
|
gr = this.stats[hg],
|
||||||
|
min = this.minCols
|
||||||
|
|
||||||
|
gr.num++
|
||||||
|
|
||||||
|
if (gr.num > min) return
|
||||||
|
if (gr.num == min) this.groupsFull++
|
||||||
|
|
||||||
|
if (gr.num <= min) this.stats[hg].cols.push(i32)
|
||||||
|
}
|
||||||
|
|
||||||
|
HueStats.prototype.inject = function injectHues(histG) {
|
||||||
|
for (var i = -1; i < this.numGroups; i++) {
|
||||||
|
if (this.stats[i].num <= this.minCols) {
|
||||||
|
switch (typeOf(histG)) {
|
||||||
|
case 'Array':
|
||||||
|
this.stats[i].cols.forEach(function (col) {
|
||||||
|
if (histG.indexOf(col) == -1) histG.push(col)
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case 'Object':
|
||||||
|
this.stats[i].cols.forEach(function (col) {
|
||||||
|
if (!histG[col]) histG[col] = 1
|
||||||
|
else histG[col]++
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rec. 709 (sRGB) luma coef
|
||||||
|
var Pr = 0.2126,
|
||||||
|
Pg = 0.7152,
|
||||||
|
Pb = 0.0722
|
||||||
|
|
||||||
|
// http://alienryderflex.com/hsp.html
|
||||||
|
function rgb2lum(r, g, b) {
|
||||||
|
return Math.sqrt(Pr * r * r + Pg * g * g + Pb * b * b)
|
||||||
|
}
|
||||||
|
|
||||||
|
var rd = 255,
|
||||||
|
gd = 255,
|
||||||
|
bd = 255
|
||||||
|
|
||||||
|
var euclMax = Math.sqrt(Pr * rd * rd + Pg * gd * gd + Pb * bd * bd)
|
||||||
|
// perceptual Euclidean color distance
|
||||||
|
function distEuclidean(rgb0, rgb1) {
|
||||||
|
var rd = rgb1[0] - rgb0[0],
|
||||||
|
gd = rgb1[1] - rgb0[1],
|
||||||
|
bd = rgb1[2] - rgb0[2]
|
||||||
|
|
||||||
|
return Math.sqrt(Pr * rd * rd + Pg * gd * gd + Pb * bd * bd) / euclMax
|
||||||
|
}
|
||||||
|
|
||||||
|
var manhMax = Pr * rd + Pg * gd + Pb * bd
|
||||||
|
// perceptual Manhattan color distance
|
||||||
|
function distManhattan(rgb0, rgb1) {
|
||||||
|
var rd = Math.abs(rgb1[0] - rgb0[0]),
|
||||||
|
gd = Math.abs(rgb1[1] - rgb0[1]),
|
||||||
|
bd = Math.abs(rgb1[2] - rgb0[2])
|
||||||
|
|
||||||
|
return (Pr * rd + Pg * gd + Pb * bd) / manhMax
|
||||||
|
}
|
||||||
|
|
||||||
|
// http://rgb2hsl.nichabi.com/javascript-function.php
|
||||||
|
function rgb2hsl(r, g, b) {
|
||||||
|
var max, min, h, s, l, d
|
||||||
|
r /= 255
|
||||||
|
g /= 255
|
||||||
|
b /= 255
|
||||||
|
max = Math.max(r, g, b)
|
||||||
|
min = Math.min(r, g, b)
|
||||||
|
l = (max + min) / 2
|
||||||
|
if (max == min) {
|
||||||
|
h = s = 0
|
||||||
|
} else {
|
||||||
|
d = max - min
|
||||||
|
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
|
||||||
|
switch (max) {
|
||||||
|
case r:
|
||||||
|
h = (g - b) / d + (g < b ? 6 : 0)
|
||||||
|
break
|
||||||
|
case g:
|
||||||
|
h = (b - r) / d + 2
|
||||||
|
break
|
||||||
|
case b:
|
||||||
|
h = (r - g) / d + 4
|
||||||
|
break
|
||||||
|
}
|
||||||
|
h /= 6
|
||||||
|
}
|
||||||
|
// h = Math.floor(h * 360)
|
||||||
|
// s = Math.floor(s * 100)
|
||||||
|
// l = Math.floor(l * 100)
|
||||||
|
return {
|
||||||
|
h: h,
|
||||||
|
s: s,
|
||||||
|
l: rgb2lum(r, g, b),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hueGroup(hue, segs) {
|
||||||
|
var seg = 1 / segs,
|
||||||
|
haf = seg / 2
|
||||||
|
|
||||||
|
if (hue >= 1 - haf || hue <= haf) return 0
|
||||||
|
|
||||||
|
for (var i = 1; i < segs; i++) {
|
||||||
|
var mid = i * seg
|
||||||
|
if (hue >= mid - haf && hue <= mid + haf) return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function satGroup(sat) {
|
||||||
|
return sat
|
||||||
|
}
|
||||||
|
|
||||||
|
function lumGroup(lum) {
|
||||||
|
return lum
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeOf(val) {
|
||||||
|
return Object.prototype.toString.call(val).slice(8, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sort = isArrSortStable() ? Array.prototype.sort : stableSort
|
||||||
|
|
||||||
|
// must be used via stableSort.call(arr, fn)
|
||||||
|
function stableSort(fn) {
|
||||||
|
var type = typeOf(this[0])
|
||||||
|
|
||||||
|
if (type == 'Number' || type == 'String') {
|
||||||
|
var ord = {},
|
||||||
|
len = this.length,
|
||||||
|
val
|
||||||
|
|
||||||
|
for (var i = 0; i < len; i++) {
|
||||||
|
val = this[i]
|
||||||
|
if (ord[val] || ord[val] === 0) continue
|
||||||
|
ord[val] = i
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.sort(function (a, b) {
|
||||||
|
return fn(a, b) || ord[a] - ord[b]
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
var ord = this.map(function (v) {
|
||||||
|
return v
|
||||||
|
})
|
||||||
|
|
||||||
|
return this.sort(function (a, b) {
|
||||||
|
return fn(a, b) || ord.indexOf(a) - ord.indexOf(b)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// test if js engine's Array#sort implementation is stable
|
||||||
|
function isArrSortStable() {
|
||||||
|
var str = 'abcdefghijklmnopqrstuvwxyz'
|
||||||
|
|
||||||
|
return (
|
||||||
|
'xyzvwtursopqmnklhijfgdeabc' ==
|
||||||
|
str
|
||||||
|
.split('')
|
||||||
|
.sort(function (a, b) {
|
||||||
|
return ~~(str.indexOf(b) / 2.3) - ~~(str.indexOf(a) / 2.3)
|
||||||
|
})
|
||||||
|
.join('')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// returns uniform pixel data from various img
|
||||||
|
// TODO?: if array is passed, createimagedata, createlement canvas? take a pxlen?
|
||||||
|
function getImageData(img, width) {
|
||||||
|
var can, ctx, imgd, buf8, buf32, height
|
||||||
|
|
||||||
|
switch (typeOf(img)) {
|
||||||
|
case 'HTMLImageElement':
|
||||||
|
can = document.createElement('canvas')
|
||||||
|
can.width = img.naturalWidth
|
||||||
|
can.height = img.naturalHeight
|
||||||
|
ctx = can.getContext('2d')
|
||||||
|
ctx.drawImage(img, 0, 0)
|
||||||
|
case 'Canvas':
|
||||||
|
case 'HTMLCanvasElement':
|
||||||
|
can = can || img
|
||||||
|
ctx = ctx || can.getContext('2d')
|
||||||
|
case 'CanvasRenderingContext2D':
|
||||||
|
case 'OffscreenCanvasRenderingContext2D':
|
||||||
|
ctx = ctx || img
|
||||||
|
can = can || ctx.canvas
|
||||||
|
imgd = ctx.getImageData(0, 0, can.width, can.height)
|
||||||
|
case 'ImageData':
|
||||||
|
imgd = imgd || img
|
||||||
|
width = imgd.width
|
||||||
|
if (typeOf(imgd.data) == 'CanvasPixelArray') buf8 = new Uint8Array(imgd.data)
|
||||||
|
else buf8 = imgd.data
|
||||||
|
case 'Array':
|
||||||
|
case 'CanvasPixelArray':
|
||||||
|
buf8 = buf8 || new Uint8Array(img)
|
||||||
|
case 'Uint8Array':
|
||||||
|
case 'Uint8ClampedArray':
|
||||||
|
buf8 = buf8 || img
|
||||||
|
buf32 = new Uint32Array(buf8.buffer)
|
||||||
|
case 'Uint32Array':
|
||||||
|
buf32 = buf32 || img
|
||||||
|
buf8 = buf8 || new Uint8Array(buf32.buffer)
|
||||||
|
width = width || buf32.length
|
||||||
|
height = buf32.length / width
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
can: can,
|
||||||
|
ctx: ctx,
|
||||||
|
imgd: imgd,
|
||||||
|
buf8: buf8,
|
||||||
|
buf32: buf32,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// partitions a rect of wid x hgt into
|
||||||
|
// array of bboxes of w0 x h0 (or less)
|
||||||
|
function makeBoxes(wid, hgt, w0, h0) {
|
||||||
|
var wnum = ~~(wid / w0),
|
||||||
|
wrem = wid % w0,
|
||||||
|
hnum = ~~(hgt / h0),
|
||||||
|
hrem = hgt % h0,
|
||||||
|
xend = wid - wrem,
|
||||||
|
yend = hgt - hrem
|
||||||
|
|
||||||
|
var bxs = []
|
||||||
|
for (var y = 0; y < hgt; y += h0)
|
||||||
|
for (var x = 0; x < wid; x += w0) bxs.push({ x: x, y: y, w: x == xend ? wrem : w0, h: y == yend ? hrem : h0 })
|
||||||
|
|
||||||
|
return bxs
|
||||||
|
}
|
||||||
|
|
||||||
|
// iterates @bbox within a parent rect of width @wid; calls @fn, passing index within parent
|
||||||
|
function iterBox(bbox, wid, fn) {
|
||||||
|
var b = bbox,
|
||||||
|
i0 = b.y * wid + b.x,
|
||||||
|
i1 = (b.y + b.h - 1) * wid + (b.x + b.w - 1),
|
||||||
|
cnt = 0,
|
||||||
|
incr = wid - b.w + 1,
|
||||||
|
i = i0
|
||||||
|
|
||||||
|
do {
|
||||||
|
fn.call(this, i)
|
||||||
|
i += ++cnt % b.w == 0 ? incr : 1
|
||||||
|
} while (i <= i1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// returns array of hash keys sorted by their values
|
||||||
|
function sortedHashKeys(obj, desc) {
|
||||||
|
var keys = []
|
||||||
|
|
||||||
|
for (var key in obj) keys.push(key)
|
||||||
|
|
||||||
|
return sort.call(keys, function (a, b) {
|
||||||
|
return desc ? obj[b] - obj[a] : obj[a] - obj[b]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RgbQuant
|
||||||
Reference in New Issue
Block a user