negframes

DOCS

What this is called

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.

Registration marks, and why squares rather than QR codes

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.

No image recognition anywhere. An earlier version located the marks by thresholding and centroids, and on clean sheets it was very accurate. It also cheerfully locked onto a dark hand instead of the border, and that failure gets more likely the more you work into the prints — collage, add-ons, an uneven wash. Arithmetic cannot fail that way, so recognition is gone entirely rather than left lying around as a trap.

Page 1 — building the sheet

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.

ControlWhat it does
Per page / auto gridTiles 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, gapPage edge, and the space between cells.
Black borderThe 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 insetClear space between photograph and border, so the image never bleeds into the fiducial.
InvertInverts the photograph only. The border stays black and the inset stays white, which is what you want for a contact-printed negative.
Mirror horizontallyFor printing emulsion-side down.
Levels / gamma / contrastBlack point, white point and gamma first, then contrast and brightness, then inversion — all baked into one 256-entry lookup table.
Frame numbersSmall numerals in the gutter below each border, so scans stay identifiable.
Fiducial squaresFour 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.
The preview is the print. Border and inset are written into the PDF as vector fills, not pixels, so they stay dead black and paper white at any resolution; only the photograph is a raster. Hover anywhere on the sheet for a loupe at preview resolution.

Exports

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.

Page 2 — registering and compiling

  1. Drop the config, then the scans — one image or PDF per sheet, in sheet order. JPEG, PNG, HEIC straight off an iPhone, or PDF (rasterised at 300 dpi).
  2. You land in corner-placing on sheet 1. Click the centres of its four outermost marks; it places that sheet and moves you to the next one.
  3. Step frames with , / . and sheets with the toolbar arrows. Green ticks in the filmstrip mark placed frames; anything unplaced is called out before export.
  4. Nudge whatever needs it: drag a corner, or select one with 14 and arrow-key it (shift = 10 px). C clears a frame so you can re-click its corners.
  5. Set the output size, tone and framing, then export. The animation preview sits at the bottom of the panel, under Export.

Placing the frames: four clicks per sheet

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.

Aim for the centre of the mark. Accuracy is limited only by your clicking: at fit-to-window zoom one screen pixel is about 1.4 scan pixels, roughly five thousandths of an inch at 300 dpi. Zoom in before clicking if you want tighter.

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.

Pushing your corrections back out

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.

HEIC and phone photos

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.

Deflicker

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

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.

Output size

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.

Export formats

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.

FormatHow
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.
WebMMediaRecorder 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 sequenceA 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.

Why there is no percentage while encoding

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.

Debug bundle

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.

Running it

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.

Files

index.htmlLanding page and workflow overview
build.htmlPage 1 — video → negative sheet
assemble.htmlPage 2 — scans → registered animation
common.jsTone LUT, homography/affine solvers, bilinear warp, Otsu, loupe, ZIP writer
pdfwriter.jsDependency-free PDF 1.4 writer (Flate-compressed image XObjects + vector operators)
style.cssShared 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.