diff --git a/AGENTS.md b/AGENTS.md index eb42e8e..ca83b79 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` @@ -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. @@ -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: diff --git a/README.md b/README.md index aa01f5c..4de02e2 100644 --- a/README.md +++ b/README.md @@ -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/).
-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) @@ -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.
- **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.
-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.
+ - **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.
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.). @@ -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)
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) +
Metro by David Vandenbogaerde ([@dxviie](https://github.com/dxviie), [@d17e.dev](https://www.instagram.com/d17e.dev/)) + + @@ -82,6 +93,10 @@

Photo

Markers (crosses, dots) + @@ -114,6 +129,7 @@

Photo

Paper Aspect ` and drop-zone copy update. +- `js/i18n.js`: drop-zone copy strings for per-frame mode (and a generic copy + that mentions multi-file support; localize across all locale tables). +- After multi-file load, if the currently selected pipeline is **not** + `"per-frame"`, the app should auto-switch to `"per-frame"` (this is the most + forgiving UX). Otherwise the user just dropped 12 files into a markerless app + and would silently lose 11 of them. +- Single-image drops continue to behave exactly as before. + +**Files touched:** +- `index.html` +- `js/load-controller.js` +- `js/i18n.js` + +**Acceptance:** +- Drag-and-drop with 1 image: identical to current behavior. +- Drag-and-drop with N images: app switches to per-frame mode, loads all N, + active index is 0, animation appears. +- File picker with multi-select: same behavior as drag. +- Mixed drag (N images + 1 `_settings.txt`): settings file is applied against + the first image's name; per-frame mode is selected if N > 1. +- Each loaded entry has its own distinct `canvas` (no two entries share a canvas + reference, and none alias the shared `state.source.canvas` once N > 1). + +**Out of scope:** +- Strip UI (Phase 7). +- Reorder/delete buttons (Phase 7). +- Settings-driven per-image overrides on reload (Phase 8 plus Phase 5). + +**As built (Phase 4 — COMPLETE):** +- `js/load-controller.js`: + - Added a module-level `decodeImageElement(src)` helper that resolves a loaded + `HTMLImageElement` (rejects on decode failure) for the additional per-frame images. + - `handleFile` now collects **all** image files (`imageFiles = allFiles.filter(isImageFile)`), + treats the first as the primary, and passes the rest as `additionalImageFiles` + (`imageFiles.slice(1)`). The sibling `_settings.txt` is still matched against the **first** + image's expected name and applied once. A lone settings file still routes to `applySettingsFile`. + - `loadImageSource` gained an `additionalImageFiles = []` dep. In `image.onload` each entry now + gets its **own** dedicated source-resolution canvas (`document.createElement("canvas")` + + `drawImageToCanvas`), replacing the Phase 2 aliasing of the shared `state.source.canvas`. The + legacy `state.source.canvas` is repointed at the active (index 0) entry's canvas, and + `state.source.image` continues to project the primary image. Additional images are decoded in a + loop; each owns its own blob URL + canvas, and a failed decode revokes that URL and is skipped + (it does not abort the whole load). This is one code path for the 1-image and N-image cases. + - When more than one image is loaded, per-frame mode is forced on + (`state.runtime.forcePerFrameMode = true`, plus `dom.alignmentPipelinePerFrame.checked = true` + when that radio exists — forward-compatible with Phase 6). Single-image loads do **not** touch + the mode, so a fresh single drop behaves exactly as before. The activation happens before + settings application and `processCurrentImage`; because `readConfig` OR-s `forcePerFrameMode` + with the radio, multi-image always resolves to per-frame even if a sibling settings file selected + markers/markerless. + - Mat/URL lifetime is unchanged in spirit: `releaseOwnedSourceUrl` → `releaseAllSourceImages` + already revokes every per-entry `ownedObjectUrl` and frees per-entry canvases/Mats on the next + load, so the extra blob URLs do not leak. +- `js/app.js`: the wrapper `loadImageSource(src, filename, mimeType, settingsFile, + additionalImageFiles = [])` threads the new argument into `loadImageSourceViaController`. Demo + loads (2-arg calls) are unaffected (default `[]`, single image, no per-frame switch). +- `index.html`: `#fileInput` gained the `multiple` attribute so the file picker allows multi-select. +- `js/i18n.js`: added a `photo.dropNotePerFrame` string to **all 13** locale tables (per-frame + guidance copy, wired into the visible drop note in Phase 6) and extended the generic `dropZone` + tooltip in all 13 locales to mention dropping several images at once (one per frame). +- Not touched: `js/ui-controls.js` (the existing drop/`change` listeners already forward the full + `FileList`), the strip UI, reorder/delete, and any settings persistence — all deferred to later + phases. + +--- + +### Phase 5 — Per-image page-corner and post-rotation overrides + +**Goal:** make the existing Page Corners editor and Post-Rotation control operate +on the *active* image in per-frame mode. + +**Scope:** +- Route `state.source.manualPageContour` reads/writes through the active image + in per-frame mode. Concretely, where `app.js` currently writes + `state.source.manualPageContour = …`, replace with a helper + `setActiveManualPageContour(state, contour)` that: + - in per-frame mode, writes to `state.source.images[activeIndex].manualPageContour` + and also mirrors to the legacy field (so other read sites keep working); + - in markers/markerless modes, writes the legacy field directly (no-op for + `images[]`). +- Same treatment for `postRotationDeg` (per-image storage; the slider reads/writes + the active image's value in per-frame mode). +- Switching active image redraws `rawCanvas`, Page Corners overlay, and + Post-Rotation slider position to match the newly active image. The rectified + preview / animation are *not* rebuilt on active-image switch — that is a UI + navigation, not a config change. +- Page Detection Threshold remains a global control (acts on all per-frame + images when reprocessing). Per-image threshold is out of scope for v1. + +**Files touched:** +- `js/app.js` (Page Corners drag handlers, Post-Rotation handlers, redraws) +- `js/dom-state.js` (per-image fields added in Phase 2) +- `js/ui-controls.js` (active-image switching invalidations) + +**Acceptance:** +- In per-frame mode with 3 images, the user can edit page corners on image #2 + without affecting images #1 or #3. +- Switching active image redraws the Page Corners overlay with that image's + saved corners. +- Reprocessing uses every per-image override correctly. +- Markers/markerless modes are completely unaffected. + +**Out of scope:** +- UI strip (Phase 7) — at this point active-image switching can be triggered by + dev console (`state.source.activeImageIndex = 1; renderRawPreview();`). +- Persisting overrides to disk (Phase 8). + +**As built (Phase 5 — COMPLETE):** +- The per-image accessors this phase needs (`setActiveManualPageContour(state, + contour, perFrameMode)` and `setActivePostRotationDeg(state, deg, perFrameMode)`) + already live in `js/source-images.js` — they take an explicit `perFrameMode` + boolean rather than re-deriving the mode inside the module, so `source-images.js` + stays DOM-free. `setActiveManualPageContour` always writes the legacy + `state.source.manualPageContour` and, only when `perFrameMode`, also mirrors to + the active entry's `manualPageContour`. `setActivePostRotationDeg` writes **only** + the active entry's `postRotationDeg` and is a no-op outside per-frame mode (the + legacy global stays the slider/`config.postRotationDeg`). No new accessors were + needed. +- `js/app.js`: + - New `isPerFrameModeActive()` helper centralizes mode detection as + `!!dom.alignmentPipelinePerFrame?.checked || !!state.runtime.forcePerFrameMode` + (optional-chained for the not-yet-existing Phase 6 radio). `readConfig` now + calls it instead of inlining the same expression, so config emission and + override routing can never diverge. + - The three user-driven manual page-corner write sites now route through + `setActiveManualPageContour(state, …, isPerFrameModeActive())`: + `updateManualPageCorner` (corner drag), `seedDefaultManualPageContour` (the + inset-rectangle seed when detection fails), and `clearPageCornerEdits` (the + `null` clear). Each preserves its existing `rawPageContour` / `pageQuadSource` + bookkeeping verbatim. The `clearAllPreviews` reset (start-of-load) was left as a + direct legacy `= null` on purpose: it is a global preview reset that runs while + the `images[]` array is about to be rebuilt, not a per-image edit. + - Post-Rotation: `readPostRotationSliderDeg()` (clamped slider read, factored from + the existing duplicated clamp) and `commitActivePostRotationFromSlider()` were + added. The latter writes the slider value onto the active entry **only** in + per-frame mode and is wired into the Post-Rotation slider's `change` handler in + `ui-controls.js` (before `scheduleProcess()`, after the unchanged-scrub early + return). In markers/markerless mode it early-returns, so the legacy slider → + `config.postRotationDeg` → pipeline path is byte-for-byte unchanged. + - New `setActiveImage(index)` performs the active-image switch as **UI + navigation, not a config change**: it calls `setActiveSourceImage`, repoints the + legacy projections (`canvas`, `image`, `filename`, `mimeType`, `dragUrl`) at the + new entry, restores that entry's `manualPageContour` into the legacy field + + overlay `rawPageContour` (or clears them), drops any stale live threshold + preview, restores the entry's `postRotationDeg` onto the slider + (`dom.postRotation.value` + `updateSliderReadouts()`), refreshes the raw-photo + heading/credit, and calls `renderRawPreview()`. It deliberately does **not** + call `scheduleProcess()` — the existing composite/animation stays live until a + real config change. It is exposed as `window.plottimation.setActiveImage` so it + can be exercised before the Phase 7 strip exists (the dev-console + `activeImageIndex = …; renderRawPreview()` route still works but won't restore + the slider/contour; prefer `setActiveImage`). +- `js/ui-controls.js`: added `commitActivePostRotationFromSlider` to the `attachUi` + deps (JSDoc + destructure) and called it in the Post-Rotation `change` handler. + No other listeners changed. +- `js/dom-state.js`: **not touched** — the per-image `manualPageContour` / + `postRotationDeg` fields and `state.runtime.forcePerFrameMode` already exist from + Phases 2–4, so the plan's listing of `dom-state.js` for Phase 5 was a no-op. +- No Mats allocated, no i18n strings added, Page Detection Threshold stayed global. +- **Notes for later phases:** (6) Phase 6 makes `dom.alignmentPipelinePerFrame` + real; `isPerFrameModeActive()` already prefers it, so no app.js change is needed + there for detection. (7) The strip should call `setActiveImage(index)` on + thumbnail click (do not poke `state.source.activeImageIndex` directly — that skips + the slider/contour restore); after reorder/delete it should re-derive the active + index and call `setActiveImage` before reprocessing. (8) Settings load must + populate each entry's `manualPageContour` / `postRotationDeg`; for the active + entry it should also refresh the legacy field + slider (or just call + `setActiveImage(activeImageIndex)` afterwards) so the editor reflects the restored + values. The post-rotation scrub *preview* (Panel 3 live rotation) is still + markerless-gated (`config.alignmentPipeline !== "markerless"` skips it), so in + per-frame mode the slider commits on `change` but does not show a live scrub + preview — wiring that for per-frame is left as later polish. + +--- + +### Phase 6 — `alignmentPipeline` radio: add "per-frame" + +**Goal:** expose per-frame mode via the existing Alignment Pipeline radio group +and wire mode-gated visibility. + +**Scope:** +- `index.html`: add a third radio + `#alignmentPipelinePerFrame` to `#alignmentPipelineField`. +- `js/dom-state.js`: add the DOM ref. +- `js/settings-defaults.js`: leave default as `"markers"`; add a sync line for + the new radio. +- `js/ui-controls.js`: add to the change listeners; on switch into per-frame + mode with `state.source.images.length === 0`, do not process; just wait for + upload. +- `js/app.js → readConfig`: emit `"per-frame"` when the new radio is checked + (already stubbed in Phase 3; this phase makes the radio real). +- Mode-gated visibility (audit and update): + - **Disabled in per-frame mode:** all marker controls, all markerless gutter + controls, Grid Edge Threshold, Grid Edge Run Length, marker editor, + Rectified Grid `Pre`/`Post` toggle, Page Detection Threshold's "live + preview" semantics (still functional but global). + - **Enabled in per-frame mode:** stabilization (Neighbor / Median both work), + Vertical Drift Compensation, Frame Corners overrides, ordering, appearance, + export options, Layout's Frame Rows/Cols (display-only). +- `js/i18n.js`: new strings for the radio label, tooltip, and any mode-gated UI + labels that diverge. +- Audit every `alignmentPipeline === "markerless"` site (≈30 in `app.js`) and + classify: + - **"markerless-only behavior"**: stays as `=== "markerless"`. Examples: + markerless gutter chart, markerless phase debug, autocorrelation. + - **"non-marker behavior"**: change to `!== "markers"` so per-frame inherits. + Examples: stabilization availability, drift comp UI. + - **"per-frame-disabled"**: add `&& alignmentPipeline !== "per-frame"` guard. + Examples: Rectified Grid Pre/Post toggle visibility. + + Produce a checklist file (or inline TODO list) during this phase listing each + classification decision; it makes the code review tractable. + +**Files touched:** +- `index.html` +- `js/dom-state.js` +- `js/settings-defaults.js` +- `js/ui-controls.js` +- `js/app.js` +- `js/i18n.js` + +**Acceptance:** +- Switching the radio between all three modes works without errors. +- Per-frame mode hides marker/markerless-specific UI cleanly (no orphan labels, + no flashed empty panels). +- Markers and markerless modes still pass demo round-trips. +- Mobile single-viewer mode still behaves correctly across all three pipelines. + +**Out of scope:** +- The image strip itself (Phase 7). +- Settings persistence (Phase 8). + +**As built (Phase 6 — COMPLETE):** + +*New radio + DOM/i18n wiring* +- `index.html`: added a third radio `#alignmentPipelinePerFrame` (`name="alignmentPipeline"`, + `value="per-frame"`) to `#alignmentPipelineField`, with a `` + label matching the two siblings. +- `js/dom-state.js`: added `alignmentPipelinePerFrame: q("#alignmentPipelinePerFrame")` to the + `alignment` group (auto-flattened to `dom.alignmentPipelinePerFrame`). No new container refs were + needed — visibility is gated entirely through the existing alignment rows plus a new body class. +- `js/settings-defaults.js`: default stays `"markers"`. `applyNonLayoutDefaults` now also sets + `dom.alignmentPipelinePerFrame.checked = (default === "per-frame")` (guarded by existence), so + reset/sync drives all three radios consistently. +- `js/ui-controls.js`: the per-frame radio joins the `attachAlignmentPipelineControls` `input`/`change` + listeners (`.filter(Boolean)` so a missing ref is harmless). Switching into per-frame with no images + loaded does NOT process — `scheduleProcess` already no-ops when `state.source.image` is null (the + empty-`images[]` case), so the handler just waits for upload. The handler also calls + `reconcilePerFrameForceFlag()` which sets `state.runtime.forcePerFrameMode` to the per-frame radio's + checked state, so once the user interacts with the radio it becomes authoritative and switching + *out* of per-frame after a multi-image load actually sticks (the Phase 4 shim no longer pins the + mode). `attachAlignmentPipelineControls` gained `state` in its deps for this. +- `js/app.js`: `readConfig`/`isPerFrameModeActive` already emit `"per-frame"` from Phase 5; verified + they now resolve through the real radio. `getActiveAlignmentPipeline()` now returns `"per-frame"` + when `isPerFrameModeActive()` (else markerless/markers as before). `resetNonLayoutControls` clears + `state.runtime.forcePerFrameMode = false` so a reset truly returns to the default markers pipeline. + +*Mode flags (new in `getAlignmentUiModeFlags`)* +- `showMarkerlessControls` (`=== "markerless"`) — unchanged, strictly markerless-only. +- `showMarkersPipelineControls` (`=== "markers"`) — unchanged. +- `showCrossOnlyControls` — unchanged (markers + cross marker type). +- `showFrameCornerControls` (`!== "markers"`) — NEW: the shared non-marker family (markerless + + per-frame) for stabilization, drift compensation, Frame Corners labels/slider/tooltips. +- `isPerFrame` (`=== "per-frame"`) — NEW: drives the per-frame drop note and the + `per-frame-pipeline` body class. +- Body classes: `markerless-pipeline` (unchanged) plus new `per-frame-pipeline`. `style.css` + extended the markerless flat-background viewport selectors to also match `per-frame-pipeline` (the + Frame Corners + preview viewports), preventing a checkerboard mismatch in per-frame mode. (Note: + `style.css` was not in the Phase 6 file list; this is a 2-line additive selector only.) + +*New i18n keys (added to ALL 13 locale tables; same locale set as Phase 4's `photo.dropNotePerFrame`)* +- `alignment.pipelineOptions.perFrame` — the radio label (locale-translated in all 13 tables). +- `tooltip.alignmentPipelinePerFrame` — the radio tooltip (locale-translated in all 13 tables), + plus a `TOOLTIP_SELECTOR_KEYS` entry `"#alignmentPipelinePerFrame": ["alignmentPipelinePerFrame", + "alignmentPipelineField"]`. +- The per-frame drop note reuses the existing `photo.dropNotePerFrame` (added in Phase 4); no new + drop-note key was needed. `syncAlignmentPipelineLabels` now shows `photo.dropNotePerFrame` when + `isPerFrame`, else `photo.dropNote`. +- Verified: `perFrame: "` appears 13×, `alignmentPipelinePerFrame` appears 14× (13 tooltip values + + 1 selector key), `dropNotePerFrame` appears 13×. + +*Classification checklist — every `alignmentPipeline === "markerless"` / `!== "markerless"` site +audited in `js/app.js` (line numbers approximate, post-edit).* Reclassification preserves +byte-identical behavior for markers and markerless: for markerless, `!== "markers"` ≡ old +`=== "markerless"` (true) and `=== "markers"` ≡ old `!== "markerless"` (false); only per-frame newly +joins the non-marker branch. + +| Site | Function | Old guard | Classification | New guard | +|------|----------|-----------|----------------|-----------| +| ~1111 | inverted marker vision | `=== "markers"` | markers-only (untouched) | `=== "markers"` | +| ~2100 | `applyManualMarkerOverrides` | `=== "markerless"` return | non-marker (skip in-place marker patch) | `!== "markers"` | +| ~2405 | `beginStabilizationStrengthScrub` | `=== "markerless"` warmup | non-marker (stabilization shared) | `!== "markers"` | +| ~3688 | `toggleMarkerlessPhaseDebug` | `!== "markerless"` return | **markerless-only** (phase debug) | unchanged | +| ~3717 | `toggleMarkerlessWorkingImage` | `!== "markerless"` return | **markerless-only** (working-image debug) | unchanged | +| ~3749 | `clearMarkerEdits` (`usesCornerNudges`) | `=== "markerless"` | non-marker (corner-nudge revert path) | `!== "markers"` | +| ~3945 | `updateSliderReadouts` ROI estimate | `=== "markerless"` | non-marker (corner-tile estimate) | `!== "markers"` | +| ~4355 | post-process stabilization warmup | `=== "markerless"` | non-marker (warm stabilization for per-frame) | `!== "markers"` | +| ~4434 | `renderRectifiedPreview` working canvas | `=== "markerless"` | **markerless-only** (working-blur canvas) | unchanged | +| ~4560 | grid-search-inset overlay | `=== "markerless"` | **markerless-only** (search-inset visual) | unchanged | +| ~4603 | markerless phase-debug chart | `=== "markerless"` | **markerless-only** (phase debug viz) | unchanged | +| ~4894 | `getPreviewFrameQuadForSourceIndex` | `=== "markerless"` | non-marker (corner-lattice extraction) | `!== "markers"` | +| ~4980 | `resolveDisplayedAlignmentPoint` | `=== "markerless"` | non-marker (corner display model) | `!== "markers"` | +| ~5655 | `getFrameExtractionAlignmentInfo` | `=== "markerless"` | non-marker (extraction lattice; phase=0 in per-frame) | `!== "markers"` | +| ~6146 | `scheduleCurrentStabilizationWarmup` | `!== "markerless"` return | non-marker (stabilization shared) | `=== "markers"` | +| ~6748 | `getMarkerlessPhaseSourceOffset` | `!== "markerless"` → {0,0} | **markerless-only** (per-frame has no phase sweep; returns 0) | unchanged | +| ~6790 | `getMarkerlessVerticalDriftSourceOffset` | `!== "markerless"` → {0,0} | non-marker (Vertical Drift Compensation enabled) | `=== "markers"` | +| ~6840 | `getMarkerlessCornerStabilizationOffset` | `!== "markerless"` → {0,0} | non-marker (stabilization + Frame Corners) | `=== "markers"` | +| ~6903 | `getMarkerlessCornerManualNudge` | `!== "markerless"` → {0,0} | non-marker (Frame Corners overrides) | `=== "markers"` | +| ~7156 | `getDisplayAlignmentInfo` early return | `!== "markerless" && !preview` | non-marker (markers-only short-circuit) | `=== "markers" && !preview` | +| ~7164 | `getDisplayAlignmentInfo` marker post-rot branch | `!== "markerless"` | non-marker (markers-only marker preview; per-frame falls to corner builder) | `=== "markers"` | +| ~7712 | `applyMarkerOverride` (`usesCornerNudges`) | `=== "markerless"` | non-marker (corner-nudge override semantics) | `!== "markers"` | +| ~7781 | `restoreMarkerOverride` (`usesCornerNudges`) | `=== "markerless"` | non-marker (corner-nudge clear path) | `!== "markers"` | +| (n/a) | `getPageBoundaryPreviewSignature` (~7211) | `config.alignmentPipeline` in cache key | passthrough (per-frame yields distinct signature naturally) | unchanged | +| (n/a) | `syncAlignmentMarkerUi` working-image reset | `pipeline !== "markerless"` | **markerless-only** (resets working-image flag; correct for per-frame) | unchanged | + +*Visibility audit (`syncAlignmentPipelineVisibility`, label/slider/tooltip syncs)* +- **Disabled in per-frame** (hidden): Grid Edge Threshold (`boundarySensitivityRow`) and Grid Edge + Run Length (`boundaryPersistenceRow`) changed `hidden = showMarkerlessControls` → + `hidden = !showMarkersPipelineControls` (marker-grid-only, so hidden in markerless AND per-frame); + marker-type field (`alignmentMarkerTypeField`, already markers-only); markerless gutter/phase + sliders (`markerlessPhaseXRow`/`markerlessPhaseYRow`, kept `!showMarkerlessControls`); the + Rectified Grid Pre/Post (`Rectified Grid` toggle) is not pipeline-radio-gated here — the marker + editor's blob view (`toggleMarkerBlobViewButton`) is already always hidden. +- **Enabled in per-frame** (visible via `showFrameCornerControls`): stabilization method group + + enable + strength + lambda rows (Neighbor / Median both work); Vertical Drift Compensation row; + ROI-size slider position (`syncAlignmentSliderOrder`); Frame Corners override editor + (`toggleMarkerEditingButton`/`clearMarkerEditsButton` — not pipeline-gated, inherits markerless + behavior via the `usesCornerNudges` override sites above). `syncStabilizationMethodUi` lambda + gating moved to `showFrameCornerControls`. +- **Labels/tooltips**: `syncAlignmentPipelineLabels` uses `showFrameCornerControls` for the Frame + Corners heading, the Centers/Stabilize viewer + mobile-control tab labels, and the ROI-size label + (so per-frame reads "Frame Corners"/"Centers"/"Stabilize", not marker wording); + `showMarkerlessControls` is still used only for the gutter-specific `summaryMarkerless` copy. + `syncAlignmentModeTooltips` now takes `showFrameCornerControls`. Drop note uses `isPerFrame ? + dropNotePerFrame : dropNote`. + +*Mobile checkpoint:* viewer tabs and mobile control tabs are always present (not pipeline-gated); +only their text changes, now via `showFrameCornerControls`, so per-frame shows Centers/Stabilize on +mobile single-viewer just like markerless. No viewer-tab visibility branch keys off pipeline mode, so +all three pipelines work in mobile single-viewer mode. + +*Mat lifetime:* Phase 6 allocates no OpenCV Mats (UI-only). No `.delete()` changes. + +*Validation:* `node --check` passes on `js/app.js`, `js/ui-controls.js`, `js/dom-state.js`, +`js/settings-defaults.js`, `js/i18n.js`. i18n key counts verified (see above). Markers/markerless +conditionals confirmed byte-identical by the `markers`/`markerless` truth-table above. + +*Notes for Phases 7–9:* +- (7) The strip should call `setActiveImage(index)` (Phase 5) on thumbnail click and re-derive the + active index after reorder/delete. Strip visibility can key off the `per-frame-pipeline` body class + or `isPerFrameModeActive()`. The strip belongs in the Photo group; nothing in Phase 6 reserved a + container, so Phase 7 adds its own (`#perFrameStripPanel`) and DOM refs. +- (8) Settings load currently still routes `alignment_pipeline = per-frame` through + `settings-io.js`'s markers/markerless branch (lines ~147-151) — **left untouched on purpose** + (Phase 8 owns per-frame settings round-trip). When Phase 8 lands, that load branch must recognize + `per-frame` and set `dom.alignmentPipelinePerFrame.checked` + `state.runtime.forcePerFrameMode` + rather than falling back to markerless. Save side already emits `config.alignmentPipeline` + (`per-frame`) correctly. +- (9) Per-frame's post-rotation live scrub preview is still markerless-gated (Phase 5 note); wiring a + per-frame scrub preview is later polish. No memory work was added here (Phase 9 owns the cell-size + ceiling and per-image cache trimming). + +--- + +### Phase 7 — Image strip UI + +**Goal:** give the user a visible way to switch active image, see upload count, +reorder, and delete. + +**Scope:** +- New section in `index.html`, visible only in per-frame mode. Likely placed + inside the Photo control group below the drop zone, or as a new collapsible + `#perFrameStripPanel`. +- Renders a horizontal scrollable strip of thumbnails (one per + `state.source.images[i]`). Each thumbnail shows: + - the image (small preview, square-cropped or letterboxed) + - the frame number `1`, `2`, … + - a delete (×) button on hover + - active state highlighting +- Drag-to-reorder within the strip (HTML5 drag-and-drop API, scoped to the + strip). +- Click selects active image. +- A `+` tile at the end accepts additional dropped or chosen images. +- New file `js/per-frame-strip.js` keeps strip rendering / event handling out of + `app.js` and `ui-controls.js`. +- Mobile: the strip should still be usable in single-viewer mode. The strip can + appear above the active raw photo viewer. +- Reorder triggers reprocessing (frame order changes). +- Delete triggers reprocessing. + +**Files touched:** +- `index.html` +- `js/dom-state.js` (strip DOM refs) +- `js/ui-controls.js` (wire-up) +- new `js/per-frame-strip.js` +- `style.css` (strip + thumbnail styling) +- `js/i18n.js` + +**Acceptance:** +- Upload 5 images, reorder them, the animation reflects the new order on next + preview tick. +- Delete an image; the animation rebuilds with N-1 frames. +- Active-image highlighting matches `state.source.activeImageIndex`. +- Mobile: strip is reachable and usable in the Page viewer tab. + +**Out of scope:** +- Cross-strip drag (e.g. dragging a thumbnail out of the app). +- Bulk operations (multi-select delete). + +**As built (Phase 7 — COMPLETE):** + +*New module `js/per-frame-strip.js`* — exports `attachPerFrameStrip(deps)` and +`renderPerFrameStrip()`. +- `attachPerFrameStrip({ dom, state, setActiveImage, reprocess, addImageFiles, + isPerFrameModeActive })` binds deps in module scope (single instance) and wires the hidden + `#perFrameStripFileInput` `change` handler (the `+` tile's file picker). Called once from + `attachUi`. +- `renderPerFrameStrip()` rebuilds the strip. It hides + empties the panel (sets `panel.hidden = + true`, clears children, resets the cached signature) whenever `isPerFrameModeActive()` is false, so + markers/markerless modes never see strip markup. When active it diffs a cheap signature + (`activeImageIndex | length | per-entry-canvas-presence`) against the last render and early-returns + when nothing visible changed, to avoid DOM thrash/flicker. Reorder and delete reset the cached + signature to `""` first so a same-length reorder still rebuilds in the new order. +- Each thumbnail shows the entry image (`` from `entry.dragUrl`, `object-fit: cover` square + crop), the 1-based frame number, and a hover/focus delete (`×`) button. The trailing `+` tile opens + the file picker and also accepts image drops directly. Click selects active via the app's + `setActiveImage(index)` (NO reprocess). Reorder uses HTML5 DnD scoped to the strip (a module-level + `dragSourceIndex` recorded on `dragstart`; file drags fall through to the add tile). Reorder splices + `state.source.images[]`, keeps the same logical entry active (re-derives its new index via + `images.indexOf(activeEntry)` and calls `setActiveImage`), then calls `reprocess()`. Delete splices + the entry, releases it (see below), clamps the active index toward the prior neighbor, calls + `setActiveImage`, then `reprocess()`; deleting the last image nulls `state.source.image` and leaves + only the `+` tile (empty state). + +*Delete release path (no leaks):* the strip's `releaseEntry(entry)` revokes `entry.ownedObjectUrl`, +calls the existing `releaseEntryRectifiedCache(entry)` from `js/source-images.js` (frees a bare `Mat` +or a `{ visionMat, styledMat }` cache), and nulls `entry.image` / `entry.canvas`. Active index is +re-clamped via `setActiveSourceImage` (through `setActiveImage`) so it always stays in range. + +*Reprocess vs select:* selecting an image routes through the app's `setActiveImage` (Phase 5) → no +`scheduleProcess`. Reorder and delete both call the `reprocess` dep, which is the app's existing +`scheduleProcess` (debounced reprocess entry point). `scheduleProcess` no-ops when `state.source.image` +is null, so deleting the last frame does not reprocess (stale preview lingers until images are +re-added — acceptable empty state). + +*`js/app.js`:* added `addPerFrameImages(files)` — decodes each image via the exported +`decodeImageElement` (Phase 4), builds an entry with its own blob URL + source canvas via +`createSourceImageEntry` + `drawImageToCanvas`, pushes to `state.source.images`, forces per-frame mode +on (`forcePerFrameMode` + ticks the radio), adopts entry 0 as active via `setActiveImage` when the +strip started empty, then `syncAlignmentMarkerUi()` + `renderPerFrameStrip()` + `scheduleProcess(0)`. +`renderPerFrameStrip()` is now also called at the tail of `syncAlignmentMarkerUi()` (covers mode +switch / post-process visibility) and at the tail of `setActiveImage()` (active-highlight refresh). +`setActiveImage`, `isPerFrameModeActive`, and `addPerFrameImages` are threaded into `wireUiControls`. +`decodeImageElement` is now exported from `js/load-controller.js`. + +*`js/ui-controls.js`:* imports `attachPerFrameStrip` and calls it near the top of `attachUi`, passing +`reprocess: scheduleProcess` and `addImageFiles: addPerFrameImages`. The three new deps +(`setActiveImage`, `isPerFrameModeActive`, `addPerFrameImages`) were added to the `attachUi` JSDoc + +destructure. + +*`index.html`:* `#perFrameStripPanel` (a `