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.
[](demo/10_cyano_kellianderson.png)
Cyanotype by Kelli Anderson ([@kellianderson](https://www.instagram.com/kellianderson/))
+
+ 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)
+
+
+ Per-frame (one image per frame)
+
@@ -114,6 +129,7 @@ Photo
Paper Aspect
Source
+ Square (12×12 in)
Letter (11×8.5 in)
Legal (14×8.5 in)
Tabloid (17×11 in)
diff --git a/js/app.js b/js/app.js
index 398b519..5e9c083 100644
--- a/js/app.js
+++ b/js/app.js
@@ -20,6 +20,7 @@ import {
updateExportButtonLabel as updateExportButtonLabelViaController,
revokeGifUrl as revokeGifUrlViaController,
sanitizeFilenameBase,
+ detectMp4ExportSupport,
exportGif as exportGifViaController,
exportMp4 as exportMp4ViaController,
exportZip as exportZipViaController,
@@ -35,6 +36,8 @@ import {
releaseOwnedSourceUrl as releaseSourceUrl,
handleFile as loadFileSource,
loadImageSource as loadImageSourceViaController,
+ decodeImageElement,
+ applyPendingPerImageOverrides,
} from "./load-controller.js";
import {
releaseRectifiedDragUrl as releaseRectifiedDragAsset,
@@ -69,6 +72,14 @@ import {
extractSingleFrameToCanvas,
} from "./pipeline.js";
import { applyTranslations, getTooltipText, t } from "./i18n.js";
+import {
+ setActiveSourceImage,
+ setActiveManualPageContour,
+ setActivePostRotationDeg,
+ createSourceImageEntry,
+ releaseEntryRectifiedCache,
+} from "./source-images.js";
+import { renderPerFrameStrip } from "./per-frame-strip.js";
// Final output-size scaling can be done either with browser canvas drawImage() or with OpenCV.
// Keep both code paths available for comparison while evaluating tiny-output quality.
const bUseOpenCvOutputScaling = true;
@@ -118,36 +129,6 @@ function mapEncodingQualityToGifEncoderQuality(encodingQuality) {
return Math.max(1, Math.min(20, Math.round(20 - (normalized * 19))));
}
-/**
- * Probe whether this browser can encode H.264 frames with WebCodecs for later MP4 muxing.
- *
- * @returns {Promise<{supported:boolean, codec:string}>}
- */
-async function detectMp4ExportSupport() {
- if (typeof globalThis.VideoEncoder === "undefined" || typeof VideoEncoder.isConfigSupported !== "function") {
- return { supported: false, codec: "" };
- }
- const candidates = ["avc1.42001f", "avc1.42E01E", "avc1.4D401E"];
- for (const codec of candidates) {
- try {
- const support = await VideoEncoder.isConfigSupported({
- codec,
- width: 16,
- height: 16,
- bitrate: 500_000,
- framerate: 20,
- avc: { format: "avc" },
- });
- if (support?.supported) {
- return { supported: true, codec };
- }
- } catch {
- // Try the next H.264 profile string.
- }
- }
- return { supported: false, codec: "" };
-}
-
/**
* Build the stable string key used for marker override storage.
*
@@ -241,7 +222,85 @@ function maybeLoadStartupDemoFromUrl(manifestFilenames) {
if (dom.loadDemoSelect) {
dom.loadDemoSelect.value = requestedDemo;
}
- void loadImageSource(`demo/${requestedDemo}`, requestedDemo);
+ void loadSelectedDemo(requestedDemo);
+}
+
+/**
+ * Guess an image MIME type from a filename extension.
+ *
+ * @param {string} filename
+ * @returns {string}
+ */
+function guessImageMimeType(filename) {
+ const lower = String(filename || "").toLowerCase();
+ if (lower.endsWith(".png")) return "image/png";
+ if (lower.endsWith(".webp")) return "image/webp";
+ if (lower.endsWith(".gif")) return "image/gif";
+ return "image/jpeg";
+}
+
+/**
+ * Load one bundled demo entry from `demo/index.json`.
+ *
+ * Single-image demos load `demo/` with a sibling settings file. Folder demos (for example
+ * `11_per_frame`) load every image listed in `demo//manifest.json` together with that folder's
+ * settings file.
+ *
+ * @param {string} demoId
+ * @returns {Promise}
+ */
+async function loadSelectedDemo(demoId) {
+ const folderManifestUrl = `demo/${demoId}/manifest.json`;
+ try {
+ const response = await fetch(folderManifestUrl, { cache: "no-store" });
+ if (response.ok) {
+ const manifest = await response.json();
+ if (manifest?.type === "per-frame" && Array.isArray(manifest.images) && manifest.images.length > 0) {
+ await loadPerFrameFolderDemo(demoId, manifest);
+ return;
+ }
+ }
+ } catch {
+ // Fall through to the single-image demo path.
+ }
+ await loadImageSource(`demo/${demoId}`, demoId);
+}
+
+/**
+ * Load a bundled per-frame demo folder: all manifest images plus the folder settings file.
+ *
+ * @param {string} demoId
+ * @param {{ settings?: string, images: string[] }} manifest
+ * @returns {Promise}
+ */
+async function loadPerFrameFolderDemo(demoId, manifest) {
+ const baseUrl = new URL(`demo/${demoId}/`, globalThis.location?.href || window.location.href);
+ const settingsName = manifest.settings || `${demoId}_settings.txt`;
+ let companionSettingsText = "";
+ try {
+ const settingsResponse = await fetch(new URL(settingsName, baseUrl).toString(), { cache: "no-store" });
+ if (settingsResponse.ok) {
+ companionSettingsText = await settingsResponse.text();
+ }
+ } catch {
+ /* settings are optional */
+ }
+ const imageNames = manifest.images.filter((name) => typeof name === "string" && name.trim());
+ const [firstImageName, ...restImageNames] = imageNames;
+ if (!firstImageName) return;
+ const additionalImageSources = restImageNames.map((name) => ({
+ src: new URL(name, baseUrl).toString(),
+ filename: name,
+ mimeType: guessImageMimeType(name),
+ }));
+ await loadImageSource(
+ new URL(firstImageName, baseUrl).toString(),
+ firstImageName,
+ guessImageMimeType(firstImageName),
+ null,
+ [],
+ { companionSettingsText, additionalImageSources, demoId },
+ );
}
/**
@@ -251,13 +310,15 @@ function maybeLoadStartupDemoFromUrl(manifestFilenames) {
* continuing to advertise the old demo after the user switches to another file or demo.
*
* @param {string} nextFilename
+ * @param {string} [demoId=""]
* @returns {void}
*/
-function clearDemoQueryIfLoadingDifferentFile(nextFilename) {
+function clearDemoQueryIfLoadingDifferentFile(nextFilename, demoId = "") {
const url = new URL(globalThis.location?.href || "", globalThis.location?.href || window.location.href);
const currentDemo = String(url.searchParams.get("demo") || "").trim();
if (!currentDemo) return;
- if (currentDemo === String(nextFilename || "").trim()) return;
+ const nextDemoId = String(demoId || "").trim();
+ if (currentDemo === nextDemoId || currentDemo === String(nextFilename || "").trim()) return;
url.searchParams.delete("demo");
history.replaceState(null, "", url.toString());
if (dom.loadDemoSelect) {
@@ -1929,6 +1990,11 @@ function init() {
attachResizeHandler();
startAnimationPreviewLoop();
void initializeMp4Support();
+
+ // Until the Phase 7 image strip exists, expose the active-image switch on a small dev handle so
+ // per-frame per-image overrides can be exercised from the console. Phase 7 will call setActiveImage
+ // directly from the strip wiring.
+ window.plottimation = Object.assign(window.plottimation || {}, { setActiveImage });
}
/**
@@ -1946,7 +2012,7 @@ function attachUi() {
makeLivePreviewDragCue,
makeGifImageDraggable,
handleFile,
- loadSelectedDemo: (filename) => { void loadImageSource(`demo/${filename}`, filename); },
+ loadSelectedDemo: (filename) => { void loadSelectedDemo(filename); },
renderRectifiedPreview,
resetAppearanceControls,
resetTrimControls,
@@ -2022,6 +2088,11 @@ function attachUi() {
beginPostRotationScrub,
endPostRotationScrub,
finishPostRotationScrubIfUnchanged,
+ commitActivePostRotationFromSlider,
+ setActiveImage,
+ isPerFrameModeActive,
+ addPerFrameImages,
+ clearAllPreviews,
bumpFrameOutputEpoch,
setGeometryProcessingCursor,
cancelInFlightProcessing,
@@ -2084,7 +2155,9 @@ async function initializeMp4Support() {
*/
function applyManualMarkerOverrides(alignmentInfo) {
if (!alignmentInfo) return;
- if (readConfig().alignmentPipeline === "markerless") return;
+ // Non-marker pipelines (markerless + per-frame) interpret corner overrides as post-stabilization
+ // nudges, not in-place marker patches, so this marker-only fast path is skipped for both.
+ if (readConfig().alignmentPipeline !== "markers") return;
for (const [key, override] of state.geometry.manualMarkerOverrides.entries()) {
const marker = alignmentInfo.markerLookup.get(key);
const tile = alignmentInfo.crossRoiTileMap?.get(key);
@@ -2205,11 +2278,7 @@ function resetTrimControls() {
*/
function resetExportControls() {
const previousOutputSize = getRequestedOutputSize();
- const maxFrameCount = Math.max(
- 1,
- Math.max(1, Math.round(Number(dom.frameCols.value) || SETTINGS_DEFAULTS.layout.frameCols)) *
- Math.max(1, Math.round(Number(dom.frameRows.value) || SETTINGS_DEFAULTS.layout.frameRows))
- );
+ const maxFrameCount = getFrameExportCountMax();
const alreadyReset =
(Number(dom.fps.value) || SETTINGS_DEFAULTS.gifExport.fps) === SETTINGS_DEFAULTS.gifExport.fps &&
(Number(dom.loopCount.value) || SETTINGS_DEFAULTS.gifExport.loopCount) === SETTINGS_DEFAULTS.gifExport.loopCount &&
@@ -2265,6 +2334,9 @@ function resetExportControls() {
*/
function resetNonLayoutControls() {
applyNonLayoutDefaults(dom);
+ // `applyNonLayoutDefaults` restores the pipeline radio to the default ("markers"); clear the
+ // legacy per-frame force shim too so `isPerFrameModeActive()` agrees with the reset radio.
+ state.runtime.forcePerFrameMode = false;
state.geometry.manualMarkerOverrides.clear();
state.preview.activeEditedMarker = null;
state.runtime.markerEditingEnabled = false;
@@ -2385,7 +2457,8 @@ function scheduleStabilizationPreviewUpdate() {
* @returns {void}
*/
function beginStabilizationStrengthScrub() {
- if (readConfig().alignmentPipeline === "markerless") {
+ // Stabilization is available in markerless and per-frame; warm the solver for both non-marker modes.
+ if (readConfig().alignmentPipeline !== "markers") {
scheduleCurrentStabilizationWarmup();
}
if (state.preview.stabilizationStrengthScrubbing) return;
@@ -2528,6 +2601,38 @@ function finishPostRotationScrubIfUnchanged() {
return true;
}
+/**
+ * Read the Post-Rotation slider value, clamped to the slider's range.
+ *
+ * @returns {number}
+ */
+function readPostRotationSliderDeg() {
+ return Math.max(
+ -1.1,
+ Math.min(
+ 1.1,
+ Number.isFinite(Number(dom.postRotation?.value))
+ ? Number(dom.postRotation.value)
+ : SETTINGS_DEFAULTS.detection.postRotationDeg
+ )
+ );
+}
+
+/**
+ * Persist the current Post-Rotation slider value onto the active per-frame image entry.
+ *
+ * In per-frame mode each image carries its own Post-Rotation; the global slider edits the active
+ * image's value so reprocessing rectifies that image with its own rotation. In markers / markerless
+ * mode this is a no-op (Post-Rotation stays a single global value read from `config.postRotationDeg`),
+ * so legacy behavior is unchanged.
+ *
+ * @returns {void}
+ */
+function commitActivePostRotationFromSlider() {
+ if (!isPerFrameModeActive()) return;
+ setActivePostRotationDeg(state, readPostRotationSliderDeg(), true);
+}
+
/**
* Populate the resampling dropdown with only the interpolation modes available in this OpenCV build.
*
@@ -2822,15 +2927,32 @@ async function handleFile(file, files = null) {
* @param {string} src
* @param {string} [filename=""]
* @param {string} [mimeType="image/jpeg"]
+ * @param {File | null} [settingsFile=null]
+ * @param {File[]} [additionalImageFiles=[]] Extra images beyond the primary, loaded as per-frame entries.
+ * @param {{
+ * companionSettingsText?: string | null,
+ * additionalImageSources?: { src: string, filename?: string, mimeType?: string }[],
+ * demoId?: string,
+ * }} [options={}]
* @returns {Promise}
*/
-async function loadImageSource(src, filename = "", mimeType = "image/jpeg", settingsFile = null) {
- clearDemoQueryIfLoadingDifferentFile(filename);
+async function loadImageSource(
+ src,
+ filename = "",
+ mimeType = "image/jpeg",
+ settingsFile = null,
+ additionalImageFiles = [],
+ options = {},
+) {
+ clearDemoQueryIfLoadingDifferentFile(filename, options.demoId || "");
await loadImageSourceViaController({
src,
filename,
mimeType,
settingsFile,
+ additionalImageFiles,
+ additionalImageSources: options.additionalImageSources || [],
+ companionSettingsText: options.companionSettingsText ?? null,
dom,
state,
setStatus,
@@ -2849,6 +2971,10 @@ async function loadImageSource(src, filename = "", mimeType = "image/jpeg", sett
invalidateAppearanceCache,
processCurrentImage,
drawImageToCanvas,
+ // After a per-frame settings restore reattaches buffered per-image overrides, refresh the active
+ // entry's legacy field + Post-Rotation slider + Page Corners overlay + strip so the editor reflects
+ // the restored values (no-op for single-image markers/markerless loads).
+ refreshActiveImage: setActiveImage,
});
}
@@ -2872,6 +2998,112 @@ function scheduleProcess(delayMs = 220) {
}, Math.max(0, delayMs));
}
+/**
+ * Decode and append additional images as per-frame entries, then reprocess with the new frame count.
+ *
+ * Reuses the Phase 4 decode path (`decodeImageElement`) so the strip's `+` tile and additional drops
+ * share one code path. Each new entry owns its own blob URL + source-resolution canvas. If the strip
+ * was empty (e.g. after deleting every image), the first added entry becomes the active image and the
+ * legacy projections are repointed at it via `setActiveImage`. Forces per-frame mode on so adding
+ * images never silently changes pipelines.
+ *
+ * @param {File[]} files
+ * @returns {Promise}
+ */
+async function addPerFrameImages(files) {
+ const imageFiles = (files || []).filter((file) => file && String(file.type || "").startsWith("image/"));
+ if (imageFiles.length === 0) return;
+
+ setGeometryProcessingCursor(true);
+ const startedEmpty = !Array.isArray(state.source.images) || state.source.images.length === 0;
+ if (!Array.isArray(state.source.images)) state.source.images = [];
+
+ let addedAny = false;
+ for (const file of imageFiles) {
+ const url = URL.createObjectURL(file);
+ let image;
+ try {
+ image = await decodeImageElement(url);
+ } catch {
+ try {
+ URL.revokeObjectURL(url);
+ } catch {
+ /* already revoked */
+ }
+ continue;
+ }
+ const canvas = document.createElement("canvas");
+ drawImageToCanvas(image, canvas);
+ state.source.images.push(
+ createSourceImageEntry({
+ image,
+ filename: file.name || "",
+ mimeType: file.type || "image/jpeg",
+ ownedObjectUrl: url,
+ dragUrl: url,
+ canvas,
+ }),
+ );
+ addedAny = true;
+ }
+ if (!addedAny) {
+ setGeometryProcessingCursor(false);
+ return;
+ }
+
+ // Adding images is an explicit per-frame action; make sure the pipeline is in per-frame mode.
+ state.runtime.forcePerFrameMode = true;
+ if (dom.alignmentPipelinePerFrame) dom.alignmentPipelinePerFrame.checked = true;
+ document.body.classList.add("has-loaded-image");
+
+ // If a per-frame settings file was loaded before any images (the reload story), reattach its
+ // buffered per-image overrides by upload order to the now-present images, then consume the buffer.
+ applyPendingPerImageOverrides(state);
+
+ if (startedEmpty) {
+ // No active image existed (fresh or fully-emptied strip): adopt the first new entry as active and
+ // repoint the legacy projections so processCurrentImage has a source image to read.
+ setActiveImage(0);
+ }
+ syncAlignmentMarkerUi();
+ renderPerFrameStrip();
+ updateSliderReadouts();
+ scheduleProcess(0);
+}
+
+/**
+ * Report whether the per-frame alignment pipeline is currently active.
+ *
+ * Per-frame mode is selected by its own radio (added in Phase 6) or, until then, by a dev/multi-file
+ * flag (`state.runtime.forcePerFrameMode`). The radio ref is read with optional chaining so this is
+ * forward-compatible with Phase 6 wiring `dom.alignmentPipelinePerFrame`. This matches the detection
+ * `readConfig` uses, so per-image override routing and the active config stay consistent.
+ *
+ * @returns {boolean}
+ */
+function isPerFrameModeActive() {
+ return !!dom.alignmentPipelinePerFrame?.checked || !!state.runtime.forcePerFrameMode;
+}
+
+/**
+ * Upper bound for the `Frames in Export` control and the export-options clamp.
+ *
+ * Grid pipelines cap at Frame Columns × Frame Rows. Per-frame mode caps at the number of uploaded
+ * images (or extracted frames once processing has run).
+ *
+ * @returns {number}
+ */
+function getFrameExportCountMax() {
+ if (isPerFrameModeActive()) {
+ const imageCount = Array.isArray(state.source.images) ? state.source.images.length : 0;
+ const frameBudget = imageCount > 0 ? imageCount : Math.max(0, state.geometry.frameCount || 0);
+ return Math.max(1, frameBudget);
+ }
+ const cols = Math.max(1, Math.min(20, Math.round(Number(dom.frameCols.value) || SETTINGS_DEFAULTS.layout.frameCols)));
+ const rows = Math.max(1, Math.min(20, Math.round(Number(dom.frameRows.value) || SETTINGS_DEFAULTS.layout.frameRows)));
+ return Math.max(1, cols * rows);
+}
+
/**
* Read the current UI state and normalize it into a processing/export config object.
*
@@ -2940,7 +3172,10 @@ function readConfig() {
const paperAspect = clampPaperAspect(paperWidth, paperHeight);
const frameCols = Math.max(1, Math.min(20, Math.round(Number(dom.frameCols.value) || SETTINGS_DEFAULTS.layout.frameCols)));
const frameRows = Math.max(1, Math.min(20, Math.round(Number(dom.frameRows.value) || SETTINGS_DEFAULTS.layout.frameRows)));
- const sourceFrameCount = Math.max(1, frameCols * frameRows);
+ const sourceFrameCount = getFrameExportCountMax();
+ // Per-frame mode is selected by its own radio (added in Phase 6) or, until then, by a dev flag so
+ // the pipeline can be exercised before the UI exists. See isPerFrameModeActive for details.
+ const perFrameModeActive = isPerFrameModeActive();
const encodingQuality = getEncodingQualityValue();
const readSearchInset = (input, fallback) => Math.max(
0,
@@ -2978,7 +3213,11 @@ function readConfig() {
: SETTINGS_DEFAULTS.detection.postRotationDeg
)
),
- alignmentPipeline: dom.alignmentPipelineMarkerless.checked ? "markerless" : "markers",
+ alignmentPipeline: perFrameModeActive
+ ? "per-frame"
+ : dom.alignmentPipelineMarkerless.checked
+ ? "markerless"
+ : "markers",
// The radio group exposes a temporary-friendly UI label, but the config keeps stable internal
// ids so settings files and solver branching do not depend on user-facing wording.
stabilizationMethod: dom.stabilizationMethodAverage?.checked ? "difference-from-average" : "pairwise-cyclic",
@@ -3011,7 +3250,11 @@ function readConfig() {
markerlessUseVariance: dom.markerlessUseVariance ? dom.markerlessUseVariance.checked : true,
lightOnDarkDesign: dom.lightOnDarkDesign ? dom.lightOnDarkDesign.checked : false,
detectCrossesWithConvolution: (dom.alignmentPipelineMarkers.checked && dom.alignmentMarkerType.value === "crosses") && dom.detectCrossesWithConvolution.checked,
- useCrossAlignment: dom.alignmentPipelineMarkerless.checked ? true : dom.useCrossAlignment.checked,
+ useCrossAlignment: perFrameModeActive
+ ? false
+ : dom.alignmentPipelineMarkerless.checked
+ ? true
+ : dom.useCrossAlignment.checked,
useRectifiedAsSource: false,
crop: {
left: Math.max(0, Math.round(Number(dom.cropLeft.value) || 0)),
@@ -3189,13 +3432,14 @@ function syncPaperPresetUi() {
}
function getActiveAlignmentPipeline() {
+ if (isPerFrameModeActive()) return "per-frame";
return dom.alignmentPipelineMarkerless.checked ? "markerless" : "markers";
}
/**
* Preserve the markerless default of zero Grid Search Inset X/Y when switching away from marker mode.
*
- * @param {"markerless"|"markers"} pipeline
+ * @param {"markerless"|"markers"|"per-frame"} pipeline
* @returns {void}
*/
function applyAlignmentPipelineDefaults(pipeline) {
@@ -3215,7 +3459,7 @@ function applyAlignmentPipelineDefaults(pipeline) {
/**
* Ensure marker-pipeline-only controls hold a valid state before being shown again.
*
- * @param {"markerless"|"markers"} pipeline
+ * @param {"markerless"|"markers"|"per-frame"} pipeline
* @returns {void}
*/
function sanitizeAlignmentPipelineState(pipeline) {
@@ -3232,31 +3476,48 @@ function sanitizeAlignmentPipelineState(pipeline) {
/**
* Return which alignment-specific control groups should be visible for the active pipeline.
*
- * @param {"markerless"|"markers"} pipeline
- * @returns {{showMarkerlessControls:boolean,showMarkersPipelineControls:boolean,showCrossOnlyControls:boolean}}
+ * `showMarkerlessControls` stays strictly markerless-only (markerless gutter/phase/working-image
+ * diagnostics must not leak into per-frame mode). `showFrameCornerControls` is the broader
+ * "non-marker" family (markerless + per-frame) that shares stabilization, drift compensation and the
+ * Frame Corners override editor. `isPerFrame` lets callers hide markerless-only controls that the
+ * non-marker family would otherwise reveal (e.g. gutter sliders, Grid Edge controls).
+ *
+ * @param {"markerless"|"markers"|"per-frame"} pipeline
+ * @returns {{showMarkerlessControls:boolean,showMarkersPipelineControls:boolean,showCrossOnlyControls:boolean,showFrameCornerControls:boolean,isPerFrame:boolean}}
*/
function getAlignmentUiModeFlags(pipeline) {
document.body.classList.toggle("markerless-pipeline", pipeline === "markerless");
+ document.body.classList.toggle("per-frame-pipeline", pipeline === "per-frame");
const markerType = dom.alignmentMarkerType.value || SETTINGS_DEFAULTS.detection.alignmentMarkerType;
const resolvedAutoType = state.geometry.alignmentInfo?.resolvedMarkerType || null;
const showMarkersPipelineControls = pipeline === "markers";
const showCrossOnlyControls = showMarkersPipelineControls && (markerType === "crosses" || (markerType === "auto" && resolvedAutoType === "crosses"));
const showMarkerlessControls = pipeline === "markerless";
+ const isPerFrame = pipeline === "per-frame";
+ const showFrameCornerControls = pipeline !== "markers";
return {
showMarkerlessControls,
showMarkersPipelineControls,
showCrossOnlyControls,
+ showFrameCornerControls,
+ isPerFrame,
};
}
/**
* Rewrite alignment-related labels so the UI language matches the active pipeline.
*
- * @param {{showMarkerlessControls:boolean}} flags
+ * Per-frame mode shares the corner/stabilization mental model with markerless mode, so the
+ * non-marker label family (`showFrameCornerControls`) drives the Frame Corners heading, the
+ * stabilize/centers viewer tabs and the ROI-size label. `showMarkerlessControls` is reserved for
+ * the markerless-only "summaryMarkerless" copy that mentions gutter fitting, which does not apply
+ * to per-frame's synthetic exact grid.
+ *
+ * @param {{showMarkerlessControls:boolean,showFrameCornerControls:boolean,isPerFrame:boolean}} flags
* @returns {void}
*/
function syncAlignmentPipelineLabels(flags) {
- const { showMarkerlessControls } = flags;
+ const { showMarkerlessControls, showFrameCornerControls, isPerFrame } = flags;
const frameAlignmentSummary = document.querySelector("#frameAlignmentSummary");
const frameAlignmentSummaryLabel =
frameAlignmentSummary?.querySelector("[data-i18n='alignment.summary']") ||
@@ -3266,26 +3527,26 @@ function syncAlignmentPipelineLabels(flags) {
frameAlignmentSummaryLabel.textContent = showMarkerlessControls ? t("alignment.summaryMarkerless") : t("alignment.summary");
}
if (dropGuidanceNote) {
- dropGuidanceNote.textContent = t("photo.dropNote");
+ dropGuidanceNote.textContent = isPerFrame ? t("photo.dropNotePerFrame") : t("photo.dropNote");
}
const isMobileViewerMode = state.runtime.mobileSingleViewerMode;
const headingText = isMobileViewerMode
- ? t(showMarkerlessControls ? "viewerTabs.centers" : "viewerTabs.markers")
- : t(showMarkerlessControls ? "panels.frameCorners" : "panels.frameAlignmentMarkers");
+ ? t(showFrameCornerControls ? "viewerTabs.centers" : "viewerTabs.markers")
+ : t(showFrameCornerControls ? "panels.frameCorners" : "panels.frameAlignmentMarkers");
if (dom.crossRegionsHeading) {
const label = dom.crossRegionsHeading.querySelector("[data-panel-heading]") || dom.crossRegionsHeading.firstElementChild;
if (label) label.textContent = headingText;
}
if (dom.viewerTabMarkers) {
- const viewerTabKey = showMarkerlessControls ? "viewerTabs.centers" : "viewerTabs.markers";
+ const viewerTabKey = showFrameCornerControls ? "viewerTabs.centers" : "viewerTabs.markers";
dom.viewerTabMarkers.textContent = t(viewerTabKey);
}
if (dom.mobileControlTabAlignment) {
- const mobileControlTabKey = showMarkerlessControls ? "mobileControlTabs.stabilize" : "mobileControlTabs.markers";
+ const mobileControlTabKey = showFrameCornerControls ? "mobileControlTabs.stabilize" : "mobileControlTabs.markers";
dom.mobileControlTabAlignment.textContent = t(mobileControlTabKey);
}
if (dom.crossRoiScaleLabel) {
- dom.crossRoiScaleLabel.textContent = showMarkerlessControls
+ dom.crossRoiScaleLabel.textContent = showFrameCornerControls
? t("alignment.frameCornerRoiSize")
: t("alignment.roiSize");
}
@@ -3301,17 +3562,17 @@ function syncAlignmentPipelineLabels(flags) {
if (markerlessPhaseYLabel) {
markerlessPhaseYLabel.textContent = t("alignment.markerlessPhaseYOffset");
}
- syncAlignmentModeTooltips(showMarkerlessControls);
+ syncAlignmentModeTooltips(showFrameCornerControls);
}
/**
* Keep shared tooltip text aligned with the active pipeline when marker terminology becomes
- * corner/stabilization terminology in markerless mode.
+ * corner/stabilization terminology in the non-marker (markerless / per-frame) pipelines.
*
- * @param {boolean} showMarkerlessControls
+ * @param {boolean} showFrameCornerControls
* @returns {void}
*/
-function syncAlignmentModeTooltips(showMarkerlessControls) {
+function syncAlignmentModeTooltips(showFrameCornerControls) {
const applyTooltip = (element, key, extraElements = []) => {
if (!element) return;
const text = t(`tooltip.${key}`);
@@ -3332,37 +3593,40 @@ function syncAlignmentModeTooltips(showMarkerlessControls) {
applyTooltip(
document.querySelector("#frameAlignmentSummary"),
- showMarkerlessControls ? "frameAlignmentSummaryMarkerless" : "frameAlignmentSummary",
+ showFrameCornerControls ? "frameAlignmentSummaryMarkerless" : "frameAlignmentSummary",
);
applyTooltip(
dom.crossRegionsHeading,
- showMarkerlessControls ? "crossRegionsHeadingMarkerless" : "crossRegionsHeading",
+ showFrameCornerControls ? "crossRegionsHeadingMarkerless" : "crossRegionsHeading",
);
applyTooltip(
dom.crossRoiScale,
- showMarkerlessControls ? "crossRoiScaleMarkerless" : "crossRoiScale",
+ showFrameCornerControls ? "crossRoiScaleMarkerless" : "crossRoiScale",
[dom.crossRoiScale?.closest("label")],
);
applyTooltip(
dom.toggleMarkerEditingButton,
- showMarkerlessControls ? "toggleMarkerEditingButtonMarkerless" : "toggleMarkerEditingButton",
+ showFrameCornerControls ? "toggleMarkerEditingButtonMarkerless" : "toggleMarkerEditingButton",
);
applyTooltip(
dom.clearMarkerEditsButton,
- showMarkerlessControls ? "clearMarkerEditsButtonMarkerless" : "clearMarkerEditsButton",
+ showFrameCornerControls ? "clearMarkerEditsButtonMarkerless" : "clearMarkerEditsButton",
);
}
/**
* Keep the shared ROI-size slider in the right position for the active alignment mode.
*
- * @param {{showMarkerlessControls:boolean}} flags
+ * Per-frame mode shares the markerless Frame Corners layout, so the corner ROI slider sits at the
+ * bottom of the stack for both non-marker pipelines.
+ *
+ * @param {{showFrameCornerControls:boolean}} flags
* @returns {void}
*/
function syncAlignmentSliderOrder(flags) {
- const { showMarkerlessControls } = flags;
+ const { showFrameCornerControls } = flags;
if (dom.alignmentSliderStack && dom.crossRoiScaleRow) {
- if (showMarkerlessControls) {
+ if (showFrameCornerControls) {
dom.alignmentSliderStack.appendChild(dom.crossRoiScaleRow);
} else {
dom.alignmentSliderStack.prepend(dom.crossRoiScaleRow);
@@ -3373,27 +3637,38 @@ function syncAlignmentSliderOrder(flags) {
/**
* Show or hide the alignment controls appropriate for the active pipeline.
*
- * @param {{showMarkerlessControls:boolean,showMarkersPipelineControls:boolean,showCrossOnlyControls:boolean}} flags
+ * Three control families:
+ * - Stabilization + Vertical Drift Compensation are the shared non-marker family
+ * (`showFrameCornerControls`): visible in markerless AND per-frame.
+ * - Grid Edge Threshold / Run Length are marker-only grid controls: shown only in markers
+ * (`showMarkersPipelineControls`), so they are hidden in BOTH markerless and per-frame.
+ * - Markerless gutter/phase sliders (`markerlessPhase*Row`) stay markerless-only
+ * (`showMarkerlessControls`): hidden in per-frame because the synthetic grid needs no phase sweep.
+ *
+ * @param {{showMarkerlessControls:boolean,showMarkersPipelineControls:boolean,showCrossOnlyControls:boolean,showFrameCornerControls:boolean}} flags
* @returns {void}
*/
function syncAlignmentPipelineVisibility(flags) {
- const { showMarkerlessControls, showMarkersPipelineControls, showCrossOnlyControls } = flags;
- dom.boundarySensitivityRow.hidden = showMarkerlessControls;
- dom.boundaryPersistenceRow.hidden = showMarkerlessControls;
+ const { showMarkerlessControls, showMarkersPipelineControls, showCrossOnlyControls, showFrameCornerControls } = flags;
+ // Grid Edge Threshold / Run Length are marker-grid controls: keep visible only in markers mode so
+ // per-frame (which has no detected grid edges) hides them alongside markerless.
+ dom.boundarySensitivityRow.hidden = !showMarkersPipelineControls;
+ dom.boundaryPersistenceRow.hidden = !showMarkersPipelineControls;
if (dom.stabilizationMethodGroup) {
- dom.stabilizationMethodGroup.hidden = !showMarkerlessControls;
+ dom.stabilizationMethodGroup.hidden = !showFrameCornerControls;
}
if (dom.stabilizationEnabledRow) {
- dom.stabilizationEnabledRow.hidden = !showMarkerlessControls;
+ dom.stabilizationEnabledRow.hidden = !showFrameCornerControls;
}
if (dom.stabilizationStrengthRow) {
- dom.stabilizationStrengthRow.hidden = !showMarkerlessControls;
+ dom.stabilizationStrengthRow.hidden = !showFrameCornerControls;
}
dom.alignmentMarkerTypeField.hidden = !showMarkersPipelineControls;
// Keep marker subpixel alignment as a settings-file/default option, not a visible UI control.
dom.useCrossAlignmentRow.hidden = true;
dom.detectCrossesWithConvolutionRow.hidden = !showCrossOnlyControls;
- dom.stabilizationLambdaRow.hidden = !showMarkerlessControls;
+ dom.stabilizationLambdaRow.hidden = !showFrameCornerControls;
+ // Markerless gutter/phase sweep sliders remain markerless-only; per-frame's exact grid skips them.
dom.markerlessPhaseXRow.hidden = !showMarkerlessControls;
dom.markerlessPhaseYRow.hidden = !showMarkerlessControls;
if (dom.markerlessPhaseDebugRow) {
@@ -3408,23 +3683,24 @@ function syncAlignmentPipelineVisibility(flags) {
if (dom.markerlessUseVarianceRow) {
dom.markerlessUseVarianceRow.hidden = true;
}
- dom.verticalDriftCompensationRow.hidden = !showMarkerlessControls;
+ dom.verticalDriftCompensationRow.hidden = !showFrameCornerControls;
}
/**
- * Enable only the stabilization controls that apply to the current markerless method.
+ * Enable only the stabilization controls that apply to the current method.
*
* `Stabilization Rigidity` (`lambda`) only affects the pairwise/cyclic least-squares solve. The
* alternate average-reference method does not use it, so the slider should be visibly inactive in
- * that mode to avoid implying that it has any effect.
+ * that mode to avoid implying that it has any effect. Per-frame mode runs the same stabilization
+ * solver as markerless, so it shares this gating via `showFrameCornerControls`.
*
- * @param {{showMarkerlessControls:boolean}} flags
+ * @param {{showFrameCornerControls:boolean}} flags
* @returns {void}
*/
function syncStabilizationMethodUi(flags) {
- const { showMarkerlessControls } = flags;
+ const { showFrameCornerControls } = flags;
const usesLambda =
- showMarkerlessControls &&
+ showFrameCornerControls &&
(dom.stabilizationMethodPairwise?.checked || !dom.stabilizationMethodAverage?.checked);
if (dom.stabilizationLambda) {
dom.stabilizationLambda.disabled = !usesLambda;
@@ -3490,6 +3766,9 @@ function syncAlignmentMarkerUi() {
}
}
syncMarkerlessPhaseDebugUi();
+ // Keep the per-frame strip's visibility + thumbnails in sync with the active pipeline. Idempotent:
+ // hides/empties the strip in markers/markerless modes and only rebuilds on real image[] changes.
+ renderPerFrameStrip();
}
/**
@@ -3548,7 +3827,9 @@ function togglePageCornerEditing() {
*/
function clearPageCornerEdits() {
if (!Array.isArray(state.source.manualPageContour) || state.source.manualPageContour.length !== 4) return;
- state.source.manualPageContour = null;
+ // Clearing the override drops it from the active image entry too in per-frame mode (legacy-only
+ // otherwise). Subsequent reprocessing re-detects this image's page automatically.
+ setActiveManualPageContour(state, null, isPerFrameModeActive());
state.preview.activePageCornerDrag = null;
state.runtime.pageCornerEditingEnabled = false;
cancelPageCornerOverrideDependentWork();
@@ -3632,8 +3913,10 @@ function clearMarkerEdits() {
state.geometry.manualMarkerOverrides.clear();
state.preview.activeEditedMarker = null;
state.runtime.markerEditingEnabled = false;
- const isMarkerless = readConfig().alignmentPipeline === "markerless";
- if (state.geometry.alignmentInfo && !isMarkerless) {
+ // Non-marker pipelines (markerless + per-frame) store overrides as corner nudges, so they take the
+ // same cache-clearing revert path; only true marker mode patches the live marker objects in place.
+ const usesCornerNudges = readConfig().alignmentPipeline !== "markers";
+ if (state.geometry.alignmentInfo && !usesCornerNudges) {
// Original auto-detected positions are cached on the live alignment objects so edits can revert instantly
// without rerunning the whole detector.
for (const [key, marker] of state.geometry.alignmentInfo.markerLookup.entries()) {
@@ -3651,7 +3934,7 @@ function clearMarkerEdits() {
}
}
revokeGifUrl();
- if (isMarkerless) {
+ if (usesCornerNudges) {
state.frames.base = new Array(state.geometry.frameCount);
state.frames.baseOutputEpoch = new Array(state.geometry.frameCount);
state.frames.stabilizedCache.clear();
@@ -3746,9 +4029,7 @@ function updateSliderReadouts() {
if (!frameCountFieldFocused) {
syncFrameCountToExportUi();
} else if (dom.frameCountToExport) {
- const cols = Math.max(1, Math.min(20, Math.round(Number(dom.frameCols.value) || SETTINGS_DEFAULTS.layout.frameCols)));
- const rows = Math.max(1, Math.min(20, Math.round(Number(dom.frameRows.value) || SETTINGS_DEFAULTS.layout.frameRows)));
- const maxFrameCount = Math.max(1, cols * rows);
+ const maxFrameCount = getFrameExportCountMax();
dom.frameCountToExport.min = "1";
dom.frameCountToExport.max = String(maxFrameCount);
state.runtime.lastFrameExportCountMax = maxFrameCount;
@@ -3828,7 +4109,7 @@ function updateSliderReadouts() {
return;
}
const config = readConfig();
- const roiSizePx = config.alignmentPipeline === "markerless"
+ const roiSizePx = config.alignmentPipeline !== "markers"
? estimateMarkerlessCornerTileSidePx(
state.geometry.alignmentInfo.rectifiedWidth,
state.geometry.alignmentInfo.rectifiedHeight,
@@ -3851,19 +4132,18 @@ function updateSliderReadouts() {
}
/**
- * Clamp the export-frame-count control to the current grid size. If the control was still at the
- * previous maximum, treat it as "use all cells" and advance it to the new maximum automatically.
+ * Clamp the export-frame-count control to the current source budget (grid cells or per-frame images).
+ * If the control was still at the previous maximum, treat it as "use all frames" and advance it to
+ * the new maximum automatically.
*
* This also covers legacy settings files that predate `Frames in Export`: an empty field is
- * interpreted as "export the whole grid", not as a literal zero or one-frame request.
+ * interpreted as "export every available frame", not as a literal zero or one-frame request.
*
* @returns {number}
*/
function syncFrameCountToExportUi() {
if (!dom.frameCountToExport) return 0;
- const cols = Math.max(1, Math.min(20, Math.round(Number(dom.frameCols.value) || SETTINGS_DEFAULTS.layout.frameCols)));
- const rows = Math.max(1, Math.min(20, Math.round(Number(dom.frameRows.value) || SETTINGS_DEFAULTS.layout.frameRows)));
- const maxFrameCount = Math.max(1, cols * rows);
+ const maxFrameCount = getFrameExportCountMax();
const previousMax = Math.max(1, state.runtime.lastFrameExportCountMax || maxFrameCount);
const rawText = String(dom.frameCountToExport.value || "").trim();
const rawValue = Number(rawText);
@@ -4162,7 +4442,7 @@ async function processCurrentImage(requestId = state.processing.requestId) {
try {
const config = timeProfiled("readConfig", () => readConfig());
- const result = timeProfiled("runPipeline", () => runPipeline(state.source.canvas, config, requestId, throwIfProcessAborted));
+ const result = timeProfiled("runPipeline", () => runPipeline(state.source.canvas, config, requestId, throwIfProcessAborted, state.source.images));
if (requestId !== state.processing.requestId) {
finishTimingProfile(timingProfile);
return;
@@ -4237,7 +4517,8 @@ async function processCurrentImage(requestId = state.processing.requestId) {
updatePageGridDetectionHeading(false);
setStatus(buildStatusWithTiming(result.statusText));
schedulePreviewFrameWarmup(requestId);
- if (config.alignmentPipeline === "markerless") {
+ // Stabilization runs in both non-marker pipelines, so warm its solver after per-frame too.
+ if (config.alignmentPipeline !== "markers") {
scheduleMarkerlessStabilizationWarmup(requestId);
}
} catch (error) {
@@ -4774,7 +5055,9 @@ function getPreviewFrameQuadForSourceIndex(sourceIndex) {
const row = Math.floor(sourceIndex / cols);
if (row < 0 || row >= alignmentInfo.rows) return null;
const extractionInfo =
- readConfig().alignmentPipeline === "markerless"
+ // Per-frame uses the same corner-cross lattice as markerless, so resolve preview frame quads
+ // through the markerless extraction builder for both non-marker pipelines.
+ readConfig().alignmentPipeline !== "markers"
? buildMarkerlessExtractionInfoForFrame(alignmentInfo, col, row)
: alignmentInfo;
const quad = resolveFrameQuadForPreview(extractionInfo, col, row);
@@ -4859,7 +5142,8 @@ function drawOmittedFrameQuads(ctx, mapRectifiedPointToPreview) {
*/
function resolveDisplayedAlignmentPoint(alignmentInfo, col, row) {
const key = getMarkerKey(col, row);
- if (readConfig().alignmentPipeline === "markerless") {
+ // Per-frame shares the markerless corner-display model (phase + stabilization + manual nudge).
+ if (readConfig().alignmentPipeline !== "markers") {
const sourceMarker = alignmentInfo?.markerLookup?.get(key);
if (sourceMarker) {
const displayed = getMarkerlessDisplayedCorner(sourceMarker, col, row, alignmentInfo);
@@ -5047,9 +5331,32 @@ function trimCachesBeforeReprocess() {
state.frames.stabilizationOffsets = null;
state.frames.adjustedCache.clear();
state.frames.adjustedOutputEpoch.clear();
+ trimInactivePerFrameRectifiedCaches();
syncRectifiedSheetHeadingLink();
}
+/**
+ * Free per-image rectified Mat caches for every image that is NOT the active one, before a
+ * reprocess. In per-frame mode each uploaded image can hold its own cached rectified warp; only the
+ * active image's cache is kept warm between reprocesses. The composite `baseRectifiedMat` (the one
+ * large Mat that must stay live) is owned by `state.geometry`, not by the per-image entries, so it is
+ * never touched here.
+ *
+ * No-op for markers/markerless modes: those flows keep at most a single entry in
+ * `state.source.images[]`, so the loop either skips the lone active entry or runs zero times.
+ *
+ * @returns {void}
+ */
+function trimInactivePerFrameRectifiedCaches() {
+ const images = state.source.images;
+ if (!Array.isArray(images) || images.length <= 1) return;
+ const activeIndex = state.source.activeImageIndex;
+ for (let i = 0; i < images.length; i++) {
+ if (i === activeIndex) continue;
+ releaseEntryRectifiedCache(images[i]);
+ }
+}
+
/**
* Invalidate the post-extraction stabilization solve and any derived frame caches.
*
@@ -5531,7 +5838,10 @@ function getFrameGridCoords(index, alignmentInfo) {
* @returns {object}
*/
function getFrameExtractionAlignmentInfo(config, alignmentInfo, col, row, includeMarkerlessNudges) {
- if (config.alignmentPipeline === "markerless" && includeMarkerlessNudges) {
+ // Per-frame extracts from the same corner-cross lattice as markerless, so both non-marker
+ // pipelines resolve frame quads via the markerless extraction builder (phase nudges are 0 in
+ // per-frame, so this only contributes stabilization/drift/manual-nudge offsets there).
+ if (config.alignmentPipeline !== "markers" && includeMarkerlessNudges) {
return buildMarkerlessExtractionInfoForFrame(alignmentInfo, col, row);
}
return alignmentInfo;
@@ -6021,7 +6331,8 @@ function scheduleMarkerlessStabilizationWarmup(requestId) {
*/
function scheduleCurrentStabilizationWarmup() {
window.setTimeout(() => {
- if (readConfig().alignmentPipeline !== "markerless") {
+ // Stabilization warmup applies to both non-marker pipelines (markerless + per-frame).
+ if (readConfig().alignmentPipeline === "markers") {
return;
}
if (!readConfig().stabilizationEnabled) {
@@ -6664,7 +6975,8 @@ function combineSourceOffsets(baseOffset, extraOffset) {
* @returns {{x:number,y:number}}
*/
function getMarkerlessVerticalDriftSourceOffset(config, alignmentInfo, frameIndex) {
- if (!alignmentInfo || config.alignmentPipeline !== "markerless") {
+ // Vertical Drift Compensation is enabled in both non-marker pipelines (markerless + per-frame).
+ if (!alignmentInfo || config.alignmentPipeline === "markers") {
return { x: 0, y: 0 };
}
const cellHeight = alignmentInfo.gridBounds.height / Math.max(1, alignmentInfo.rows);
@@ -6713,7 +7025,8 @@ function getAutomaticMarkerlessSourceOffset(config, alignmentInfo, frameIndex, i
* @returns {{x:number,y:number}}
*/
function getMarkerlessCornerStabilizationOffset(col, row, alignmentInfo) {
- if (!alignmentInfo || readConfig().alignmentPipeline !== "markerless") {
+ // Stabilization and the Frame Corners panel are shared by both non-marker pipelines.
+ if (!alignmentInfo || readConfig().alignmentPipeline === "markers") {
return { x: 0, y: 0 };
}
const offsets = getStabilizationOffsets();
@@ -6775,7 +7088,8 @@ function getFrameStabilizationSourceOffset(index) {
* @returns {{x:number,y:number}}
*/
function getMarkerlessCornerManualNudge(col, row) {
- if (readConfig().alignmentPipeline !== "markerless") {
+ // Frame Corners overrides (stored as corner nudges) are enabled in markerless and per-frame.
+ if (readConfig().alignmentPipeline === "markers") {
return { x: 0, y: 0 };
}
const override = state.geometry.manualMarkerOverrides.get(getMarkerKey(col, row));
@@ -7014,8 +7328,12 @@ function buildPostRotationPreviewTile(previewCanvas, expected, alignmentInfo, te
}
/**
- * Build a display-only alignment view for markerless mode that incorporates the current manual
- * phase offset while leaving the underlying stored marker coordinates unphased.
+ * Build a display-only alignment view for the non-marker pipelines (markerless + per-frame) that
+ * incorporates the current corner display offsets while leaving the stored coordinates unphased.
+ *
+ * Per-frame shares the markerless corner-tile display path so its Frame Corners panel renders the
+ * same stabilized/nudged corner tiles. Only true marker mode short-circuits to the raw alignment
+ * (or, while scrubbing post-rotation, the marker post-rotation preview tiles).
*
* @param {object | null} alignmentInfo
* @returns {object | null}
@@ -7024,7 +7342,7 @@ function getDisplayAlignmentInfo(alignmentInfo) {
if (!alignmentInfo) return null;
const config = readConfig();
const showingPostRotationPreview = state.preview.postRotationScrubbing && !!state.preview.rectifiedCanvas;
- if (config.alignmentPipeline !== "markerless" && !showingPostRotationPreview) {
+ if (config.alignmentPipeline === "markers" && !showingPostRotationPreview) {
return alignmentInfo;
}
const crossRoiScale = config.crossRoiScale;
@@ -7032,7 +7350,7 @@ function getDisplayAlignmentInfo(alignmentInfo) {
? getPostRotationPreviewCanvas(state.preview.rectifiedCanvas, getPostRotationPreviewDeg())
: null;
- if (config.alignmentPipeline !== "markerless") {
+ if (config.alignmentPipeline === "markers") {
const markerLookup = new Map(alignmentInfo.markerLookup);
const crossRoiTiles = alignmentInfo.crossRoiTiles.map((tile) => buildPostRotationPreviewTile(
previewCanvas,
@@ -7188,7 +7506,9 @@ function seedDefaultManualPageContour() {
{ x: right, y: bottom },
{ x: inset, y: bottom },
];
- state.source.manualPageContour = contour.map((point) => ({ x: point.x, y: point.y }));
+ // Seeding a default editable quad is a manual override, so mirror it to the active image entry in
+ // per-frame mode (legacy-only otherwise).
+ setActiveManualPageContour(state, contour.map((point) => ({ x: point.x, y: point.y })), isPerFrameModeActive());
state.source.rawPageContour = contour.map((point) => ({ x: point.x, y: point.y }));
state.source.pageQuadSource = "manual-override";
state.source.thresholdPreviewPageContour = null;
@@ -7282,7 +7602,9 @@ function updateManualPageCorner(index, point) {
if (!Array.isArray(baseContour) || baseContour.length !== 4) return;
const nextContour = baseContour.map((corner) => ({ x: corner.x, y: corner.y }));
nextContour[index] = point;
- state.source.manualPageContour = nextContour;
+ // In per-frame mode this also stores the override on the active image entry; in markers/markerless
+ // it only writes the legacy field, exactly as before.
+ setActiveManualPageContour(state, nextContour, isPerFrameModeActive());
state.source.rawPageContour = nextContour.map((corner) => ({ x: corner.x, y: corner.y }));
state.source.pageQuadSource = "manual-override";
}
@@ -7359,6 +7681,55 @@ function attachRawPageCornerEditing() {
dom.rawCanvas.addEventListener("pointercancel", finishDrag);
}
+/**
+ * Switch the active per-frame image and redraw the raw photo, Page Corners overlay, and
+ * Post-Rotation slider to match it — without rebuilding the rectified preview or animation.
+ *
+ * Active-image switching is UI navigation, not a config change: each image's page-corner override and
+ * Post-Rotation are restored from its entry so the editor operates on the newly active image, but no
+ * reprocessing is scheduled (the existing composite stays live until a real config change). In
+ * markers / markerless modes there is at most one entry, so this just refreshes the raw preview.
+ *
+ * Until the Phase 7 image strip exists this is the supported active-image switch entry point (the
+ * dev console can call `window.plottimation.setActiveImage(1)` or set
+ * `state.source.activeImageIndex` and call `renderRawPreview()` directly).
+ *
+ * @param {number} index Zero-based image index (clamped to the loaded range).
+ * @returns {void}
+ */
+function setActiveImage(index) {
+ const entry = setActiveSourceImage(state, index);
+ if (!entry) return;
+ // Repoint the legacy projections at the newly active entry so every existing reader (raw preview,
+ // Page Corners editor, drag/download, status) sees this image.
+ if (entry.canvas) state.source.canvas = entry.canvas;
+ if (entry.image) state.source.image = entry.image;
+ state.source.filename = entry.filename || "";
+ state.source.mimeType = entry.mimeType || "";
+ state.source.dragUrl = entry.dragUrl || "";
+ // Restore this image's manual page-corner override into the legacy field and the overlay contour.
+ // A live threshold preview belongs to whichever image was last reprocessed, so drop it on switch.
+ const contour = Array.isArray(entry.manualPageContour) && entry.manualPageContour.length === 4
+ ? entry.manualPageContour.map((point) => ({ x: point.x, y: point.y }))
+ : null;
+ state.source.manualPageContour = contour;
+ state.source.rawPageContour = contour ? contour.map((point) => ({ x: point.x, y: point.y })) : null;
+ state.source.pageQuadSource = contour ? "manual-override" : "";
+ state.source.thresholdPreviewPageContour = null;
+ state.source.thresholdPreviewSignature = "";
+ state.preview.activePageCornerDrag = null;
+ // Restore this image's Post-Rotation onto the slider so the control reflects the active image.
+ if (dom.postRotation) {
+ dom.postRotation.value = String(Number.isFinite(entry.postRotationDeg) ? entry.postRotationDeg : 0);
+ updateSliderReadouts();
+ }
+ syncRawPhotoHeadingLink();
+ syncRawPhotoCreditDisplay();
+ renderRawPreview();
+ // Refresh the strip's active-thumbnail highlight to match the newly selected image.
+ renderPerFrameStrip();
+}
+
/**
* Render the Page Corners preview and overlay the detected page quad in lime.
*
@@ -7526,9 +7897,12 @@ function applyMarkerOverride(tile, local, finalize) {
let detectedX = roiCenterX + (local.x - center);
let detectedY = roiCenterY + (local.y - center);
const config = readConfig();
- const isMarkerless = config.alignmentPipeline === "markerless";
+ // Per-frame shares the markerless corner-nudge override model (stored as deltas from the displayed
+ // corner), so both non-marker pipelines take the nudge path; only true marker mode stores absolute
+ // detected positions and patches the marker objects in place.
+ const usesCornerNudges = config.alignmentPipeline !== "markers";
const key = getMarkerKey(tile.col, tile.row);
- if (isMarkerless && state.geometry.alignmentInfo) {
+ if (usesCornerNudges && state.geometry.alignmentInfo) {
const sourceMarker = state.geometry.alignmentInfo.markerLookup.get(key);
if (sourceMarker) {
const displayed = getMarkerlessDisplayedCorner(sourceMarker, tile.col, tile.row, state.geometry.alignmentInfo, false);
@@ -7541,37 +7915,37 @@ function applyMarkerOverride(tile, local, finalize) {
state.geometry.manualMarkerOverrides.set(key, { x: detectedX, y: detectedY });
}
state.preview.activeEditedMarker = finalize ? null : { col: tile.col, row: tile.row };
- if (state.geometry.alignmentInfo && !isMarkerless) {
+ if (state.geometry.alignmentInfo && !usesCornerNudges) {
// Manual overrides patch the already-detected alignment object in place, which lets preview/extraction
// update lazily from the edited marker positions without another CV pass.
applyManualMarkerOverrides(state.geometry.alignmentInfo);
}
revokeGifUrl();
if (!finalize) {
- if (isMarkerless) {
+ if (usesCornerNudges) {
beginMarkerOverrideScrub();
} else {
state.preview.markerOverrideScrubbing = true;
}
}
let affectedMarkerFrames = null;
- if (isMarkerless && !finalize) {
+ if (usesCornerNudges && !finalize) {
invalidateCurrentPreviewFrameForMarker(tile.col, tile.row);
- } else if (isMarkerless) {
+ } else if (usesCornerNudges) {
invalidateMarkerlessNudgedFramesForMarker(tile.col, tile.row);
} else {
affectedMarkerFrames = invalidateFramesForMarker(tile.col, tile.row);
}
syncMarkerEditingUi();
if (finalize) {
- if (isMarkerless) {
+ if (usesCornerNudges) {
endMarkerOverrideScrub();
} else {
state.preview.markerOverrideScrubbing = false;
}
renderCrossRoiGrid(state.geometry.alignmentInfo);
scheduleStabilizationPreviewUpdate();
- if (!isMarkerless) {
+ if (!usesCornerNudges) {
if (affectedMarkerFrames) {
scheduleCurrentPreviewFrameWarmupForSourceIndices(affectedMarkerFrames);
} else {
@@ -7593,8 +7967,10 @@ function restoreMarkerOverride(tile) {
const key = getMarkerKey(tile.col, tile.row);
state.geometry.manualMarkerOverrides.delete(key);
state.preview.activeEditedMarker = null;
- const isMarkerless = readConfig().alignmentPipeline === "markerless";
- if (state.geometry.alignmentInfo && !isMarkerless) {
+ // Non-marker pipelines (markerless + per-frame) clear corner-nudge overrides via cache
+ // invalidation; only marker mode restores auto-detected positions on the live marker objects.
+ const usesCornerNudges = readConfig().alignmentPipeline !== "markers";
+ if (state.geometry.alignmentInfo && !usesCornerNudges) {
const marker = state.geometry.alignmentInfo.markerLookup.get(key);
const liveTile = state.geometry.alignmentInfo.crossRoiTileMap?.get(key);
if (marker && Number.isFinite(marker.autoDetectedX)) {
@@ -7610,7 +7986,7 @@ function restoreMarkerOverride(tile) {
}
revokeGifUrl();
let affectedMarkerFrames = null;
- if (isMarkerless) {
+ if (usesCornerNudges) {
invalidateMarkerlessNudgedFramesForMarker(tile.col, tile.row);
} else {
affectedMarkerFrames = invalidateFramesForMarker(tile.col, tile.row);
@@ -7618,7 +7994,7 @@ function restoreMarkerOverride(tile) {
syncMarkerEditingUi();
renderCrossRoiGrid(state.geometry.alignmentInfo);
scheduleStabilizationPreviewUpdate();
- if (!isMarkerless) {
+ if (!usesCornerNudges) {
if (affectedMarkerFrames) {
scheduleCurrentPreviewFrameWarmupForSourceIndices(affectedMarkerFrames);
} else {
@@ -7647,6 +8023,10 @@ function buildSettingsTsv(config) {
sourceCredit: state.source.sourceCredit,
manualMarkerOverrides: state.geometry.manualMarkerOverrides,
manualPageContour: state.source.manualPageContour,
+ // Per-frame mode persists one page-corner/post-rotation override set per uploaded image, keyed by
+ // upload order. buildSettingsTsv only emits the indexed keys when config.alignmentPipeline is
+ // "per-frame", so passing the entries unconditionally is harmless for markers/markerless saves.
+ perImageEntries: state.source.images,
sanitizeFilenameBase,
});
}
diff --git a/js/dom-state.js b/js/dom-state.js
index 5c466d6..be1f289 100644
--- a/js/dom-state.js
+++ b/js/dom-state.js
@@ -8,6 +8,7 @@
* Preset paper dimensions used only for aspect ratio and UI convenience.
*/
export const PAPER_PRESETS = {
+ square: { width: 12, height: 12 },
letter: { width: 11, height: 8.5 },
legal: { width: 14, height: 8.5 },
tabloid: { width: 17, height: 11 },
@@ -47,6 +48,13 @@ const domGroups = {
fileInput: q("#fileInput"),
loadDemoSelect: q("#loadDemoSelect"),
},
+ perFrameStripRefs: {
+ perFrameStripPanel: q("#perFrameStripPanel"),
+ perFrameStripHeading: q("#perFrameStripHeading"),
+ perFrameStripCount: q("#perFrameStripCount"),
+ perFrameStrip: q("#perFrameStrip"),
+ perFrameStripFileInput: q("#perFrameStripFileInput"),
+ },
groups: {
layoutGroup: q("#layoutGroup"),
pageGridDetectionGroup: q("#pageGridDetectionGroup"),
@@ -97,6 +105,7 @@ const domGroups = {
alignmentPipelineField: q("#alignmentPipelineField"),
alignmentPipelineMarkerless: q("#alignmentPipelineMarkerless"),
alignmentPipelineMarkers: q("#alignmentPipelineMarkers"),
+ alignmentPipelinePerFrame: q("#alignmentPipelinePerFrame"),
stabilizationMethodGroup: q("#stabilizationMethodGroup"),
stabilizationMethodField: q("#stabilizationMethodField"),
stabilizationMethodPairwise: q("#stabilizationMethodPairwise"),
@@ -276,6 +285,9 @@ export const state = {
mobileSingleViewerMode: false,
activeViewerTab: "raw",
activeMobileControlTab: "layout",
+ // Phase 3 dev flag: forces readConfig() to emit alignmentPipeline "per-frame" before the
+ // real radio (Phase 6) exists, so the per-frame pipeline can be exercised from the console.
+ forcePerFrameMode: false,
},
source: {
image: null,
@@ -286,6 +298,19 @@ export const state = {
dragUrl: "",
ownedObjectUrl: "",
canvas: document.createElement("canvas"),
+ // Per-image source entries for the per-frame pipeline. The legacy fields above project the
+ // active entry. During Phase 2 this array always holds 0 or 1 entries. See js/source-images.js.
+ images: [],
+ activeImageIndex: 0,
+ // Per-frame settings restore buffer. When a per-frame `_settings.txt` is loaded before its images
+ // arrive (the reload story: a saved project cannot embed image data, so the user re-uploads the
+ // same N images in the same order), the parsed per-image overrides are stashed here and applied
+ // to `state.source.images[i]` by upload order as each image arrives, then consumed/cleared.
+ // Shape: { count: number, overrides: Array<{ manualPageContour: {x:number,y:number}[] | null,
+ // postRotationDeg: number } | null> } | null. `null` means no pending restore (legacy /
+ // markers / markerless files leave this null). Each `overrides[i]` is null when image `i` had no
+ // saved override of either kind.
+ pendingPerImageOverrides: null,
rawPageContour: null,
// Tracks where the currently drawn page quad came from; this is display/status metadata, not
// an instruction to bypass full-resolution page detection.
diff --git a/js/export-controller.js b/js/export-controller.js
index 6c1f98b..cc65e21 100644
--- a/js/export-controller.js
+++ b/js/export-controller.js
@@ -207,6 +207,130 @@ function applyGifPreviewDisplaySize(dom, width, height) {
dom.gifImage.style.height = "";
}
+/** Baseline-profile H.264 codec strings in ascending AVC level order. */
+const H264_BASELINE_CODEC_LEVELS = [
+ { maxCodedArea: 921_600, codec: "avc1.42001f" }, // Level 3.1 — up to ~1280×720 coded
+ { maxCodedArea: 1_311_744, codec: "avc1.420020" }, // Level 3.2
+ { maxCodedArea: 2_097_152, codec: "avc1.420028" }, // Level 4.0 — up to ~1920×1088 coded
+ { maxCodedArea: 2_097_152, codec: "avc1.420029" }, // Level 4.1
+ { maxCodedArea: 2_222_080, codec: "avc1.42002a" }, // Level 4.2
+ { maxCodedArea: 5_670_400, codec: "avc1.420032" }, // Level 5.0
+ { maxCodedArea: 9_437_184, codec: "avc1.420033" }, // Level 5.1
+];
+
+/** Legacy codec strings kept for browsers that reject the newer baseline level forms at probe time. */
+const H264_LEGACY_CODEC_CANDIDATES = ["avc1.42E01E", "avc1.4D401E"];
+
+/**
+ * Return the H.264 coded-area size after 16×16 macroblock alignment.
+ *
+ * @param {number} width
+ * @param {number} height
+ * @returns {number}
+ */
+function getH264CodedArea(width, height) {
+ const codedWidth = Math.ceil(width / 16) * 16;
+ const codedHeight = Math.ceil(height / 16) * 16;
+ return codedWidth * codedHeight;
+}
+
+/**
+ * Return baseline-profile codec candidates whose AVC level can contain the coded area.
+ *
+ * @param {number} codedArea
+ * @returns {string[]}
+ */
+function getBaselineCodecCandidatesForCodedArea(codedArea) {
+ const firstSufficientIndex = H264_BASELINE_CODEC_LEVELS.findIndex((entry) => codedArea <= entry.maxCodedArea);
+ if (firstSufficientIndex === -1) {
+ return [H264_BASELINE_CODEC_LEVELS[H264_BASELINE_CODEC_LEVELS.length - 1].codec];
+ }
+ const candidates = [];
+ for (let i = firstSufficientIndex; i < H264_BASELINE_CODEC_LEVELS.length; i++) {
+ const codec = H264_BASELINE_CODEC_LEVELS[i].codec;
+ if (!candidates.includes(codec)) {
+ candidates.push(codec);
+ }
+ }
+ return candidates;
+}
+
+/**
+ * Probe whether the browser can encode one H.264 configuration with WebCodecs.
+ *
+ * @param {string} codec
+ * @param {number} width
+ * @param {number} height
+ * @param {number} [fps=20]
+ * @param {number} [bitrate=500_000]
+ * @returns {Promise}
+ */
+async function isMp4EncoderConfigSupported(codec, width, height, fps = 20, bitrate = 500_000) {
+ if (typeof globalThis.VideoEncoder === "undefined" || typeof VideoEncoder.isConfigSupported !== "function") {
+ return false;
+ }
+ try {
+ const support = await VideoEncoder.isConfigSupported({
+ codec,
+ width,
+ height,
+ bitrate,
+ framerate: fps,
+ avc: { format: "avc" },
+ });
+ return !!support?.supported;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Pick the lowest supported baseline H.264 codec that can encode the requested frame size.
+ *
+ * @param {number} width
+ * @param {number} height
+ * @param {{fps?:number, bitrate?:number}} [options]
+ * @returns {Promise}
+ */
+export async function resolveMp4Codec(width, height, options = {}) {
+ const safeWidth = Math.max(1, Math.round(width || 1));
+ const safeHeight = Math.max(1, Math.round(height || 1));
+ const fps = options.fps ?? 20;
+ const bitrate = options.bitrate ?? 500_000;
+ const candidates = getBaselineCodecCandidatesForCodedArea(getH264CodedArea(safeWidth, safeHeight));
+ for (const codec of candidates) {
+ if (await isMp4EncoderConfigSupported(codec, safeWidth, safeHeight, fps, bitrate)) {
+ return codec;
+ }
+ }
+ return "";
+}
+
+/**
+ * Probe whether this browser can encode H.264 frames with WebCodecs for later MP4 muxing.
+ *
+ * @returns {Promise<{supported:boolean, codec:string}>}
+ */
+export async function detectMp4ExportSupport() {
+ if (typeof globalThis.VideoEncoder === "undefined" || typeof VideoEncoder.isConfigSupported !== "function") {
+ return { supported: false, codec: "" };
+ }
+ const probeWidth = 640;
+ const probeHeight = 480;
+ const probeBitrate = 500_000;
+ const probeFps = 20;
+ const codec = await resolveMp4Codec(probeWidth, probeHeight, { fps: probeFps, bitrate: probeBitrate });
+ if (codec) {
+ return { supported: true, codec };
+ }
+ for (const legacyCodec of H264_LEGACY_CODEC_CANDIDATES) {
+ if (await isMp4EncoderConfigSupported(legacyCodec, probeWidth, probeHeight, probeFps, probeBitrate)) {
+ return { supported: true, codec: legacyCodec };
+ }
+ }
+ return { supported: false, codec: "" };
+}
+
/**
* Estimate a target H.264 bitrate from dimensions, frame rate, and the shared quality slider.
*
@@ -421,6 +545,15 @@ export async function exportMp4(deps) {
try {
const { Muxer, ArrayBufferTarget } = await loadMp4MuxerModule();
const bitrate = estimateMp4Bitrate(mp4Size.width, mp4Size.height, config.fps, config.exportOptions.mp4Quality);
+ const codec = await resolveMp4Codec(mp4Size.width, mp4Size.height, {
+ fps: config.fps,
+ bitrate,
+ });
+ if (!codec) {
+ throw new Error(
+ `No supported H.264 encoder configuration for ${mp4Size.width}x${mp4Size.height}. Try a smaller output size.`
+ );
+ }
const muxer = new Muxer({
target: new ArrayBufferTarget(),
fastStart: "in-memory",
@@ -438,7 +571,7 @@ export async function exportMp4(deps) {
},
});
encoder.configure({
- codec: state.runtime.mp4Codec,
+ codec,
width: mp4Size.width,
height: mp4Size.height,
bitrate,
diff --git a/js/i18n.js b/js/i18n.js
index aaf2cdd..b1f022f 100644
--- a/js/i18n.js
+++ b/js/i18n.js
@@ -7,7 +7,7 @@
* shared translation block, such as Traditional Chinese or region-specific Portuguese tags.
*/
-export const APP_VERSION = "v1.17";
+export const APP_VERSION = "v1.18";
const LOCALES = {
en: {
@@ -32,6 +32,15 @@ const LOCALES = {
dropLeadMobile2: "or load a demo.",
dropNote: "Animation frames should be separated\nby crosses, dots, or empty gutters.",
dropNoteMarkerless: "Animation frames should be separated\nby crosses, dots, or empty gutters.",
+ dropNotePerFrame: "Drop one image per animation frame.\nThey'll be aligned into an animation.",
+ strip: {
+ heading: "Frames",
+ frameCount: "{count} images",
+ frameCountOne: "1 image",
+ addLabel: "Add images",
+ deleteLabel: "Remove frame {index}",
+ selectLabel: "Select frame {index}",
+ },
},
layout: {
summary: "Layout",
@@ -55,6 +64,7 @@ const LOCALES = {
a3: "A3",
a2: "A2",
a1: "A1",
+ square: "Square",
source: "Source",
custom: "Custom",
},
@@ -86,6 +96,7 @@ const LOCALES = {
pipelineOptions: {
markerless: "Markerless (gutters, frames)",
markers: "Markers (crosses, dots)",
+ perFrame: "Per-frame (one image per frame)",
},
stabilizationMethod: "Stabilization Method",
stabilizationMethodOptions: {
@@ -226,7 +237,7 @@ const LOCALES = {
appLedeSecondary: "",
photoHeading: "Loads a source photo or scan for processing.",
loadDemoSelect: "Loads one of the bundled demo images listed in the demo manifest, along with its accompanying settings file.",
- dropZone: "Drop a photo or scan of a frame-sheet here, or click to choose a file.",
+ dropZone: "Drop a photo or scan of a frame-sheet here, or click to choose a file. You can also drop several images at once — one per animation frame — for the per-frame pipeline.",
layoutSummary: "Sets the frame-grid dimensions and paper format assumptions.",
frameCols: "Number of animation frame columns in the plotted grid.",
frameRows: "Number of animation frame rows in the plotted grid.",
@@ -248,6 +259,7 @@ const LOCALES = {
alignmentPipelineField: "Choose between markerless frame estimation and marker-based alignment.",
alignmentPipelineMarkerless: "Estimate frame divisions without registration marks by fitting a straight grid from image autocorrelation and gutter evidence.",
alignmentPipelineMarkers: "Use registration markers between frames to refine the grid alignment.",
+ alignmentPipelinePerFrame: "Upload one image per animation frame; each is page-rectified and stacked into the animation. Drop several images at once to fill the frames.",
stabilizationMethodField: "Choose which translation-only stabilization strategy is used in markerless mode.",
stabilizationMethodPairwise: "Compare each frame with neighboring frames in the sheet or loop and solve one weighted global offset field.",
stabilizationMethodAverage: "Compare each frame independently against one median reference frame built from the whole animation.",
@@ -402,6 +414,15 @@ const LOCALES = {
dropLeadMobile2: "o carga una demo.",
dropNote: "Los fotogramas de animación deben estar separados\npor cruces, puntos o canales vacíos.",
dropNoteMarkerless: "Los fotogramas de animación deben estar separados\npor cruces, puntos o canales vacíos.",
+ dropNotePerFrame: "Suelta una imagen por cada fotograma.\nSe alinearán en una animación.",
+ strip: {
+ heading: "Fotogramas",
+ frameCount: "{count} imágenes",
+ frameCountOne: "1 imagen",
+ addLabel: "Añadir imágenes",
+ deleteLabel: "Quitar fotograma {index}",
+ selectLabel: "Seleccionar fotograma {index}",
+ },
},
layout: {
summary: "Diseño",
@@ -425,6 +446,7 @@ const LOCALES = {
a3: "A3",
a2: "A2",
a1: "A1",
+ square: "Cuadrado",
source: "Origen",
custom: "Personalizado",
},
@@ -456,6 +478,7 @@ const LOCALES = {
pipelineOptions: {
markerless: "Sin marcadores (canales, fotogramas)",
markers: "Marcadores (cruces, puntos)",
+ perFrame: "Por fotograma (una imagen por fotograma)",
},
stabilizationMethod: "Método de estabilización",
stabilizationMethodOptions: {
@@ -595,7 +618,7 @@ const LOCALES = {
appLedeSecondary: "",
photoHeading: "Carga una foto o escaneo de origen para procesarlo.",
loadDemoSelect: "Carga una de las imágenes de demostración incluidas en el manifiesto de demos, junto con su archivo de configuración correspondiente.",
- dropZone: "Suelta aquí una foto o escaneo de una hoja de fotogramas, o haz clic para elegir un archivo.",
+ dropZone: "Suelta aquí una foto o escaneo de una hoja de fotogramas, o haz clic para elegir un archivo. También puedes soltar varias imágenes a la vez, una por fotograma, para la canalización por fotograma.",
layoutSummary: "Configura las dimensiones de la cuadrícula de fotogramas y las suposiciones sobre el formato del papel.",
frameCols: "Número de columnas de fotogramas en la cuadrícula animada.",
frameRows: "Número de filas de fotogramas en la cuadrícula animada.",
@@ -617,6 +640,7 @@ const LOCALES = {
alignmentPipelineField: "Elige entre la estimación de fotogramas sin marcadores y la alineación basada en marcadores.",
alignmentPipelineMarkerless: "Estima las divisiones de los fotogramas sin marcas de registro ajustando una cuadrícula recta a partir de la autocorrelación de la imagen y la evidencia de los canales.",
alignmentPipelineMarkers: "Usa marcadores de registro entre fotogramas para refinar la alineación de la cuadrícula.",
+ alignmentPipelinePerFrame: "Sube una imagen por cada fotograma de la animación; cada una se rectifica y se apila en la animación. Suelta varias imágenes a la vez para llenar los fotogramas.",
stabilizationMethodField: "Elige qué estrategia de estabilización solo por traslación se usa en el modo sin marcadores.",
stabilizationMethodPairwise: "Compara cada fotograma con los fotogramas vecinos de la hoja o del bucle y resuelve un único campo global de desplazamientos ponderados.",
stabilizationMethodAverage: "Compara cada fotograma de forma independiente con un fotograma de referencia mediano construido a partir de toda la animación.",
@@ -770,6 +794,15 @@ const LOCALES = {
dropLeadMobile2: "oppure carica una demo.",
dropNote: "I fotogrammi devono essere separati\nda croci, punti o spazi vuoti.",
dropNoteMarkerless: "I fotogrammi devono essere separati\nda croci, punti o spazi vuoti.",
+ dropNotePerFrame: "Trascina un'immagine per ogni fotogramma.\nVerranno allineate in un'animazione.",
+ strip: {
+ heading: "Fotogrammi",
+ frameCount: "{count} immagini",
+ frameCountOne: "1 immagine",
+ addLabel: "Aggiungi immagini",
+ deleteLabel: "Rimuovi fotogramma {index}",
+ selectLabel: "Seleziona fotogramma {index}",
+ },
},
layout: {
summary: "Layout",
@@ -793,6 +826,7 @@ const LOCALES = {
a3: "A3",
a2: "A2",
a1: "A1",
+ square: "Quadrato",
source: "Sorgente",
custom: "Personalizzato",
},
@@ -824,6 +858,7 @@ const LOCALES = {
pipelineOptions: {
markerless: "Senza marcatori (spazi, fotogrammi)",
markers: "Marcatori (croci, punti)",
+ perFrame: "Per fotogramma (un'immagine per fotogramma)",
},
stabilizationMethod: "Metodo di stabilizzazione",
stabilizationMethodOptions: {
@@ -963,7 +998,7 @@ const LOCALES = {
appLedeSecondary: "",
photoHeading: "Carica una foto o scansione sorgente per l’elaborazione.",
loadDemoSelect: "Carica una delle immagini demo incluse nel manifesto delle demo, insieme al relativo file di impostazioni.",
- dropZone: "Trascina qui una foto o scansione di un foglio di fotogrammi, oppure fai clic per scegliere un file.",
+ dropZone: "Trascina qui una foto o scansione di un foglio di fotogrammi, oppure fai clic per scegliere un file. Puoi anche trascinare più immagini insieme, una per fotogramma, per la pipeline per-fotogramma.",
layoutSummary: "Imposta le dimensioni della griglia dei fotogrammi e le ipotesi sul formato del foglio.",
frameCols: "Numero di colonne di fotogrammi nella griglia animata.",
frameRows: "Numero di righe di fotogrammi nella griglia animata.",
@@ -985,6 +1020,7 @@ const LOCALES = {
alignmentPipelineField: "Scegli tra la stima dei fotogrammi senza marcatori e l’allineamento basato sui marcatori.",
alignmentPipelineMarkerless: "Stima le divisioni dei fotogrammi senza segni di registro adattando una griglia diritta dall’autocorrelazione dell’immagine e dall’evidenza degli spazi vuoti.",
alignmentPipelineMarkers: "Usa marcatori di registro tra i fotogrammi per rifinire l’allineamento della griglia.",
+ alignmentPipelinePerFrame: "Carica un'immagine per ogni fotogramma dell'animazione; ognuna viene rettificata e impilata nell'animazione. Trascina più immagini insieme per riempire i fotogrammi.",
stabilizationMethodField: "Scegli quale strategia di stabilizzazione solo per traslazione usare in modalità senza marcatori.",
stabilizationMethodPairwise: "Confronta ogni fotogramma con i fotogrammi vicini nel foglio o nel loop e risolve un unico campo globale di offset pesati.",
stabilizationMethodAverage: "Confronta ogni fotogramma in modo indipendente con un fotogramma di riferimento mediano costruito dall'intera animazione.",
@@ -1138,6 +1174,15 @@ const LOCALES = {
dropLeadMobile2: "またはデモを読み込みます。",
dropNote: "フレームは区切ってください\n十字、点、または空のガターで。",
dropNoteMarkerless: "フレームは区切ってください\n十字、点、または空のガターで。",
+ dropNotePerFrame: "1フレームにつき1枚の画像をドロップ。\n自動で位置合わせしてアニメ化します。",
+ strip: {
+ heading: "フレーム",
+ frameCount: "{count} 枚",
+ frameCountOne: "1 枚",
+ addLabel: "画像を追加",
+ deleteLabel: "フレーム {index} を削除",
+ selectLabel: "フレーム {index} を選択",
+ },
},
layout: {
summary: "レイアウト",
@@ -1161,6 +1206,7 @@ const LOCALES = {
a3: "A3",
a2: "A2",
a1: "A1",
+ square: "正方形",
source: "ソース",
custom: "カスタム",
},
@@ -1192,6 +1238,7 @@ const LOCALES = {
pipelineOptions: {
markerless: "マーカーレス(ガター、フレーム)",
markers: "マーカー使用(十字、点)",
+ perFrame: "フレームごと(1フレームに1枚)",
},
stabilizationMethod: "スタビライズ方式",
stabilizationMethodOptions: {
@@ -1331,7 +1378,7 @@ const LOCALES = {
appLedeSecondary: "",
photoHeading: "処理する元写真またはスキャン画像を読み込みます。",
loadDemoSelect: "デモ一覧にある同梱デモ画像を、対応する設定ファイルと一緒に読み込みます。",
- dropZone: "フレームシートの写真またはスキャン画像をここにドラッグするか、クリックしてファイルを選択します。",
+ dropZone: "フレームシートの写真またはスキャン画像をここにドラッグするか、クリックしてファイルを選択します。複数の画像(1フレームにつき1枚)をまとめてドロップして、フレームごとのパイプラインを使うこともできます。",
layoutSummary: "フレームグリッドの寸法と用紙サイズの前提を設定します。",
frameCols: "アニメーション用フレームグリッドの列数です。",
frameRows: "アニメーション用フレームグリッドの行数です。",
@@ -1353,6 +1400,7 @@ const LOCALES = {
alignmentPipelineField: "マーカーレスのフレーム推定と、マーカーを使う整列のどちらを使うか選びます。",
alignmentPipelineMarkerless: "登録マークなしで、画像の自己相関とガターの手掛かりから直線的な格子を当てはめてフレーム境界を推定します。",
alignmentPipelineMarkers: "フレーム間の登録マーカーを使って格子の整列を微調整します。",
+ alignmentPipelinePerFrame: "アニメの1フレームにつき1枚の画像をアップロードします。各画像はページ補正されてアニメに積み重ねられます。複数枚をまとめてドロップしてフレームを埋められます。",
stabilizationMethodField: "マーカーレスモードで使う並進のみのスタビライズ手法を選びます。",
stabilizationMethodPairwise: "各フレームをシート内またはループ内の近傍フレームと比較し、重み付きの全体オフセット場を解きます。",
stabilizationMethodAverage: "各フレームを、アニメーション全体から作った中央値参照フレームと個別に比較します。",
@@ -1506,6 +1554,15 @@ const LOCALES = {
dropLeadMobile2: "或加载示例。",
dropNote: "动画帧之间应分隔\n用十字、圆点或空白间隔。",
dropNoteMarkerless: "动画帧之间应分隔\n用十字、圆点或空白间隔。",
+ dropNotePerFrame: "每帧拖入一张图片。\n它们将被对齐成动画。",
+ strip: {
+ heading: "帧",
+ frameCount: "{count} 张图片",
+ frameCountOne: "1 张图片",
+ addLabel: "添加图片",
+ deleteLabel: "移除第 {index} 帧",
+ selectLabel: "选择第 {index} 帧",
+ },
},
layout: {
summary: "布局",
@@ -1529,6 +1586,7 @@ const LOCALES = {
a3: "A3",
a2: "A2",
a1: "A1",
+ square: "正方形",
source: "源图像",
custom: "自定义",
},
@@ -1560,6 +1618,7 @@ const LOCALES = {
pipelineOptions: {
markerless: "无标记(空隙、帧)",
markers: "标记(十字、圆点)",
+ perFrame: "逐帧(每帧一张图片)",
},
stabilizationMethod: "稳定化方法",
stabilizationMethodOptions: {
@@ -1699,7 +1758,7 @@ const LOCALES = {
appLedeSecondary: "",
photoHeading: "加载源照片或扫描图像进行处理。",
loadDemoSelect: "加载演示清单中包含的示例图像及其配套设置文件。",
- dropZone: "将帧表照片或扫描图像拖到这里,或点击选择文件。",
+ dropZone: "将帧表照片或扫描图像拖到这里,或点击选择文件。你也可以一次拖入多张图片(每帧一张),以使用逐帧管线。",
layoutSummary: "设置帧网格尺寸以及纸张格式假设。",
frameCols: "动画帧网格中的列数。",
frameRows: "动画帧网格中的行数。",
@@ -1721,6 +1780,7 @@ const LOCALES = {
alignmentPipelineField: "选择无标记帧估计或基于标记的对齐。",
alignmentPipelineMarkerless: "不使用注册标记,而是根据图像自相关和空隙证据拟合直线网格来估计帧分割。",
alignmentPipelineMarkers: "使用帧之间的注册标记来细化网格对齐。",
+ alignmentPipelinePerFrame: "为动画的每一帧上传一张图片;每张图片都会经过页面矫正并叠加成动画。可一次拖入多张图片来填充各帧。",
stabilizationMethodField: "选择在无标记模式下使用哪种仅平移的稳定化策略。",
stabilizationMethodPairwise: "将每一帧与纸面或循环中的邻近帧比较,并求解一个加权的全局偏移场。",
stabilizationMethodAverage: "将每一帧独立地与由整段动画构建出的中值参考帧进行比较。",
@@ -1874,6 +1934,15 @@ const LOCALES = {
dropLeadMobile2: "或載入示例。",
dropNote: "動畫影格之間應分隔\n以十字、圓點或空白間隔。",
dropNoteMarkerless: "動畫影格之間應分隔\n以十字、圓點或空白間隔。",
+ dropNotePerFrame: "每格拖入一張圖片。\n它們將被對齊成動畫。",
+ strip: {
+ heading: "影格",
+ frameCount: "{count} 張圖片",
+ frameCountOne: "1 張圖片",
+ addLabel: "新增圖片",
+ deleteLabel: "移除第 {index} 格",
+ selectLabel: "選擇第 {index} 格",
+ },
},
layout: {
summary: "版面",
@@ -1897,6 +1966,7 @@ const LOCALES = {
a3: "A3",
a2: "A2",
a1: "A1",
+ square: "正方形",
source: "來源圖像",
custom: "自訂",
},
@@ -1928,6 +1998,7 @@ const LOCALES = {
pipelineOptions: {
markerless: "無標記(空隙、影格)",
markers: "標記(十字、圓點)",
+ perFrame: "逐格(每格一張圖片)",
},
stabilizationMethod: "穩定化方法",
stabilizationMethodOptions: {
@@ -2067,7 +2138,7 @@ const LOCALES = {
appLedeSecondary: "",
photoHeading: "載入來源照片或掃描影像進行處理。",
loadDemoSelect: "載入示例清單中包含的範例影像,以及其對應的設定檔。",
- dropZone: "將幀表照片或掃描影像拖到這裡,或點擊選擇檔案。",
+ dropZone: "將幀表照片或掃描影像拖到這裡,或點擊選擇檔案。你也可以一次拖入多張圖片(每格一張),以使用逐幀管線。",
layoutSummary: "設定影格網格尺寸以及紙張格式假設。",
frameCols: "動畫影格網格中的列數。",
frameRows: "動畫影格網格中的行數。",
@@ -2089,6 +2160,7 @@ const LOCALES = {
alignmentPipelineField: "選擇無標記影格估計或基於標記的對齊。",
alignmentPipelineMarkerless: "不使用註冊標記,而是根據影像自相關與空隙證據擬合直線網格來估計影格分割。",
alignmentPipelineMarkers: "使用影格之間的註冊標記來細化網格對齊。",
+ alignmentPipelinePerFrame: "為動畫的每一格上傳一張圖片;每張圖片都會經過頁面矯正並疊加成動畫。可一次拖入多張圖片來填滿各格。",
stabilizationMethodField: "選擇在無標記模式下使用哪種僅平移的穩定化策略。",
stabilizationMethodPairwise: "將每個影格與紙面或循環中的鄰近影格比較,並求解一個加權的全域偏移場。",
stabilizationMethodAverage: "將每個影格獨立地與由整段動畫建立出的中值參考影格進行比較。",
@@ -2242,6 +2314,15 @@ const LOCALES = {
dropLeadMobile2: "데모를 불러오세요.",
dropNote: "애니메이션 프레임은 구분되어야 합니다\n십자, 점 또는 빈 간격으로.",
dropNoteMarkerless: "애니메이션 프레임은 구분되어야 합니다\n십자, 점 또는 빈 간격으로.",
+ dropNotePerFrame: "프레임당 이미지 한 장을 드롭하세요.\n정렬되어 애니메이션이 됩니다.",
+ strip: {
+ heading: "프레임",
+ frameCount: "이미지 {count}장",
+ frameCountOne: "이미지 1장",
+ addLabel: "이미지 추가",
+ deleteLabel: "프레임 {index} 제거",
+ selectLabel: "프레임 {index} 선택",
+ },
},
layout: {
summary: "레이아웃",
@@ -2265,6 +2346,7 @@ const LOCALES = {
a3: "A3",
a2: "A2",
a1: "A1",
+ square: "정사각형",
source: "원본",
custom: "사용자 지정",
},
@@ -2296,6 +2378,7 @@ const LOCALES = {
pipelineOptions: {
markerless: "마커 없음 (간격, 프레임)",
markers: "마커 사용 (십자, 점)",
+ perFrame: "프레임별 (프레임당 이미지 한 장)",
},
stabilizationMethod: "안정화 방법",
stabilizationMethodOptions: {
@@ -2435,7 +2518,7 @@ const LOCALES = {
appLedeSecondary: "",
photoHeading: "처리할 원본 사진 또는 스캔 이미지를 불러옵니다.",
loadDemoSelect: "데모 목록에 포함된 샘플 이미지를 해당 설정 파일과 함께 불러옵니다.",
- dropZone: "프레임 시트 사진 또는 스캔 이미지를 여기에 드래그하거나 클릭해 파일을 선택하세요.",
+ dropZone: "프레임 시트 사진 또는 스캔 이미지를 여기에 드래그하거나 클릭해 파일을 선택하세요. 여러 이미지를 한 번에(프레임당 한 장) 드롭하여 프레임별 파이프라인을 사용할 수도 있습니다.",
layoutSummary: "프레임 그리드 크기와 용지 형식 가정을 설정합니다.",
frameCols: "애니메이션 프레임 그리드의 열 수입니다.",
frameRows: "애니메이션 프레임 그리드의 행 수입니다.",
@@ -2457,6 +2540,7 @@ const LOCALES = {
alignmentPipelineField: "마커 없는 프레임 추정과 마커 기반 정렬 중에서 선택합니다.",
alignmentPipelineMarkerless: "등록 마크 없이 이미지 자기상관과 간격 단서를 바탕으로 직선 격자를 맞춰 프레임 경계를 추정합니다.",
alignmentPipelineMarkers: "프레임 사이의 등록 마커를 사용해 격자 정렬을 세밀하게 맞춥니다.",
+ alignmentPipelinePerFrame: "애니메이션 프레임마다 이미지 한 장을 업로드합니다. 각 이미지는 페이지 보정 후 애니메이션으로 쌓입니다. 여러 장을 한 번에 드롭해 프레임을 채울 수 있습니다.",
stabilizationMethodField: "마커 없는 모드에서 사용할 평행이동 전용 안정화 방식을 선택합니다.",
stabilizationMethodPairwise: "각 프레임을 시트나 루프에서 이웃한 프레임과 비교하고 가중 전역 오프셋 필드를 풉니다.",
stabilizationMethodAverage: "각 프레임을 전체 애니메이션으로 만든 중앙값 참조 프레임과 독립적으로 비교합니다.",
@@ -2610,6 +2694,15 @@ const LOCALES = {
dropLeadMobile2: "ou carregue uma demo.",
dropNote: "Os quadros da animação devem ser\nseparados por cruzes, pontos\nou espaços vazios.",
dropNoteMarkerless: "Os quadros da animação devem ser\nseparados por cruzes, pontos\nou espaços vazios.",
+ dropNotePerFrame: "Solte uma imagem por quadro da animação.\nElas serão alinhadas em uma animação.",
+ strip: {
+ heading: "Quadros",
+ frameCount: "{count} imagens",
+ frameCountOne: "1 imagem",
+ addLabel: "Adicionar imagens",
+ deleteLabel: "Remover quadro {index}",
+ selectLabel: "Selecionar quadro {index}",
+ },
},
layout: {
summary: "Layout",
@@ -2633,6 +2726,7 @@ const LOCALES = {
a3: "A3",
a2: "A2",
a1: "A1",
+ square: "Quadrado",
source: "Fonte",
custom: "Personalizado",
},
@@ -2664,6 +2758,7 @@ const LOCALES = {
pipelineOptions: {
markerless: "Sem marcadores (espaços, quadros)",
markers: "Marcadores (cruzes, pontos)",
+ perFrame: "Por quadro (uma imagem por quadro)",
},
stabilizationMethod: "Método de estabilização",
stabilizationMethodOptions: {
@@ -2803,7 +2898,7 @@ const LOCALES = {
appLedeSecondary: "",
photoHeading: "Carrega uma foto ou digitalização de origem para processamento.",
loadDemoSelect: "Carrega uma das imagens de demonstração incluídas no manifesto de demos, junto com seu arquivo de configurações correspondente.",
- dropZone: "Solte aqui uma foto ou digitalização de uma folha de quadros, ou clique para escolher um arquivo.",
+ dropZone: "Solte aqui uma foto ou digitalização de uma folha de quadros, ou clique para escolher um arquivo. Você também pode soltar várias imagens de uma vez — uma por quadro — para o pipeline por quadro.",
layoutSummary: "Define as dimensões da grade de quadros e as premissas sobre o formato do papel.",
frameCols: "Número de colunas de quadros na grade animada.",
frameRows: "Número de linhas de quadros na grade animada.",
@@ -2825,6 +2920,7 @@ const LOCALES = {
alignmentPipelineField: "Escolha entre a estimativa de quadros sem marcadores e o alinhamento baseado em marcadores.",
alignmentPipelineMarkerless: "Estima as divisões dos quadros sem marcas de registro, ajustando uma grade reta a partir da autocorrelação da imagem e da evidência dos espaços vazios.",
alignmentPipelineMarkers: "Usa marcadores de registro entre os quadros para refinar o alinhamento da grade.",
+ alignmentPipelinePerFrame: "Envie uma imagem por quadro da animação; cada uma é retificada e empilhada na animação. Solte várias imagens de uma vez para preencher os quadros.",
stabilizationMethodField: "Escolha qual estratégia de estabilização apenas por translação será usada no modo sem marcadores.",
stabilizationMethodPairwise: "Compara cada quadro com os quadros vizinhos na folha ou no loop e resolve um único campo global de deslocamentos ponderados.",
stabilizationMethodAverage: "Compara cada quadro independentemente com um quadro de referência mediano construído a partir de toda a animação.",
@@ -2978,6 +3074,15 @@ const LOCALES = {
dropLeadMobile2: "oder laden Sie eine Demo.",
dropNote: "Animationsbilder sollten getrennt sein\ndurch Kreuze, Punkte oder leere Zwischenräume.",
dropNoteMarkerless: "Animationsbilder sollten getrennt sein\ndurch Kreuze, Punkte oder leere Zwischenräume.",
+ dropNotePerFrame: "Ein Bild pro Animationsbild ablegen.\nSie werden zu einer Animation ausgerichtet.",
+ strip: {
+ heading: "Einzelbilder",
+ frameCount: "{count} Bilder",
+ frameCountOne: "1 Bild",
+ addLabel: "Bilder hinzufügen",
+ deleteLabel: "Einzelbild {index} entfernen",
+ selectLabel: "Einzelbild {index} auswählen",
+ },
},
layout: {
summary: "Layout",
@@ -3001,6 +3106,7 @@ const LOCALES = {
a3: "A3",
a2: "A2",
a1: "A1",
+ square: "Quadrat",
source: "Quelle",
custom: "Benutzerdefiniert",
},
@@ -3032,6 +3138,7 @@ const LOCALES = {
pipelineOptions: {
markerless: "Markerlos (Zwischenräume, Frames)",
markers: "Marker (Kreuze, Punkte)",
+ perFrame: "Pro Bild (ein Bild pro Animationsbild)",
},
stabilizationMethod: "Stabilisierungsmethode",
stabilizationMethodOptions: {
@@ -3171,7 +3278,7 @@ const LOCALES = {
appLedeSecondary: "",
photoHeading: "Lädt ein Quellfoto oder einen Scan zur Verarbeitung.",
loadDemoSelect: "Lädt eines der im Demo-Manifest aufgeführten Beispielbilder zusammen mit der dazugehörigen Einstellungsdatei.",
- dropZone: "Ziehen Sie hier ein Foto oder einen Scan eines Frame-Sheets hinein oder klicken Sie, um eine Datei auszuwählen.",
+ dropZone: "Ziehen Sie hier ein Foto oder einen Scan eines Frame-Sheets hinein oder klicken Sie, um eine Datei auszuwählen. Sie können auch mehrere Bilder gleichzeitig ablegen – eines pro Animationsbild – für die Per-Frame-Pipeline.",
layoutSummary: "Legt die Abmessungen des Frame-Rasters und die Annahmen zum Papierformat fest.",
frameCols: "Anzahl der Spalten im Animationsraster.",
frameRows: "Anzahl der Zeilen im Animationsraster.",
@@ -3193,6 +3300,7 @@ const LOCALES = {
alignmentPipelineField: "Wählen Sie zwischen markerloser Frame-Schätzung und markerbasierter Ausrichtung.",
alignmentPipelineMarkerless: "Schätzt Frame-Unterteilungen ohne Registrierungsmarken, indem aus Bildautokorrelation und Zwischenraum-Hinweisen ein gerades Raster angepasst wird.",
alignmentPipelineMarkers: "Verwendet Registrierungsmarker zwischen den Frames, um die Rasterausrichtung zu verfeinern.",
+ alignmentPipelinePerFrame: "Lade ein Bild pro Animationsbild hoch; jedes wird seitenkorrigiert und zur Animation gestapelt. Mehrere Bilder auf einmal ablegen, um die Frames zu füllen.",
stabilizationMethodField: "Wählen Sie, welche reine Translations-Stabilisierungsstrategie im markerlosen Modus verwendet wird.",
stabilizationMethodPairwise: "Vergleicht jeden Frame mit benachbarten Frames im Blatt oder in der Schleife und löst ein gewichtetes globales Offset-Feld.",
stabilizationMethodAverage: "Vergleicht jeden Frame unabhängig mit einem Median-Referenzframe, der aus der gesamten Animation gebildet wird.",
@@ -3346,6 +3454,15 @@ const LOCALES = {
dropLeadMobile2: "albo wczytaj demo.",
dropNote: "Klatki animacji powinny być oddzielone\nkrzyżykami, kropkami lub pustymi odstępami.",
dropNoteMarkerless: "Klatki animacji powinny być oddzielone\nkrzyżykami, kropkami lub pustymi odstępami.",
+ dropNotePerFrame: "Upuść jeden obraz na każdą klatkę.\nZostaną wyrównane w animację.",
+ strip: {
+ heading: "Klatki",
+ frameCount: "{count} obrazów",
+ frameCountOne: "1 obraz",
+ addLabel: "Dodaj obrazy",
+ deleteLabel: "Usuń klatkę {index}",
+ selectLabel: "Wybierz klatkę {index}",
+ },
},
layout: {
summary: "Układ",
@@ -3369,6 +3486,7 @@ const LOCALES = {
a3: "A3",
a2: "A2",
a1: "A1",
+ square: "Kwadrat",
source: "Źródło",
custom: "Własny",
},
@@ -3400,6 +3518,7 @@ const LOCALES = {
pipelineOptions: {
markerless: "Bez znaczników (odstępy, klatki)",
markers: "Znaczniki (krzyżyki, kropki)",
+ perFrame: "Na klatkę (jeden obraz na klatkę)",
},
stabilizationMethod: "Metoda stabilizacji",
stabilizationMethodOptions: {
@@ -3539,7 +3658,7 @@ const LOCALES = {
appLedeSecondary: "",
photoHeading: "Wczytuje zdjęcie lub skan źródłowy do przetwarzania.",
loadDemoSelect: "Wczytuje jeden z dołączonych obrazów demo wymienionych w manifeście wraz z odpowiadającym mu plikiem ustawień.",
- dropZone: "Upuść tutaj zdjęcie lub skan arkusza klatek albo kliknij, aby wybrać plik.",
+ dropZone: "Upuść tutaj zdjęcie lub skan arkusza klatek albo kliknij, aby wybrać plik. Możesz też upuścić kilka obrazów naraz — po jednym na klatkę — dla potoku klatka po klatce.",
layoutSummary: "Ustawia wymiary siatki klatek i założenia dotyczące formatu papieru.",
frameCols: "Liczba kolumn klatek w siatce animacji.",
frameRows: "Liczba wierszy klatek w siatce animacji.",
@@ -3561,6 +3680,7 @@ const LOCALES = {
alignmentPipelineField: "Wybierz między bezznacznikowym szacowaniem klatek a wyrównaniem opartym na znacznikach.",
alignmentPipelineMarkerless: "Szacuje podziały klatek bez znaków rejestracyjnych, dopasowując prostą siatkę na podstawie autokorelacji obrazu i sygnałów z odstępów.",
alignmentPipelineMarkers: "Używa znaczników rejestracyjnych między klatkami do doprecyzowania wyrównania siatki.",
+ alignmentPipelinePerFrame: "Prześlij jeden obraz na każdą klatkę animacji; każdy zostaje wyprostowany i ułożony w animację. Upuść kilka obrazów naraz, aby wypełnić klatki.",
stabilizationMethodField: "Wybierz, która strategia stabilizacji oparta wyłącznie na przesunięciu ma być używana w trybie bez znaczników.",
stabilizationMethodPairwise: "Porównuje każdą klatkę z sąsiednimi klatkami na arkuszu lub w pętli i wyznacza jedno ważone globalne pole przesunięć.",
stabilizationMethodAverage: "Porównuje każdą klatkę niezależnie z medianową klatką odniesienia zbudowaną z całej animacji.",
@@ -3714,6 +3834,15 @@ const LOCALES = {
dropLeadMobile2: "eller last en demo.",
dropNote: "Animasjonsruter bør være skilt\nmed kryss, prikker eller tomme mellomrom.",
dropNoteMarkerless: "Animasjonsruter bør være skilt\nmed kryss, prikker eller tomme mellomrom.",
+ dropNotePerFrame: "Slipp ett bilde per animasjonsrute.\nDe justeres til en animasjon.",
+ strip: {
+ heading: "Ruter",
+ frameCount: "{count} bilder",
+ frameCountOne: "1 bilde",
+ addLabel: "Legg til bilder",
+ deleteLabel: "Fjern rute {index}",
+ selectLabel: "Velg rute {index}",
+ },
},
layout: {
summary: "Oppsett",
@@ -3737,6 +3866,7 @@ const LOCALES = {
a3: "A3",
a2: "A2",
a1: "A1",
+ square: "Kvadrat",
source: "Kilde",
custom: "Tilpasset",
},
@@ -3768,6 +3898,7 @@ const LOCALES = {
pipelineOptions: {
markerless: "Uten markører (mellomrom, ruter)",
markers: "Markører (kryss, prikker)",
+ perFrame: "Per rute (ett bilde per rute)",
},
stabilizationMethod: "Stabiliseringsmetode",
stabilizationMethodOptions: {
@@ -3907,7 +4038,7 @@ const LOCALES = {
appLedeSecondary: "",
photoHeading: "Laster inn et kildebilde eller en skanning for behandling.",
loadDemoSelect: "Laster inn ett av demo-bildene som er oppført i demo-manifestet, sammen med den tilhørende innstillingsfilen.",
- dropZone: "Slipp et bilde eller en skanning av et frame-sheet her, eller klikk for å velge en fil.",
+ dropZone: "Slipp et bilde eller en skanning av et frame-sheet her, eller klikk for å velge en fil. Du kan også slippe flere bilder samtidig – ett per animasjonsrute – for per-rute-rørledningen.",
layoutSummary: "Setter dimensjonene for rutenettet og antakelser om papirformatet.",
frameCols: "Antall kolonner i animasjonsrutenettet.",
frameRows: "Antall rader i animasjonsrutenettet.",
@@ -3929,6 +4060,7 @@ const LOCALES = {
alignmentPipelineField: "Velg mellom markørløs ruteberegning og markørbasert justering.",
alignmentPipelineMarkerless: "Estimerer ruteinndelingen uten registreringsmerker ved å tilpasse et rett rutenett fra bildeautokorrelasjon og tegn på tomme mellomrom.",
alignmentPipelineMarkers: "Bruker registreringsmarkører mellom rutene for å finjustere rutenettjusteringen.",
+ alignmentPipelinePerFrame: "Last opp ett bilde per animasjonsrute; hvert bilde sidekorrigeres og stables til animasjonen. Slipp flere bilder samtidig for å fylle rutene.",
stabilizationMethodField: "Velg hvilken stabiliseringsstrategi som kun bruker translasjon i markørløs modus.",
stabilizationMethodPairwise: "Sammenligner hver rute med nabobildene i arket eller løkken og løser ett vektet globalt forskyvningsfelt.",
stabilizationMethodAverage: "Sammenligner hver rute uavhengig med en medianreferanserute bygget fra hele animasjonen.",
@@ -4082,6 +4214,15 @@ const LOCALES = {
dropLeadMobile2: "або завантажте демо.",
dropNote: "Кадри анімації мають бути розділені\nхрестиками, крапками або порожніми проміжками.",
dropNoteMarkerless: "Кадри анімації мають бути розділені\nхрестиками, крапками або порожніми проміжками.",
+ dropNotePerFrame: "Перетягніть по одному зображенню на кадр.\nВони вирівняються в анімацію.",
+ strip: {
+ heading: "Кадри",
+ frameCount: "{count} зображень",
+ frameCountOne: "1 зображення",
+ addLabel: "Додати зображення",
+ deleteLabel: "Видалити кадр {index}",
+ selectLabel: "Вибрати кадр {index}",
+ },
},
layout: {
summary: "Макет",
@@ -4105,6 +4246,7 @@ const LOCALES = {
a3: "A3",
a2: "A2",
a1: "A1",
+ square: "Квадрат",
source: "Джерело",
custom: "Власний",
},
@@ -4136,6 +4278,7 @@ const LOCALES = {
pipelineOptions: {
markerless: "Без міток (проміжки, кадри)",
markers: "Мітки (хрестики, точки)",
+ perFrame: "Покадрово (одне зображення на кадр)",
},
stabilizationMethod: "Метод стабілізації",
stabilizationMethodOptions: {
@@ -4275,7 +4418,7 @@ const LOCALES = {
appLedeSecondary: "",
photoHeading: "Завантажує вихідне фото або скан для обробки.",
loadDemoSelect: "Завантажує одне з демо-зображень із маніфесту демонстрацій разом із відповідним файлом налаштувань.",
- dropZone: "Перетягніть сюди фото або скан аркуша кадрів або клацніть, щоб вибрати файл.",
+ dropZone: "Перетягніть сюди фото або скан аркуша кадрів або клацніть, щоб вибрати файл. Ви також можете перетягнути кілька зображень одночасно — по одному на кадр — для покадрового конвеєра.",
layoutSummary: "Налаштовує розміри сітки кадрів і припущення щодо формату паперу.",
frameCols: "Кількість стовпців у сітці анімації.",
frameRows: "Кількість рядків у сітці анімації.",
@@ -4297,6 +4440,7 @@ const LOCALES = {
alignmentPipelineField: "Виберіть між безмітковим оцінюванням кадрів і вирівнюванням на основі міток.",
alignmentPipelineMarkerless: "Оцінює поділ кадрів без реєстраційних міток, підганяючи пряму сітку за автокореляцією зображення та ознаками порожніх проміжків.",
alignmentPipelineMarkers: "Використовує реєстраційні мітки між кадрами для уточнення вирівнювання сітки.",
+ alignmentPipelinePerFrame: "Завантажте по одному зображенню на кадр анімації; кожне вирівнюється за сторінкою та складається в анімацію. Перетягніть кілька зображень одразу, щоб заповнити кадри.",
stabilizationMethodField: "Виберіть, яку стратегію стабілізації лише за зсувом використовувати в режимі без міток.",
stabilizationMethodPairwise: "Порівнює кожен кадр із сусідніми кадрами на аркуші або в циклі та розв’язує одне зважене глобальне поле зміщень.",
stabilizationMethodAverage: "Порівнює кожен кадр окремо з медіанним опорним кадром, побудованим з усієї анімації.",
@@ -4450,6 +4594,15 @@ const LOCALES = {
dropLeadMobile2: "ou chargez une démo.",
dropNote: "Les images d’animation doivent être séparées\npar des croix, des points ou des gouttières vides.",
dropNoteMarkerless: "Les images d’animation doivent être séparées\npar des croix, des points ou des gouttières vides.",
+ dropNotePerFrame: "Déposez une image par image d’animation.\nElles seront alignées en une animation.",
+ strip: {
+ heading: "Images",
+ frameCount: "{count} images",
+ frameCountOne: "1 image",
+ addLabel: "Ajouter des images",
+ deleteLabel: "Retirer l’image {index}",
+ selectLabel: "Sélectionner l’image {index}",
+ },
},
layout: {
summary: "Mise en page",
@@ -4473,6 +4626,7 @@ const LOCALES = {
a3: "A3",
a2: "A2",
a1: "A1",
+ square: "Carré",
source: "Source",
custom: "Personnalisé",
},
@@ -4504,6 +4658,7 @@ const LOCALES = {
pipelineOptions: {
markerless: "Sans repères (gouttières, images)",
markers: "Repères (croix, points)",
+ perFrame: "Par image (une image par image d’animation)",
},
stabilizationMethod: "Méthode de stabilisation",
stabilizationMethodOptions: {
@@ -4643,7 +4798,7 @@ const LOCALES = {
appLedeSecondary: "",
photoHeading: "Charge une photo ou un scan source pour le traitement.",
loadDemoSelect: "Charge l’une des images de démonstration listées dans le manifeste des démos, ainsi que son fichier de réglages associé.",
- dropZone: "Déposez ici une photo ou un scan d’une feuille d’images, ou cliquez pour choisir un fichier.",
+ dropZone: "Déposez ici une photo ou un scan d’une feuille d’images, ou cliquez pour choisir un fichier. Vous pouvez aussi déposer plusieurs images à la fois — une par image d’animation — pour le pipeline image par image.",
layoutSummary: "Règle les dimensions de la grille d’images et les hypothèses de format de papier.",
frameCols: "Nombre de colonnes d’images dans la grille animée.",
frameRows: "Nombre de lignes d’images dans la grille animée.",
@@ -4665,6 +4820,7 @@ const LOCALES = {
alignmentPipelineField: "Choisissez entre l’estimation des images sans repères et l’alignement basé sur des repères.",
alignmentPipelineMarkerless: "Estime les divisions d’images sans marques de repérage en ajustant une grille droite à partir de l’autocorrélation de l’image et des indices de gouttières.",
alignmentPipelineMarkers: "Utilise des repères de registration entre les images pour affiner l’alignement de la grille.",
+ alignmentPipelinePerFrame: "Importez une image par image d’animation ; chacune est redressée puis empilée dans l’animation. Déposez plusieurs images à la fois pour remplir les images.",
stabilizationMethodField: "Choisissez quelle stratégie de stabilisation uniquement par translation utiliser en mode sans repères.",
stabilizationMethodPairwise: "Compare chaque image aux images voisines sur la feuille ou dans la boucle et résout un unique champ global de décalages pondérés.",
stabilizationMethodAverage: "Compare chaque image indépendamment à une image de référence médiane construite à partir de l’ensemble de l’animation.",
@@ -4913,6 +5069,7 @@ const TOOLTIP_SELECTOR_KEYS = {
"#alignmentPipelineField": "alignmentPipelineField",
"#alignmentPipelineMarkerless": ["alignmentPipelineMarkerless", "alignmentPipelineField"],
"#alignmentPipelineMarkers": ["alignmentPipelineMarkers", "alignmentPipelineField"],
+ "#alignmentPipelinePerFrame": ["alignmentPipelinePerFrame", "alignmentPipelineField"],
"#stabilizationMethodField": "stabilizationMethodField",
"#stabilizationMethodPairwise": ["stabilizationMethodPairwise", "stabilizationMethodField"],
"#stabilizationMethodAverage": ["stabilizationMethodAverage", "stabilizationMethodField"],
diff --git a/js/load-controller.js b/js/load-controller.js
index 9dce1f9..253e290 100644
--- a/js/load-controller.js
+++ b/js/load-controller.js
@@ -5,6 +5,7 @@
* and the staged process of loading a new source image into the app.
*/
import { t } from "./i18n.js";
+import { createSourceImageEntry, releaseAllSourceImages } from "./source-images.js";
/**
* Toggle the small busy spinners used during image loading and processing.
*
@@ -38,17 +39,70 @@ export async function waitForNextPaint() {
}
/**
- * Release any blob URL that the app currently owns for raw-photo drag/download behavior.
+ * Decode an image URL into a fully-loaded `HTMLImageElement`.
+ *
+ * Used by the multi-file (per-frame) load path to decode each additional uploaded image into its
+ * own entry. Rejects if the image fails to decode so the caller can skip just that file.
+ *
+ * @param {string} src
+ * @returns {Promise}
+ */
+export function decodeImageElement(src) {
+ return new Promise((resolve, reject) => {
+ const image = new Image();
+ image.onload = () => resolve(image);
+ image.onerror = () => reject(new Error("Failed to decode image"));
+ image.src = src;
+ });
+}
+
+/**
+ * Release any blob URLs the app currently owns for raw-photo drag/download behavior, including the
+ * per-image source entries, so nothing leaks across image reloads.
*
* @param {import("./dom-state.js").state} state
* @returns {void}
*/
export function releaseOwnedSourceUrl(state) {
+ // Revoke per-image blob URLs and free cached Mats; clears state.source.images back to empty.
+ releaseAllSourceImages(state);
if (!state.source.ownedObjectUrl) return;
URL.revokeObjectURL(state.source.ownedObjectUrl);
state.source.ownedObjectUrl = "";
}
+/**
+ * Reattach buffered per-image overrides (from a per-frame settings restore) to the loaded images.
+ *
+ * `applyLoadedSettingsText` parses a per-frame `_settings.txt`'s indexed page-corner / post-rotation
+ * keys into `state.source.pendingPerImageOverrides` (shape documented in `js/dom-state.js`). Because a
+ * saved project cannot embed image data, those overrides wait here until the user re-uploads the same
+ * N images in the same order; this applies each override to `state.source.images[i]` by upload order
+ * (index 0 → first uploaded image) and clears the buffer so a later re-load never reapplies stale data.
+ *
+ * @param {import("./dom-state.js").state} state
+ * @returns {boolean} `true` when a per-frame restore buffer was present and consumed.
+ */
+export function applyPendingPerImageOverrides(state) {
+ const pending = state.source.pendingPerImageOverrides;
+ if (!pending || !Array.isArray(pending.overrides)) return false;
+ const images = state.source.images || [];
+ for (let index = 0; index < images.length; index += 1) {
+ const override = pending.overrides[index];
+ if (!override) continue;
+ const entry = images[index];
+ if (!entry) continue;
+ entry.manualPageContour =
+ Array.isArray(override.manualPageContour) && override.manualPageContour.length === 4
+ ? override.manualPageContour.map((point) => ({ x: point.x, y: point.y }))
+ : null;
+ entry.postRotationDeg = Number.isFinite(override.postRotationDeg) ? override.postRotationDeg : 0;
+ }
+ // Consume the buffer so a subsequent image load does not reapply these (now-stale) overrides.
+ state.source.pendingPerImageOverrides = null;
+ return true;
+}
+
/**
* Test whether a dropped/selected file should be treated as an image source.
*
@@ -89,22 +143,28 @@ function getExpectedSettingsFilename(filename) {
* @param {FileList | File[] | null} [files=null]
* @param {{
* state: import("./dom-state.js").state,
- * loadImageSource: (src:string, filename?:string, mimeType?:string, settingsFile?:File | null) => Promise,
+ * loadImageSource: (src:string, filename?:string, mimeType?:string, settingsFile?:File | null, additionalImageFiles?:File[]) => Promise,
* applySettingsFile: (file: File) => Promise
* }} deps
* @returns {Promise}
*/
export async function handleFile(file, files = null, { state, loadImageSource, applySettingsFile }) {
const allFiles = [...(files || [file])].filter(Boolean);
- // A drag payload may contain an image plus its sibling settings file, or just a settings file.
- // Prefer the image when present; otherwise treat a lone settings file as an override request.
- const imageFile = allFiles.find(isImageFile) || (isImageFile(file) ? file : null);
- if (imageFile) {
+ // A drag payload may contain one image, several images (one per animation frame), and/or a
+ // sibling settings file. Prefer images when present; otherwise treat a lone settings file as an
+ // override request.
+ const imageFiles = allFiles.filter(isImageFile);
+ const primaryImageFile = imageFiles[0] || (isImageFile(file) ? file : null);
+ if (primaryImageFile) {
releaseOwnedSourceUrl(state);
- const url = URL.createObjectURL(imageFile);
- const settingsFilename = getExpectedSettingsFilename(imageFile.name || "");
+ const url = URL.createObjectURL(primaryImageFile);
+ // A sibling settings file is matched against the first image's name and applied once.
+ const settingsFilename = getExpectedSettingsFilename(primaryImageFile.name || "");
const siblingSettingsFile = allFiles.find((candidate) => candidate && isSettingsFile(candidate) && candidate.name === settingsFilename) || null;
- await loadImageSource(url, imageFile.name || "", imageFile.type || "image/jpeg", siblingSettingsFile);
+ // Any images beyond the first become additional per-frame entries; the loader switches into
+ // per-frame mode when more than one image is present so none are silently dropped.
+ const additionalImageFiles = imageFiles.length > 0 ? imageFiles.slice(1) : [];
+ await loadImageSource(url, primaryImageFile.name || "", primaryImageFile.type || "image/jpeg", siblingSettingsFile, additionalImageFiles);
return;
}
@@ -122,6 +182,9 @@ export async function handleFile(file, files = null, { state, loadImageSource, a
* filename?: string,
* mimeType?: string,
* settingsFile?: File | null,
+ * additionalImageFiles?: File[],
+ * additionalImageSources?: { src: string, filename?: string, mimeType?: string }[],
+ * companionSettingsText?: string | null,
* dom: import("./dom-state.js").dom,
* state: import("./dom-state.js").state,
* setStatus: (text:string) => void,
@@ -140,6 +203,7 @@ export async function handleFile(file, files = null, { state, loadImageSource, a
* invalidateAppearanceCache: () => void,
* processCurrentImage: () => Promise,
* drawImageToCanvas: (image: HTMLImageElement, canvas: HTMLCanvasElement) => void,
+ * refreshActiveImage?: (index:number) => void,
* }} deps
* @returns {Promise}
*/
@@ -148,6 +212,9 @@ export async function loadImageSource({
filename = "",
mimeType = "image/jpeg",
settingsFile = null,
+ additionalImageFiles = [],
+ additionalImageSources = [],
+ companionSettingsText = null,
dom,
state,
setStatus,
@@ -166,6 +233,7 @@ export async function loadImageSource({
invalidateAppearanceCache,
processCurrentImage,
drawImageToCanvas,
+ refreshActiveImage,
}) {
releaseOwnedSourceUrl(state);
if (src.startsWith("blob:")) {
@@ -194,7 +262,10 @@ export async function loadImageSource({
syncRawPhotoCreditDisplay?.();
clearAllPreviews();
// The UI resets to defaults first, then an optional sibling settings file is layered on top.
- const settingsText = await loadCompanionSettingsText(src, filename, settingsFile);
+ const settingsText =
+ companionSettingsText != null
+ ? companionSettingsText
+ : await loadCompanionSettingsText(src, filename, settingsFile);
const image = new Image();
image.onload = async () => {
@@ -208,7 +279,85 @@ export async function loadImageSource({
syncRawPhotoHeadingLink?.();
syncRawPhotoCreditDisplay?.();
state.source.rawPageContour = null;
- drawImageToCanvas(image, state.source.canvas);
+ // Each per-frame entry owns a distinct source-resolution canvas so runPerFramePipeline can
+ // rectify a different image per cell. The legacy state.source.canvas / image fields project the
+ // active (index 0) entry, keeping single-image markers/markerless callers working unchanged.
+ const activeCanvas = document.createElement("canvas");
+ drawImageToCanvas(image, activeCanvas);
+ state.source.canvas = activeCanvas;
+ const sourceEntry = createSourceImageEntry({
+ image,
+ filename: state.source.filename,
+ mimeType: state.source.mimeType,
+ ownedObjectUrl: state.source.ownedObjectUrl,
+ dragUrl: state.source.dragUrl,
+ canvas: activeCanvas,
+ });
+ const entries = [sourceEntry];
+ // Decode any additional uploaded images into their own entries (multi-file / per-frame upload).
+ // Each extra image owns its own blob URL and canvas; a failed decode is skipped rather than
+ // aborting the whole load.
+ for (const extraFile of additionalImageFiles) {
+ if (!extraFile) continue;
+ const extraUrl = URL.createObjectURL(extraFile);
+ let extraImage;
+ try {
+ extraImage = await decodeImageElement(extraUrl);
+ } catch {
+ try {
+ URL.revokeObjectURL(extraUrl);
+ } catch {
+ /* already revoked */
+ }
+ continue;
+ }
+ const extraCanvas = document.createElement("canvas");
+ drawImageToCanvas(extraImage, extraCanvas);
+ entries.push(
+ createSourceImageEntry({
+ image: extraImage,
+ filename: extraFile.name || "",
+ mimeType: extraFile.type || "image/jpeg",
+ ownedObjectUrl: extraUrl,
+ dragUrl: extraUrl,
+ canvas: extraCanvas,
+ }),
+ );
+ }
+ // URL-based extras (bundled per-frame demos) decode directly without creating blob URLs.
+ for (const extraSource of additionalImageSources) {
+ if (!extraSource?.src) continue;
+ let extraImage;
+ try {
+ extraImage = await decodeImageElement(extraSource.src);
+ } catch {
+ continue;
+ }
+ const extraCanvas = document.createElement("canvas");
+ drawImageToCanvas(extraImage, extraCanvas);
+ entries.push(
+ createSourceImageEntry({
+ image: extraImage,
+ filename: extraSource.filename || "",
+ mimeType: extraSource.mimeType || "image/jpeg",
+ ownedObjectUrl: "",
+ dragUrl: extraSource.src,
+ canvas: extraCanvas,
+ }),
+ );
+ }
+ state.source.images = entries;
+ state.source.activeImageIndex = 0;
+ // Multiple uploaded images mean the user wants the per-frame pipeline (image count = frame
+ // count), so force it on and never silently drop the extras. The real radio arrives in Phase 6;
+ // until then state.runtime.forcePerFrameMode drives readConfig. Ticking the radio when present
+ // keeps this forward-compatible.
+ if (entries.length > 1) {
+ state.runtime.forcePerFrameMode = true;
+ if (dom.alignmentPipelinePerFrame) {
+ dom.alignmentPipelinePerFrame.checked = true;
+ }
+ }
syncPaperPresetUi?.();
renderRawPreview();
const hasSettingsText = !!settingsText.trim();
@@ -219,6 +368,20 @@ export async function loadImageSource({
applyLoadedSettingsText(settingsText);
state.source.settingsLoaded = true;
}
+ // Keep the active entry's page-corner override in sync with the legacy field after any
+ // settings load. Per-image override routing (the per-frame buffer below) takes precedence; this
+ // just mirrors the single-image/legacy case.
+ sourceEntry.manualPageContour = state.source.manualPageContour ?? null;
+ // Per-frame settings restore: a saved project cannot embed image data, so the user re-uploads the
+ // same N images in the same order. applyLoadedSettingsText parsed the indexed per-image overrides
+ // into state.source.pendingPerImageOverrides; reattach each one to its image by upload order, then
+ // consume the buffer so a later re-load does not reapply stale overrides. Only when overrides were
+ // actually restored do we refresh the active entry's legacy field + Post-Rotation slider + Page
+ // Corners overlay + strip via setActiveImage, so single-image markers/markerless loads are
+ // untouched.
+ if (applyPendingPerImageOverrides(state)) {
+ refreshActiveImage?.(state.source.activeImageIndex);
+ }
invalidateAppearanceCache();
setStatus(`${loadedWhat}\n${t("status.analyzingPage")}`);
await waitForNextPaint();
diff --git a/js/per-frame-strip.js b/js/per-frame-strip.js
new file mode 100644
index 0000000..6ce8d6c
--- /dev/null
+++ b/js/per-frame-strip.js
@@ -0,0 +1,373 @@
+/**
+ * Per-frame image strip UI (Phase 7).
+ *
+ * The strip is only meaningful in the per-frame alignment pipeline, where each uploaded image is one
+ * animation frame. It renders one thumbnail per `state.source.images[i]` and lets the user:
+ * - switch the active image (click) — UI navigation only, no reprocessing;
+ * - reorder frames (HTML5 drag-and-drop within the strip) — reprocesses (frame order changed);
+ * - delete a frame (× on hover) — releases that entry's blob URL / canvas / cached Mat and
+ * reprocesses with N-1 frames;
+ * - add more frames (the trailing `+` tile) — reuses the Phase 4 decode path and reprocesses.
+ *
+ * This module deliberately holds all strip rendering + event handling so app.js / ui-controls.js stay
+ * lean. It is wired in from ui-controls.js with the small set of callbacks it needs (active-image
+ * select, reprocess trigger, add-images load path) plus the shared `dom` / `state` handles.
+ */
+import { t } from "./i18n.js";
+import {
+ getActiveSourceImage,
+ releaseEntryRectifiedCache,
+} from "./source-images.js";
+
+/** @type {StripDeps | null} Bound dependencies, set once by attachPerFrameStrip. */
+let deps = null;
+
+/** Signature of the last render so re-renders only rebuild when images[]/activeIndex changed. */
+let lastRenderSignature = "";
+
+/** Index of the thumbnail currently being dragged (reorder), or -1 when not dragging. */
+let dragSourceIndex = -1;
+
+/**
+ * @typedef {Object} StripDeps
+ * @property {import("./dom-state.js").dom} dom
+ * @property {import("./dom-state.js").state} state
+ * @property {(index:number) => void} setActiveImage Switch the active image (no reprocess).
+ * @property {() => void} reprocess Trigger a debounced reprocess (reorder/delete).
+ * @property {(files: File[]) => Promise} addImageFiles Decode + append images, then reprocess.
+ * @property {() => boolean} isPerFrameModeActive Whether the per-frame pipeline is currently active.
+ * @property {() => void} clearPreviews Blank all downstream previews (used for the empty state when the
+ * last image is deleted, since `reprocess` no-ops with no source image).
+ */
+
+/**
+ * Wire the per-frame strip's add-images file input and remember the shared dependencies.
+ *
+ * Safe to call once during app startup. The strip itself is rendered lazily via `renderPerFrameStrip`.
+ *
+ * @param {StripDeps} boundDeps
+ * @returns {void}
+ */
+export function attachPerFrameStrip(boundDeps) {
+ deps = boundDeps;
+ const { dom } = deps;
+ const addInput = dom.perFrameStripFileInput;
+ if (addInput) {
+ addInput.addEventListener("change", () => {
+ const files = Array.from(addInput.files || []).filter(Boolean);
+ // Reset the input so picking the same file again still fires `change`.
+ addInput.value = "";
+ if (files.length === 0) return;
+ void deps.addImageFiles(files);
+ });
+ }
+}
+
+/**
+ * Build a cheap signature describing the current strip contents so we only rebuild on real changes.
+ *
+ * @returns {string}
+ */
+function computeSignature() {
+ const { state } = deps;
+ const images = Array.isArray(state.source.images) ? state.source.images : [];
+ const parts = images.map((entry) => (entry && entry.canvas ? "1" : "0"));
+ return `${state.source.activeImageIndex}|${images.length}|${parts.join(",")}`;
+}
+
+/**
+ * Re-render the strip if the per-frame mode visibility or the images[]/activeIndex changed.
+ *
+ * Hidden (and emptied) in markers / markerless modes so legacy flows never see strip markup.
+ * Idempotent: a no-op when neither the visibility nor the signature changed since the last render.
+ *
+ * @returns {void}
+ */
+export function renderPerFrameStrip() {
+ if (!deps) return;
+ const { dom, state, isPerFrameModeActive } = deps;
+ const panel = dom.perFrameStripPanel;
+ const container = dom.perFrameStrip;
+ if (!panel || !container) return;
+
+ const active = isPerFrameModeActive();
+ if (!active) {
+ // Leave no strip markup behind in legacy modes.
+ if (!panel.hidden || container.childElementCount > 0) {
+ panel.hidden = true;
+ container.replaceChildren();
+ lastRenderSignature = "";
+ }
+ return;
+ }
+ panel.hidden = false;
+
+ const signature = computeSignature();
+ if (signature === lastRenderSignature && container.childElementCount > 0) {
+ // Nothing visible changed (count + per-entry presence + activeIndex all match); avoid thrashing
+ // the DOM. Reorder/delete reset `lastRenderSignature` to "" first to force a rebuild, since a
+ // reorder can leave the signature unchanged while the visible order differs.
+ return;
+ }
+ lastRenderSignature = signature;
+
+ const images = Array.isArray(state.source.images) ? state.source.images : [];
+ const activeIndex = state.source.activeImageIndex;
+ const fragment = document.createDocumentFragment();
+
+ images.forEach((entry, index) => {
+ fragment.appendChild(buildThumbnail(entry, index, index === activeIndex));
+ });
+ fragment.appendChild(buildAddTile());
+ container.replaceChildren(fragment);
+
+ // Frame-count readout (singular / plural).
+ const count = images.length;
+ if (dom.perFrameStripCount) {
+ dom.perFrameStripCount.textContent =
+ count === 1 ? t("photo.strip.frameCountOne") : t("photo.strip.frameCount", { count });
+ }
+}
+
+/**
+ * Build one thumbnail tile for a source entry.
+ *
+ * @param {import("./source-images.js").SourceImageEntry} entry
+ * @param {number} index
+ * @param {boolean} isActive
+ * @returns {HTMLElement}
+ */
+function buildThumbnail(entry, index, isActive) {
+ const tile = document.createElement("div");
+ tile.className = "per-frame-thumb";
+ tile.setAttribute("role", "listitem");
+ tile.dataset.index = String(index);
+ tile.draggable = true;
+ if (isActive) tile.classList.add("is-active");
+ tile.setAttribute("aria-label", t("photo.strip.selectLabel", { index: index + 1 }));
+
+ const img = document.createElement("img");
+ // Prefer a drag/display URL; fall back to drawing from the entry's canvas via a data URL is avoided
+ // for cost — the dragUrl (blob/demo) is always present for loaded entries.
+ if (entry && entry.dragUrl) {
+ img.src = entry.dragUrl;
+ } else if (entry && entry.canvas) {
+ try {
+ img.src = entry.canvas.toDataURL("image/jpeg", 0.5);
+ } catch {
+ /* tainted/empty canvas — leave src empty */
+ }
+ }
+ img.alt = "";
+ tile.appendChild(img);
+
+ const number = document.createElement("span");
+ number.className = "per-frame-thumb-number";
+ number.textContent = String(index + 1);
+ tile.appendChild(number);
+
+ const del = document.createElement("button");
+ del.type = "button";
+ del.className = "per-frame-thumb-delete";
+ del.textContent = "×"; // ×
+ del.setAttribute("aria-label", t("photo.strip.deleteLabel", { index: index + 1 }));
+ del.addEventListener("click", (event) => {
+ event.stopPropagation();
+ deleteImageAt(index);
+ });
+ tile.appendChild(del);
+
+ tile.addEventListener("click", () => {
+ selectImageAt(index);
+ });
+
+ attachReorderHandlers(tile, index);
+ return tile;
+}
+
+/**
+ * Build the trailing `+` tile that adds more images.
+ *
+ * @returns {HTMLElement}
+ */
+function buildAddTile() {
+ const tile = document.createElement("button");
+ tile.type = "button";
+ tile.className = "per-frame-thumb-add";
+ tile.textContent = "+";
+ tile.setAttribute("aria-label", t("photo.strip.addLabel"));
+ tile.addEventListener("click", () => {
+ deps.dom.perFrameStripFileInput?.click();
+ });
+ // Allow dropping additional images directly onto the add tile.
+ tile.addEventListener("dragover", (event) => {
+ if (event.dataTransfer?.types?.includes("Files")) {
+ event.preventDefault();
+ tile.classList.add("is-drag-over");
+ }
+ });
+ tile.addEventListener("dragleave", () => {
+ tile.classList.remove("is-drag-over");
+ });
+ tile.addEventListener("drop", (event) => {
+ tile.classList.remove("is-drag-over");
+ const files = Array.from(event.dataTransfer?.files || []).filter((file) =>
+ String(file.type || "").startsWith("image/"),
+ );
+ if (files.length === 0) return;
+ event.preventDefault();
+ event.stopPropagation();
+ void deps.addImageFiles(files);
+ });
+ return tile;
+}
+
+/**
+ * Wire HTML5 drag-and-drop reorder handlers on a thumbnail tile. Scoped to the strip: we only react
+ * to drags whose source index we recorded on `dragstart`.
+ *
+ * @param {HTMLElement} tile
+ * @param {number} index
+ * @returns {void}
+ */
+function attachReorderHandlers(tile, index) {
+ tile.addEventListener("dragstart", (event) => {
+ dragSourceIndex = index;
+ tile.classList.add("is-dragging");
+ if (event.dataTransfer) {
+ event.dataTransfer.effectAllowed = "move";
+ // Some browsers require data to be set for the drag to begin.
+ try {
+ event.dataTransfer.setData("text/plain", String(index));
+ } catch {
+ /* ignore */
+ }
+ }
+ });
+ tile.addEventListener("dragend", () => {
+ dragSourceIndex = -1;
+ tile.classList.remove("is-dragging");
+ });
+ tile.addEventListener("dragover", (event) => {
+ // Only handle in-strip thumbnail reorders (a recorded source index). File drags fall through to
+ // the add tile / drop zone.
+ if (dragSourceIndex < 0) return;
+ event.preventDefault();
+ if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
+ tile.classList.add("is-drag-over");
+ });
+ tile.addEventListener("dragleave", () => {
+ tile.classList.remove("is-drag-over");
+ });
+ tile.addEventListener("drop", (event) => {
+ tile.classList.remove("is-drag-over");
+ if (dragSourceIndex < 0) return;
+ event.preventDefault();
+ event.stopPropagation();
+ const from = dragSourceIndex;
+ dragSourceIndex = -1;
+ reorderImages(from, index);
+ });
+}
+
+/**
+ * Select the active image at `index` (UI navigation; no reprocess).
+ *
+ * @param {number} index
+ * @returns {void}
+ */
+function selectImageAt(index) {
+ deps.setActiveImage(index);
+ renderPerFrameStrip();
+}
+
+/**
+ * Move the entry at `from` to occupy position `to`, keeping the same logical entry active, and
+ * reprocess because frame order changed.
+ *
+ * @param {number} from
+ * @param {number} to
+ * @returns {void}
+ */
+function reorderImages(from, to) {
+ const { state } = deps;
+ const images = state.source.images;
+ if (!Array.isArray(images)) return;
+ if (from === to || from < 0 || to < 0 || from >= images.length || to >= images.length) return;
+
+ // Track which entry is currently active so the same logical image stays selected after reorder.
+ const activeEntry = getActiveSourceImage(state);
+ const [moved] = images.splice(from, 1);
+ images.splice(to, 0, moved);
+
+ // Re-derive the active index from the moved entry's new position.
+ const newActiveIndex = activeEntry ? images.indexOf(activeEntry) : 0;
+ // Repoint legacy projections at the (unchanged logical) active entry without redundant work.
+ deps.setActiveImage(newActiveIndex >= 0 ? newActiveIndex : 0);
+
+ // Force a rebuild even though signature length is unchanged (order changed but counts did not).
+ lastRenderSignature = "";
+ renderPerFrameStrip();
+ deps.reprocess();
+}
+
+/**
+ * Delete the entry at `index`: release its blob URL + canvas + cached rectified Mat, adjust the
+ * active index, and reprocess with N-1 frames. Deleting the last image returns to an empty state.
+ *
+ * @param {number} index
+ * @returns {void}
+ */
+function deleteImageAt(index) {
+ const { state } = deps;
+ const images = state.source.images;
+ if (!Array.isArray(images) || index < 0 || index >= images.length) return;
+
+ const [removed] = images.splice(index, 1);
+ releaseEntry(removed);
+
+ if (images.length === 0) {
+ // Empty state: clear the active index and the legacy projections so the app reads "no image".
+ state.source.activeImageIndex = 0;
+ state.source.image = null;
+ lastRenderSignature = "";
+ renderPerFrameStrip();
+ // `reprocess` (scheduleProcess) no-ops with no source image, which would leave the prior
+ // rectified sheet / animation on screen. Blank the downstream previews instead so the empty
+ // state is visually consistent.
+ deps.clearPreviews();
+ return;
+ }
+
+ // Keep the active selection sensible: clamp to the new range, biasing toward the prior neighbor.
+ let nextActive = state.source.activeImageIndex;
+ if (index < nextActive) nextActive -= 1;
+ if (nextActive >= images.length) nextActive = images.length - 1;
+ if (nextActive < 0) nextActive = 0;
+ deps.setActiveImage(nextActive);
+
+ lastRenderSignature = "";
+ renderPerFrameStrip();
+ deps.reprocess();
+}
+
+/**
+ * Release every owned resource of a removed entry: its blob URL, cached rectified Mat, and canvas.
+ *
+ * @param {import("./source-images.js").SourceImageEntry | null | undefined} entry
+ * @returns {void}
+ */
+function releaseEntry(entry) {
+ if (!entry) return;
+ if (entry.ownedObjectUrl) {
+ try {
+ URL.revokeObjectURL(entry.ownedObjectUrl);
+ } catch {
+ /* already revoked */
+ }
+ entry.ownedObjectUrl = "";
+ }
+ releaseEntryRectifiedCache(entry);
+ entry.image = null;
+ entry.canvas = null;
+}
diff --git a/js/pipeline.js b/js/pipeline.js
index cd895a5..49292f7 100644
--- a/js/pipeline.js
+++ b/js/pipeline.js
@@ -26,6 +26,20 @@ const MARKERLESS_PHASE_BAND_WIDTH = 3;
const MARKERLESS_DEFAULT_CORNER_TILE_SCALE = 0.52;
const MARKERLESS_DEFAULT_CORNER_TILE_MAX_SIDE_PX = 127;
const RECTIFIED_PREVIEW_LONG_EDGE_PX = 2200;
+// Per-frame mode resizes every rectified page to one common cell size before stacking them into a
+// synthetic 1×N composite sheet. The chosen median cell dimensions are clamped to this range so a
+// single oversized page cannot blow up the composite Mat. This is the per-dimension guard; the strict
+// composite-area ceiling (PER_FRAME_COMPOSITE_AREA_CEILING_PX) below bounds the whole stacked sheet.
+const PER_FRAME_MIN_CELL_PX = 16;
+const PER_FRAME_MAX_CELL_PX = 1600;
+// Strict composite-area ceiling for the stacked 1×N per-frame sheet. The existing single-page path
+// never materializes a rectified sheet larger than RECTIFIED_PREVIEW_LONG_EDGE_PX on its long edge
+// (matToPreviewCanvas caps the preview, and the high-res warp is diagonal-capped), so the largest
+// single working sheet is bounded by RECTIFIED_PREVIEW_LONG_EDGE_PX². The composite (cellW × N × cellH)
+// is held to that same total-area budget; if the median cell size would exceed it, cellW and cellH are
+// scaled down uniformly (aspect preserved) before allocation.
+const PER_FRAME_COMPOSITE_AREA_CEILING_PX =
+ RECTIFIED_PREVIEW_LONG_EDGE_PX * RECTIFIED_PREVIEW_LONG_EDGE_PX;
const NEAR_IDENTITY_PAGE_AREA_PCT = 0.998;
const NEAR_IDENTITY_CORNER_TOLERANCE_PX = 2;
const NEAR_IDENTITY_DIM_TOLERANCE_PX = 2;
@@ -246,7 +260,384 @@ function clampPositiveConvolutionToUint8(conv32, target8) {
* rectifiedDownloadUsesRawSource: boolean
* }}
*/
-export function runPipeline(sourceCanvas, config, requestId, throwIfAborted) {
+export function runPipeline(sourceCanvas, config, requestId, throwIfAborted, images = null) {
+ // Per-frame mode does not rectify a single frame-sheet; it rectifies one page per uploaded image
+ // and stacks them into a synthetic 1×N composite. Dispatch before touching the single-page path.
+ // `images` is passed in by the caller so this module never reaches into `state` directly.
+ if (config.alignmentPipeline === "per-frame") {
+ return runPerFramePipeline(images || [], config, requestId, throwIfAborted);
+ }
+
+ const useMarkerlessAlignment = config.alignmentPipeline === "markerless";
+ let rectifiedWarp = null;
+ let pageRectification = null;
+
+ try {
+ // Page detection + rectification + Post-Rotation now live in a reusable helper so the per-frame
+ // pipeline can run them once per uploaded image. The caller owns the returned `rectifiedWarp`.
+ pageRectification = rectifySinglePage(sourceCanvas, config, requestId, throwIfAborted);
+ rectifiedWarp = pageRectification.rectifiedWarp;
+ const {
+ pageQuad,
+ pageQuadSource,
+ pageWarpPreviewCanvas,
+ pageWarpPreviewWidth,
+ pageWarpPreviewHeight,
+ useNearIdentityRectification,
+ threshVal,
+ pageSizeLow,
+ pageSizeHigh,
+ } = pageRectification;
+
+ const pagePreviewGridBounds = rectifiedWarp.previewGridQuad
+ ? { ...rectifiedWarp.gridBounds }
+ : null;
+
+ // Resolve the marker lattice if enabled; otherwise keep the nominal grid and unrefined ROI views.
+ const alignmentInfo = config.useCrossAlignment
+ ? (
+ useMarkerlessAlignment
+ ? buildMarkerlessAlignmentData(
+ rectifiedWarp.visionMat,
+ config.frameCols,
+ config.frameRows,
+ config.crossRoiScale,
+ rectifiedWarp.gridBounds,
+ config.paperMarginXPx,
+ config.paperMarginYPx,
+ {
+ useDarkness: config.markerlessUseDarkness,
+ useTexture: config.markerlessUseTexture,
+ useVariance: config.markerlessUseVariance,
+ lightOnDark: config.lightOnDarkDesign,
+ blurScale: config.markerlessAutocorrelationBlurScale,
+ },
+ )
+ : buildCrossAlignmentData(
+ rectifiedWarp.visionMat,
+ config.frameCols,
+ config.frameRows,
+ config.crossRoiScale,
+ rectifiedWarp.gridBounds,
+ {
+ markerType: config.alignmentMarkerType,
+ includeCornerCrosses: rectifiedWarp.includeCornerCrosses,
+ detectCrossesWithConvolution: config.detectCrossesWithConvolution,
+ }
+ )
+ )
+ : buildUnrefinedCrossRegionInfo(
+ rectifiedWarp.visionMat,
+ config.frameCols,
+ config.frameRows,
+ "disabled",
+ rectifiedWarp.gridBounds,
+ config.crossRoiScale,
+ {
+ markerType: config.alignmentMarkerType,
+ includeCornerCrosses: rectifiedWarp.includeCornerCrosses,
+ }
+ );
+ // In the all-cross format, the coarse quad is only approximate; use the detected corner crosses
+ // to tighten the working grid bounds before frame extraction.
+ if (rectifiedWarp.includeCornerCrosses) {
+ refineAlignmentBoundsFromCornerCrosses(alignmentInfo);
+ }
+ throwIfAborted(requestId);
+
+ // Extract each animation frame from the styled rectified sheet with the chosen interpolation mode.
+ const frames = sliceRectifiedToCanvases(
+ rectifiedWarp.styledMat,
+ alignmentInfo,
+ config.crop,
+ getCvInterpolationFlag(config.exportOptions.resampling),
+ requestId,
+ throwIfAborted
+ );
+ const rectifiedCanvas = matToPreviewCanvas(rectifiedWarp.styledMat, RECTIFIED_PREVIEW_LONG_EDGE_PX);
+ const rectifiedMat = rectifiedWarp.styledMat;
+ rectifiedWarp.styledMat = null;
+ const statusText = buildStatusText({
+ threshVal,
+ rawWidth: sourceCanvas.width,
+ rawHeight: sourceCanvas.height,
+ pageAreaPct: pageQuad.areaPct,
+ pageWarpWidth: pageSizeLow.width,
+ pageWarpHeight: pageSizeLow.height,
+ highPageWarpWidth: pageSizeHigh.width,
+ highPageWarpHeight: pageSizeHigh.height,
+ alignmentInfo,
+ frameCount: frames.length,
+ expectedFrameCount: config.frameCols * config.frameRows,
+ rectifiedWidth: rectifiedMat.cols,
+ rectifiedHeight: rectifiedMat.rows,
+ animationWidth: frames[0]?.width || 0,
+ animationHeight: frames[0]?.height || 0,
+ gridDetector: "cross-only",
+ });
+
+ return {
+ frames,
+ rectifiedCanvas,
+ rectifiedMat,
+ pagePreviewCanvas: pageWarpPreviewCanvas,
+ pagePreviewWidth: pageWarpPreviewWidth,
+ pagePreviewHeight: pageWarpPreviewHeight,
+ pagePreviewGridQuad: rectifiedWarp.previewGridQuad || null,
+ pagePreviewGridBounds,
+ alignmentInfo,
+ statusText,
+ pageQuadPoints: pageQuad.points,
+ pageQuadSource,
+ rectifiedDownloadUsesRawSource:
+ useNearIdentityRectification &&
+ useMarkerlessAlignment &&
+ Math.abs(Number(config.postRotationDeg) || 0) < 1e-6,
+ };
+ } catch (error) {
+ // `rectifySinglePage` already attaches a partialResult for failures in the page-rectification
+ // stage. Only fill it in here for failures that happen later (alignment / extraction), where the
+ // page quad and warp preview are already available from the returned rectification info.
+ if (error?.name !== "ProcessAbortedError") {
+ if (error && typeof error === "object" && !error.partialResult) {
+ error.partialResult = {
+ pageQuadPoints: pageRectification?.pageQuad?.points || null,
+ pageQuadSource: pageRectification?.pageQuadSource || "pipeline-detection",
+ rectifiedCanvas: pageRectification?.pageWarpPreviewCanvas || null,
+ };
+ }
+ }
+ throw error;
+ } finally {
+ // `rectifySinglePage` released every intermediate Mat itself; only the returned rectifiedWarp is
+ // still owned here. Its styledMat is set to null after being handed off as `rectifiedMat`.
+ rectifiedWarp?.visionMat?.delete();
+ rectifiedWarp?.styledMat?.delete();
+ }
+}
+
+/**
+ * Per-frame alignment pipeline.
+ *
+ * Instead of one frame-sheet photo, the user uploads one image per animation frame. Each image is
+ * page-rectified independently (reusing {@link rectifySinglePage}), every rectified page is resized
+ * to a single common cell size, and the cells are stacked horizontally into a synthetic `1 × N`
+ * composite sheet. From that point on the rest of the app treats the composite exactly like a
+ * markerless frame-sheet, so stabilization / ordering / appearance / export need no per-frame
+ * branching.
+ *
+ * Ownership: the caller takes ownership of the returned `rectifiedMat` (the composite) and must
+ * delete it. Every other Mat allocated here — per-image rectified pages and per-cell resize buffers —
+ * is released internally, including on abort or failure.
+ *
+ * Each `rectifySinglePage` call is handed a shallow copy of `config` with the per-image fields
+ * overridden. `alignmentPipeline` is aliased to `"markerless"` for that copy because
+ * `rectifySinglePage` branches its near-identity fast path and grid rectification on that value; the
+ * base `config.alignmentPipeline` stays `"per-frame"` for the dispatcher and downstream UI gating.
+ *
+ * @param {Array<{canvas:HTMLCanvasElement, manualPageContour?:object|null, postRotationDeg?:number}>} images
+ * @param {object} config
+ * @param {number} requestId
+ * @param {(requestId:number) => void} throwIfAborted
+ * @returns {{
+ * frames: HTMLCanvasElement[],
+ * rectifiedCanvas: HTMLCanvasElement,
+ * rectifiedMat: cv.Mat,
+ * pagePreviewCanvas: null,
+ * pagePreviewWidth: number,
+ * pagePreviewHeight: number,
+ * pagePreviewGridQuad: null,
+ * pagePreviewGridBounds: null,
+ * alignmentInfo: object,
+ * statusText: string,
+ * pageQuadPoints: null,
+ * pageQuadSource: "per-frame",
+ * rectifiedDownloadUsesRawSource: false
+ * }}
+ */
+function runPerFramePipeline(images, config, requestId, throwIfAborted) {
+ const entries = Array.isArray(images) ? images.filter((entry) => entry && entry.canvas) : [];
+ if (entries.length === 0) {
+ throw new Error("Per-frame mode requires at least one uploaded image.");
+ }
+ const interpolation = getCvInterpolationFlag(config.exportOptions.resampling);
+ /** @type {(cv.Mat | null)[]} Per-image styled (BGR) rectified pages, freed as they are composited. */
+ const cellStyledMats = [];
+ let composite = null;
+
+ try {
+ // 1-3. Rectify each uploaded page independently. Per-frame mode does not run per-image alignment,
+ // so the grayscale vision Mat is released immediately and only the styled (BGR) page is kept as
+ // the cell source.
+ for (const entry of entries) {
+ throwIfAborted(requestId);
+ const perImageConfig = {
+ ...config,
+ // rectifySinglePage branches its fast path / grid rectification on this value, so per-frame
+ // mode aliases to "markerless" here rather than passing "per-frame".
+ alignmentPipeline: "markerless",
+ // Per-image page-corner override (source-space quad). Phase 5 will populate this from the UI.
+ manualPageQuadPoints: entry.manualPageContour ?? null,
+ // No live threshold-preview fallback when rectifying per image.
+ fallbackPageQuadPoints: null,
+ // Per-image Post-Rotation. Phase 5 stores this on the image entry.
+ postRotationDeg: entry.postRotationDeg ?? 0,
+ };
+ const rectification = rectifySinglePage(entry.canvas, perImageConfig, requestId, throwIfAborted);
+ const rectifiedWarp = rectification.rectifiedWarp;
+ rectifiedWarp.visionMat?.delete();
+ rectifiedWarp.visionMat = null;
+ cellStyledMats.push(rectifiedWarp.styledMat);
+ }
+ throwIfAborted(requestId);
+
+ // 4. Pick a single common cell size from the median rectified dimensions. The per-dimension cap
+ // is applied as a UNIFORM long-edge scale so the rectified-page aspect ratio (which carries the
+ // Layout paper aspect) survives; clamping width and height independently would square off any
+ // cell whose both dimensions exceed the cap and squish the frames.
+ const frameCount = cellStyledMats.length;
+ const widths = cellStyledMats.map((mat) => mat.cols);
+ const heights = cellStyledMats.map((mat) => mat.rows);
+ let cellW = Math.max(1, Math.round(computeMedian(widths)));
+ let cellH = Math.max(1, Math.round(computeMedian(heights)));
+ const longEdgeScale = Math.min(1, PER_FRAME_MAX_CELL_PX / Math.max(cellW, cellH));
+ cellW = Math.max(PER_FRAME_MIN_CELL_PX, Math.round(cellW * longEdgeScale));
+ cellH = Math.max(PER_FRAME_MIN_CELL_PX, Math.round(cellH * longEdgeScale));
+ // Strict composite-area ceiling: the whole stacked sheet (cellW × N × cellH) must fit the same
+ // memory budget the single-page path uses (RECTIFIED_PREVIEW_LONG_EDGE_PX²). If the median cells
+ // would exceed it, scale cellW and cellH down UNIFORMLY so the cell aspect ratio is preserved.
+ const compositeArea = cellW * cellH * frameCount;
+ if (compositeArea > PER_FRAME_COMPOSITE_AREA_CEILING_PX) {
+ const scale = Math.sqrt(PER_FRAME_COMPOSITE_AREA_CEILING_PX / compositeArea);
+ cellW = Math.max(PER_FRAME_MIN_CELL_PX, Math.round(cellW * scale));
+ cellH = Math.max(PER_FRAME_MIN_CELL_PX, Math.round(cellH * scale));
+ }
+
+ // 5-6. Resize each rectified page to the common cell size and copy it into its composite column.
+ // Each per-image styled Mat is freed immediately after it is consumed.
+ composite = new cv.Mat(cellH, cellW * frameCount, cv.CV_8UC3, new cv.Scalar(0, 0, 0));
+ const cellSize = new cv.Size(cellW, cellH);
+ for (let i = 0; i < frameCount; i++) {
+ throwIfAborted(requestId);
+ const styledMat = cellStyledMats[i];
+ const resized = new cv.Mat();
+ try {
+ if (styledMat.cols === cellW && styledMat.rows === cellH) {
+ styledMat.copyTo(resized);
+ } else {
+ cv.resize(styledMat, resized, cellSize, 0, 0, interpolation);
+ }
+ const roi = composite.roi(new cv.Rect(i * cellW, 0, cellW, cellH));
+ try {
+ resized.copyTo(roi);
+ } finally {
+ roi.delete();
+ }
+ } finally {
+ resized.delete();
+ styledMat.delete();
+ cellStyledMats[i] = null;
+ }
+ }
+ throwIfAborted(requestId);
+
+ // 7. Synthesize the 1×N corner lattice the downstream subsystems expect. The cell boundaries are
+ // known exactly (no detection needed), so the unrefined-region builder used by markerless mode is
+ // reused directly with regular corner intersections at the column boundaries.
+ const gridBounds = { left: 0, top: 0, width: composite.cols, height: composite.rows };
+ const alignmentInfo = buildUnrefinedCrossRegionInfo(
+ composite,
+ frameCount,
+ 1,
+ "per-frame",
+ gridBounds,
+ config.crossRoiScale,
+ { markerType: "crosses", includeCornerCrosses: true }
+ );
+ throwIfAborted(requestId);
+
+ // 9. Extract each frame canvas from the composite using the same slicer as the other pipelines.
+ const frames = sliceRectifiedToCanvases(
+ composite,
+ alignmentInfo,
+ config.crop,
+ interpolation,
+ requestId,
+ throwIfAborted
+ );
+
+ // 8. Bounded preview canvas of the composite sheet for the Rectified Grid panel.
+ const rectifiedCanvas = matToPreviewCanvas(composite, RECTIFIED_PREVIEW_LONG_EDGE_PX);
+
+ const statusText = [
+ t("status.framesExtracted", { count: frames.length, expected: frameCount }),
+ t("status.rectifiedSheet", { width: composite.cols, height: composite.rows }),
+ t("status.animationSize", { width: frames[0]?.width || 0, height: frames[0]?.height || 0 }),
+ ].join("\n");
+
+ // 10. Hand the composite to the caller; clear our reference so the finally block does not free it.
+ const rectifiedMat = composite;
+ composite = null;
+ return {
+ frames,
+ rectifiedCanvas,
+ rectifiedMat,
+ pagePreviewCanvas: null,
+ pagePreviewWidth: 0,
+ pagePreviewHeight: 0,
+ pagePreviewGridQuad: null,
+ pagePreviewGridBounds: null,
+ alignmentInfo,
+ statusText,
+ pageQuadPoints: null,
+ pageQuadSource: "per-frame",
+ rectifiedDownloadUsesRawSource: false,
+ };
+ } finally {
+ // Release any per-image styled Mats not yet consumed (abort/failure before they were composited).
+ for (const mat of cellStyledMats) {
+ mat?.delete?.();
+ }
+ // If we threw after allocating the composite but before handing it off, release it here.
+ composite?.delete?.();
+ }
+}
+
+/**
+ * Detect, rectify, and Post-Rotate a single source page into a working sheet.
+ *
+ * This is the shared page-localization + rectification stage of the pipeline, extracted so it can be
+ * reused per uploaded image by the per-frame pipeline. Markers and markerless modes both call it via
+ * {@link runPipeline}; behaviour is identical to the previous inline implementation.
+ *
+ * Ownership: the caller takes ownership of the returned `rectifiedWarp` (both `visionMat` and
+ * `styledMat`) and must delete those Mats. Every other intermediate Mat is released internally,
+ * including on failure.
+ *
+ * Reads from `config`: `alignmentPipeline` (near-identity fast path branches on markerless),
+ * `lightOnDarkDesign`, `thresholdMethod`, `thresholdOffset`, `manualPageQuadPoints`,
+ * `fallbackPageQuadPoints`, `paperAspect`, `postRotationDeg`, plus the frame-grid fields consumed by
+ * the marker rectifier (`useRectifiedAsSource`, `frameCols`, `frameRows`, `crossRoiScale`,
+ * `paperMarginXPx`, `paperMarginYPx`, `boundarySensitivity`, `boundaryPersistencePx`).
+ *
+ * @param {HTMLCanvasElement} sourceCanvas
+ * @param {object} config
+ * @param {number} requestId
+ * @param {(requestId:number) => void} throwIfAborted
+ * @returns {{
+ * rectifiedWarp: {visionMat:cv.Mat, styledMat:cv.Mat, gridBounds:{left:number, top:number, width:number, height:number}, includeCornerCrosses:boolean, previewGridQuad:object|null},
+ * pageQuad: {points:{x:number,y:number}[], areaPct:number, quadAreaPx:number},
+ * pageQuadSource: "pipeline-detection" | "manual-override" | "threshold-preview-fallback",
+ * pageWarpPreviewCanvas: HTMLCanvasElement | null,
+ * pageWarpPreviewWidth: number,
+ * pageWarpPreviewHeight: number,
+ * useNearIdentityRectification: boolean,
+ * threshVal: number,
+ * pageSizeLow: cv.Size,
+ * pageSizeHigh: cv.Size
+ * }}
+ */
+function rectifySinglePage(sourceCanvas, config, requestId, throwIfAborted) {
const useMarkerlessAlignment = config.alignmentPipeline === "markerless";
const useInvertedMarkerVision = !useMarkerlessAlignment && config.lightOnDarkDesign;
const styledSrcRgba = cv.imread(sourceCanvas);
@@ -378,126 +769,33 @@ export function runPipeline(sourceCanvas, config, requestId, throwIfAborted) {
pageWarpHigh = null;
}
applyPostRectificationRotation(rectifiedWarp, config.postRotationDeg);
- const pagePreviewGridBounds = rectifiedWarp.previewGridQuad
- ? { ...rectifiedWarp.gridBounds }
- : null;
throwIfAborted(requestId);
- // Resolve the marker lattice if enabled; otherwise keep the nominal grid and unrefined ROI views.
- const alignmentInfo = config.useCrossAlignment
- ? (
- useMarkerlessAlignment
- ? buildMarkerlessAlignmentData(
- rectifiedWarp.visionMat,
- config.frameCols,
- config.frameRows,
- config.crossRoiScale,
- rectifiedWarp.gridBounds,
- config.paperMarginXPx,
- config.paperMarginYPx,
- {
- useDarkness: config.markerlessUseDarkness,
- useTexture: config.markerlessUseTexture,
- useVariance: config.markerlessUseVariance,
- lightOnDark: config.lightOnDarkDesign,
- blurScale: config.markerlessAutocorrelationBlurScale,
- },
- )
- : buildCrossAlignmentData(
- rectifiedWarp.visionMat,
- config.frameCols,
- config.frameRows,
- config.crossRoiScale,
- rectifiedWarp.gridBounds,
- {
- markerType: config.alignmentMarkerType,
- includeCornerCrosses: rectifiedWarp.includeCornerCrosses,
- detectCrossesWithConvolution: config.detectCrossesWithConvolution,
- }
- )
- )
- : buildUnrefinedCrossRegionInfo(
- rectifiedWarp.visionMat,
- config.frameCols,
- config.frameRows,
- "disabled",
- rectifiedWarp.gridBounds,
- config.crossRoiScale,
- {
- markerType: config.alignmentMarkerType,
- includeCornerCrosses: rectifiedWarp.includeCornerCrosses,
- }
- );
- // In the all-cross format, the coarse quad is only approximate; use the detected corner crosses
- // to tighten the working grid bounds before frame extraction.
- if (rectifiedWarp.includeCornerCrosses) {
- refineAlignmentBoundsFromCornerCrosses(alignmentInfo);
- }
- throwIfAborted(requestId);
-
- // Extract each animation frame from the styled rectified sheet with the chosen interpolation mode.
- const frames = sliceRectifiedToCanvases(
- rectifiedWarp.styledMat,
- alignmentInfo,
- config.crop,
- getCvInterpolationFlag(config.exportOptions.resampling),
- requestId,
- throwIfAborted
- );
- const rectifiedCanvas = matToPreviewCanvas(rectifiedWarp.styledMat, RECTIFIED_PREVIEW_LONG_EDGE_PX);
- const rectifiedMat = rectifiedWarp.styledMat;
- rectifiedWarp.styledMat = null;
- const statusText = buildStatusText({
- threshVal,
- rawWidth: sourceCanvas.width,
- rawHeight: sourceCanvas.height,
- pageAreaPct: pageQuad.areaPct,
- pageWarpWidth: pageSizeLow.width,
- pageWarpHeight: pageSizeLow.height,
- highPageWarpWidth: pageSizeHigh.width,
- highPageWarpHeight: pageSizeHigh.height,
- alignmentInfo,
- frameCount: frames.length,
- expectedFrameCount: config.frameCols * config.frameRows,
- rectifiedWidth: rectifiedMat.cols,
- rectifiedHeight: rectifiedMat.rows,
- animationWidth: frames[0]?.width || 0,
- animationHeight: frames[0]?.height || 0,
- gridDetector: "cross-only",
- });
-
return {
- frames,
- rectifiedCanvas,
- rectifiedMat,
- pagePreviewCanvas: pageWarpPreviewCanvas,
- pagePreviewWidth: pageWarpPreviewWidth,
- pagePreviewHeight: pageWarpPreviewHeight,
- pagePreviewGridQuad: rectifiedWarp.previewGridQuad || null,
- pagePreviewGridBounds,
- alignmentInfo,
- statusText,
- pageQuadPoints: pageQuad.points,
+ rectifiedWarp,
+ pageQuad,
pageQuadSource,
- rectifiedDownloadUsesRawSource:
- useNearIdentityRectification &&
- useMarkerlessAlignment &&
- Math.abs(Number(config.postRotationDeg) || 0) < 1e-6,
+ pageWarpPreviewCanvas,
+ pageWarpPreviewWidth,
+ pageWarpPreviewHeight,
+ useNearIdentityRectification,
+ threshVal,
+ pageSizeLow,
+ pageSizeHigh,
};
} catch (error) {
- if (error?.name !== "ProcessAbortedError") {
- if (error && typeof error === "object") {
- error.partialResult = {
- pageQuadPoints: pageQuad?.points || null,
- pageQuadSource,
- rectifiedCanvas: pageWarpPreviewCanvas,
- };
- }
+ if (error?.name !== "ProcessAbortedError" && error && typeof error === "object") {
+ error.partialResult = {
+ pageQuadPoints: pageQuad?.points || null,
+ pageQuadSource,
+ rectifiedCanvas: pageWarpPreviewCanvas,
+ };
}
- throw error;
- } finally {
+ // On failure the caller never receives `rectifiedWarp`, so release it here to avoid a Mat leak.
rectifiedWarp?.visionMat?.delete();
rectifiedWarp?.styledMat?.delete();
+ throw error;
+ } finally {
pageWarpLow?.visionMat?.delete();
pageWarpLow?.styledMat?.delete();
if (pageWarpHigh && pageWarpHigh.visionMat !== visionSrc) pageWarpHigh.visionMat?.delete();
@@ -876,6 +1174,12 @@ function makeManualPageQuad(points, sourceWidth, sourceHeight) {
* detection on a small image. The low-res pass matches the live slider preview and is less prone to
* choosing a one-pixel-connected image border when the paper/background tones are close.
*
+ * The downscaled retry first reuses the user's threshold settings; if that still selects the image
+ * border (common when a bright background merges with the paper under the default offset-peak
+ * threshold), it falls back to Otsu candidates, which separate paper from a uniformly bright
+ * surface much more reliably. A rescue quad is only accepted when it is a substantial interior
+ * quad, so the user's threshold settings stay authoritative whenever they produce a real page.
+ *
* This fallback is intentionally narrow: scanner-like inputs can still use the source boundary as
* the page when the downscaled pass does not find a substantial interior quad.
*
@@ -914,24 +1218,37 @@ function refineBorderPageQuadWithDownscaledDetection(
0,
cv.INTER_AREA
);
- applyPaperThreshold(previewGray, previewThresh, thresholdMethod, thresholdOffset);
- const previewQuad = findLargestQuad(previewThresh, previewWidth * previewHeight);
- if (isNearSourceBorderQuad(previewQuad.points, previewWidth, previewHeight)) return pageQuad;
- if (previewQuad.areaPct < BORDER_QUAD_REFINEMENT_MIN_AREA_PCT) return pageQuad;
-
- const scaledPoints = previewQuad.points.map((point) => ({
- x: point.x / scale,
- y: point.y / scale,
- }));
- const scaledContourArea = previewQuad.areaPx / (scale * scale);
- const scaledQuadArea = getPolygonArea(scaledPoints);
- return {
- points: scaledPoints,
- areaPx: scaledContourArea,
- quadAreaPx: scaledQuadArea,
- areaPct: scaledContourArea / Math.max(1, sourceWidth * sourceHeight),
- };
- } catch {
+ // Candidate threshold settings, tried in order until one yields a substantial interior quad.
+ // The user's own settings come first so explicit slider choices keep priority.
+ const thresholdCandidates = [
+ { method: thresholdMethod, offset: thresholdOffset },
+ { method: "otsu", offset: thresholdOffset },
+ { method: "otsu", offset: 0 },
+ ];
+ for (const candidate of thresholdCandidates) {
+ let previewQuad = null;
+ try {
+ applyPaperThreshold(previewGray, previewThresh, candidate.method, candidate.offset);
+ previewQuad = findLargestQuad(previewThresh, previewWidth * previewHeight);
+ } catch {
+ continue;
+ }
+ if (isNearSourceBorderQuad(previewQuad.points, previewWidth, previewHeight)) continue;
+ if (previewQuad.areaPct < BORDER_QUAD_REFINEMENT_MIN_AREA_PCT) continue;
+
+ const scaledPoints = previewQuad.points.map((point) => ({
+ x: point.x / scale,
+ y: point.y / scale,
+ }));
+ const scaledContourArea = previewQuad.areaPx / (scale * scale);
+ const scaledQuadArea = getPolygonArea(scaledPoints);
+ return {
+ points: scaledPoints,
+ areaPx: scaledContourArea,
+ quadAreaPx: scaledQuadArea,
+ areaPct: scaledContourArea / Math.max(1, sourceWidth * sourceHeight),
+ };
+ }
return pageQuad;
} finally {
previewGray.delete();
diff --git a/js/settings-defaults.js b/js/settings-defaults.js
index a49048e..1bb9c27 100644
--- a/js/settings-defaults.js
+++ b/js/settings-defaults.js
@@ -115,6 +115,9 @@ export function applyCropGeometryDefaults(dom) {
export function applyNonLayoutDefaults(dom) {
dom.alignmentPipelineMarkers.checked = SETTINGS_DEFAULTS.detection.alignmentPipeline === "markers";
dom.alignmentPipelineMarkerless.checked = SETTINGS_DEFAULTS.detection.alignmentPipeline === "markerless";
+ if (dom.alignmentPipelinePerFrame) {
+ dom.alignmentPipelinePerFrame.checked = SETTINGS_DEFAULTS.detection.alignmentPipeline === "per-frame";
+ }
if (dom.stabilizationMethodPairwise) {
dom.stabilizationMethodPairwise.checked = SETTINGS_DEFAULTS.detection.stabilizationMethod === "pairwise-cyclic";
}
diff --git a/js/settings-io.js b/js/settings-io.js
index b86d4db..cb26255 100644
--- a/js/settings-io.js
+++ b/js/settings-io.js
@@ -93,6 +93,9 @@ export function applyLoadedSettingsText({
if (!settingsText.trim()) return;
state.geometry.manualMarkerOverrides.clear();
state.source.manualPageContour = null;
+ // Legacy / markers / markerless files leave this null; per-frame files repopulate it below. Reset
+ // first so a markers file loaded after a per-frame file does not leave a stale pending buffer.
+ state.source.pendingPerImageOverrides = null;
const entries = new Map(
settingsText
.split(/\r?\n/)
@@ -144,11 +147,25 @@ export function applyLoadedSettingsText({
setIfPresent("post_rotation_deg", dom.postRotation);
const pipeline = entries.get("alignment_pipeline");
const markerType = entries.get("alignment_marker_type");
+ const usePerFramePipeline = pipeline === "per-frame";
const useMarkerlessPipeline =
- pipeline === "markerless" ||
- (pipeline !== "markers" && markerType === "none");
+ !usePerFramePipeline &&
+ (pipeline === "markerless" || (pipeline !== "markers" && markerType === "none"));
+ if (dom.alignmentPipelinePerFrame) {
+ dom.alignmentPipelinePerFrame.checked = usePerFramePipeline;
+ }
dom.alignmentPipelineMarkerless.checked = useMarkerlessPipeline;
- dom.alignmentPipelineMarkers.checked = !useMarkerlessPipeline;
+ // Per-frame is mutually exclusive with markers; without the radio (older DOM) we fall back to
+ // markers so the legacy two-radio invariant (`markers = !markerless`) is preserved.
+ dom.alignmentPipelineMarkers.checked = !useMarkerlessPipeline && !usePerFramePipeline;
+ // Reconcile the legacy `forcePerFrameMode` shim the same way the alignment-pipeline change-listener
+ // does (Phase 6), so the radio and the runtime flag never diverge after a settings load.
+ state.runtime.forcePerFrameMode = usePerFramePipeline;
+ // Parse the indexed per-image override keys into a pending buffer (consumed as images arrive, by
+ // upload order). Only present for per-frame files; legacy files leave the buffer null.
+ if (usePerFramePipeline) {
+ state.source.pendingPerImageOverrides = parsePerImageOverrides(entries);
+ }
const stabilizationMethod = entries.get("stabilization_method");
if (dom.stabilizationMethodAverage && dom.stabilizationMethodPairwise) {
const useAverageMethod = stabilizationMethod === "difference-from-average";
@@ -248,14 +265,6 @@ export function applyLoadedSettingsText({
}
syncPageCornerEditingUi?.();
syncRawPhotoCreditDisplay?.();
- if (dom.frameCountToExport) {
- const cols = Math.max(1, Math.min(20, Math.round(Number(dom.frameCols.value) || settingsDefaults.layout.frameCols)));
- const rows = Math.max(1, Math.min(20, Math.round(Number(dom.frameRows.value) || settingsDefaults.layout.frameRows)));
- const maxFrameCount = Math.max(1, cols * rows);
- dom.frameCountToExport.min = "1";
- dom.frameCountToExport.max = String(maxFrameCount);
- state.runtime.lastFrameExportCountMax = maxFrameCount;
- }
updateSliderReadouts();
}
@@ -277,6 +286,47 @@ function parsePageCornerOverrides(entries) {
return points.every(Boolean) ? points : null;
}
+/**
+ * Parse the indexed per-image overrides emitted by per-frame settings files into a pending buffer.
+ *
+ * Reads `per_frame_image_count` to size the buffer, then for each index `i` collects the four
+ * `page_corner_override_{tl,tr,br,bl}_i` rows (only when all four are present and valid) and the
+ * optional `per_frame_post_rotation_deg_i` row. The result is consumed by upload order as images
+ * arrive (index 0 → first uploaded image); see `state.source.pendingPerImageOverrides` in
+ * `js/dom-state.js` for the shape. An index with no saved override of either kind yields `null`.
+ *
+ * @param {Map} entries
+ * @returns {{ count: number, overrides: Array<{ manualPageContour: {x:number,y:number}[] | null, postRotationDeg: number } | null> } | null}
+ */
+function parsePerImageOverrides(entries) {
+ const rawCount = Number(entries.get("per_frame_image_count"));
+ const count = Number.isFinite(rawCount) && rawCount > 0 ? Math.floor(rawCount) : 0;
+ const overrides = [];
+ for (let index = 0; index < count; index += 1) {
+ const cornerKeys = [
+ `page_corner_override_tl_${index}`,
+ `page_corner_override_tr_${index}`,
+ `page_corner_override_br_${index}`,
+ `page_corner_override_bl_${index}`,
+ ];
+ let manualPageContour = null;
+ if (cornerKeys.every((key) => entries.has(key))) {
+ const points = cornerKeys.map((key) => parsePoint(entries.get(key)));
+ if (points.every(Boolean)) manualPageContour = points;
+ }
+ let postRotationDeg = 0;
+ const rawRotation = entries.get(`per_frame_post_rotation_deg_${index}`);
+ if (rawRotation !== undefined) {
+ const parsed = Number(rawRotation);
+ if (Number.isFinite(parsed)) postRotationDeg = parsed;
+ }
+ overrides.push(
+ manualPageContour || postRotationDeg !== 0 ? { manualPageContour, postRotationDeg } : null,
+ );
+ }
+ return { count, overrides };
+}
+
/**
* Parse one comma-separated point.
*
@@ -301,6 +351,7 @@ function parsePoint(value) {
* sourceCredit?: string,
* manualMarkerOverrides: Map,
* manualPageContour?: {x:number, y:number}[] | null,
+ * perImageEntries?: Array<{ manualPageContour?: {x:number, y:number}[] | null, postRotationDeg?: number }> | null,
* sanitizeFilenameBase: (filename:string) => string,
* }} deps
* @returns {string}
@@ -311,6 +362,7 @@ export function buildSettingsTsv({
sourceCredit = "",
manualMarkerOverrides,
manualPageContour = null,
+ perImageEntries = null,
sanitizeFilenameBase,
}) {
const rows = [
@@ -381,5 +433,28 @@ export function buildSettingsTsv({
return [`page_corner_override_${cornerName}`, `${point.x},${point.y}`];
})
: [];
- return [...rows, ...pageCornerOverrideRows, ...overrideRows].map(([key, value]) => `${key}\t${value}`).join("\n") + "\n";
+ // Per-frame mode persists one set of page-corner overrides + post-rotation per uploaded image,
+ // keyed by upload-order index. Reusing the exact single-image serialization format (corner names,
+ // comma-separated points) suffixed with `_i` keeps the file humanly inspectable. The keys are only
+ // emitted in per-frame mode; markers/markerless files stay byte-identical to before (additive).
+ const perFrameRows = [];
+ if (config.alignmentPipeline === "per-frame" && Array.isArray(perImageEntries)) {
+ perFrameRows.push(["per_frame_image_count", String(perImageEntries.length)]);
+ perImageEntries.forEach((entry, index) => {
+ const contour = entry?.manualPageContour;
+ if (Array.isArray(contour) && contour.length === 4) {
+ ["tl", "tr", "br", "bl"].forEach((cornerName, cornerIndex) => {
+ const point = contour[cornerIndex];
+ perFrameRows.push([`page_corner_override_${cornerName}_${index}`, `${point.x},${point.y}`]);
+ });
+ }
+ const postRotationDeg = Number(entry?.postRotationDeg);
+ if (Number.isFinite(postRotationDeg) && postRotationDeg !== 0) {
+ perFrameRows.push([`per_frame_post_rotation_deg_${index}`, String(postRotationDeg)]);
+ }
+ });
+ }
+ return [...rows, ...pageCornerOverrideRows, ...overrideRows, ...perFrameRows]
+ .map(([key, value]) => `${key}\t${value}`)
+ .join("\n") + "\n";
}
diff --git a/js/source-images.js b/js/source-images.js
new file mode 100644
index 0000000..adfa2f9
--- /dev/null
+++ b/js/source-images.js
@@ -0,0 +1,183 @@
+/**
+ * Per-image source state helpers.
+ *
+ * The per-frame alignment pipeline lets the user upload one image per animation frame. To support
+ * that without rewriting every legacy caller at once, the canonical per-image data lives in
+ * `state.source.images[]` and the existing `state.source.image / canvas / dragUrl / ...` fields are
+ * kept as projections of the active entry.
+ *
+ * During Phase 2 of the per-frame rollout, `state.source.images[]` always holds 0 or 1 entries.
+ * Later phases add multi-image upload, per-image overrides, and the strip UI on top of this shape.
+ */
+
+/**
+ * @typedef {Object} SourceImageEntry
+ * @property {HTMLImageElement | null} image Decoded source image element.
+ * @property {string} filename Original filename for status text / settings matching.
+ * @property {string} mimeType Source MIME type (e.g. `image/jpeg`).
+ * @property {string} ownedObjectUrl Blob URL this entry owns and must revoke on release ("" if none).
+ * @property {string} dragUrl URL used for raw-photo drag/download (may be a demo path or blob URL).
+ * @property {HTMLCanvasElement | null} canvas Source-resolution canvas the CV pipeline reads from.
+ * @property {Array<{x:number,y:number}> | null} manualPageContour Per-image source-space page quad override.
+ * @property {number} postRotationDeg Per-image Post-Rotation, applied after page rectification.
+ * @property {*} rectifiedMatCache Cached rectified Mat (or `{visionMat, styledMat}`); released on clear.
+ * @property {boolean} rectifiedDirty Whether the cached rectified Mat must be rebuilt.
+ */
+
+/**
+ * Build a fresh per-image source entry with sensible defaults.
+ *
+ * @param {Partial} [fields={}]
+ * @returns {SourceImageEntry}
+ */
+export function createSourceImageEntry(fields = {}) {
+ return {
+ image: fields.image ?? null,
+ filename: fields.filename ?? "",
+ mimeType: fields.mimeType ?? "",
+ ownedObjectUrl: fields.ownedObjectUrl ?? "",
+ dragUrl: fields.dragUrl ?? "",
+ canvas: fields.canvas ?? null,
+ manualPageContour: fields.manualPageContour ?? null,
+ postRotationDeg: fields.postRotationDeg ?? 0,
+ rectifiedMatCache: fields.rectifiedMatCache ?? null,
+ rectifiedDirty: fields.rectifiedDirty ?? true,
+ };
+}
+
+/**
+ * Return the active per-image source entry, or `null` when no images are loaded.
+ *
+ * @param {import("./dom-state.js").state} state
+ * @returns {SourceImageEntry | null}
+ */
+export function getActiveSourceImage(state) {
+ const images = state.source.images;
+ if (!Array.isArray(images) || images.length === 0) return null;
+ const index = state.source.activeImageIndex;
+ if (!Number.isInteger(index) || index < 0 || index >= images.length) return null;
+ return images[index];
+}
+
+/**
+ * Set the active per-image index (clamped to the loaded range) and return the new active entry.
+ *
+ * @param {import("./dom-state.js").state} state
+ * @param {number} index
+ * @returns {SourceImageEntry | null}
+ */
+export function setActiveSourceImage(state, index) {
+ const images = state.source.images;
+ if (!Array.isArray(images) || images.length === 0) {
+ state.source.activeImageIndex = 0;
+ return null;
+ }
+ const clamped = Math.max(0, Math.min(Math.trunc(index) || 0, images.length - 1));
+ state.source.activeImageIndex = clamped;
+ return images[clamped];
+}
+
+/**
+ * Store a manual page-corner override for the active image.
+ *
+ * The legacy `state.source.manualPageContour` field is the authoritative input for page detection in
+ * markers / markerless modes and is also read by many UI sites (status text, settings save, overlay
+ * draw). In per-frame mode the canonical per-image override lives on the active entry, so this helper
+ * writes the active entry's `manualPageContour` **and** mirrors to the legacy field so those legacy
+ * read sites keep working without per-frame-specific branching. In markers / markerless mode it only
+ * writes the legacy field (the per-image array is irrelevant there).
+ *
+ * @param {import("./dom-state.js").state} state
+ * @param {Array<{x:number,y:number}> | null} contour Source-space page quad override, or `null` to clear.
+ * @param {boolean} perFrameMode Whether the per-frame pipeline is currently active.
+ * @returns {void}
+ */
+export function setActiveManualPageContour(state, contour, perFrameMode) {
+ state.source.manualPageContour = contour;
+ if (perFrameMode) {
+ const active = getActiveSourceImage(state);
+ if (active) active.manualPageContour = contour;
+ }
+}
+
+/**
+ * Store the Post-Rotation value (degrees) for the active image.
+ *
+ * In per-frame mode each image carries its own Post-Rotation, so this writes the active entry's
+ * `postRotationDeg`. In markers / markerless mode Post-Rotation is a single global value applied by
+ * the pipeline from `config.postRotationDeg`, so the per-image array is left untouched.
+ *
+ * @param {import("./dom-state.js").state} state
+ * @param {number} deg
+ * @param {boolean} perFrameMode Whether the per-frame pipeline is currently active.
+ * @returns {void}
+ */
+export function setActivePostRotationDeg(state, deg, perFrameMode) {
+ if (!perFrameMode) return;
+ const active = getActiveSourceImage(state);
+ if (active) active.postRotationDeg = Number.isFinite(deg) ? deg : 0;
+}
+
+/**
+ * Release any cached rectified Mat held on a per-image entry.
+ *
+ * Handles both a bare OpenCV `Mat` and the `{ visionMat, styledMat }` rectified-warp shape produced
+ * by `rectifySinglePage`.
+ *
+ * @param {SourceImageEntry | null | undefined} entry
+ * @returns {void}
+ */
+export function releaseEntryRectifiedCache(entry) {
+ if (!entry) return;
+ const cache = entry.rectifiedMatCache;
+ if (cache) {
+ if (typeof cache.delete === "function") {
+ try {
+ cache.delete();
+ } catch {
+ /* already freed */
+ }
+ } else {
+ for (const mat of [cache.visionMat, cache.styledMat]) {
+ if (mat && typeof mat.delete === "function") {
+ try {
+ mat.delete();
+ } catch {
+ /* already freed */
+ }
+ }
+ }
+ }
+ }
+ entry.rectifiedMatCache = null;
+ entry.rectifiedDirty = true;
+}
+
+/**
+ * Release every per-image source entry: revoke owned blob URLs, free cached Mats, and reset the
+ * per-image array back to empty. Safe to call when no images are loaded.
+ *
+ * @param {import("./dom-state.js").state} state
+ * @returns {void}
+ */
+export function releaseAllSourceImages(state) {
+ const images = state.source.images;
+ if (Array.isArray(images)) {
+ for (const entry of images) {
+ if (!entry) continue;
+ if (entry.ownedObjectUrl) {
+ try {
+ URL.revokeObjectURL(entry.ownedObjectUrl);
+ } catch {
+ /* already revoked */
+ }
+ entry.ownedObjectUrl = "";
+ }
+ releaseEntryRectifiedCache(entry);
+ entry.image = null;
+ entry.canvas = null;
+ }
+ }
+ state.source.images = [];
+ state.source.activeImageIndex = 0;
+}
diff --git a/js/ui-controls.js b/js/ui-controls.js
index 4c0236d..ae2022d 100644
--- a/js/ui-controls.js
+++ b/js/ui-controls.js
@@ -5,6 +5,7 @@
* can supply callbacks without carrying the full DOM-listener implementation inline.
*/
import { t } from "./i18n.js";
+import { attachPerFrameStrip } from "./per-frame-strip.js";
/**
* Wire a small header reset button without toggling the parent details element.
@@ -92,6 +93,7 @@ export function initializeTooltips({ tooltipText, state, dom, applyTooltipState
*
* @param {{
* dom: import("./dom-state.js").dom,
+ * state: import("./dom-state.js").state,
* revokeGifUrl: () => void,
* updateSliderReadouts: () => void,
* scheduleProcess: (delayMs?: number) => void,
@@ -101,25 +103,43 @@ export function initializeTooltips({ tooltipText, state, dom, applyTooltipState
*/
function attachAlignmentPipelineControls({
dom,
+ state,
revokeGifUrl,
updateSliderReadouts,
scheduleProcess,
syncAlignmentMarkerUi,
}) {
const alignmentDom = dom.alignment;
- [alignmentDom.alignmentPipelineMarkerless, alignmentDom.alignmentPipelineMarkers].forEach((input) => {
- input.addEventListener("input", () => {
- syncAlignmentMarkerUi();
- revokeGifUrl();
- updateSliderReadouts();
- scheduleProcess();
- });
- input.addEventListener("change", () => {
- syncAlignmentMarkerUi();
- revokeGifUrl();
- scheduleProcess();
+ // The radio group is now the authoritative source of pipeline mode. A multi-image load set the
+ // legacy `forcePerFrameMode` shim (and ticked the per-frame radio); once the user interacts with
+ // the radio, reconcile the shim to the radio so switching *out* of per-frame actually sticks.
+ const reconcilePerFrameForceFlag = () => {
+ state.runtime.forcePerFrameMode = !!alignmentDom.alignmentPipelinePerFrame?.checked;
+ };
+ [
+ alignmentDom.alignmentPipelineMarkerless,
+ alignmentDom.alignmentPipelineMarkers,
+ alignmentDom.alignmentPipelinePerFrame,
+ ]
+ .filter(Boolean)
+ .forEach((input) => {
+ input.addEventListener("input", () => {
+ reconcilePerFrameForceFlag();
+ syncAlignmentMarkerUi();
+ revokeGifUrl();
+ updateSliderReadouts();
+ // Switching into per-frame mode before any images are uploaded must not process — there is
+ // nothing to rectify yet. `scheduleProcess` already no-ops when `state.source.image` is null
+ // (which is the empty-`images[]` case), so just wait for the upload to drive processing.
+ scheduleProcess();
+ });
+ input.addEventListener("change", () => {
+ reconcilePerFrameForceFlag();
+ syncAlignmentMarkerUi();
+ revokeGifUrl();
+ scheduleProcess();
+ });
});
- });
}
/**
@@ -581,6 +601,11 @@ function attachMarkerlessPhaseMetricToggles({
* beginPostRotationScrub: () => void,
* endPostRotationScrub: () => void,
* finishPostRotationScrubIfUnchanged: () => boolean,
+ * commitActivePostRotationFromSlider: () => void,
+ * setActiveImage: (index:number) => void,
+ * isPerFrameModeActive: () => boolean,
+ * addPerFrameImages: (files: File[]) => Promise,
+ * clearAllPreviews: () => void,
* bumpFrameOutputEpoch: () => void,
* setGeometryProcessingCursor: (active:boolean) => void,
* cancelInFlightProcessing: () => void,
@@ -651,6 +676,11 @@ export function attachUi({
beginPostRotationScrub,
endPostRotationScrub,
finishPostRotationScrubIfUnchanged,
+ commitActivePostRotationFromSlider,
+ setActiveImage,
+ isPerFrameModeActive,
+ addPerFrameImages,
+ clearAllPreviews,
bumpFrameOutputEpoch,
cancelInFlightProcessing,
invalidateFrameCaches,
@@ -686,6 +716,16 @@ export function attachUi({
makeLivePreviewDragCue();
makeGifImageDraggable();
+ attachPerFrameStrip({
+ dom,
+ state,
+ setActiveImage,
+ reprocess: scheduleProcess,
+ addImageFiles: addPerFrameImages,
+ isPerFrameModeActive,
+ clearPreviews: clearAllPreviews,
+ });
+
dom.dropZone.addEventListener("dragover", (event) => {
event.preventDefault();
dom.dropZone.classList.add("dragging");
@@ -814,6 +854,7 @@ export function attachUi({
attachAlignmentPipelineControls({
dom,
+ state,
revokeGifUrl,
updateSliderReadouts,
scheduleProcess,
@@ -989,6 +1030,9 @@ export function attachUi({
revokeGifUrl();
updateSliderReadouts();
if (finishPostRotationScrubIfUnchanged()) return;
+ // Per-frame mode stores Post-Rotation per image, so persist the slider value onto the active
+ // image before reprocessing. No-op (and unchanged behavior) in markers / markerless mode.
+ commitActivePostRotationFromSlider?.();
scheduleProcess();
});
}
diff --git a/llm_readme.md b/llm_readme.md
index 2029cbe..5e90fb9 100644
--- a/llm_readme.md
+++ b/llm_readme.md
@@ -44,9 +44,34 @@ This shared output structure is why the same editing panel can be reused as:
- `Frame Alignment Markers` in marker mode
- `Frame Alignment Centers` / `Corners` in markerless mode
+### Per-Frame
+
+Used when each animation frame is its own uploaded image rather than one cell of a single
+frame-sheet photo. Image count equals frame count (treated internally as a flat 1×N strip).
+
+- `pipeline.js → runPerFramePipeline(images, config, requestId, throwIfAborted)` is the entry point;
+ `runPipeline` dispatches to it when `config.alignmentPipeline === "per-frame"`.
+- each uploaded image is page-rectified independently via `rectifySinglePage` (called with a per-image
+ config copy aliased to `"markerless"`, carrying that image's `manualPageContour` and `postRotationDeg`)
+- the rectified pages are resized to a single common cell size (median rectified dimensions, clamped
+ uniformly by long edge so the Layout paper aspect survives, and held to the same total-area memory
+ ceiling as the single-page rectified path) and stacked into a synthetic 1×N composite
+ `baseRectifiedMat`
+- synthetic corner intersections are emitted into the same `markerLookup` structure (via
+ `buildUnrefinedCrossRegionInfo`), so downstream extraction/stabilization/ordering/export run unchanged
+- per-image page-corner and post-rotation overrides are post-load, pre-rectification edits on the
+ *active* image; switching the active image is UI navigation only and does not reprocess
+- the image strip (`js/per-frame-strip.js`) handles select / reorder / delete / add; reorder and delete
+ reprocess, select does not
+- per-image overrides round-trip through `_settings.txt` keyed by upload order (see
+ `js/settings-io.js`); reloading a saved project requires re-uploading the same images in the same order
+
+The full implementation plan, including the phased build-out and the per-phase "As built" notes, lives
+in [per_frame_pipeline_plan.md](per_frame_pipeline_plan.md).
+
## Light-on-dark design
-`Light-on-dark design` is shared by both pipelines.
+`Light-on-dark design` is shared by the marker and markerless pipelines.
### Markers
@@ -281,6 +306,16 @@ Overlays currently include:
- green connected edge preview while actively editing a marker/corner override
- red omitted-frame quads with a translucent gray fill and diagonal slash
+## Page Detection Border-Quad Rescue
+
+When full-resolution page detection selects (near-)the whole image as the largest quad,
+`pipeline.js → refineBorderPageQuadWithDownscaledDetection` retries detection on a 512px downscale.
+The retry first reuses the user's threshold method/offset; if that still yields a border quad it
+tries Otsu candidates (`otsu` at the user offset, then `otsu` at offset 0), which handle bright
+backgrounds that the default `offset-peak` threshold merges with the paper. A rescue quad is only
+accepted when it is a substantial interior quad (≥10% area, not near the image border), so genuine
+scanner-style full-bleed inputs still fall through to the source-boundary page.
+
## Page Detection Threshold Preview
`Page Detection Threshold` live scrubbing intentionally uses the lightweight grayscale preview cache for
diff --git a/per_frame_pipeline_plan.md b/per_frame_pipeline_plan.md
new file mode 100644
index 0000000..7652a77
--- /dev/null
+++ b/per_frame_pipeline_plan.md
@@ -0,0 +1,1212 @@
+# Per-Frame Alignment Pipeline — Implementation Plan
+
+This document plans the addition of a third `alignmentPipeline` mode — `"per-frame"` —
+that lets the user upload N images, one per animation frame, instead of a single
+frame-sheet photo. Each image is page-rectified independently and the rectified
+results are stacked into a synthetic `baseRectifiedMat`, after which the existing
+stabilization / ordering / appearance / export path runs unchanged.
+
+This is an engineering plan, not user docs. User-facing copy belongs in
+`documentation.md`; durable invariants belong in `AGENTS.md`.
+
+## Locked Decisions
+
+These were chosen up front so individual sessions do not need to relitigate them.
+
+1. **Frame count = image count (Option 1A).** The number of uploaded images is the
+ number of animation frames. Internally treated as a flat `1 × N` strip. The
+ `Frame Rows` / `Frame Columns` Layout controls become display-only / disabled
+ in per-frame mode.
+2. **Rescale to common cell size (Option 2B).** After per-image page rectification,
+ every cell is resized to a single common cell size (default: median rectified
+ width × median rectified height). All cells in the synthetic `baseRectifiedMat`
+ are the same size; stabilization and extraction work on uniform cells.
+3. **Persist per-image overrides (Option 3A).** Page-corner overrides and
+ post-rotation are saved per image using indexed settings keys
+ (`page_corner_override_tl_0`, `..._tl_1`, …, `per_frame_post_rotation_deg_0`,
+ …). Settings files round-trip cleanly.
+4. **Single image in per-frame mode is allowed (Option 4A).** Loading a single
+ image in per-frame mode produces a 1-frame animation. The user can add more
+ images via the strip afterwards.
+
+## Architectural Summary
+
+- Add `"per-frame"` as a third value for `config.alignmentPipeline`.
+- New per-image source structure under `state.source.images[]`; the existing
+ `state.source.image` / `manualPageContour` / `dragUrl` / etc. become *views into*
+ the active image (`state.source.images[state.source.activeImageIndex]`) so most
+ legacy callers keep working.
+- Factor out the page-detection + page-rectification block from
+ `pipeline.js → runPipeline` into a reusable `rectifySinglePage(...)`. Markers and
+ markerless modes continue to call it as before; per-frame mode calls it once per
+ uploaded image.
+- New `pipeline.js → runPerFramePipeline(images, config, …)`:
+ 1. Run `rectifySinglePage` per image (respecting per-image page-corner and
+ post-rotation overrides).
+ 2. Pick a common cell size (median of rectified sizes; clamped so the composite
+ fits within the existing large-image limits).
+ 3. Resize each rectified Mat to the common cell size.
+ 4. Concatenate horizontally into a single composite `rectifiedMat`
+ (`cellWidth * N × cellHeight × 1` row × N column grid).
+ 5. Synthesize an `alignmentInfo` whose marker lookup contains regular corner
+ intersections at the known cell boundaries.
+ 6. Return the same result shape that `runPipeline` returns.
+- `runPipeline` becomes a dispatcher: per-frame → `runPerFramePipeline`; otherwise
+ the existing markers/markerless path.
+- Downstream (frame extraction, stabilization, ordering, appearance, export) does
+ not branch on per-frame mode. The synthetic sheet looks like a 1×N markerless
+ sheet to those subsystems.
+
+## Phases
+
+Each phase below is self-contained: it lists scope, files, acceptance criteria,
+and explicit out-of-scope items so individual implementation sessions do not
+overreach.
+
+---
+
+### Phase 0 — Spike (½ day, throwaway)
+
+**Goal:** prove that a tiled composite Mat plus existing stabilization can produce
+a working animation end-to-end, before doing any real refactoring.
+
+**Scope:**
+- Hardcode two demo images (or load two arbitrary images via the existing file
+ input twice in sequence).
+- Add a dev-only code path that builds a `2 × cellW × cellH` composite Mat from
+ the two latest rectified Mats and stuffs it into `state.geometry.baseRectifiedMat`
+ alongside a fake `alignmentInfo` with two cell corners.
+- Confirm: preview animates, stabilization measurement runs, GIF export produces
+ the expected 2-frame GIF.
+
+**Files touched (throwaway code, do not merge as-is):**
+- `js/app.js` — small scratch block, clearly marked `// SPIKE: per-frame, remove`.
+
+**Acceptance:**
+- Two-frame GIF exports correctly.
+- Stabilization runs without errors and offsets are non-zero when the two source
+ images are deliberately misregistered.
+- Nothing about the marker/markerless code paths regresses for a normal source
+ image.
+
+**Out of scope:**
+- Real UI, real `state.source.images[]`, settings, i18n, mobile, memory trimming.
+- Any code that should survive into Phase 2+.
+
+**Rollback:** delete the spike block; no other files were touched.
+
+---
+
+### Phase 1 — Refactor: extract `rectifySinglePage`
+
+**Goal:** isolate the page-detection + page-rectification stage of `runPipeline`
+into a reusable helper without behavioral change to markers or markerless mode.
+
+**Scope:**
+- In `js/pipeline.js`, factor lines roughly 250–385 of `runPipeline` (page
+ detection through `applyPostRectificationRotation`) into a new function:
+ ```
+ rectifySinglePage(sourceCanvas, perPageConfig, requestId, throwIfAborted)
+ -> { rectifiedWarp, pageQuad, pageQuadSource, pageWarpPreviewCanvas,
+ pageWarpPreviewWidth, pageWarpPreviewHeight,
+ useNearIdentityRectification }
+ ```
+ Caller is responsible for Mat lifetime of the returned `rectifiedWarp`.
+- `perPageConfig` is a strict subset of the full config: `paperAspect`,
+ `manualPageQuadPoints`, `fallbackPageQuadPoints`, `thresholdMethod`,
+ `thresholdOffset`, `postRotationDeg`, `lightOnDarkDesign`, `alignmentPipeline`
+ (still needed because near-identity fast-path branches on markerless).
+- `runPipeline` is unchanged behaviorally; it just calls `rectifySinglePage` and
+ then proceeds with the existing grid-rectification + alignment + extraction
+ path.
+
+**Files touched:**
+- `js/pipeline.js`
+
+**Acceptance:**
+- Demo round-trip on every demo image listed in `index.html#loadDemoSelect`
+ produces visually identical previews to `main`.
+- Settings save/load round-trip is byte-identical.
+- No new console errors. Memory profile during a large-image reprocess does not
+ regress (peak Mat count unchanged).
+- `runPipeline` is meaningfully shorter; `rectifySinglePage` has a clear contract
+ documented in JSDoc.
+
+**Out of scope:**
+- Any per-frame mode code.
+- Any change to `runPipeline`'s output shape.
+- Any change to the marker/markerless detector functions.
+
+**As built (Phase 1 — COMPLETE):**
+- `rectifySinglePage` was implemented in `js/pipeline.js`, taking the
+ **full `config` object** rather than the trimmed `perPageConfig` subset
+ proposed above. The block it owns (`buildFrameGridRectification_fromCrosses`,
+ via the marker path) needs the frame-grid fields (`useRectifiedAsSource`,
+ `frameCols`, `frameRows`, `crossRoiScale`, `paperMarginXPx`, `paperMarginYPx`,
+ `boundarySensitivity`, `boundaryPersistencePx`) in addition to the page-stage
+ fields, so passing a strict subset would have meant re-threading the entire
+ grid config anyway. The JSDoc on `rectifySinglePage` enumerates exactly which
+ `config` keys it reads. Phase 3's per-image call therefore passes a shallow
+ copy of `config` with per-image overrides (see Phase 3, step 2).
+- Actual signature/return:
+ ```
+ rectifySinglePage(sourceCanvas, config, requestId, throwIfAborted)
+ -> { rectifiedWarp, pageQuad, pageQuadSource, pageWarpPreviewCanvas,
+ pageWarpPreviewWidth, pageWarpPreviewHeight,
+ useNearIdentityRectification, threshVal, pageSizeLow, pageSizeHigh }
+ ```
+ `threshVal`, `pageSizeLow`, and `pageSizeHigh` were added beyond the proposed
+ shape so `runPipeline` can keep building byte-identical status text.
+- Mat lifetime: `rectifySinglePage` releases every intermediate Mat in its own
+ `finally` and releases `rectifiedWarp` in its `catch` on failure, so the
+ caller only ever owns a successfully returned `rectifiedWarp`. `runPipeline`'s
+ `finally` shrank to just deleting `rectifiedWarp.visionMat` / `styledMat`.
+- Error handling: `rectifySinglePage` attaches `error.partialResult` for
+ page-stage failures; `runPipeline` only fills it in for later
+ (alignment/extraction) failures, guarded by `!error.partialResult`.
+- Phase 0 spike code in `js/app.js` (the `SPIKE_PER_FRAME` constants and the
+ 2-frame composite block in `processCurrentImage`) was removed as part of this
+ work. Verified via demo round-trip; markers and markerless modes unchanged.
+
+---
+
+### Phase 2 — State: `state.source.images[]` and active-image accessor
+
+**Goal:** introduce per-image source state without yet adding any UI or pipeline
+support. Existing markers/markerless flows continue to use the active image
+transparently.
+
+**Scope:**
+- In `js/dom-state.js`, extend `state.source`:
+ ```
+ images: [], // [{ image, filename, mimeType, ownedObjectUrl, dragUrl,
+ // canvas, manualPageContour, postRotationDeg,
+ // rectifiedMatCache: null, rectifiedDirty: true }]
+ activeImageIndex: 0,
+ ```
+- Add accessor helpers (likely in a new tiny module `js/source-images.js`, or
+ inline in `dom-state.js`):
+ - `getActiveSourceImage(state)` returns the active entry, or `null`.
+ - `setActiveSourceImage(state, index)`.
+ - `releaseAllSourceImages(state)` revokes object URLs and clears Mats.
+- Mutate `load-controller.js → loadImageSource` to push the loaded image into
+ `state.source.images` as a single-entry array and set `activeImageIndex = 0`,
+ while *also* keeping the existing `state.source.image / filename / dragUrl /
+ manualPageContour` fields populated. The legacy fields become projections of
+ the active entry for the duration of the migration.
+- No mode logic yet — `state.source.images[]` always has exactly 0 or 1 entries
+ during this phase.
+
+**Files touched:**
+- `js/dom-state.js`
+- `js/load-controller.js`
+- new `js/source-images.js` (optional)
+
+**Acceptance:**
+- Markers and markerless modes still load the same demos and round-trip
+ identically.
+- `releaseOwnedSourceUrl` is replaced by / wraps `releaseAllSourceImages`; no
+ blob URLs leak across reloads.
+- Loading a new image clears prior `images[]` entries.
+
+**Out of scope:**
+- Multi-file `handleFile`. (That arrives in Phase 4.)
+- Per-image overrides. (Phase 5.)
+- Any UI for the image strip.
+
+**As built (Phase 2 — COMPLETE):**
+- `state.source` gained `images: []` and `activeImageIndex: 0` in `js/dom-state.js`.
+ The legacy `state.source.image / filename / mimeType / dragUrl / ownedObjectUrl /
+ canvas / manualPageContour` fields stay populated and are treated as projections
+ of the active entry.
+- New module `js/source-images.js` exports:
+ - `createSourceImageEntry(fields)` — builds an entry with the documented shape
+ (`image, filename, mimeType, ownedObjectUrl, dragUrl, canvas,
+ manualPageContour, postRotationDeg, rectifiedMatCache: null,
+ rectifiedDirty: true`).
+ - `getActiveSourceImage(state)` — active entry or `null`.
+ - `setActiveSourceImage(state, index)` — clamps and returns the new active entry.
+ - `releaseAllSourceImages(state)` — revokes per-entry blob URLs, frees cached
+ Mats, resets `images` to `[]` and `activeImageIndex` to `0`.
+ - `releaseEntryRectifiedCache(entry)` — helper that frees either a bare `Mat` or
+ a `{ visionMat, styledMat }` rectified-warp cache (added beyond the proposed
+ list because Phase 9 / Phase 3 caches use that shape).
+- `js/load-controller.js`:
+ - `loadImageSource`'s `image.onload` now registers the loaded image as the single
+ entry (`state.source.images = [entry]; activeImageIndex = 0`). The entry's
+ `canvas` aliases the shared `state.source.canvas` (only one image exists in this
+ phase); the entry's `manualPageContour` is mirrored from the legacy field after
+ any settings file is applied. **Note for Phase 4:** this canvas aliasing is only
+ safe while `images[]` holds at most one entry — Phase 4 must give each entry its
+ own dedicated canvas (see Phase 4 scope).
+ - `releaseOwnedSourceUrl(state)` now wraps `releaseAllSourceImages(state)` before
+ revoking the legacy `ownedObjectUrl`, so loading a new image clears prior
+ `images[]` entries and no blob URLs leak across reloads.
+- No mode logic, multi-file handling, per-image overrides, or strip UI were added.
+ Markers/markerless flows are unaffected (changes are additive; the legacy fields
+ the pipeline reads remain authoritative).
+
+---
+
+### Phase 3 — Pipeline: `runPerFramePipeline` + dispatcher
+
+**Goal:** wire the new per-frame pipeline behind the existing `runPipeline`
+contract. No UI yet — exercised through manual config hacking or unit-level
+testing.
+
+**Scope:**
+- In `js/pipeline.js`:
+ - Add `runPerFramePipeline(images, config, requestId, throwIfAborted)`:
+ 1. For each `image` in `images`, build a per-image `sourceCanvas` (already
+ in `state.source.images[i].canvas`).
+ 2. Call `rectifySinglePage(sourceCanvas, perImageConfig, …)`. As implemented
+ in Phase 1, `rectifySinglePage` takes the **full `config` object** (not a
+ trimmed `perPageConfig` subset) and reads the page-relevant fields plus the
+ frame-grid fields needed by the marker rectifier; its JSDoc lists exactly
+ which keys it consumes. So `perImageConfig` is a shallow copy of the base
+ `config` with the per-image fields overridden:
+ ```
+ const perImageConfig = {
+ ...config,
+ // Treat each rectified page as a whole working sheet (no cross sweep,
+ // no frame-grid crop). rectifySinglePage branches its near-identity
+ // fast path and grid rectification on this value, so per-frame mode
+ // must alias to "markerless" here rather than passing "per-frame".
+ alignmentPipeline: "markerless",
+ // Per-image page-corner override (source-space quad) for this image.
+ manualPageQuadPoints: images[i].manualPageContour ?? null,
+ // No live threshold-preview fallback when rectifying per image.
+ fallbackPageQuadPoints: null,
+ // Per-image Post-Rotation (Phase 5 stores this on the image entry).
+ postRotationDeg: images[i].postRotationDeg ?? 0,
+ };
+ ```
+ The base `config.alignmentPipeline` stays `"per-frame"` for the dispatcher
+ and for downstream readConfig/UI gating; only the per-image copy handed to
+ `rectifySinglePage` is aliased to `"markerless"`.
+ 3. From each call, take `result.rectifiedWarp`; the caller owns it (per the
+ Phase 1 contract). Use `rectifiedWarp.styledMat` as the cell source and
+ delete `rectifiedWarp.visionMat` immediately (per-frame mode does not run
+ per-image alignment on it). Each `styledMat` is deleted after it has been
+ resized/copied into the composite in step 6.
+ 4. Decide common cell size:
+ - `cellW = clamp(median(rectifiedWidths), MIN_CELL_PX, MAX_CELL_PX)`
+ - `cellH = clamp(median(rectifiedHeights), MIN_CELL_PX, MAX_CELL_PX)`
+ - Limits chosen to keep `cellW × cellH × N` inside existing
+ `RECTIFIED_PREVIEW_LONG_EDGE_PX` / large-image-memory budgets.
+ 5. Resize each rectified `styledMat` to `(cellW, cellH)` using the
+ interpolation flag from `config.exportOptions.resampling`.
+ 6. Allocate composite `rectifiedMat` of size `(cellW * N, cellH)` and copy
+ each resized cell into its column.
+ 7. Synthesize `alignmentInfo` whose corner intersection points are at
+ `(i * cellW, 0)`, `((i+1) * cellW, 0)`, `(i * cellW, cellH)`,
+ `((i+1) * cellW, cellH)` for `i = 0 … N`. The structure should match what
+ `buildUnrefinedCrossRegionInfo` produces for a 1×N markerless sheet.
+ 8. Build a `rectifiedCanvas` preview from the composite Mat using the
+ existing `matToPreviewCanvas`.
+ 9. Extract frame canvases via the existing `sliceRectifiedToCanvases` so the
+ returned `frames` array matches what marker/markerless modes produce.
+ 10. Return the same result shape as `runPipeline`: `{ frames, rectifiedCanvas,
+ rectifiedMat, pagePreviewCanvas: null, pagePreviewGridQuad: null,
+ pagePreviewGridBounds: null, alignmentInfo, statusText, pageQuadPoints:
+ null, pageQuadSource: "per-frame", rectifiedDownloadUsesRawSource: false }`.
+ (`pagePreview*` is null because there is no single "page" in per-frame mode;
+ Phase 7 will hide the corresponding UI.)
+ - Modify `runPipeline` so its first lines dispatch:
+ ```
+ if (config.alignmentPipeline === "per-frame") {
+ return runPerFramePipeline(state.source.images, config, requestId, throwIfAborted);
+ }
+ ```
+ `runPipeline` should not import `state` directly; pass `images` from the
+ caller. So update the caller in `app.js → processCurrentImage` to pass
+ `state.source.images` alongside `state.source.canvas`.
+- `app.js → readConfig`: when the per-frame radio is checked, set
+ `alignmentPipeline: "per-frame"`. Also force `useCrossAlignment: false` for
+ this mode (the synthetic grid does not need refinement).
+
+**Files touched:**
+- `js/pipeline.js`
+- `js/app.js` (readConfig + the `runPipeline` invocation site only)
+
+**Acceptance:**
+- With per-frame mode forced via dev console (`dom.alignmentPipelineMarkerless.checked = false; dom.alignmentPipelineMarkers.checked = false; /* + manual flag */`)
+ and two images loaded via dev hack, a 2-frame animation appears in
+ `gifPreviewCanvas` and exports correctly.
+- Stabilization measurement runs on the synthetic sheet without errors.
+- Markers/markerless flows continue to work unchanged.
+- Memory: `MAX_CELL_PX` and median selection keep peak Mat size bounded.
+
+**Out of scope:**
+- The "per-frame" radio button itself (Phase 6 adds it).
+- Multi-file upload (Phase 4).
+- Per-image overrides (Phase 5).
+- UI strip (Phase 7).
+- Settings persistence (Phase 8).
+
+**As built (Phase 3 — COMPLETE):**
+- `js/pipeline.js`:
+ - Added `runPerFramePipeline(images, config, requestId, throwIfAborted)`. It filters the entries
+ to those with a `canvas`, throws if none, then per image calls `rectifySinglePage` with a shallow
+ `config` copy overriding `alignmentPipeline: "markerless"`, `manualPageQuadPoints:
+ entry.manualPageContour ?? null`, `fallbackPageQuadPoints: null`, `postRotationDeg:
+ entry.postRotationDeg ?? 0`. The grayscale `visionMat` is deleted immediately; only the styled
+ (BGR) page is kept as the cell source.
+ - Common cell size is `Math.round(clamp(median(dim), PER_FRAME_MIN_CELL_PX,
+ PER_FRAME_MAX_CELL_PX))` per dimension (new constants `16` / `1600`). The strict composite-area
+ ceiling and uniform scale-down remain deferred to Phase 9; this is the per-dimension guard.
+ - Each styled page is resized (or `copyTo` when already cell-sized) into its column of a
+ `cv.CV_8UC3` composite via `composite.roi(...)`, then freed right after it is consumed. The
+ `finally` block releases any still-unconsumed per-image Mats and the composite itself if the
+ function throws before handing it off.
+ - `alignmentInfo` is synthesized with `buildUnrefinedCrossRegionInfo(composite, N, 1, "per-frame",
+ fullBounds, config.crossRoiScale, { markerType: "crosses", includeCornerCrosses: true })` — i.e.
+ a 1×N markerless-style lattice with regular corner intersections at the column boundaries.
+ - Frames come from the existing `sliceRectifiedToCanvases`; the preview canvas from
+ `matToPreviewCanvas`. `statusText` is built from existing `status.framesExtracted` /
+ `status.rectifiedSheet` / `status.animationSize` keys (no new i18n strings). The return shape
+ matches `runPipeline` with `pagePreview*` null, `pageQuadSource: "per-frame"`,
+ `rectifiedDownloadUsesRawSource: false`.
+ - `runPipeline` gained an optional 5th param `images = null` and dispatches to
+ `runPerFramePipeline` when `config.alignmentPipeline === "per-frame"` before the single-page
+ path. It still does not import `state`; `images` is passed in by the caller.
+- `js/app.js`:
+ - `readConfig` computes `perFrameModeActive = !!dom.alignmentPipelinePerFrame?.checked ||
+ !!state.runtime.forcePerFrameMode`. The radio ref is read with optional chaining so this is
+ forward-compatible with the real radio added in Phase 6. `alignmentPipeline` emits `"per-frame"`
+ when active, and `useCrossAlignment` is forced `false` in that mode.
+ - The `runPipeline` invocation site now passes `state.source.images` as the 5th argument.
+- `js/dom-state.js`: added `state.runtime.forcePerFrameMode = false` — a Phase 3 dev flag so the
+ pipeline can be driven from the console before the Phase 6 radio exists. Set it `true` (with images
+ loaded) and reprocess to exercise per-frame mode.
+- No other `app.js` sites were touched (e.g. the markerless stabilization-warmup gating at the
+ `processCurrentImage` tail). Mode-gated downstream behavior — including auto-scheduling
+ stabilization for per-frame — is deferred to Phase 6's `=== "markerless"` audit, per this phase's
+ "readConfig + the runPipeline invocation site only" scope.
+
+---
+
+### Phase 4 — Multi-file upload (`handleFile`, drop zone)
+
+**Goal:** support dragging or selecting multiple image files at once. Always
+populate `state.source.images[]`; never silently drop additional images.
+
+**Scope:**
+- `js/load-controller.js → handleFile`: when multiple image files are present in
+ the drag payload (or file input), build per-image entries for each. Sibling
+ `_settings.txt` is still matched against the first image's filename and applied
+ once after all images are loaded.
+- **Per-entry canvases (carried over from Phase 2):** Phase 2 left each entry's
+ `canvas` aliasing the shared `state.source.canvas`, which is only safe while
+ `images[]` holds at most one entry. Phase 4 introduces multiple entries, so each
+ entry **must** get its own dedicated source-resolution `canvas` drawn from its
+ own decoded image (do not reuse the shared `state.source.canvas` for more than
+ one entry, or every cell in `runPerFramePipeline` would rectify the same image).
+ The legacy `state.source.canvas` / `state.source.image` should then project the
+ **active** entry (e.g. point `state.source.canvas` at
+ `images[activeImageIndex].canvas`), so legacy single-image callers keep reading
+ the active image. Update the single-image load path accordingly so it produces a
+ per-entry canvas too, keeping one code path for the 1-image and N-image cases.
+- `index.html`: ` ` 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 `` inside the Photo control group, below the
+drop zone) containing `#perFrameStripHeading` (`data-i18n="photo.strip.heading"`),
+`#perFrameStripCount` (frame-count readout), `#perFrameStrip` (`role="list"` thumbnail container), and
+a hidden `#perFrameStripFileInput`.
+
+*`js/dom-state.js`:* new `perFrameStrip` group → `dom.perFrameStripPanel`, `dom.perFrameStripHeading`,
+`dom.perFrameStripCount`, `dom.perFrameStrip`, `dom.perFrameStripFileInput`.
+
+*`style.css` (additive):* `.per-frame-strip-panel` (hidden in non-per-frame via
+`body:not(.per-frame-pipeline) .per-frame-strip-panel { display:none }`), `.per-frame-strip-head`,
+`.per-frame-strip-heading`, `.per-frame-strip-count`, `.per-frame-strip` (horizontal scroll),
+`.per-frame-thumb` (+ `.is-active`, `.is-drag-over`, `.is-dragging`), `.per-frame-thumb img`,
+`.per-frame-thumb-number`, `.per-frame-thumb-delete` (hover/focus reveal), `.per-frame-thumb-add`.
+Reuses existing tokens (`--accent`, `--accent-soft`, `--line`, `--radius`, `--muted`,
+`--panel-strong`, `--panel`).
+
+*`js/i18n.js`:* new `photo.strip` subgroup added to **all 13** locale tables, keys: `heading`,
+`frameCount` (`{count}` plural), `frameCountOne` (singular), `addLabel`, `deleteLabel` (`{index}`),
+`selectLabel` (`{index}`). Localized where straightforward, English elsewhere; every table has all 6
+keys (verified 13× each).
+
+*Visibility / legacy safety:* the strip is triple-guarded — HTML `hidden` default, CSS body-class
+gate, and JS `panel.hidden` toggle keyed off `isPerFrameModeActive()`. Markers/markerless flows are
+untouched (the strip is hidden + emptied there and no legacy wiring changed).
+
+*Mobile:* the strip lives in the Photo control group (not the viewer), is horizontally scrollable, and
+does not alter the mobile single-viewer layout; it is reachable from the control panel in all modes
+but only rendered in per-frame mode.
+
+*Mat lifetime:* the strip allocates no OpenCV Mats; deletes route through `releaseEntryRectifiedCache`.
+
+*Validation:* `node --check` passes on `js/per-frame-strip.js`, `js/app.js`, `js/ui-controls.js`,
+`js/dom-state.js`, `js/i18n.js`, `js/load-controller.js`. i18n key counts verified 13× per key.
+
+*Notes for Phases 8–9:* (8) Settings load that restores per-image overrides should call
+`renderPerFrameStrip()` (or `setActiveImage(activeImageIndex)`, which calls it) after applying buffered
+overrides so the strip reflects restored state; the strip already re-renders via `syncAlignmentMarkerUi`
+after processing. (9) The empty-after-delete state currently keeps the prior preview because
+`scheduleProcess` no-ops with no source image — Phase 9 polish could explicitly clear previews there.
+`addPerFrameImages` does not yet enforce the cell-size/large-image ceiling (Phase 9 owns memory
+bounding); thumbnails use `entry.dragUrl` directly (full-res blob in an ` ` scaled by CSS) rather
+than downscaled thumbnails — fine for typical counts, but Phase 9 may want true thumbnail generation
+for very large/many images.
+
+---
+
+### Phase 8 — Settings persistence for per-image state
+
+**Goal:** make `_settings.txt` round-trip in per-frame mode.
+
+**Scope:**
+- `js/settings-io.js` — save side:
+ - Emit `alignment_pipeline = per-frame`.
+ - For each image `i = 0 … N-1`:
+ - `page_corner_override_tl_i`, `_tr_i`, `_br_i`, `_bl_i` (only if that image
+ has overrides; otherwise omit).
+ - `per_frame_post_rotation_deg_i` (only if non-zero).
+ - Emit `per_frame_image_count = N` so reload knows how many to expect.
+- `js/settings-io.js` — load side:
+ - Recognize `per_frame_image_count` to size the per-image override array
+ before images themselves load.
+ - On image load (single image first, additional images added later), apply the
+ matching indexed overrides to the corresponding `state.source.images[i]`.
+ - Backward-compat: legacy files without `per_frame_*` keys load with empty
+ per-image overrides.
+- Loading a saved per-frame project requires the user to re-upload all N images
+ (we cannot save image data in the settings file). On load with no images yet,
+ the saved per-image overrides are buffered into a pending structure and
+ applied as images arrive (matching by upload order — index 0 → first uploaded
+ image). Document this clearly in `documentation.md`.
+
+**Files touched:**
+- `js/settings-io.js`
+- `js/dom-state.js` (pending overrides buffer)
+- `js/load-controller.js` (apply pending overrides as images arrive)
+- `documentation.md`
+
+**Acceptance:**
+- Edit per-image corners + post-rotation across 3 images; save settings; reload
+ the page; re-upload the same 3 images; per-image overrides reappear correctly.
+- Legacy `_settings.txt` files (markers / markerless) still load without
+ regression.
+- The saved file remains a TSV that humans can inspect.
+
+**Out of scope:**
+- Saving image data inside the settings file.
+- Auto-matching by filename across reloads (matching is strictly by upload
+ order in v1).
+
+**As built (Phase 8 — COMPLETE):**
+
+*Save side (`js/settings-io.js → buildSettingsTsv`)*
+- `buildSettingsTsv` gained an additive `perImageEntries` param (default `null`; JSDoc updated). The
+ per-frame rows are appended **only** when `config.alignmentPipeline === "per-frame"` and
+ `perImageEntries` is an array, so markers/markerless saves stay byte-identical (the param is passed
+ unconditionally by the caller but ignored outside per-frame mode).
+- Emitted keys (exact names / formats, appended after the page-corner-override and marker-override
+ rows):
+ - `per_frame_image_countN` — always emitted in per-frame mode (e.g. `per_frame_image_count\t3`).
+ - `page_corner_override_tl_{i}` / `_tr_{i}` / `_br_{i}` / `_bl_{i}` — emitted as a set of four **only**
+ when image `i` has a valid 4-point `manualPageContour`; reuses the exact single-image
+ `page_corner_override_*` serialization (`${point.x},${point.y}`) suffixed with `_${i}` (e.g.
+ `page_corner_override_tl_1\t1,2`). Images without an override emit nothing.
+ - `per_frame_post_rotation_deg_{i}deg` — emitted **only** when image `i`'s `postRotationDeg` is
+ a finite non-zero number (e.g. `per_frame_post_rotation_deg_2\t5`).
+ - `alignment_pipeline\tper-frame` is emitted by the pre-existing `["alignment_pipeline",
+ String(config.alignmentPipeline)]` row (verified — no change needed).
+- Caller `js/app.js → buildSettingsTsv(config)` now passes `perImageEntries: state.source.images`.
+
+*Load side (`js/settings-io.js → applyLoadedSettingsText` + new `parsePerImageOverrides`)*
+- `state.source.pendingPerImageOverrides` is reset to `null` at the top of every load (so a markers
+ file loaded after a per-frame file leaves no stale buffer).
+- Pipeline selection reconciled: `usePerFramePipeline = (pipeline === "per-frame")`. When true:
+ `dom.alignmentPipelinePerFrame.checked = true`, `dom.alignmentPipelineMarkerless.checked = false`,
+ `dom.alignmentPipelineMarkers.checked = false`, and `state.runtime.forcePerFrameMode = true` (mirrors
+ Phase 6's change-listener so radio + flag never diverge). `useMarkerlessPipeline` now also requires
+ `!usePerFramePipeline`, and `markers = !markerless && !perFrame`. For markers/markerless files the
+ truth table is unchanged (`perFrame` false), so legacy selection does not regress. The
+ `dom.alignmentPipelinePerFrame` write is existence-guarded for older DOMs.
+- New `parsePerImageOverrides(entries)` reads `per_frame_image_count` (floored, `>0`, else `0`) and for
+ each index `i` collects the four `page_corner_override_*_{i}` rows (only when all four present and
+ every point parses) and the optional `per_frame_post_rotation_deg_{i}`. Returns
+ `{ count, overrides }` where each `overrides[i]` is `{ manualPageContour, postRotationDeg }` or `null`
+ when image `i` had no saved override of either kind. Called **only** in the per-frame branch; legacy
+ files leave the buffer `null`.
+
+*Pending buffer (`js/dom-state.js`)*
+- `state.source.pendingPerImageOverrides` added (default `null`). Shape:
+ `{ count: number, overrides: Array<{ manualPageContour: {x,y}[] | null, postRotationDeg: number } | null> } | null`.
+ `null` = no pending restore (legacy/markers/markerless). Documented inline in `dom-state.js`. It holds
+ parsed-but-not-yet-applied overrides because a saved project cannot embed image data — the user must
+ re-upload the same N images in the same order.
+
+*Apply-on-arrival (`js/load-controller.js`)*
+- New exported `applyPendingPerImageOverrides(state)` iterates `state.source.images` and, by upload-order
+ index, copies each non-null buffered override onto `images[i].manualPageContour` (deep-copied points)
+ and `images[i].postRotationDeg`, then sets `state.source.pendingPerImageOverrides = null` (consume) and
+ returns `true` if a buffer was present (else `false`).
+- `loadImageSource`'s `image.onload` calls it right after `applyLoadedSettingsText` (so the buffer is
+ fresh) and after the legacy `sourceEntry.manualPageContour` mirror; only when it returns `true` does it
+ call the new `refreshActiveImage?.(activeImageIndex)` dep (= app.js `setActiveImage`) to refresh the
+ active entry's legacy field + Post-Rotation slider + Page Corners overlay + strip. Gating on the return
+ value keeps single-image markers/markerless loads untouched (no `setActiveImage` side effects there).
+- `js/app.js` threads `refreshActiveImage: setActiveImage` into `loadImageSourceViaController`, imports
+ `applyPendingPerImageOverrides`, and also calls it in `addPerFrameImages` (the strip `+` tile / lone
+ settings-file-then-upload path) so a settings file loaded before any images still reattaches its
+ buffered overrides when images arrive via the strip; `setActiveImage(0)` (run when the strip started
+ empty) then refreshes the editor.
+- Consume/clear is verified: a second `applyPendingPerImageOverrides` call returns `false` and mutates
+ nothing, so re-loading images later never reapplies stale overrides.
+
+*`documentation.md`*
+- Added the per-image keys to the "stored settings" list and a new "Reloading a per-frame project"
+ subsection under Sibling Settings Files: settings files contain no image data; to restore a saved
+ per-frame project the user re-uploads the **same images in the same order**, and buffered per-image
+ overrides reattach strictly by upload order (no filename matching).
+
+*Validation*
+- `node --check` passes on `js/settings-io.js`, `js/dom-state.js`, `js/load-controller.js`, `js/app.js`.
+- Round-trip traced + script-verified: per-frame mode, 3 images, image #2 corners + image #3
+ post-rotation → SAVE emits exactly `per_frame_image_count\t3`, `page_corner_override_{tl,tr,br,bl}_1`,
+ `per_frame_post_rotation_deg_2\t5` (nothing for image 0) → LOAD sets `forcePerFrameMode=true` and a
+ buffer `{count:3, overrides:[null, {contour}, {postRotationDeg:5}]}` → apply reattaches to the right
+ entries and clears the buffer (second apply is a no-op).
+- Legacy markers file traced + script-verified: `forcePerFrameMode=false`, buffer stays `null`, no
+ per-frame path triggered, selection unchanged.
+
+*Notes for Phase 9*
+- No Mats allocated, no i18n strings added (settings keys are not localized).
+- Matching is strictly by upload order (no filename matching) — a deliberately deferred item.
+- The empty-after-delete preview-clear and the cell-size/large-image memory ceiling remain Phase 9 work;
+ Phase 8 added no memory bounding. AGENTS.md/llm_readme invariants (incl. the per-frame settings
+ round-trip invariant) are Phase 9 scope and were intentionally NOT added here.
+
+---
+
+### Phase 9 — Memory and polish
+
+**Goal:** make per-frame mode safe on large inputs, finish documentation, and
+write the AGENTS.md invariants.
+
+**Scope:**
+- `js/app.js → trimCachesBeforeReprocess`: release any per-image rectified Mat
+ cache entries that are not the active image. The composite `baseRectifiedMat`
+ is the only large Mat that needs to stay live between reprocesses.
+- `js/pipeline.js → runPerFramePipeline`: bound the cell size such that
+ `cellW × N × cellH` cannot exceed the same memory ceiling that the existing
+ large-image path uses for `pageSizeHigh`. If the median would exceed the
+ ceiling, scale the cell size down uniformly.
+- Mobile single-viewer audit: confirm the Page tab works with the new strip and
+ active-image switching.
+- `documentation.md`: add a "Per-Frame Pipeline" section explaining when to use
+ it, how to upload multiple images, per-image page-corner editing, and the
+ reload story for saved settings.
+- `AGENTS.md`: add invariants:
+ - Per-image page-corner overrides are post-load, pre-rectification. They feed
+ into `rectifySinglePage` for that image only.
+ - Active-image switching is a UI navigation and does NOT trigger reprocessing.
+ - Per-frame mode disables marker and markerless-specific controls.
+ - Per-frame `_settings.txt` round-trip requires re-uploading images in the
+ same order.
+- `llm_readme.md`: add a "Per-Frame Pipeline" section that mirrors the marker /
+ markerless sections and points back to this plan.
+
+**Files touched:**
+- `js/app.js`
+- `js/pipeline.js`
+- `documentation.md`
+- `AGENTS.md`
+- `llm_readme.md`
+
+**Acceptance:**
+- Loading 20 images at 4000×3000 each does not crash the tab (cell size scaled
+ down).
+- Switching active image is instant (no reprocessing).
+- Mobile UX is acceptable.
+- Documentation is current and the AGENTS.md invariants are stated.
+
+**Out of scope:**
+- New features beyond what Phases 0–8 added.
+
+**As built (Phase 9 — COMPLETE):**
+
+*Composite-area memory ceiling (`js/pipeline.js → runPerFramePipeline`)*
+- New constant `PER_FRAME_COMPOSITE_AREA_CEILING_PX = RECTIFIED_PREVIEW_LONG_EDGE_PX *
+ RECTIFIED_PREVIEW_LONG_EDGE_PX` (= 2200² = 4,840,000 px). This is the exact total-area budget the
+ single-page path already lives within: `matToPreviewCanvas` caps the rectified preview at
+ `RECTIFIED_PREVIEW_LONG_EDGE_PX` on its long edge and the high-res warp is diagonal-capped, so the
+ largest single working sheet is bounded by `RECTIFIED_PREVIEW_LONG_EDGE_PX²`. The stacked
+ `cellW × N × cellH` composite is held to that same area.
+- After the existing per-dimension median clamp (`PER_FRAME_MIN_CELL_PX` 16 / `PER_FRAME_MAX_CELL_PX`
+ 1600 — unchanged), `cellW`/`cellH` became `let`. When `cellW * cellH * frameCount >`
+ the ceiling, `scale = Math.sqrt(ceiling / compositeArea)` is applied to BOTH dimensions
+ (`cellW = max(MIN, round(cellW*scale))`, same for `cellH`), so the cell aspect ratio is preserved
+ and the whole sheet fits. With N images the cells shrink uniformly; e.g. 20 × 4000×3000 pages
+ (median cell clamped to 1600×1200, area 1600·1200·20 = 38.4M ≫ 4.84M) scale by
+ `sqrt(4.84M/38.4M) ≈ 0.355` → ~568×426 cells, a ~11.6M-px composite that the tab can hold.
+- Mat lifetime unchanged and explicit: `composite`, each per-cell `resized`, and each `roi` are freed
+ in their `finally` blocks; the uniform downscale only adjusts two scalars before allocation, so it
+ introduces no new Mats and no leak. (Constants + the area-ceiling block were the only edits; this
+ finalizes the Phase 3 deferral.)
+
+*Cache trim (`js/app.js → trimCachesBeforeReprocess`)*
+- Added `trimInactivePerFrameRectifiedCaches()`, called from `trimCachesBeforeReprocess`. It returns
+ immediately unless `state.source.images` is an array of length > 1, then for every index that is NOT
+ `state.source.activeImageIndex` it calls `releaseEntryRectifiedCache(images[i])` (the existing helper
+ in `js/source-images.js`, which frees a bare `Mat` or a `{ visionMat, styledMat }` cache and resets
+ `rectifiedDirty`). It only frees NON-active per-image caches; the active image's cache is kept warm,
+ and the composite `baseRectifiedMat` lives on `state.geometry` (not on any per-image entry), so it is
+ never touched. No-op for markers/markerless (those modes hold ≤1 entry, so the `length <= 1` guard
+ short-circuits).
+
+*Empty-state preview clear (Phase 7 polish item)*
+- Deleting the last image in the strip previously left a stale rectified sheet / animation because the
+ strip's `reprocess` (= `scheduleProcess`) no-ops with no source image. `js/per-frame-strip.js`'s
+ empty-state branch in `deleteImageAt` now calls a new `clearPreviews` dep instead of the no-op
+ `reprocess`. `clearPreviews` is wired to the existing, tested `clearAllPreviews()` (threaded
+ `app.js → wireUiControls` deps → `ui-controls.js attachUi` → `attachPerFrameStrip`), so the empty
+ state blanks the raw canvas and all downstream panels consistently. Low-risk: reuses the same reset
+ that runs at the start of every load; markers/markerless never hit this path (strip is per-frame-only).
+
+*Mobile single-viewer audit (read-only; no bug found)*
+- The mobile viewer tabs (`raw`/`rectified`/`markers`/`preview`) and their cards are always present;
+ their visibility never branches on `alignmentPipeline` (confirmed in `app.js
+ syncMobileViewerLayout` — only the tab TEXT changes via `showFrameCornerControls`, per Phase 6). The
+ per-frame image strip lives in the Photo *control group* (`#perFrameStripPanel`), not in the viewer
+ area, so it does not perturb the single-viewer layout and is reachable via the mobile control tabs.
+ The raw/Page tab renders `renderRawPreview()` of the active image, and `setActiveImage` (Phase 5)
+ calls `renderRawPreview()` on every active-image switch, so switching images updates the Page tab
+ correctly. Conclusion: the Page tab works with the strip and active-image switching as-is — no
+ speculative changes made.
+
+*Documentation additions*
+- `documentation.md`: new "Per-Frame Pipeline" section (+ TOC entry) covering when to use it, the three
+ ways to upload multiple images (drag several / multi-select picker / strip `+` tile), the image strip
+ (select = navigation, drag = reorder/reprocess, delete, add), per-image page-corner / post-rotation
+ editing on the active image, and a cross-reference to the existing "Reloading a per-frame project"
+ subsection (no duplication). The Alignment Pipeline section now says "three workflows" and lists the
+ Per-Frame mode.
+- `AGENTS.md`: Architecture line updated from "two alignment pipelines" to "three" (adds `Per-frame`);
+ the stale "check both pipelines" verification line now reads "all three pipelines". New "Per-Frame
+ Notes" block states the durable invariants: per-image overrides are post-load/pre-rectification and
+ feed `rectifySinglePage` for that image only; active-image switching is UI navigation and does NOT
+ reprocess; per-frame mode disables marker/markerless-specific controls; `_settings.txt` round-trip
+ requires re-uploading images in the same order; and the composite cell-size area ceiling.
+- `llm_readme.md`: new "Per-Frame" subsection under "Current Pipelines" mirroring Markers/Markerless
+ (entry point, per-image rectification, common-cell composite + memory ceiling, synthetic corner
+ lattice, strip, settings round-trip) and pointing back to `per_frame_pipeline_plan.md` for the full
+ plan; the stale "shared by both pipelines" line under Light-on-dark now reads "by the marker and
+ markerless pipelines".
+
+*Validation*
+- `node --check` passes on `js/app.js`, `js/pipeline.js`, `js/ui-controls.js`, `js/per-frame-strip.js`.
+- Markers/markerless are unaffected: the cache trim and empty-state clear are per-frame-only (guarded
+ on `images[]` length / strip-only path), and the pipeline area-ceiling code only runs inside
+ `runPerFramePipeline`.
+- No new i18n strings were added (Phase 9 was memory + docs only).
+
+*Plan status:* the per-frame alignment pipeline is now fully implemented — Phases 0–9 are all COMPLETE.
+
+---
+
+## Cross-Cutting Invariants (apply across every phase)
+
+- Markers and markerless modes are not allowed to regress at any phase boundary.
+ Run a demo round-trip for at least one markers demo and one markerless demo
+ before declaring a phase done.
+- `_settings.txt` files saved by `main` must still load in every phase up to and
+ including Phase 8. Per-image keys are additive.
+- Mat lifetime stays explicit. Every new Mat allocation in
+ `runPerFramePipeline` has a matching `.delete()` in a `finally` block.
+- i18n strings are added to **all** locale tables, not just English.
+- Mobile single-viewer mode is checked at Phase 6, Phase 7, and Phase 9.
+
+## Files Touched (cumulative reference)
+
+| File | Phases |
+|------|--------|
+| `index.html` | 4, 6, 7 |
+| `style.css` | 7 |
+| `js/dom-state.js` | 2, 6, 7, 8 |
+| `js/load-controller.js` | 2, 4, 8 |
+| `js/pipeline.js` | 1, 3, 9 |
+| `js/app.js` | 3, 5, 6, 9 |
+| `js/ui-controls.js` | 5, 6, 7 |
+| `js/settings-io.js` | 8 |
+| `js/settings-defaults.js` | 6 |
+| `js/i18n.js` | 4, 6, 7 |
+| `js/per-frame-strip.js` (new) | 7 |
+| `js/source-images.js` (new, optional) | 2 |
+| `documentation.md` | 8, 9 |
+| `AGENTS.md` | 9 |
+| `llm_readme.md` | 9 |
+
+## Open Items Deliberately Deferred
+
+- Per-image Page Detection Threshold (v1 keeps it global).
+- Auto-matching saved per-image overrides by filename on reload (v1 matches by
+ upload order).
+- Saving image data inside settings files (out of scope; settings stay text).
+- Per-image grid mode (every image as its own 1×1; out of scope — frame count
+ equals image count).
diff --git a/style.css b/style.css
index b1e2caf..8e67c9f 100644
--- a/style.css
+++ b/style.css
@@ -98,9 +98,16 @@ select {
flex-direction: column;
gap: 16px;
padding: 22px;
+ overflow-x: hidden;
overflow-y: auto;
}
+/* Flex column children default to min-width:auto, which lets wide content (e.g. the
+ per-frame thumbnail strip) expand the whole sidebar instead of scrolling locally. */
+.control-panel > .control-group {
+ min-width: 0;
+}
+
.panel-head {
padding-bottom: 10px;
border-bottom: 1px solid var(--line);
@@ -772,7 +779,9 @@ body.has-loaded-image .output-preview {
}
body.has-loaded-image.markerless-pipeline .viewer-card[data-view="preview"] .output-preview,
-body.has-loaded-image.markerless-pipeline .viewer-card[data-view="markers"] .cross-roi-viewport {
+body.has-loaded-image.markerless-pipeline .viewer-card[data-view="markers"] .cross-roi-viewport,
+body.has-loaded-image.per-frame-pipeline .viewer-card[data-view="preview"] .output-preview,
+body.has-loaded-image.per-frame-pipeline .viewer-card[data-view="markers"] .cross-roi-viewport {
background-color: rgb(243, 244, 246);
background-image: none;
}
@@ -1258,3 +1267,149 @@ body.has-loaded-image .output-preview.is-empty,
display: none;
}
}
+
+/* ---------------------------------------------------------------------------
+ Per-frame image strip (Phase 7)
+ Visible only in per-frame mode. The panel's `hidden` attribute is also driven
+ in JS, but keying off the body class keeps it hidden in markers/markerless
+ modes even if a stale `hidden` toggle is missed.
+ --------------------------------------------------------------------------- */
+.per-frame-strip-panel {
+ margin-top: 12px;
+ min-width: 0;
+ max-width: 100%;
+}
+
+body:not(.per-frame-pipeline) .per-frame-strip-panel {
+ display: none;
+}
+
+.per-frame-strip-head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 8px;
+ margin-bottom: 6px;
+}
+
+.per-frame-strip-heading {
+ margin: 0;
+ font-size: 0.85rem;
+ color: var(--accent);
+}
+
+.per-frame-strip-count {
+ font-size: 0.75rem;
+ color: var(--muted);
+}
+
+.per-frame-strip {
+ display: flex;
+ gap: 8px;
+ min-width: 0;
+ max-width: 100%;
+ overflow-x: auto;
+ padding: 4px;
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ background: var(--accent-soft);
+ -webkit-overflow-scrolling: touch;
+}
+
+.per-frame-thumb {
+ position: relative;
+ flex: 0 0 auto;
+ width: 64px;
+ height: 64px;
+ border: 2px solid transparent;
+ border-radius: var(--radius);
+ background: var(--panel-strong);
+ overflow: hidden;
+ cursor: pointer;
+ padding: 0;
+}
+
+.per-frame-thumb.is-active {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 1px var(--accent);
+}
+
+.per-frame-thumb.is-drag-over {
+ border-color: var(--accent);
+ border-style: dashed;
+}
+
+.per-frame-thumb.is-dragging {
+ opacity: 0.4;
+}
+
+.per-frame-thumb img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+ pointer-events: none;
+}
+
+.per-frame-thumb-number {
+ position: absolute;
+ left: 2px;
+ bottom: 2px;
+ min-width: 16px;
+ padding: 0 3px;
+ font-size: 0.7rem;
+ line-height: 16px;
+ text-align: center;
+ color: var(--panel-strong);
+ background: rgba(32, 33, 36, 0.7);
+ border-radius: 2px;
+ pointer-events: none;
+}
+
+.per-frame-thumb-delete {
+ position: absolute;
+ top: 2px;
+ right: 2px;
+ width: 18px;
+ height: 18px;
+ padding: 0;
+ border: none;
+ border-radius: 50%;
+ font-size: 0.8rem;
+ line-height: 18px;
+ text-align: center;
+ color: var(--panel-strong);
+ background: rgba(32, 33, 36, 0.7);
+ cursor: pointer;
+ opacity: 0;
+ transition: opacity 120ms ease;
+}
+
+.per-frame-thumb:hover .per-frame-thumb-delete,
+.per-frame-thumb:focus-within .per-frame-thumb-delete {
+ opacity: 1;
+}
+
+.per-frame-thumb-delete:hover {
+ background: rgba(180, 40, 40, 0.85);
+}
+
+.per-frame-thumb-add {
+ display: grid;
+ place-items: center;
+ flex: 0 0 auto;
+ width: 64px;
+ height: 64px;
+ border: 1px dashed rgba(74, 77, 85, 0.45);
+ border-radius: var(--radius);
+ background: var(--panel);
+ color: var(--accent);
+ font-size: 1.6rem;
+ line-height: 1;
+ cursor: pointer;
+}
+
+.per-frame-thumb-add:hover {
+ border-color: var(--accent);
+ background: rgba(74, 77, 85, 0.12);
+}