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.
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 border, for accurate automatic registration on page 2. Turn them off for a cleaner print; the border alone still works. |
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.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.
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. |