{"slug": "show-hn-autoportrait-painting-timelapses-in-javascript", "title": "Show HN: Autoportrait- Painting Timelapses in JavaScript", "summary": "Autoportrait, a new JavaScript library, renders any image as a deterministic, reproducible painting timelapse on an HTML canvas, simulating graphite underdrawing and watercolor washes entirely client-side without machine learning. The tool, inspired by Nobel portrait artist Niklas Elmehed, offers configurable choreography, automatic segmentation, and exports to GIF, WebM, or self-contained HTML.", "body_md": "**autoportrait** renders an image as a timed painting performance on an HTML\ncanvas: a graphite underdrawing traced along the image's edge field, then\nwatercolor washes applied region by region in a configurable order. Analysis\nruns entirely client side, the full stroke plan is computed before the first\nframe, and a seeded PRNG makes every performance reproducible and seekable.\nThere are no dependencies and no runtime ML.\n\nautoportrait does not generate a painting. It generates a deterministic painting performance for an existing image.\n\nI love timelapses of painters at work, and I wondered whether the effect\ncould be replicated in software: the underdrawing going down first, the\nwashes pooling, the subject finished before the world behind it. The visual\nstyle is inspired by the sketch-then-color announcement portraits\n[Niklas Elmehed paints for the Nobel Prizes](https://physicsworld.com/a/meet-the-artist-behind-the-nobel-portraits-how-to-avoid-nobelitus/).\nI built the engine for [my personal site](https://philipweiss.net), where it\npaints my portrait for each visitor. The animation above plays at the real\nspeed of the default configuration, about 48 seconds.\n\n[Playground](https://philipweiss.net/autoportrait/) ·\n[Usage](#usage) ·\n[Options](#options) ·\n[Choreography](#choreography) ·\n[How it works](#how-it-works)\n\n**Deterministic.** A seed reproduces a painting stroke for stroke.`seek(t)`\n\nrebuilds the canvas at any instant.**Automatic segmentation.** k-means in CIELAB with spatial weighting, plus heuristic labels (`face`\n\n,`figure`\n\n,`sky`\n\n,`water`\n\n,`dark`\n\n,`warm`\n\n) that the ordering options understand.**Choreography control.** Presets, an explicit region order, focus points, or a callback over the full stroke plan.**Optional figure mask.** With a subject mask, figure and background paint on separate layers and the background can arrive behind a finished subject. A one-command script generates the mask.**Events.** Caption, progress, and ready callbacks for building UI around the performance.\n\n[philipweiss.net/autoportrait](https://philipweiss.net/autoportrait/) hosts the\nplayground. Pick a sample painting or upload your own picture, steer the\nchoreography with presets and focus points, scrub the timeline (the colored\ntrack marks the acts), and export the result as a GIF, a WebM video, or a\nself-contained HTML page that repaints it. Everything runs in the browser;\nuploads never leave your machine.\n\n```\nnpm install autoportrait\n```\n\nOr skip the build entirely and import from a CDN; the package is plain ES modules:\n\n``` js\nimport { paint } from \"https://cdn.jsdelivr.net/npm/autoportrait@0.1/src/index.js\";\njs\n<canvas id=\"c\"></canvas>\n<script type=\"module\">\n  import { paint } from \"autoportrait\";\n\n  const painting = paint(document.getElementById(\"c\"), {\n    image: \"me.jpg\",\n    mask: \"me-mask.png\", // optional, see tools/make_mask.py\n    seed: 42,\n  });\n</script>\n```\n\nThe returned object exposes `pause()`\n\n, `resume()`\n\n, `seek(t)`\n\n, `repaint()`\n\n,\n`finish()`\n\n, `palette()`\n\n, `dispose()`\n\n, and a `ready`\n\npromise.\n\nIn React (or any framework), the same call runs in a mount effect; there is no framework wrapper to install:\n\n``` js\nfunction Portrait({ image, seed }) {\n  const ref = useRef(null);\n  useEffect(() => {\n    const painting = paint(ref.current, { image, seed });\n    return () => painting.dispose();\n  }, [image, seed]);\n  return <canvas ref={ref} />;\n}\n```\n\nInput quality notes:\n\n- The engine performs the input's pixels without restyling them. Any image\nworks, and plain photographs come out respectably, since the sketch and the\nregion-by-region reveal carry the effect. Input that already looks like\nwatercolor looks the best. See\n[preparing the input](#preparing-the-input). `prefers-reduced-motion`\n\nvisitors get the finished painting immediately (configurable via`respectReducedMotion`\n\n).\n\n| option | default | description |\n|---|---|---|\n`image` |\nrequired | URL, `HTMLImageElement` , or `ImageBitmap` |\n`mask` |\nnone | figure mask (white subject on black); enables layered reveal |\n`seed` |\nrandom | reproduces a performance exactly |\n`preset` |\n`\"portraitist\"` |\n`\"portraitist\"` , `\"landscapist\"` , or `\"printmaker\"` |\n`order` |\nnone | array of region names/tags; overrides the preset ordering |\n`focus` |\nnone | `[{x, y}]` in canvas fractions; regions paint by distance |\n`plan` |\nnone | `(plan) => plan` callback over the final stroke schedule |\n`tempo` |\n`1` |\nglobal speed multiplier |\n`acts.sketch` |\n`13` |\nseconds of underdrawing; `0` skips it |\n`acts.wash` |\n`30` |\nseconds of watercolor |\n`acts.dry` |\n`2.4` |\nseconds of drying at the end |\n`brushes.big` |\n`110` |\nwet-pass stamp radius, canvas px |\n`brushes.small` |\n`55` |\nrefining-pass stamp radius |\n`regions.k` |\n`7` |\ntarget cluster count for segmentation |\n`paper` |\n`\"#fbf6ea\"` |\nbackground color |\n`resolution` |\n`1000` |\ninternal long-edge resolution |\n`autostart` |\n`true` |\npaint on load, or wait for `.play()` |\n`respectReducedMotion` |\n`true` |\nreduced-motion visitors get the finished frame |\n`onCaption` |\nnone | `(text, phase)` narration events |\n`onProgress` |\nnone | `(t, total)` per frame |\n`onReady` |\nnone | `({seed, regions})` after analysis |\n\nFour mechanisms control paint order, from coarse to fine.\n\n**Presets.** `portraitist`\n\npaints subject regions first and revisits the face\nwith small brushes at the end. `landscapist`\n\npaints background regions first.\n`printmaker`\n\ncompletes the entire underdrawing, then applies washes ordered by\nlightness.\n\n```\npaint(canvas, { image, preset: \"landscapist\" });\n```\n\n**Region order.** Names and tags from the segmentation, painted in the order\nlisted. Unlisted regions follow.\n\n```\npaint(canvas, { image, mask, order: [\"figure\", \"face\", \"sky\", \"background\"] });\n```\n\n**Focus points.** Regions and sub-areas paint in order of distance from the\nnearest point.\n\n```\npaint(canvas, { image, focus: [{ x: 0.3, y: 0.6 }] });\n```\n\n**Plan callback.** Receives the computed schedule (an array of stroke groups\nwith times, kinds, clip layers, and region names) before playback. Whatever it\nreturns is what plays.\n\n```\npaint(canvas, {\n  image,\n  plan(p) {\n    p.groups.reverse();\n    return p;\n  },\n});\n```\n\nTo make an autoportrait image, the image is pipelined in three phases: analysis (what does the image contain), planning (every mark and its timestamp), and playback (replay the plan against a clock).\n\nThe sketch layer is color dodge: luminance\n\nwhere\n\nOn a 4x-downsampled grid, central differences give a gradient\n\nthe gradient rotated ninety degrees, so strokes run along edges rather than across them. A contour stroke seeds wherever magnitude exceeds a threshold and advects four to seven segments, blending its heading toward the local tangent with bounded jitter at each step.\n\nWashes are applied per region, so segmentation determines the structure of the performance. Cells are clustered by k-means over\n\nCIELAB color, where Euclidean distance approximates perceptual color\ndifference, plus position at a weight `figure`\n\n/`background`\n\n, `light`\n\n, `dark`\n\n, `warm`\n\n, `cool`\n\n, `sky`\n\n,\n`water`\n\n, `greenery`\n\n), and a skin-tone prior over the upper figure promotes\none cluster to `face`\n\n. These labels are the vocabulary that `order`\n\nand the\npresets match against.\n\nWhen a mask is supplied, figure and background cluster independently and reveal through separate compositing layers. The mask boundary is feathered a few pixels so washes bleed slightly across the silhouette, which matches how wet media behaves and avoids a hard cutout edge.\n\nEach region splits into one to four spatial sub-areas (k-means on position), and each sub-area gets two passes of wash stamps: large low-alpha blooms first, then smaller refining ones. Stamp positions are sampled inside the sub-area; stamp order is a greedy nearest-neighbor walk, so consecutive stamps are adjacent and the brush appears to travel. Bloom lobe geometry is generated at plan time from the seeded PRNG. The renderer adds no randomness, which is what makes a seed reproduce a painting exactly.\n\nMarks are never drawn in color. Strokes and stamps accumulate as white marks in offscreen reveal masks, and the compositor shows the sketch layer or the source image wherever the corresponding mask has been touched, multiplied into the paper tone. Drying is a per-frame pass that raises the color masks toward full reveal while the sketch layer fades to about a third of its strength.\n\nTwo preprocessing steps improve results, both optional:\n\n**Figure mask.** `tools/make_mask.py`\n\nruns u2net_human_seg (via\n[rembg](https://github.com/danielgatis/rembg)) and writes the mask the engine\nexpects:\n\n```\npip install rembg pillow\npython3 tools/make_mask.py photo.jpg\n```\n\n**Painterly stylization.** The demo portrait was generated with an image\nmodel (GPT image generation) from a prompt along these lines:\n\nA loose watercolor painting of this photo, warm palette, soft wet-on-wet washes, paper texture visible at the edges, no hard photographic detail.\n\n`tools/watercolorize.py`\n\nis an offline alternative (median filtering, edge\ndarkening, paper grain). Its output is serviceable; the image-model route\nproduces better paintings.\n\n```\nsrc/            library source (ES modules)\ndemo/           playground app (vite)\ntools/          python preprocessing + README media generators\ntest/           deterministic smoke test\ndocs/media/     README figures, rendered by the engine\nnpm install\nnpm run dev        # playground on localhost\nnpm test           # paints twice with one seed, asserts identical pixels\nnpm run figures    # regenerate README figures\nnpm run hero       # regenerate the hero gif\n```\n\nEvery figure in this README is rendered by the library from a fixed seed, so documentation drift shows up as an image diff.\n\nMIT. The demo portrait is the author; run the engine on it locally, but please don't reuse the image itself in other projects.", "url": "https://wpnews.pro/news/show-hn-autoportrait-painting-timelapses-in-javascript", "canonical_source": "https://github.com/philipfweiss/autoportrait", "published_at": "2026-07-15 22:35:03+00:00", "updated_at": "2026-07-15 22:55:07.076117+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Autoportrait", "Niklas Elmehed", "Nobel Prizes", "philipweiss.net", "HTML", "JavaScript", "React"], "alternates": {"html": "https://wpnews.pro/news/show-hn-autoportrait-painting-timelapses-in-javascript", "markdown": "https://wpnews.pro/news/show-hn-autoportrait-painting-timelapses-in-javascript.md", "text": "https://wpnews.pro/news/show-hn-autoportrait-painting-timelapses-in-javascript.txt", "jsonld": "https://wpnews.pro/news/show-hn-autoportrait-painting-timelapses-in-javascript.jsonld"}}