Painterly video
You paint with code the way a painter paints with a brush: decide the picture first (composition, values, palette), then lay real strokes back-to-front, look at the result, and adjust. The engine turns those strokes into frames; nothing is a filter over a render — every mark is a brush stroke you placed.
The method was learned from four painting demonstrations (process, pine tree, painterly trees, sunset clouds) and from measuring 13 public-domain master landscapes. The distilled lessons are in references/; the working code is in engine/ and examples/.
Setup (once per project)
cp -R /path/to/painterly-video/engine ./painter-engine
cd painter-engine
npm ci
node bin/paint.mjs still ../path/to/scene.js --t 8 # → PNG next to the scene (stills/<scene>/)
Replace /path/to/painterly-video with the folder containing this SKILL.md (for example ~/.claude/skills/painterly-video). Needs Node ≥ 22.12, Google Chrome with WebGPU (or CHROME_PATH), ffmpeg and ffprobe. Reference studies additionally need Python with tools/requirements.txt. No build step or rendering API keys; rendering runs on local hardware. Start a new scene by copying the closest file in examples/ (sunset-river.js sky/clouds/water · lone-pine.js vertical, single subject · lake-path.js trees/depth/path · sunset-study.js dramatic sky of dark cloud masses with rim light (a study of a demonstration's structure) · mountain-hamlet.js mountains, cabin, rocks, walking figure, smoke · brush-chart.js brushes and mixes).
Workflow — do not skip the looking steps
- Brief. Subject, mood, time of day, format (16:9, 9:16, 1:1 — any
--size), duration, and how the time is split: paint-on reveal, living painting, or both (default: ~60 % reveal, ~40 % living). - Study a reference when one exists (
tools/study.py, seereferences/composition.md). It reports value masses, key, chroma, palette and focal point — copy the structure, never the picture. - Compose before colour. Write the composition as a comment at the top of the scene: horizon height (never the middle), focal point (on a third, where the strongest value contrast and highest chroma go), the lead-in, what frames it, and a value plan — one value (0–1) for each big shape, in three unequal masses.
references/composition.md. - Palette from pigments, placed on the value plan.
tone(mix({ ultramarine: 1, transYellowOxide: 1 }), 0.42). Keep chroma low everywhere except the focal accent (masters: mean OKLab chroma 0.02–0.06, high-chroma area under ~8 %). - Paint back to front with the subject painters (
paintSky,paintCumulus,paintHills,paintMountains,paintWater,paintTreeline,paintPine,paintFir,paintTree,paintGrass,paintRocks,paintHouse,paintGlow,paintMoon,walker,boat,smoke…) and rawlayer.scatter/stroke/pathfor anything else. Onep.phase('name')per stage, layers created far → near.references/api.md. - Look, critique, adjust — at least two rounds. Render a still, open the PNG, and judge it like a painting: squint (do the three value masses read?), is the focal point where planned, is anything equally spaced / equally sized / equally saturated, does any mark look mechanical? Fix the cause in code. Run
study.pyon your own still and compare with the plan. This loop is the skill; first renders are never right. - Time.
p.timelapse()for the reveal, then motion:sway,drift,shimmer,p.wind,p.camera(parallax by layer depth),p.dynamicfor things that travel.references/animation.md. - Contact sheet, then render.
still --n 12 --sheet→ check every stage of the timeline; thenrender→qa.references/pipeline.md. - Report honestly: what was checked automatically, what you looked at, what you did not.
Scene skeleton
import { Painting, mix, tone, mixOk, skyColor, paintSky, ridge, paintHills, paintGrass, key, ease } from 'painter';
export const meta = { duration: 16, fps: 30 };
export default function ({ aspect, seed }) { // aspect = width / height of the render
const p = new Painting({ aspect, seed, ground: '#c9b89a' }); // toned canvas, never white
p.duration = 16;
const A = aspect; // scene units: x 0…A, y 0 (top) … 1 (bottom)
const skyTop = tone(mix({ ultramarine: 2, titaniumWhite: 2 }), 0.72), skyLow = tone(mix({ cadmiumOrange: 1, titaniumWhite: 3 }), 0.88);
const hillCol = tone(mix({ ultramarine: 1, burntUmber: 1 }), 0.3);
const hill = ridge({ y: 0.68, amp: 0.05, freq: 2, seed });
p.phase('sky');
paintSky(p.layer('sky', { depth: 0 }), { color: skyColor({ top: 0, bottom: 0.7, stops: [[0, skyTop], [1, skyLow]] }), bottom: 0.72 });
p.phase('land');
paintHills(p.layer('hill', { depth: 0.6 }), { top: hill, color: (x, y, k) => hillCol, size: 0.03 });
paintGrass(p.layer('grass', { depth: 1 }), { bounds: [0, 0.8, A, 1.05], dark: hillCol, light: tone(hillCol, 0.5), blade: 0.05 });
p.timelapse({ start: 0.3, end: 10 }); // picture finished at 10 s
p.camera = (t) => ({ zoom: key(t, [[10, 1], [16, 1.07]], ease.inOut) });
p.wind = (t) => ({ strength: key(t, [[0, 0], [10, 0.15], [12, 1]]) });
return p;
}
Rules that make it look painted (the short list)
- Value does the work, colour gets the credit. Plan three unequal value masses; squint-test every still.
- Toned ground, never white. Pick the imprimatura to harmonise the picture (warm sienna under greens, pale cream under a sunset).
- Distance first, then forward. Far = lighter, cooler, greyer, softer, thinner paint, smaller marks. Near = darkest darks, highest contrast and chroma, thickest paint, biggest marks. Use
atmosphere(color, d, air). - Darks first and thin, lights last and thick.
thick0.1–0.3 for shadows and distance, 0.8–1 for highlights; highlights are small and few. - Light against dark, dark against light. Break every mass with marks one small value step (±0.05) from what is under them;
p.valueAt(x, y)/p.sample(x, y)tell you what is there. - Strokes follow the form: around a cloud lump, along a slope, flat on water, up a blade of grass, down a trunk.
- Mix greens and earth them (
earth(mix({ ultramarine: 1, cadmiumYellow: 1 }), 0.3)); save pure chroma for one accent. - Vary everything: sizes, spacing, heights, intervals. Equal = mechanical. Odd groups, overlapping shapes, one dominant.
- Lost and found edges: soft where forms turn away or recede (
soft0.5+), crisp at the focal point. - Pure function of time. A scene is deterministic from
seed; a frame depends only ont. Never useMath.random()or wall-clock time — uselayer.rand/rng(seed).
Where things go wrong (learned building this)
- White swallows mixes. The spectral mixer weights pigments by luminance; a 1:9 colour:white mix is nearly white. Mix the hue with little or no white, then place it with
tone(c, value). Cut garish results withchroma(c, 0.5–0.8). - Clouds as balls with sticks → many small lobes on a flat base (
cumulusShapelobes 40–60), light on the side facing the sun only, round soft dabs, modestthick. - Pine as palm tree → needles are rounded clumps (bursts of short fan strokes) sitting on the boughs with sky holes between, dark underneath, lights on top; boughs droop low and reach up high.
- Trees with umbrella-spoke branches / black trunks → limbs must end inside the crown; trunk darks are value ~0.22, not black, and get air (
atmosphere) with distance. - A whole area in saturated colour (orange ground, turquoise sky) → earth it; scatter the pure colour as a few small marks.
- Path reads as a river → warm light grey, strokes along the direction of travel, cool violet only in its shadows.
- Water made of short dashes looks digital → water is long level drags blended into each other, with only a few ripple marks near the viewer (what
paintWaterdoes); do not add rows of dashes by hand. - Sea reads as a glassy mirror →
paintWater({ chop: 1 })for wave backs and white caps; keepchop: 0for lakes and rivers. - Lamp / window light is a flat patch →
paintGlowover a dark surround, then the small hot core; one long soft stroke is not a glow. - Rays as white spikes → few, broad, very soft, low-opacity shafts (
paintRaysdefaults). - Canvas weave turns into a grid on pale thin paint → keep
look.weave≤ 0.2. - Mountains as a scaly wall or zebra stripes → build the skyline with
peaks(), keep the range far (thin paint,size≈ 0.02, narrow value range) and letpaintMountainssplit it into a few big lit and shadow planes. - A flat band is not a cloud bank / hill / treeline → vary the top edge, the inside values and leave gaps.
- Scene fails with a vague browser error → the CLI syntax-checks first; a runtime error prints the scene line. A local variable shadowing a colour (
leaf,sky,hill) is the usual cause ofc.map is not a function. - Reading a stale still: always check the command output for an error before opening the PNG.
- WebGPU and WebCodecs only exist on a secure origin — the CLI serves
http://127.0.0.1; never open scenes viafile://.
Reference files
references/painting-lessons.md— what each demonstration teaches and how it maps to engine calls.references/composition.md— composing a scene, the value plan, numbers from the masters,study.py.references/api.md— Painting / layer / brush / colour / subject-painter reference, writing a new subject painter.references/animation.md— reveal pacing, living motion, camera and parallax, dynamic layers, boil, audio.references/pipeline.md— CLI, render settings, performance, QA, troubleshooting, deliverables.