Add grid image system with multi-panel support and SPA routing

- Grid images: photograph component boxes in sub-sections, assemble
  into one logical grid via perspective warp (homography)
- Source image gallery: bulk upload photos separately from grid setup
- Grid drafts: persist partial work, resume across sessions
- Multi-panel grids: define rows/cols per photo, system computes panel
  layout; process partially configured grids, edit individual panels
- Pan/zoom canvas editor (HiDPI-aware, touch support) for corner alignment
- SPA routing with canonical URLs (history.pushState, server catch-all)
- Express error visibility: uncaughtException/unhandledRejection handlers
  and 4-arg error middleware
- Original filename stored on source image upload
- Various null-safety fixes and CSS [hidden] override fixes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-20 23:30:17 +00:00
parent ef2e53ea18
commit cf37759893
11 changed files with 2835 additions and 250 deletions

169
lib/grid-image.mjs Normal file
View File

@@ -0,0 +1,169 @@
import sharp from 'sharp';
import { join } from 'node:path';
import { generate_id } from './ids.mjs';
// ---------------------------------------------------------------------------
// Homography math
// ---------------------------------------------------------------------------
// Solve 8x8 linear system Ax=b via Gaussian elimination with partial pivoting
function gaussian_solve(A, b) {
const n = A.length;
const M = A.map((row, i) => [...row, b[i]]);
for (let col = 0; col < n; col++) {
let max_row = col;
for (let row = col + 1; row < n; row++) {
if (Math.abs(M[row][col]) > Math.abs(M[max_row][col])) max_row = row;
}
[M[col], M[max_row]] = [M[max_row], M[col]];
for (let row = col + 1; row < n; row++) {
const f = M[row][col] / M[col][col];
for (let j = col; j <= n; j++) M[row][j] -= f * M[col][j];
}
}
const x = new Array(n).fill(0);
for (let i = n - 1; i >= 0; i--) {
x[i] = M[i][n];
for (let j = i + 1; j < n; j++) x[i] -= M[i][j] * x[j];
x[i] /= M[i][i];
}
return x;
}
// Compute 3x3 homography H such that src_pts[i] -> dst_pts[i]
// Points: [{x, y}] x4, order TL TR BR BL
function compute_homography(src_pts, dst_pts) {
const A = [];
for (let i = 0; i < 4; i++) {
const { x: sx, y: sy } = src_pts[i];
const { x: dx, y: dy } = dst_pts[i];
A.push([-sx, -sy, -1, 0, 0, 0, dx * sx, dx * sy, dx]);
A.push([ 0, 0, 0, -sx, -sy, -1, dy * sx, dy * sy, dy]);
}
const M8 = A.map(row => row.slice(0, 8));
const b = A.map(row => -row[8]);
const h = gaussian_solve(M8, b);
return [
[h[0], h[1], h[2]],
[h[3], h[4], h[5]],
[h[6], h[7], 1.0 ],
];
}
// Invert a 3x3 matrix
function invert_3x3(m) {
const [[a, b, c], [d, e, f], [g, h, k]] = m;
const det = a*(e*k - f*h) - b*(d*k - f*g) + c*(d*h - e*g);
if (Math.abs(det) < 1e-10) throw new Error('Singular homography matrix');
const s = 1 / det;
return [
[(e*k - f*h)*s, (c*h - b*k)*s, (b*f - c*e)*s],
[(f*g - d*k)*s, (a*k - c*g)*s, (c*d - a*f)*s],
[(d*h - e*g)*s, (b*g - a*h)*s, (a*e - b*d)*s],
];
}
// Bilinear sample from RGBA pixel buffer (Uint8Array, row-major)
function bilinear_sample(pixels, width, height, x, y, out, out_idx) {
const x0 = x | 0, y0 = y | 0;
const tx = x - x0, ty = y - y0;
function px(xi, yi, ch) {
if (xi < 0 || xi >= width || yi < 0 || yi >= height) return 0;
return pixels[(yi * width + xi) * 4 + ch];
}
for (let ch = 0; ch < 4; ch++) {
const v00 = px(x0, y0, ch);
const v10 = px(x0+1, y0, ch);
const v01 = px(x0, y0+1, ch);
const v11 = px(x0+1, y0+1, ch);
out[out_idx + ch] = (v00 + (v10 - v00)*tx + (v01 - v00)*ty + (v11 - v10 - v01 + v00)*tx*ty) + 0.5 | 0;
}
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
// Compute natural cell size from corner quadrilateral + grid dimensions
export function compute_cell_size(corners, rows, cols) {
const [tl, tr, br, bl] = corners;
const top_w = Math.hypot(tr.x - tl.x, tr.y - tl.y);
const bot_w = Math.hypot(br.x - bl.x, br.y - bl.y);
const left_h = Math.hypot(bl.x - tl.x, bl.y - tl.y);
const rgt_h = Math.hypot(br.x - tr.x, br.y - tr.y);
const raw_cw = Math.max(top_w, bot_w) / cols;
const raw_ch = Math.max(left_h, rgt_h) / rows;
return {
cell_w: Math.round(Math.min(480, Math.max(48, raw_cw))),
cell_h: Math.round(Math.min(480, Math.max(48, raw_ch))),
};
}
// Process a source image: apply perspective warp, slice into cells, save to images_dir.
// Returns 2D array cells[row][col] = filename.
export async function process_grid_image(source_path, corners, rows, cols, cell_w, cell_h, images_dir) {
const out_w = cols * cell_w;
const out_h = rows * cell_h;
// H maps source corners -> output rectangle
const dst_pts = [
{ x: 0, y: 0 },
{ x: out_w, y: 0 },
{ x: out_w, y: out_h },
{ x: 0, y: out_h },
];
// Optionally downscale source to at most 2x the output resolution for speed
const meta = await sharp(source_path).metadata();
const max_src = Math.max(out_w, out_h) * 2;
const src_scale = Math.min(1.0, max_src / Math.max(meta.width, meta.height));
const scaled_src_w = Math.round(meta.width * src_scale);
const scaled_src_h = Math.round(meta.height * src_scale);
const pipeline = src_scale < 0.99
? sharp(source_path).resize(scaled_src_w, scaled_src_h)
: sharp(source_path);
const { data: src_px } = await pipeline.ensureAlpha().raw().toBuffer({ resolveWithObject: true });
// Scale corner coords if we downscaled the source
const sc = src_scale;
const sc_corners = corners.map(c => ({ x: c.x * sc, y: c.y * sc }));
const H_inv2 = invert_3x3(compute_homography(sc_corners, dst_pts));
// Warp full output grid
const out_px = new Uint8Array(out_w * out_h * 4);
const [hi0, hi1, hi2] = H_inv2;
for (let oy = 0; oy < out_h; oy++) {
for (let ox = 0; ox < out_w; ox++) {
const denom = hi2[0]*ox + hi2[1]*oy + hi2[2];
const sx = (hi0[0]*ox + hi0[1]*oy + hi0[2]) / denom;
const sy = (hi1[0]*ox + hi1[1]*oy + hi1[2]) / denom;
bilinear_sample(src_px, scaled_src_w, scaled_src_h, sx, sy, out_px, (oy*out_w + ox)*4);
}
}
// Slice into cells and save as JPEG
const cells = [];
for (let row = 0; row < rows; row++) {
const row_arr = [];
for (let col = 0; col < cols; col++) {
const filename = generate_id() + '.jpg';
const cell_buf = Buffer.alloc(cell_w * cell_h * 4);
const ox0 = col * cell_w, oy0 = row * cell_h;
for (let cy = 0; cy < cell_h; cy++) {
const src_row_off = ((oy0 + cy) * out_w + ox0) * 4;
const dst_row_off = cy * cell_w * 4;
cell_buf.set(out_px.subarray(src_row_off, src_row_off + cell_w * 4), dst_row_off);
}
await sharp(cell_buf, { raw: { width: cell_w, height: cell_h, channels: 4 } })
.jpeg({ quality: 88 })
.toFile(join(images_dir, filename));
row_arr.push(filename);
}
cells.push(row_arr);
}
return cells;
}