The thing you described — clicking known points on each scan and warping so every frame lands in the same place — is image registration, and specifically fiducial-based (or marker-based) registration. The printed marks you align to are fiducial marks; in printing they are registration marks, and in traditional animation the physical equivalent is peg registration — the Acme peg bar that holds every cel in the same position under the camera. Fixing frame-to-frame drift afterwards is image stabilisation.
The maths has a name too. A flat sheet imaged by a flat sensor is related to the original by a planar homography — a projective transform with 8 degrees of freedom, which absorbs translation, rotation, scale, shear and keystone all at once. Estimating it from four point correspondences is the four-point algorithm, solved by the DLT (direct linear transform). That is exactly what happens here: your four clicked corners are the correspondences, the solver builds an 8×8 system, and every output pixel is inverse-mapped back through the homography and sampled bilinearly.
Three points would only give an affine transform (6 dof) — fine for rotation, scale and shear, but blind to the slight keystone you get if the sheet lifts off the platen or you photograph it instead of scanning. Four corners costs one extra click and covers that case, so the tool asks for four. This page uses affine in one place only: after you register a single frame precisely, it least-squares-fits an affine map for the whole sheet to predict where every other frame sits, because extrapolating a homography fitted to one small cell across a big page is numerically unstable.
Page 1 prints a small solid black square diagonally outside each corner of every frame's border. They are not there to be recognised by software — they are there for you to click. A small solid dot is much easier to hit dead-centre by eye than the corner of a hairline rule, and because the sheet came out of this tool, the exact position of every mark on the page is already known to four decimal places. Four clicks on the four outermost marks is all the information the geometry needs.
The per-frame squares have one weakness, and it only shows up on the last sheet. Ask for 64 frames at 36 per page and the second sheet carries 28 — its bottom row stops halfway across, so the cell that would have held the bottom-right mark was never printed. The four outermost marks are then not the four corners of the page but the corners of whatever happens to be filled, and if the corner cell is missing entirely there is nothing there to click at all.
So page 1 also prints four sheet corner marks, out in the page margin, one near each corner of the paper. They belong to the sheet rather than to a cell, so they are printed on every sheet whether or not it is full, they are in the same four places on all of them, and they span the whole page — which is the best-conditioned set of four points a sheet can offer, because a homography fitted to points spanning the page extrapolates to it far better than one fitted to a huddle in the middle. Each mark is centred in the clear band between the paper edge and the first cell, so it touches neither; where the margin is too narrow for that the size readout turns amber, because your printer's own unprintable margin may clip it. The checkbox is on from the start and is there to be turned off, not to be turned on.
Their positions go into the config to four decimal places, so page 2 does not re-derive them — it reads them. A config written before these existed still works: page 2 falls back to the outermost printed cell corners, and now takes the extreme point in each diagonal direction rather than looking up the cell at the last row and column, which on a part-full sheet is a cell that does not exist.
A QR code would also work, and it could carry the frame index rather than just a position — that is its real appeal. But it needs a decoder, it eats far more paper per frame, and its own positional accuracy comes from three finder patterns, which are squares. The printed gutter number already gives identity, so plain squares get all of the geometry for a fraction of the ink.
Drop a video (1–2 s is the design point). Frames are sampled at the midpoint of
n equal slices — at times (i + 0.5)/n × duration — rather than at slice edges,
so the set loops cleanly and never lands on a missing final frame.
| Control | What it does |
|---|---|
| Per page / auto grid | Tiles 4, 9, 16, 25, 36… to a sheet. Auto grid tries every columns×rows factorisation and keeps whichever makes the frames biggest for your clip's aspect. |
| Margin, gap | Page edge, and the space between cells. |
| Black border | The registration target, printed hard black. This is what you click on page 2, so don't set it to zero if you plan to assemble. |
| White inset | Clear space between photograph and border, so the image never bleeds into the fiducial. |
| Invert | Inverts the photograph only. The border stays black and the inset stays white, which is what you want for a contact-printed negative. |
| Mirror horizontally | For printing emulsion-side down. |
| Levels / gamma / contrast | Black point, white point and gamma first, then contrast and brightness, then inversion — all baked into one 256-entry lookup table. |
| Frame numbers | Small numerals in the gutter below each border, so scans stay identifiable. |
| Fiducial squares | Four small solid squares outside each frame's border. Turn them off for a cleaner print; the border alone still works. |
| Sheet corner marks | Four more out in the page margin, one per corner of the paper, printed on every sheet whether or not its cells are all filled — so a part-full last sheet still has four things to click, and they span the whole page. On by default. |
| Mark size | Sets both. The sheet corner marks are drawn half again as large, since they are the primary click target on page 2. |
One button, Export PDF + config, saves both files under the same stem — say
hand2_16up_300dpi.pdf and hand2_16up_300dpi.json. They are useless apart,
so they always leave together and you cannot walk off with half the pair.
DeviceGray, so files
stay about a third the size of colour..json — the exact geometry of every cell in inches, the frame times,
the intended frame rate and the tone settings. Page 2 needs it, and the two halves of the
job can be weeks apart.Scans are paired with sheets by position, not by file name — names off a scanner rarely say anything useful, and a multi-page PDF has none at all. So the panel lists them as draggable rows: drag one to where it belongs, or nudge it with the arrows. A row shows its sheet number, the file, the pixel dimensions, and a green tick once that sheet has been placed.
Moving a scan does not cost you the work already done on it. The fit page 2 stores is a map from page inches to that image's own pixels, so it travels with the photograph and stays correct wherever the scan lands in the order — sheets you have already placed stay placed after a move. What does not survive is any corner you nudged by hand: those were adjustments against a photograph that is no longer on that sheet, so they are dropped and every frame on the affected sheets is re-derived from its own fit.
Drop the config and your scans and you land straight in corner-placing on sheet 1 — no button to find. Click the centre of each of the four outermost registration squares, clockwise from the top-left. Every frame on that sheet is placed, and you are taken to sheet 2 to do the same. The toolbar shows which sheet you are on, whether it has been placed, and steps between them.
It works because nothing about the sheet is unknown. The PDF came from this tool, so every cell's position is recorded in the config; a flat sheet imaged by a lens maps to the photograph by a single homography; and four points spanning the page determine that homography exactly. Every remaining frame's corners then follow by arithmetic. Nudge anything that needs it afterwards — drag a corner, or arrow-key it.
Because it is arithmetic rather than recognition, nothing in the image can throw it off. In testing, a sheet with heavy black shapes painted over every frame registered to exactly the same accuracy as the clean one — 1.13 px either way.
If the four-corner fit leaves some frames slightly off — lens distortion is not a homography, so a wide-angle phone shot can drift toward the edges — correct a few frames by hand and press Re-fit sheet from the frames I corrected. Every frame you touched contributes four correspondences to a least-squares fit of the page, which re-places the frames you have not touched and never overwrites the ones you have.
Safari decodes HEIC on its own because macOS ships the codec; nothing else does. So HEIC is tried natively first, and on any other browser it falls back to libheif, vendored locally like ffmpeg and fetched only the first time you drop one — 1.2 MB, self-contained, no network after that. A 900 × 1164 test file decodes in about 50 ms, pixel-identical to the original.
Images are also asked to honour their EXIF rotation, so a sheet photographed in portrait comes in the way you shot it rather than on its side. It would register correctly either way — a homography absorbs rotation — but it is easier to click when it is the right way up.
Hand-printed frames are never identically exposed, and photographing a sheet lays a lighting gradient on top. Left alone the animation pulses from dark to light, which reads as far worse than it is — on one real 16-frame cyanotype sheet the frame brightness ramped across 49% of full scale, and that pulsing is easy to mistake for bad registration.
Match exposure across frames (off by default) anchors every frame's 25th and 75th percentile to the sequence median, measured on Rec.709 luma. Percentiles rather than extremes matter here: paper white clips at 255 on a bright scan, and a clipped anchor carries no information, so anchoring on the extremes would quietly do nothing to exactly the frames that need it most. On that real sheet it cut the swing from 49% to 3.7% of scale — a 13× improvement.
It is off by default because it normalises interquartile range, not just level, and that is a real edit rather than a correction. Frames whose content genuinely differs — a wide bright hand against a small dark one — carry genuinely different histograms, and pushing them onto a common one flattens the difference along with the flicker. Judge it on the preview: turn it on when the sequence pulses, leave it off when the variation is the picture.
Luma, not one channel. An earlier version sampled the red channel alone as a stand-in for brightness. On a neutral scan the two agree and the substitution is invisible; on a toned print it is fatal. A cyanotype has almost no red anywhere, so every red percentile collapses towards zero, the measurement carries no exposure information, and the fitted gain can reach zero — which maps every level onto a single value and renders the frame solid black. Two frames of a sixteen-frame sheet failed exactly that way. The fix is luma; the guard is that both the frame and the sequence target must span at least six levels before any gain is applied, because a flat target is just as dangerous as a flat frame.
Overscan is on by default at 20%. It widens the crop outward past the photograph so the printed black border, the white inset, the corner marks and the gutter number all land inside the exported frame — the print as an object, rather than just the image on it. Turn it off and the opposite control applies: edge trim crops slightly inward to hide any border bleed. Because the homography is a global map, sampling outside the border is perfectly well defined.
The two axes are set independently — overscan ↔ and overscan ↕. They are one signed framing term per axis internally, read by both the output-size calculation and the warp, so the exported aspect ratio and the sampled region cannot drift apart. Equal values leave the aspect ratio alone; unequal values deliberately change it, which is what you want when the gutter number sits below the frame and there is nothing worth keeping at the sides. Edge trim stays symmetric.
The size selector defaults to native, which measures how many scan pixels the sampled region actually covers — it pushes the framed rectangle back through each frame's homography, measures the resulting edges in scan pixels, and takes the largest across the placed frames so no frame is the one that gets softened. That is the most detail the scan holds: anything smaller discards it, anything larger only interpolates. On a 4284 × 5712 phone photo of a 4 × 4 sheet at 20% overscan it comes out around 1660 px on the long edge, so the old fixed 720 default was throwing away rather more than half the linear resolution. Fixed sizes from 480 to 3240 are still there when you need a specific one.
Quality picks the encoder settings. High is CRF 14 at 4:2:0 — past the point most eyes separate it from the source, and playable everywhere. Maximum is CRF 10 at 4:4:4, full colour resolution. Lossless is CRF 0. The two 4:4:4 modes are worth knowing about but rarely worth choosing here: 4:2:0 halves the colour resolution only, and a single-hue cyanotype carries all of its structure in luma, so there is almost nothing there to lose. Raise it when the print is toned, collaged or hand-coloured. QuickTime does not play 4:4:4; Chrome and VLC do, and if the bundled libx264 turns out not to carry the 4:4:4 profile the export re-encodes at 4:2:0 rather than handing back an empty file.
Every export is tagged bt709 for primaries, transfer and matrix. Untagged files leave the
player to guess, and players guess wrong often enough to shift a blue — which, on this material, is
the whole subject. The tags are written twice, via -color_primaries/-color_trc/-colorspace
and again via -x264-params, because libx264 honours only the matrix from the first form
and silently drops the other two; checked with ffprobe, the flags alone leave primaries
and transfer reading unknown. +faststart moves the index to the front so the
file scrubs without loading first.
A repeat is encoded once and looped by the muxer — one pass through libx264, then
-stream_loop with -c copy — rather than feeding the same frames in ten
times over. At native resolution a ten-times repeat of a long sheet is several hundred megabytes of
PNG sitting in the wasm heap, which is a real way to run the encoder out of memory; this way the cost
is flat in the repeat count and the output is bit-identical. If the build lacks
-stream_loop it falls back to feeding every frame.
Boomerang, on by default, plays the sequence forward then back without repeating the endpoints, so an n-frame clip renders 2n − 2 frames. A one-second clip is a short thing to watch once, and the turnaround at each end is what makes a hand-printed loop read as deliberate rather than as a cut.
Repeat sets how many times that pass is written out — from play once up to 10×. The repeat multiplies the boomeranged pass rather than the raw frames, so every join lands on the same seam the loop already has and nothing stutters at the turn. Both controls apply to the preview and to all three exports, and the line under them always states the exact frame count and duration you are about to get.
Output is left in colour by default. Cyanotype is blue, van dyke is brown, and a print photographed under warm light carries that too — discarding it is a decision, not a default. Tick Black & white in the tone panel when you want it gone.
| Format | How |
|---|---|
| MP4 (H.264) | ffmpeg.wasm, vendored under vendor/ so it is served from
this same origin. Off a CDN the browser refuses to construct its class worker, and being local
means it also works offline. The core is ~32 MB and loads on first export only. |
| WebM | MediaRecorder off a canvas stream, at a bitrate scaled to the
frame size rather than a flat 12 Mbps, which starved anything above 720p. Recorded in real time, so it
takes as long as the clip plays. |
| PNG sequence | A ZIP of numbered PNGs — the lossless master, since every other
export is derived from exactly these — plus a sequence.txt carrying four ready
commands: the settings the in-page export just used, a visually lossless one, a truly lossless
one, and ProRes 4444 for editing. The best input for any other pipeline, and the automatic
fallback if ffmpeg fails to start. |
Rendered frames are cached, and every edit that could change a frame bumps a revision counter that the cache is checked against — so an export can never ship a render made before your last registration change.
Every render is named the way page 1 names its pair: the stem of the job, then one
<value><unit> token for each thing that varies between renders.
page 1 hand2_16up_300dpi.pdf hand2_16up_300dpi.json
page 2 hand2_16up_300dpi_1660px_12fps.mp4
hand2_16up_300dpi_1660px_12fps.webm
hand2_16up_300dpi_1660px_12fps.zip
hand2_16up_300dpi_debug_2026-09-10T20-59-26.zip
The stem is taken from the config file you dropped, because that is the name page 1 wrote and it
already ties the PDF, the config and every render off them together — so a folder of exports sorts
next to the sheet it came from. Failing that it falls back to the source clip recorded in the config,
then to the first scan, and only then to a generic name. The long edge and the frame rate follow,
which is what stops a second export at a different size from silently overwriting the first. The line
under the quality note tells you the exact name before you click, and the ffmpeg recipes
inside sequence.txt write to the same one.
ffmpeg.wasm raises a progress event, and it is useless here. It reports elapsed time
over input duration, and an image sequence gives this core no dependable duration — so the ratio
reads zero for the whole encode and the job then simply finishes. That is exactly the shape of a
hung progress bar, and it was being displayed as Encoding… 0% for the entire run.
The honest signal is ffmpeg's own frame= stats line, which is parsed out of the log and
shown as a real count against the frames actually fed in. But ffmpeg emits that line on a half-second
timer, so a fast encode produces exactly one — at the end. So the indicator animates on its own from
the moment the encode starts, and fills in the frame count only once there is a real one to show.
It never claims to know more than it does.
Download debug bundle zips up everything the page currently believes: state.json
(every frame's quad, which sheets are placed, the tone and framing settings, the exposure statistics,
the render-cache state), the loaded config.json, the scan exactly as drawn with its
overlays, and a contact sheet of every frame as it would export. If something looks wrong, that
bundle is enough for someone else to see precisely what you are seeing.
cd negframes
pws # or: python3 -m http.server 8000
open http://localhost:8000/
It has to be served over http:// rather than opened as a file:// path —
canvas pixel reads and the ffmpeg worker are both blocked on file URLs. Nothing leaves the machine
except the one-time ffmpeg.wasm and pdf.js fetches.
index.html | Landing page and workflow overview |
build.html | Page 1 — video → negative sheet |
assemble.html | Page 2 — scans → registered animation |
common.js | Tone LUT, homography/affine solvers, bilinear warp, Otsu, loupe, ZIP writer |
pdfwriter.js | Dependency-free PDF 1.4 writer (Flate-compressed image XObjects + vector operators) |
style.css | Shared styles |
vendor/ | ffmpeg.wasm and libheif, served same-origin |
tests/ | Browser round-trip suite — builds a sheet from a clip, re-scans
it synthetically through a rotation and scale, and checks registration accuracy, framing, the
click flow and the render cache. Serve the folder and open
tests/_roundtrip.html. |