Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@
- Do not create a separate desktop/Electron fork unless explicitly requested.

## Architecture
- The app has two alignment pipelines:
- The app has three alignment pipelines:
- `Markers`
- `Markerless`
- `Per-frame` (one uploaded image per animation frame; see `per_frame_pipeline_plan.md`)
- `Markerless` currently has two stabilization methods:
- `Neighbor Comparison`
- `Median-Frame Comparison`
Expand Down Expand Up @@ -71,6 +72,27 @@
that GIF via the generated object URL and `download` filename. Revoking the GIF URL must also
remove that header link.

## Per-Frame Notes
- Per-frame mode treats each uploaded image as one animation frame; image count equals frame count.
The rectified pages are resized to a common cell size and stacked into a synthetic 1×N composite
`baseRectifiedMat`, after which extraction/stabilization/ordering/export run unchanged.
- Per-image page-corner overrides (and per-image post-rotation) are post-load, pre-rectification
edits. They feed `rectifySinglePage` for that image only and never affect other images.
- Active-image switching is a UI navigation, not a config change: it redraws the raw photo, Page
Corners overlay, and Post-Rotation slider for the newly active image but does NOT trigger
reprocessing. The live composite/animation stays until a real config change.
- Per-frame mode disables the marker-specific and markerless-specific controls (marker editor,
grid-edge controls, gutter/phase sliders, Rectified Grid pre/post toggle). Stabilization, Vertical
Drift Compensation, Frame Corners overrides, ordering, appearance, and export stay enabled.
- Per-frame `_settings.txt` round-trip stores per-image overrides and post-rotation keyed by upload
order plus the image count. Settings files carry no image data, so restoring a saved per-frame
project requires re-uploading the same images in the same order; overrides reattach by upload
order, not by filename.
- The per-frame composite cell size is clamped by long edge (uniformly, so the rectified-page /
Layout paper aspect ratio is preserved) and held to the same total-area memory ceiling the
single-page rectified path uses; if the median cell size would exceed either bound, `cellW`/`cellH`
are scaled down uniformly so the composite Mat cannot blow up on large inputs.

## Markerless Notes
- Markerless pitch estimation comes from grayscale blurred autocorrelation.
- Markerless phase estimation uses combined gutter evidence.
Expand Down Expand Up @@ -107,7 +129,7 @@

## Verification
- After JS edits, run lightweight parse checks.
- If shared UI changes, check both pipelines.
- If shared UI changes, check all three pipelines (markers, markerless, per-frame).
- If viewer-tab or mobile-control naming changes, check mobile mode behavior.
- If settings-bearing controls change, check settings round-trip behavior.
- If Page Corners override behavior changes, check:
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Plottimation Tool

[**This free tool**](https://golanlevin.github.io/plottimation/) builds a looping GIF from a scan or photograph of an animation contact-sheet. It automatically aligns the frames; works both with or without alignment markers; and can even work with casual photographs. You can find the tool [**here**](https://golanlevin.github.io/plottimation/).<br/>
Version 1.17 • By @GolanLevin, Spring 2026.
Version 1.18 • By @GolanLevin, Spring 2026.

* [**Plottimation Tool Online Here**](https://golanlevin.github.io/plottimation/)
* [**Quickstart Instructions**](#quickstart-instructions)
Expand All @@ -19,7 +19,8 @@ Version 1.17 • By @GolanLevin, Spring 2026.
1. **Create** a "frame sheet" of your animation. You can work in either of two ways:
- **With Markers.** Make a marker-based sheet, with frames separated by small crosses (`+`) or filled circular dots (`●`), rendered in a high-contrast ink. [Here's a p5.js sketch](https://editor.p5js.org/golan/sketches/_ZMbagYFc) to get started. Using markers gives the most accurate frame alignment.<br/><img src="doc/marker-page-examples.png" width="400">
- **Or, Without Markers.** Create a markerless sheet, with frames separated by empty gutters. *Note:* depending on your design, the markerless pipeline may produce more jittery animations.<br/><img src="doc/markerless-page-example.png" height="131">
2. **Photograph or scan** your frame sheet. It's OK to use a casual photo, but your page must have good contrast against a plain background. For example, a light-colored sheet should be completely surrounded by a uniform dark background, as shown [here](demo/1_dmawer_crosses.jpg) and below. It is *strongly recommended* to keep the resolution of your frame sheet under 8000×8000 pixels.<br/><img src="doc/page-photo-recommendations.png">
- **Or, Per-Frame.** Upload a sequence of individual images — one photo or scan per animation frame — instead of a single contact-sheet photo. Drop several images at once, or choose `Per-Frame (one image per frame)` under `Alignment Pipeline`. The bundled `11_per_frame` demo in the Load Demo menu shows this workflow with 24 separate photos.
2. **Photograph or scan** your frame sheet (or collect one image per frame for per-frame mode). It's OK to use a casual photo, but your page must have good contrast against a plain background. For example, a light-colored sheet should be completely surrounded by a uniform dark background, as shown [here](demo/1_dmawer_crosses.jpg) and below. It is *strongly recommended* to keep the resolution of your frame sheet under 8000×8000 pixels.<br/><img src="doc/page-photo-recommendations.png">
3. **Open** the [**Plottimation Tool**](https://golanlevin.github.io/plottimation/) in a browser, from [**here**](https://golanlevin.github.io/plottimation).
4. **Load** the image of your frame sheet into the Plottimation Tool. You can do this by dragging your image file onto the Tool's load target (where it says "Drop a photo or scan here"), or by clicking the target to load a file.
5. Under the *Layout* tab, **set** `Frame Columns` and `Frame Rows` to match the layout of your sheet's grid of frames. You should also set your sheet's orientation (landscape or portrait) and page size (11×8.5, etc.).
Expand Down Expand Up @@ -78,6 +79,9 @@ Version 1.17 • By @GolanLevin, Spring 2026.
[![10_cyano_kellianderson_i.png](doc/10_cyano_kellianderson_i.png)](demo/10_cyano_kellianderson.png)
<br>Cyanotype by Kelli Anderson ([@kellianderson](https://www.instagram.com/kellianderson/))

![Metro by David Vandenbogaerde (per-frame mode demo) (@dxviie, @d17e.dev)](doc/11_per_frame_d17e.gif)
<br>Metro by David Vandenbogaerde ([@dxviie](https://github.com/dxviie), [@d17e.dev](https://www.instagram.com/d17e.dev/))

<!--

References:
Expand Down
55 changes: 55 additions & 0 deletions demo/11_per_frame/11_per_frame_settings.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
source_filename 2026-06-12 10-05-02.jpeg
source_credit Per-frame demo by @dxviie (@d17e.dev)
paper_preset square
paper_orientation landscape
paper_width 12
paper_height 12
frame_cols 5
frame_rows 4
threshold_method offset-peak
threshold_offset -20
light_on_dark_design false
search_inset_margin_x_px 80
search_inset_margin_y_px 80
boundary_threshold 8
boundary_persistence_px 7
post_rotation_deg 0
alignment_pipeline per-frame
stabilization_method pairwise-cyclic
stabilization_enabled true
stabilization_strength 100
alignment_marker_type auto
alignment_marker_region_scale_pct 30
stabilization_lambda 0.037
markerless_phase_x 0
markerless_phase_y 0
vertical_drift_compensation 0
detect_crosses_with_convolution false
use_cross_alignment false
crop_left 25
crop_right 25
crop_top 25
crop_bottom 25
flip_horizontal false
flip_vertical false
rotate_90_cw false
brightness 0
contrast 0
vibrance 0
color_temperature 0
unsharp_amount 0
unsharp_radius 1
invert false
fps 11
loop_count 1
frame_count_to_export 24
reverse_order false
boustrophedon_order false
ping_pong true
output_width 1080
output_height 1080
encoding_quality 100
dither FloydSteinberg-serpentine
resampling lanczos
use_global_palette true
per_frame_image_count 24
Binary file added demo/11_per_frame/2026-06-12 10-05-02 Medium.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added demo/11_per_frame/2026-06-12 10-05-16 Medium.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added demo/11_per_frame/2026-06-12 10-05-25 Medium.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added demo/11_per_frame/2026-06-12 10-05-34 Medium.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added demo/11_per_frame/2026-06-12 10-05-43 Medium.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added demo/11_per_frame/2026-06-12 10-05-53 Medium.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added demo/11_per_frame/2026-06-12 10-06-19 Medium.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added demo/11_per_frame/2026-06-12 10-06-26 Medium.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added demo/11_per_frame/2026-06-12 10-06-35 Medium.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added demo/11_per_frame/2026-06-12 10-06-41 Medium.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added demo/11_per_frame/2026-06-12 10-06-57 Medium.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added demo/11_per_frame/2026-06-12 10-07-13 Medium.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added demo/11_per_frame/2026-06-12 10-07-20 Medium.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added demo/11_per_frame/2026-06-12 10-07-28 Medium.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added demo/11_per_frame/2026-06-12 10-07-35 Medium.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added demo/11_per_frame/2026-06-12 10-07-43 Medium.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added demo/11_per_frame/2026-06-12 10-07-52 Medium.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added demo/11_per_frame/2026-06-12 10-07-59 Medium.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added demo/11_per_frame/2026-06-12 10-08-07 Medium.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added demo/11_per_frame/2026-06-12 10-08-14 Medium.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
30 changes: 30 additions & 0 deletions demo/11_per_frame/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"type": "per-frame",
"settings": "11_per_frame_settings.txt",
"images": [
"2026-06-12 10-05-02 Medium.jpeg",
"2026-06-12 10-05-16 Medium.jpeg",
"2026-06-12 10-05-25 Medium.jpeg",
"2026-06-12 10-05-34 Medium.jpeg",
"2026-06-12 10-05-43 Medium.jpeg",
"2026-06-12 10-05-53 Medium.jpeg",
"2026-06-12 10-06-01 Medium.jpeg",
"2026-06-12 10-06-10 Medium.jpeg",
"2026-06-12 10-06-19 Medium.jpeg",
"2026-06-12 10-06-26 Medium.jpeg",
"2026-06-12 10-06-35 Medium.jpeg",
"2026-06-12 10-06-41 Medium.jpeg",
"2026-06-12 10-06-48 Medium.jpeg",
"2026-06-12 10-06-57 Medium.jpeg",
"2026-06-12 10-07-04 Medium.jpeg",
"2026-06-12 10-07-13 Medium.jpeg",
"2026-06-12 10-07-20 Medium.jpeg",
"2026-06-12 10-07-28 Medium.jpeg",
"2026-06-12 10-07-35 Medium.jpeg",
"2026-06-12 10-07-43 Medium.jpeg",
"2026-06-12 10-07-52 Medium.jpeg",
"2026-06-12 10-07-59 Medium.jpeg",
"2026-06-12 10-08-07 Medium.jpeg",
"2026-06-12 10-08-14 Medium.jpeg"
]
}
3 changes: 2 additions & 1 deletion demo/index.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@
"7_riso_zinehug_clay.png",
"8_riso_zinehug_summoner.jpg",
"9_riso_kellianderson.jpg",
"10_cyano_kellianderson.png"
"10_cyano_kellianderson.png",
"11_per_frame"
]
Binary file added doc/11_per_frame_d17e.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
42 changes: 40 additions & 2 deletions documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
**Contents**:

* [Alignment Pipeline](#alignment-pipeline)
* [Per-Frame Pipeline](#per-frame-pipeline)
* [Layout](#layout)
* [Page & Grid Detection](#page--grid-detection)
* [Automatic Frame Alignment](#automatic-frame-alignment)
Expand All @@ -28,14 +29,42 @@

## Alignment Pipeline

*Use `Alignment Pipeline` to choose between the two different workflows for finding and aligning the animation frames.*
*Use `Alignment Pipeline` to choose between three different workflows for finding and aligning the animation frames.*

<img src="doc/ui_alignment_pipeline.png" width="330">

* `Markers (crosses, dots)` –
This mode expects a page with registration markers between frames. The markers can be small crosses (`+`) or filled circular dots (`●`), rendered in a high-contrast ink. This pipeline produces the most stable results.
* `Markerless (gutters, frames)` –
This mode estimates the frame grid without registration markers, strictly using the spacing and gutters between frames. Markerless alignment may be more "jittery" or inaccurate, depending on your design.
* `Per-Frame (one image per frame)` –
This mode treats each uploaded image as a single animation frame, instead of slicing one frame-sheet photo into a grid. See [Per-Frame Pipeline](#per-frame-pipeline) below.


---

## Per-Frame Pipeline

*Use the `Per-Frame` alignment pipeline when each animation frame is its own separate image, rather than one photographed sheet of many frames.*

This is the right mode when you have a folder of individual drawings or photographs — one image per frame — and want them assembled into an animation. The number of uploaded images is the number of animation frames; the `Frame Rows` and `Frame Columns` layout controls become display-only in this mode.

**Uploading multiple images:**

* Drag several image files onto the drop zone at once. Dropping more than one image automatically switches the app into per-frame mode.
* Use the file picker and select multiple files together (the file input accepts multi-select).
* Add more images later with the `+` tile at the end of the image strip.

**The image strip:** in per-frame mode a horizontal strip of thumbnails appears below the drop zone, one tile per uploaded image, numbered in frame order. From the strip you can:

* click a thumbnail to make it the *active* image (this is navigation — it changes which image the Page editor shows, but does not rebuild the animation);
* drag thumbnails to reorder frames (this changes the animation order and reprocesses);
* delete a frame with the `×` button (the animation rebuilds with one fewer frame);
* add more frames with the trailing `+` tile.

**Per-image page-corner editing:** the Page Corners editor and the Post-Rotation control operate on the *active* image only. Edit the page corners (or post-rotation) for the active image, then switch to another image in the strip to edit it independently. Each image keeps its own page-corner override and post-rotation; switching the active image just navigates the editor to that image's saved settings and does not reprocess. The Page Detection Threshold remains a global control applied to all images when reprocessing.

**Reloading a saved per-frame project:** settings files do not contain image data, so reloading a saved per-frame project means re-uploading the same images in the same order. See [Reloading a per-frame project](#reloading-a-per-frame-project) under Sibling Settings Files for details.


---
Expand All @@ -53,7 +82,7 @@
* `Paper Orientation` –
Choose `Landscape` or `Portrait`. This changes the effective aspect ratio used by the page warp.
* `Paper Aspect` –
Select a preset paper format such as `Letter`, `Tabloid`, `A4`, `Source`, or `Custom`.
Select a preset paper format such as `Square`, `Letter`, `Tabloid`, `A4`, `Source`, or `Custom`.
* `Source (width:height)` – This option uses the raw source image dimensions as an aspect-ratio hint. This is still just an aspect guide; it does not request a rectified grid or export at that literal pixel size.
* `Custom` –
If `Paper Aspect` is set to `Custom`, the `Sheet Width` and `Sheet Height` fields appear. These are used only as aspect-ratio hints.
Expand Down Expand Up @@ -406,6 +435,7 @@ These settings files store the current UI state, including:
* crop/export settings
* any manual page-corner overrides
* any manual marker overrides
* in per-frame mode, the per-image page-corner overrides and post-rotation for each uploaded image, plus the image count
* optional metadata

How they are used:
Expand All @@ -420,6 +450,14 @@ Note:
* Pressing `Reset` restores built-in defaults, not values from a loaded settings file
* If you load a lone local image file from the browser file picker, the browser's security settings do not permit the app to inspect the rest of that directory automatically, so a sibling settings file may need to be provided separately.

### Reloading a per-frame project

Settings files do not contain any image data; they only store settings. In per-frame mode, where each uploaded image is one animation frame, this affects how a saved project is reloaded:

* The settings file records each image's per-image page-corner overrides and post-rotation, keyed by upload order (first image, second image, and so on), along with the number of images.
* To restore a saved per-frame project, re-upload the **same images in the same order** you originally uploaded them. The saved per-image overrides are reattached by upload order — the first image you re-upload receives the overrides saved for image 1, the second receives image 2's, and so on.
* If you re-upload the images in a different order, or upload a different set of images, the overrides will reattach to the wrong frames. Matching is strictly by upload order; the settings file does not match images by filename.


---

Expand Down
20 changes: 18 additions & 2 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ <h1 id="appTitle" data-i18n="app.title">Plottimation Tool</h1>
</p>
<p id="appLedeMobileNote" class="lede mobile-header-note" data-i18n="app.mobileNote">(Please use desktop version for advanced controls.)</p>
<p id="appLedeSecondary" class="lede">
<span data-app-version>v1.17</span> • <a href="https://github.com/golanlevin/plottimation/tree/main/README.md" target="_blank" rel="noopener noreferrer" data-i18n="app.documentation">Instructions</a> • <a href="https://www.youtube.com/watch?v=MOXB63DgItQ" target="_blank" rel="noopener noreferrer" data-i18n="app.demoVideo">Demo Video</a> • <a href="templates/index.html" target="_blank" rel="noopener noreferrer" data-i18n="app.templates">Templates</a>
<span data-app-version>v1.18</span> • <a href="https://github.com/golanlevin/plottimation/tree/main/README.md" target="_blank" rel="noopener noreferrer" data-i18n="app.documentation">Instructions</a> • <a href="https://www.youtube.com/watch?v=MOXB63DgItQ" target="_blank" rel="noopener noreferrer" data-i18n="app.demoVideo">Demo Video</a> • <a href="templates/index.html" target="_blank" rel="noopener noreferrer" data-i18n="app.templates">Templates</a>
</p>
<p id="appLedeTertiary" class="lede"><span data-i18n="app.bylinePrefix">By @golanlevin, 2026 •</span> <a href="https://github.com/golanlevin/plottimation" target="_blank" rel="noopener noreferrer">Source</a></p>
</div>
Expand All @@ -67,7 +67,18 @@ <h2 id="photoHeading" data-i18n="photo.heading">Photo</h2>
<span id="dropGuidanceNote" class="drop-note" data-i18n="photo.dropNote">Animation frames should be separated
by crosses, dots, or empty gutters.</span>
</label>
<input id="fileInput" type="file" accept="image/*,.txt,text/plain" />
<input id="fileInput" type="file" accept="image/*,.txt,text/plain" multiple />
<!-- Per-frame image strip: visible only in per-frame mode (CSS keys off the
`per-frame-pipeline` body class). Lets the user switch the active image, reorder
frames (drag), delete frames, and add more. Populated by js/per-frame-strip.js. -->
<section id="perFrameStripPanel" class="per-frame-strip-panel" hidden>
<div class="per-frame-strip-head">
<h3 id="perFrameStripHeading" class="per-frame-strip-heading" data-i18n="photo.strip.heading">Frames</h3>
<span id="perFrameStripCount" class="per-frame-strip-count"></span>
</div>
<div id="perFrameStrip" class="per-frame-strip" role="list"></div>
<input id="perFrameStripFileInput" type="file" accept="image/*" multiple hidden />
</section>
</section>

<!-- Top-level choice between markerless gutter fitting and marker-based alignment. -->
Expand All @@ -82,6 +93,10 @@ <h2 id="photoHeading" data-i18n="photo.heading">Photo</h2>
<input id="alignmentPipelineMarkers" name="alignmentPipeline" type="radio" value="markers" checked />
<span data-i18n="alignment.pipelineOptions.markers">Markers (crosses, dots)</span>
</label>
<label class="toggle">
<input id="alignmentPipelinePerFrame" name="alignmentPipeline" type="radio" value="per-frame" />
<span data-i18n="alignment.pipelineOptions.perFrame">Per-frame (one image per frame)</span>
</label>
</fieldset>
</section>

Expand Down Expand Up @@ -114,6 +129,7 @@ <h2 id="photoHeading" data-i18n="photo.heading">Photo</h2>
<span id="paperPresetLabel" data-i18n="layout.paperSize">Paper Aspect</span>
<select id="paperPreset">
<option value="source">Source</option>
<option value="square">Square (12×12 in)</option>
<option value="letter" selected>Letter (11×8.5 in)</option>
<option value="legal">Legal (14×8.5 in)</option>
<option value="tabloid">Tabloid (17×11 in)</option>
Expand Down
Loading