pictel
v0.3.0
Published
Pictel is a framework for Photoshop-like image editing via React code. It provides primitives for comprehensive pixel processing, live preview, and headless rendering.
Readme
pictel
Pictel is a framework for Photoshop-like image editing via React code. It provides primitives for comprehensive pixel processing, live preview, and headless rendering.
Install
npm install pictel @pictel/effectsPackages
@pictel/effects— the effect library: colour grading, blurs, blend modes, halftone, displacement, line-integral convolution, generative sources, and more.@pictel/ml— ML-powered effects via Transformers.js + WebGPU: background removal, segmentation, depth maps, and upscaling.@pictel/cli— headless renderer (Puppeteer + Sharp) that exports compositions to PNG/JPEG/WebP/AVIF.
A corpus of worked examples — each a single composition with its intent, before/after images, and full source — lives in apps/demos/out/.
Quick start
import { Canvas, Clip, Image } from "pictel";
import { Blur } from "@pictel/effects";
export default () => (
<Canvas dimensions={{ width: 800, height: 800 }}>
<Clip>
<Blur radius={4}>
<Image src="/photo.jpg" />
</Blur>
</Clip>
</Canvas>
);Examples
Oil Painting
| Before | After | | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | | |
import { Direction, Duotone, Hatch } from "@pictel/effects";
import { Canvas, Image } from "pictel";
import headshot from "../../assets/headshot.jpg";
const INK: [number, number, number] = [38, 30, 54];
const PAPER: [number, number, number] = [240, 234, 220];
export default function OilPainting() {
return (
<Canvas
mode="display"
dimensions={{ width: 640, height: 640 }}
>
<Duotone
dark={INK}
light={PAPER}
>
<Hatch
bands={4}
spacing={[5, 8, 12, 16]}
length={24}
uniformStep
map={
<Direction mode="structure">
<Image
src={headshot}
width={640}
height={640}
fit="cover" />
</Direction>
}
>
<Image
src={headshot}
width={640}
height={640}
fit="cover"
/>
</Hatch>
</Duotone>
</Canvas>
);
}Pop Art
| Before | After | | --------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | | |
import { Contrast, Halftone, Multiply, Outline, Saturate, Threshold } from "@pictel/effects";
import { Canvas, Image } from "pictel";
import photo from "../../assets/Golden Hour Portrait.jpg";
export default function PopArt() {
return (
<Canvas
mode="display"
dimensions={{ width: 640, height: 960 }}
>
<Multiply
apply={
<Threshold threshold={140}>
<Outline
sigma={2.4}
k={1.6}
epsilon={0.005}
phi={200}
>
<Image
src={photo}
width={640}
height={960}
fit="cover" />
</Outline>
</Threshold>
}
>
<Halftone
colorMode="color"
dotSize={10}
>
<Contrast amount={1.35}>
<Saturate amount={2.4}>
<Image
src={photo}
width={640}
height={960}
fit="cover" />
</Saturate>
</Contrast>
</Halftone>
</Multiply>
</Canvas>
);
}Tilt-Shift
| Before | After | | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | | |
import { Blur, Brightness, Contrast, Invert, Saturate } from "@pictel/effects";
import { DepthMap } from "@pictel/ml";
import { Canvas, Clip, Image } from "pictel";
import cityPhoto from "../../assets/city overview.jpg";
export default function TiltShift() {
return (
<Canvas
mode="display"
dimensions={{ width: 1024, height: 683 }}
>
<Clip>
<Blur
radius={7}
mode="parameter"
map={
<Invert>
<Brightness amount={2}>
<Contrast amount={0.35}>
<DepthMap>
<Image
src={cityPhoto}
width={1024}
height={683}
fit="cover"
/>
</DepthMap>
</Contrast>
</Brightness>
</Invert>
}
>
<Saturate amount={1.1}>
<Contrast amount={1.1}>
<Image
src={cityPhoto}
width={1024}
height={683}
fit="cover" />
</Contrast>
</Saturate>
</Blur>
</Clip>
<div
style={{
position: "absolute",
inset: 0,
boxShadow: "inset 0 0 100px 30px rgba(0,0,0,0.5)",
pointerEvents: "none",
}}
/>
</Canvas>
);
}Banknote Print
| Before | After | | --- | --- | | | |
import { Bilateral, Brightness, DisplacementMap, Duotone, Engrave } from "@pictel/effects";
import { DepthMap, RemoveBackground } from "@pictel/ml";
import { Canvas, Image } from "pictel";
const HEADSHOT_URL = "https://pictel-demos.s3.us-east-1.amazonaws.com/sources/headshot.jpg";
const INK: [number, number, number] = [24, 56, 38];
const CREAM: [number, number, number] = [234, 230, 213];
const canvasW = 512;
const canvasH = 512;
export default function Banknote() {
const subject = (
<RemoveBackground>
<Image src={HEADSHOT_URL} width={canvasW} height={canvasH} fit="cover" />
</RemoveBackground>
);
return (
<Canvas
mode="display"
dimensions={{ width: canvasW, height: canvasH }}
style={{ backgroundColor: `rgb(${String(CREAM[0])}, ${String(CREAM[1])}, ${String(CREAM[2])})` }}
>
<Duotone dark={INK} light={CREAM}>
<DisplacementMap scaleX={10} scaleY={14} map={<DepthMap>{subject}</DepthMap>}>
<Engrave spacing={5} relief={0}>
<Brightness amount={1.35}>
<Bilateral spatialSigma={4} colorSigma={60}>
{subject}
</Bilateral>
</Brightness>
</Engrave>
</DisplacementMap>
</Duotone>
</Canvas>
);
}Night Vision
| Before | After | | --- | --- | | | |
import { Contrast, Duotone, Grain, LinePattern, Multiply } from "@pictel/effects";
import { Canvas, Image } from "pictel";
const LANDSCAPE_URL = "https://pictel-demos.s3.us-east-1.amazonaws.com/sources/evening-landscape.jpg";
const W = 1024;
const H = 1536;
const DARK_GREEN: [number, number, number] = [4, 18, 8];
const PHOSPHOR_GREEN: [number, number, number] = [120, 240, 110];
export default function NightVision() {
return (
<Canvas mode="display" dimensions={{ width: W, height: H }}>
<Grain intensity={24} seed={6173}>
<Multiply
apply={
<LinePattern
width={W}
height={H}
seed={0}
spacing={3}
thickness={1}
angle={0}
color="rgb(40, 80, 30)"
background="rgb(230, 240, 220)"
/>
}
>
<Duotone dark={DARK_GREEN} light={PHOSPHOR_GREEN}>
<Contrast amount={1.35} mode="parameter">
<Image src={LANDSCAPE_URL} width={W} height={H} fit="cover" />
</Contrast>
</Duotone>
</Multiply>
</Grain>
</Canvas>
);
}Risograph Print
| Before | After | | --- | --- | | | |
import { Quantize } from "@pictel/effects";
import { Canvas, Image } from "pictel";
const PORTRAIT_URL = "https://pictel-demos.s3.us-east-1.amazonaws.com/sources/portrait-with-background-1.jpg";
const W = 1024;
const H = 1536;
const RISO_PALETTE = [
[245, 240, 230],
[235, 60, 130],
[40, 80, 180],
[35, 35, 40],
] as const;
export default function RisographPrint() {
return (
<Canvas mode="display" dimensions={{ width: W, height: H }}>
<Quantize palette={RISO_PALETTE} dither="bayer-8">
<Image src={PORTRAIT_URL} width={W} height={H} fit="cover" />
</Quantize>
</Canvas>
);
}Effect output cache
Pictel keeps an IndexedDB-backed cache of effect outputs, keyed by the input pixels plus a version string. When inputs are byte-identical to a prior run and the version is unchanged, the cached pixels are rehydrated and the effect's apply callback is not called. The cache persists across HMR cycles and full page refreshes — ML effects (RemoveBackground, DepthMap, ...) benefit most, but every effect that publishes a version participates.
Every standard effect in @pictel/effects and @pictel/ml accepts an optional version?: string prop and composes it with an internal version that captures its own props. Caching is on by default; bumping version at any layer invalidates that node and everything its output feeds into.
import { Canvas, Image } from "pictel";
import { RemoveBackground } from "@pictel/ml";
<RemoveBackground version="v2">
<Image src="/portrait.jpg" width={1024} height={1024} fit="cover" />
</RemoveBackground>;Entries are bounded by a per-entry size cap (50 MB default — rejects pathologically large outputs at write) and a TTL (24 h default — expired entries are treated as misses on read and deleted inline). Both are configurable on createEffectCache; pass ttlMs: Infinity to disable expiry.
Effects that produce bleed (Blur, DropShadow, Bloom, Outline, certain Hatch / LIC configurations) skip the cache write in v1 — the entry shape stores output pixels only. See design-effect-output-cache for the full contract and rejected alternatives.
API reference below — generated from JSDoc on the source.
