fix(heal): upgrade inpaint architecture to hybrid 2-tier LaMa ONNX and fix starburst artifacts
- backend: preserve aspect ratio with edge-replicated square padding into 512x512 LaMa model - backend: fix inverted mask threshold (>0.85*plane) and prune CUDA/TensorRT binaries for slim image - frontend: enable server AI by default and route masks >3.5% or edge-crossing to LaMa Tier 2 - frontend: enforce 60%-80% context padding (min 64px) aligned to multiples of 8 - frontend: add 3-5px binary mask dilation and high-frequency sensor grain matching - frontend: anchor deletion badge to topmost point of brush stroke - build & export: updated docker containers and recipescam-images.tar.gz (357MB)
This commit is contained in:
@@ -7,6 +7,14 @@ RUN npm ci
|
||||
COPY tsconfig.json ./
|
||||
COPY src ./src
|
||||
RUN npm run build && npm prune --omit=dev
|
||||
# onnxruntime-node ships every platform's runtime, and 260MB of it is the CUDA /
|
||||
# TensorRT providers this container (CPU inference, no GPU) never loads. The
|
||||
# bindings for the other two operating systems go the same way; both Linux
|
||||
# architectures stay so the image can still be built for an ARM host.
|
||||
RUN rm -rf node_modules/onnxruntime-node/bin/napi-v6/darwin \
|
||||
node_modules/onnxruntime-node/bin/napi-v6/win32 \
|
||||
node_modules/onnxruntime-node/bin/napi-v6/linux/x64/libonnxruntime_providers_cuda.so \
|
||||
node_modules/onnxruntime-node/bin/napi-v6/linux/x64/libonnxruntime_providers_tensorrt.so
|
||||
|
||||
FROM node:22-slim
|
||||
ENV NODE_ENV=production \
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"start": "node dist/server.js",
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"test": "node test/security.mjs",
|
||||
"test:immich": "node test/immich.mjs"
|
||||
"test:immich": "node test/immich.mjs",
|
||||
"test:inpaint": "node test/inpaint-onnx.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.11.1",
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import * as ort from 'onnxruntime-node';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { cpus } from 'node:os';
|
||||
|
||||
const MODEL_PATH = process.env.INPAINT_MODEL_PATH || '/models/lama_fp32.onnx';
|
||||
|
||||
// What the graph will take spatially, learned on the first call: a resolution-robust export runs
|
||||
// at the crop's own size, a pinned one reports the square it was traced at and we remember it.
|
||||
let fixedSide: number | null = null;
|
||||
|
||||
let sessionPromise: Promise<ort.InferenceSession | null> | null = null;
|
||||
|
||||
export async function getInpaintSession(): Promise<ort.InferenceSession | null> {
|
||||
@@ -13,8 +18,6 @@ export async function getInpaintSession(): Promise<ort.InferenceSession | null>
|
||||
'/app/models/lama_fp32.onnx',
|
||||
'/models/lama_fp32.onnx',
|
||||
'./models/lama_fp32.onnx',
|
||||
'/home/locpham/RecipesCam/docker/frontend/public/models/lama_fp32.onnx',
|
||||
'../frontend/public/models/lama_fp32.onnx',
|
||||
];
|
||||
let foundPath: string | null = null;
|
||||
for (const p of candidates) {
|
||||
@@ -33,6 +36,9 @@ export async function getInpaintSession(): Promise<ort.InferenceSession | null>
|
||||
const session = await ort.InferenceSession.create(foundPath, {
|
||||
executionProviders: ['cpu'],
|
||||
graphOptimizationLevel: 'all',
|
||||
// Measured on the dev box's 4-core i3: 4 threads 3.0s, default (8) 3.8s, 2 threads 4.0s.
|
||||
// Half the cores is the knee, and it leaves the rest of the API its share on a NAS.
|
||||
intraOpNumThreads: Math.max(1, Math.min(4, Math.floor(cpus().length / 2))),
|
||||
});
|
||||
console.log('[inpaintService] Model loaded in ' + (Date.now() - t0) + 'ms');
|
||||
console.log('[inpaintService] Inputs:', session.inputNames, 'Outputs:', session.outputNames);
|
||||
@@ -106,38 +112,113 @@ export async function runServerInpaint(
|
||||
rawRgb[i] = srcBytes[i] / 255.0;
|
||||
}
|
||||
|
||||
// Any non-zero byte is the hole: the model's convention is a binary white-on-black mask, and
|
||||
// the caller's white may arrive as 255, 1 or anything between. A mask that reads as all-zero
|
||||
// here is not a repair at all — LaMa answers with the image it was handed — so the byte that
|
||||
// says "hole" is worth accepting in every spelling.
|
||||
// Inversion check: only flip if > 85% of the patch is marked as hole (suggesting inverted alpha mask)
|
||||
const rawMask = new Float32Array(plane);
|
||||
let holes = 0;
|
||||
for (let i = 0; i < plane; i++) {
|
||||
rawMask[i] = maskBytes[i] > 128 ? 1.0 : 0.0;
|
||||
if (maskBytes[i] !== 0) {
|
||||
rawMask[i] = 1.0;
|
||||
holes++;
|
||||
}
|
||||
}
|
||||
|
||||
// Bilinear resize to 512x512 for LaMa
|
||||
const inImage512 = resizeBilinear(rawRgb, w, h, 512, 512, 3);
|
||||
const inMask512 = resizeBilinear(rawMask, w, h, 512, 512, 1);
|
||||
for (let i = 0; i < 512 * 512; i++) {
|
||||
inMask512[i] = inMask512[i] > 0.15 ? 1.0 : 0.0;
|
||||
if (holes > 0.85 * plane) {
|
||||
console.warn(`[inpaintService] mask is ${Math.round((100 * holes) / plane)}% of the crop: read as inverted, flipping`);
|
||||
for (let i = 0; i < plane; i++) rawMask[i] = rawMask[i] > 0 ? 0 : 1;
|
||||
}
|
||||
console.log(`[inpaintService] mask ${w}x${h}: ${holes} hole px of ${plane} (${((100 * holes) / plane).toFixed(1)}%)`);
|
||||
|
||||
const imageInputName = session.inputNames.includes('image') ? 'image' : session.inputNames[0];
|
||||
const maskInputName = session.inputNames.includes('mask') ? 'mask' : session.inputNames[1];
|
||||
|
||||
const out = await session.run({
|
||||
[imageInputName]: new ort.Tensor('float32', inImage512, [1, 3, 512, 512]),
|
||||
[maskInputName]: new ort.Tensor('float32', inMask512, [1, 1, 512, 512]),
|
||||
});
|
||||
// Uniform aspect-ratio preserving resize and padding to fit the side the graph accepts (e.g. 512).
|
||||
// Preserving aspect ratio prevents anamorphic squashing/stretching that causes radial starburst
|
||||
// and Fourier spectral leakage artifacts.
|
||||
const runAt = async (side: number): Promise<Float32Array> => {
|
||||
const scale = Math.min(side / w, side / h);
|
||||
const scaledW = Math.max(1, Math.min(side, Math.round(w * scale)));
|
||||
const scaledH = Math.max(1, Math.min(side, Math.round(h * scale)));
|
||||
|
||||
const outTensor = out[session.outputNames[0]];
|
||||
const out512 = outTensor.data as Float32Array;
|
||||
const resizedImg = resizeBilinear(rawRgb, w, h, scaledW, scaledH, 3);
|
||||
const resizedMask = resizeBilinear(rawMask, w, h, scaledW, scaledH, 1);
|
||||
|
||||
// Resize output back to w x h
|
||||
const outSw = resizeBilinear(out512, 512, 512, w, h, 3);
|
||||
// Pad into side x side buffer with edge replication for image and 0 for mask
|
||||
const inImage = new Float32Array(3 * side * side);
|
||||
const inMask = new Float32Array(side * side);
|
||||
|
||||
for (let y = 0; y < side; y++) {
|
||||
const sy = Math.min(scaledH - 1, y);
|
||||
for (let x = 0; x < side; x++) {
|
||||
const sx = Math.min(scaledW - 1, x);
|
||||
const dstIdx = y * side + x;
|
||||
const srcIdx = sy * scaledW + sx;
|
||||
|
||||
inImage[dstIdx] = resizedImg[srcIdx];
|
||||
inImage[side * side + dstIdx] = resizedImg[scaledW * scaledH + srcIdx];
|
||||
inImage[2 * side * side + dstIdx] = resizedImg[2 * scaledW * scaledH + srcIdx];
|
||||
|
||||
if (y < scaledH && x < scaledW) {
|
||||
inMask[dstIdx] = resizedMask[srcIdx] > 0.15 ? 1.0 : 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const out = await session.run({
|
||||
[imageInputName]: new ort.Tensor('float32', inImage, [1, 3, side, side]),
|
||||
[maskInputName]: new ort.Tensor('float32', inMask, [1, 1, side, side]),
|
||||
});
|
||||
|
||||
const outTensor = out[session.outputNames[0]];
|
||||
const outData = outTensor.data as Float32Array;
|
||||
|
||||
// Extract the scaledW x scaledH subregion
|
||||
const croppedOut = new Float32Array(3 * scaledW * scaledH);
|
||||
for (let y = 0; y < scaledH; y++) {
|
||||
for (let x = 0; x < scaledW; x++) {
|
||||
const srcIdx = y * side + x;
|
||||
const dstIdx = y * scaledW + x;
|
||||
croppedOut[dstIdx] = outData[srcIdx];
|
||||
croppedOut[scaledW * scaledH + dstIdx] = outData[side * side + srcIdx];
|
||||
croppedOut[2 * scaledW * scaledH + dstIdx] = outData[2 * side * side + srcIdx];
|
||||
}
|
||||
}
|
||||
|
||||
// Bilinear resize back to crop w x h
|
||||
return resizeBilinear(croppedOut, scaledW, scaledH, w, h, 3);
|
||||
};
|
||||
|
||||
let outSw: Float32Array;
|
||||
if (fixedSide) {
|
||||
outSw = await runAt(fixedSide);
|
||||
} else {
|
||||
const side = Math.max(64, Math.min(768, Math.ceil(Math.max(w, h) / 8) * 8));
|
||||
try {
|
||||
outSw = await runAt(side);
|
||||
} catch (err) {
|
||||
const expected = /Expected: (\d+)/.exec(String((err as Error)?.message || err));
|
||||
if (!expected) throw err;
|
||||
fixedSide = Number(expected[1]);
|
||||
outSw = await runAt(fixedSide);
|
||||
}
|
||||
}
|
||||
|
||||
// Adaptive scale: detect if model output is [0..1] vs [0..255]
|
||||
let maxVal = -Infinity;
|
||||
const testLen = Math.min(500, outSw.length);
|
||||
for (let i = 0; i < testLen; i++) {
|
||||
if (outSw[i] > maxVal) maxVal = outSw[i];
|
||||
}
|
||||
const mult = maxVal <= 2.5 && maxVal > 0 ? 255.0 : 1.0;
|
||||
|
||||
const patch = new Uint8Array(4 * plane);
|
||||
const plane2 = 2 * plane;
|
||||
for (let i = 0; i < plane; i++) {
|
||||
const r = Math.round(outSw[i]);
|
||||
const g = Math.round(outSw[plane + i]);
|
||||
const b = Math.round(outSw[plane2 + i]);
|
||||
const r = Math.round(outSw[i] * mult);
|
||||
const g = Math.round(outSw[plane + i] * mult);
|
||||
const b = Math.round(outSw[plane2 + i] * mult);
|
||||
|
||||
patch[i * 4] = r < 0 ? 0 : r > 255 ? 255 : r;
|
||||
patch[i * 4 + 1] = g < 0 ? 0 : g > 255 ? 255 : g;
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// The server's half of HEAL: lama_fp32.onnx behind ONNX Runtime, and the one thing about it the
|
||||
// client cannot know — which spatial sizes the graph will actually take. The shipped export is
|
||||
// pinned to 512x512 (its own traced square), while a resolution-robust export takes the crop's
|
||||
// own size; either way the endpoint must answer at the size it was asked, so the service tries
|
||||
// the crop first, learns the square from the refusal, and goes straight there afterwards.
|
||||
//
|
||||
// What is asserted: a 256x256 crop comes back as a 256x256 RGBA patch with an opaque alpha (the
|
||||
// refused call is retried, not surfaced), the hole is filled with something coloured like the
|
||||
// ground around it rather than black or noise, and the pixels outside it are left alone by the
|
||||
// resize round-trip.
|
||||
//
|
||||
// node test/inpaint-onnx.mjs (from docker/backend/)
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import ts from 'typescript';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const MODEL = join(ROOT, 'models', 'lama_fp32.onnx');
|
||||
if (!existsSync(MODEL)) {
|
||||
console.log(`SKIP no model at ${MODEL}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Transpiled next to node_modules, not in /tmp: the service imports onnxruntime-node.
|
||||
const dir = mkdtempSync(join(ROOT, 'node_modules', '.inpaint-onnx-'));
|
||||
writeFileSync(
|
||||
join(dir, 'inpaintService.mjs'),
|
||||
ts.transpileModule(readFileSync(join(ROOT, 'src', 'inpaintService.ts'), 'utf8'), {
|
||||
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText
|
||||
);
|
||||
process.env.INPAINT_MODEL_PATH = MODEL;
|
||||
const { runServerInpaint } = await import(pathToFileURL(join(dir, 'inpaintService.mjs')).href);
|
||||
|
||||
// A 256x256 crop of a sky-and-foliage sort of scene, with a dab over one corner of it. The dab
|
||||
// is a different colour from everything around it, so a patch that kept it is a patch that failed.
|
||||
const W = 256, H = 256, PLANE = W * H;
|
||||
const src = new Uint8Array(3 * PLANE);
|
||||
const mask = new Uint8Array(PLANE);
|
||||
const truth = (x, y) => 120 + 40 * Math.sin(x * 0.03) + 25 * Math.cos(y * 0.05);
|
||||
for (let y = 0; y < H; y++) {
|
||||
for (let x = 0; x < W; x++) {
|
||||
const i = y * W + x;
|
||||
const v = truth(x, y) + 8 * Math.sin(x * 0.7 + y * 0.9);
|
||||
src[i] = Math.max(0, Math.min(255, v));
|
||||
src[PLANE + i] = Math.max(0, Math.min(255, v * 0.95 + 6));
|
||||
src[2 * PLANE + i] = Math.max(0, Math.min(255, v * 0.8 + 20));
|
||||
if (x > 90 && x < 130 && y > 90 && y < 130) {
|
||||
mask[i] = 255;
|
||||
src[i] = 250;
|
||||
src[PLANE + i] = 10;
|
||||
src[2 * PLANE + i] = 250; // magenta mark, nothing like the ground
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const t0 = Date.now();
|
||||
const patch = await runServerInpaint(src, mask, W, H);
|
||||
const ms = Date.now() - t0;
|
||||
assert.ok(patch, 'the model must answer at 256x256 even though the export is pinned to 512');
|
||||
|
||||
assert.equal(patch.length, 4 * PLANE, 'RGBA patch at the size asked for');
|
||||
let opaque = true;
|
||||
for (let i = 3; i < patch.length; i += 4) if (patch[i] !== 255) opaque = false;
|
||||
assert.ok(opaque, 'every pixel of the patch is opaque');
|
||||
|
||||
// The mark is gone: inside the hole the patch has to look like the ground, not like the mark and
|
||||
// not like nothing at all.
|
||||
let holeErr = 0, holeN = 0, outsideErr = 0, outsideN = 0;
|
||||
for (let y = 0; y < H; y++) {
|
||||
for (let x = 0; x < W; x++) {
|
||||
const i = y * W + x, p = i * 4;
|
||||
const want = truth(x, y);
|
||||
const got = 0.299 * patch[p] + 0.587 * patch[p + 1] + 0.114 * patch[p + 2];
|
||||
if (mask[i]) {
|
||||
holeErr += Math.abs(got - want);
|
||||
holeN++;
|
||||
} else {
|
||||
const srcLuma = 0.299 * src[i] + 0.587 * src[PLANE + i] + 0.114 * src[2 * PLANE + i];
|
||||
outsideErr += Math.abs(got - srcLuma);
|
||||
outsideN++;
|
||||
}
|
||||
}
|
||||
}
|
||||
const holeMae = holeErr / Math.max(1, holeN);
|
||||
const outsideMae = outsideErr / Math.max(1, outsideN);
|
||||
console.log(`patch ${W}x${H} in ${ms}ms, hole MAE ${holeMae.toFixed(2)}, outside MAE ${outsideMae.toFixed(2)}`);
|
||||
assert.ok(holeMae < 25, `hole MAE ${holeMae.toFixed(2)} — the mark must be gone, not kept`);
|
||||
assert.ok(outsideMae < 6, `outside MAE ${outsideMae.toFixed(2)} — clean pixels must survive the round trip`);
|
||||
console.log('PASS inpaint-onnx');
|
||||
@@ -59,15 +59,16 @@ assert.match(engine, /drawImageRect\(cutImage, box, onto, /, 'the fill is drawn
|
||||
// answer is written into the fill surface over the box, and the mask it is
|
||||
// handed is built from the brush's own circles. What it is NOT is measured
|
||||
// here — a working model is invisible to the numbers below, which are the
|
||||
// fallback's. The polarity is MI-GAN's, 255 KEEP and 0 HOLE, and it is the one
|
||||
// fallback's. The polarity is the model's, 255 HOLE and 0 KEEP, and it is the one
|
||||
// thing that, got backwards, repaints the frame and leaves the hole (measured in
|
||||
// engine/inpaint.ts; the probe is gone).
|
||||
const inpaint = readFileSync(new URL('../src/engine/inpaint.ts', import.meta.url), 'utf8');
|
||||
assert.match(engine, /await inpaintCrop\(cropPixels, mw, mh, hole\)/, 'exportEngine does not ask the model for the masked crop');
|
||||
assert.match(engine, /await inpaintCrop\(cropPixels, mw, mh, hole, brushArea \/ \(width \* height\)\)/, 'exportEngine does not ask the model for the masked crop, measured against the frame');
|
||||
assert.match(engine, /patchSurface\.getCanvas\(\)\.writePixels\(patch, mw, mh, 0, 0\)/, 'the model answer is never written into the fill surface');
|
||||
assert.match(engine, /new Uint8Array\(mw \* mh\)/, 'the mask handed to the model is not built at the crop\'s own resolution');
|
||||
assert.match(engine, /hole\[row \+ x\] = 1/, 'the mask handed to the model is not the brush\'s own circles');
|
||||
assert.match(inpaint, /fill\(255\)/, 'the model mask no longer starts at KEEP');
|
||||
assert.match(inpaint, /const mask = new Uint8Array\(plane\)/, 'the model mask no longer starts at KEEP');
|
||||
assert.match(inpaint, /mask\[d\] = 255/, 'the brush\'s circles are no longer marked as holes in the model mask');
|
||||
assert.match(inpaint, /INPAINT_MAX_SIDE/, 'the crop handed to the model is no longer bounded in size');
|
||||
assert.match(inpaint, /inpaintCropSize/, 'nothing tells the renderer the resolution the model will run at');
|
||||
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
// The hybrid's three client-side rules, measured on synthetic frames whose answer is known:
|
||||
//
|
||||
// * the router — which of the two engines a mask goes to (a speck of dust on open ground is the
|
||||
// fast engine's job and costs nothing; an object, or a mark that sits across a real edge,
|
||||
// goes to the model),
|
||||
// * the mask the model is given — grown a few pixels over the stroke so the object's shadow
|
||||
// goes with it, and only over the stroke, because the brush is not the wound,
|
||||
// * the grain the model leaves behind — a model patch is a clean surface, and a clean surface
|
||||
// in a photograph of sensor noise reads as a smudge, so it is given back the ground's own
|
||||
// grain and nothing where the ground has none.
|
||||
//
|
||||
// node scripts/heal-route-check.mjs
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import ts from 'typescript';
|
||||
|
||||
const SRC = new URL('../src/engine/inpaint.ts', import.meta.url);
|
||||
const dir = mkdtempSync(join(tmpdir(), 'heal-route-'));
|
||||
writeFileSync(
|
||||
join(dir, 'inpaint.mjs'),
|
||||
ts.transpileModule(readFileSync(SRC, 'utf8'), {
|
||||
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText
|
||||
);
|
||||
const { aiTierNeeded, dilateMask, matchGrain, AI_MAX_MASK_AREA, fastLocalInpaint, localFillWasFlat } = await import(
|
||||
pathToFileURL(join(dir, 'inpaint.mjs')).href
|
||||
);
|
||||
|
||||
const W = 512, H = 512;
|
||||
const luma = (v) => Math.max(0, Math.min(255, v));
|
||||
|
||||
// A frame builder: `at(x, y)` says what the ground looks like, and the hole is a disc on it.
|
||||
function frame(at, { cx = 256, cy = 256, r = 12, noise = 0, seed = 7 } = {}) {
|
||||
const rgba = new Uint8Array(W * H * 4);
|
||||
const hole = new Uint8Array(W * H);
|
||||
let s = seed;
|
||||
const rnd = () => ((s = (s * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff) - 0.5;
|
||||
for (let y = 0; y < H; y++) {
|
||||
for (let x = 0; x < W; x++) {
|
||||
const i = y * W + x, p = i * 4;
|
||||
const v = luma(at(x, y) + (noise ? rnd() * noise : 0));
|
||||
rgba[p] = rgba[p + 1] = rgba[p + 2] = v;
|
||||
rgba[p + 3] = 255;
|
||||
if (r > 0 && (x - cx) ** 2 + (y - cy) ** 2 <= r * r) hole[i] = 1;
|
||||
}
|
||||
}
|
||||
return { rgba, hole };
|
||||
}
|
||||
|
||||
const flat = () => 128;
|
||||
const steps = (x) => (x < 128 || x > 380 ? 80 : 176);
|
||||
const grain = () => 128;
|
||||
const FRAME_AREA = W * H; // this frame is the whole picture for the routing tests
|
||||
|
||||
// 1. A speck of dust on open ground: the fast engine, no server.
|
||||
const speck = frame(flat, { r: 12 });
|
||||
assert.equal(aiTierNeeded(speck.rgba, W, H, speck.hole, (Math.PI * 12 * 12) / FRAME_AREA), false);
|
||||
|
||||
// 2. Something the size of an object: the model. On this 512x512 frame a 60px radius is 4.3% of
|
||||
// the area, which is the same order as a 5%-of-width brush on a 3:2 frame.
|
||||
const object = frame(flat, { r: 60 });
|
||||
assert.equal(aiTierNeeded(object.rgba, W, H, object.hole, (Math.PI * 60 * 60) / FRAME_AREA), true);
|
||||
assert.ok(
|
||||
(Math.PI * 60 * 60) / FRAME_AREA > AI_MAX_MASK_AREA,
|
||||
'the object case must be over the 3.5% the chart draws'
|
||||
);
|
||||
|
||||
// 2b. The brush at its widest on open ground is still a speck by that measure: the fast engine.
|
||||
const wideDab = frame(flat, { r: 26 });
|
||||
assert.equal(aiTierNeeded(wideDab.rgba, W, H, wideDab.hole, (Math.PI * 26 * 26) / FRAME_AREA), false);
|
||||
|
||||
// 2c. With no frame to judge the area against, the window is what is left — and a real window is
|
||||
// a tight one around its hole, so a mark that fills an eighth of it goes to the model.
|
||||
const filled = frame(flat, { r: 100 });
|
||||
assert.equal(aiTierNeeded(filled.rgba, W, H, filled.hole), true);
|
||||
|
||||
// 3. A speck, but one sitting across a real edge: the model, however small it is. The ground here
|
||||
// is quiet (rim 1.88 of 255 either side of the line) so the area test would have kept it home;
|
||||
// what sends it out is that the rim carries six times the window's own structure.
|
||||
const onEdge = frame(steps, { cx: 128, cy: 256, r: 12 });
|
||||
assert.equal(aiTierNeeded(onEdge.rgba, W, H, onEdge.hole, (Math.PI * 12 * 12) / FRAME_AREA), true);
|
||||
|
||||
// 3b. Busy ground — foliage, water ripples, a crowd — is where a copied patch shows up as a slab
|
||||
// of somewhere else, so a speck there goes to the model too, even with no edge in sight.
|
||||
const foliage = frame((x, y) => 120 + 50 * Math.abs(Math.sin(x * 0.21 + y * 0.13)) + 30 * Math.sin(x * 0.7 + y * 1.3), { r: 26 });
|
||||
assert.equal(aiTierNeeded(foliage.rgba, W, H, foliage.hole, (Math.PI * 26 * 26) / FRAME_AREA), true);
|
||||
|
||||
// 3c. ...but ripples are not that busy, and the engine does answer them: a speck on water stays.
|
||||
const water = frame((x, y) => 128 + 18 * Math.sin(x * 0.35 + y * 0.5) + 10 * Math.sin(x * 0.9 - y * 0.7), { r: 26 });
|
||||
assert.equal(aiTierNeeded(water.rgba, W, H, water.hole, (Math.PI * 26 * 26) / FRAME_AREA), false);
|
||||
|
||||
// 4. A speck on ground that is nothing but sensor grain: the fast engine. Grain is not structure.
|
||||
const noisy = frame(grain, { r: 12, noise: 14 });
|
||||
assert.equal(aiTierNeeded(noisy.rgba, W, H, noisy.hole, (Math.PI * 12 * 12) / FRAME_AREA), false);
|
||||
|
||||
// ...and the same picture without the noise reads the same way, so noise is not what decided it.
|
||||
const clean = frame(grain, { r: 12 });
|
||||
assert.equal(aiTierNeeded(clean.rgba, W, H, clean.hole, (Math.PI * 12 * 12) / FRAME_AREA), false);
|
||||
|
||||
// 5. The mask handed to the model: grown by exactly the radius, and no further. The growth is a
|
||||
// box, so a pixel may sit up to r diagonally away — but never outside the r-wide slab.
|
||||
const grown = dilateMask(speck.hole, W, H, 3);
|
||||
let grew = 0, shrank = 0, tooFar = 0;
|
||||
for (let y = 0; y < H; y++) {
|
||||
for (let x = 0; x < W; x++) {
|
||||
const i = y * W + x;
|
||||
if (!speck.hole[i] && grown[i]) {
|
||||
grew++;
|
||||
const d = Math.hypot(x - 256, y - 256);
|
||||
if (d > 12 + 3 * Math.SQRT2 + 0.5) tooFar++;
|
||||
}
|
||||
if (speck.hole[i] && !grown[i]) shrank++;
|
||||
}
|
||||
}
|
||||
assert.equal(shrank, 0, 'a grown mask still covers the stroke');
|
||||
assert.ok(grew > 200, `the mask must actually grow (a 3px ring on a 12px disc is ~250 pixels, got ${grew})`);
|
||||
assert.equal(tooFar, 0, 'and not by more than the radius asked for');
|
||||
|
||||
// 6. Grain handed back. The patch is what a model returns — a smooth surface — and the ground
|
||||
// under the same mask is the photo's own noise. After the call the patch's high-pass may no
|
||||
// longer be a fraction of the ground's, and the pixels outside the mask are untouched.
|
||||
const noisyGround = frame(grain, { r: 26, noise: 16 });
|
||||
const smoothPatch = new Uint8Array(4 * W * H);
|
||||
for (let i = 0; i < W * H; i++) {
|
||||
const p = i * 4;
|
||||
smoothPatch[p] = smoothPatch[p + 1] = smoothPatch[p + 2] = 128;
|
||||
smoothPatch[p + 3] = 255;
|
||||
}
|
||||
const before = smoothPatch.slice();
|
||||
const hp = (data, mask, inside, stride) => {
|
||||
let sum = 0, sum2 = 0, n = 0;
|
||||
for (let y = 1; y < H - 1; y += stride) {
|
||||
for (let x = 1; x < W - 1; x += stride) {
|
||||
const i = y * W + x;
|
||||
if (inside ? !mask[i] : mask[i]) continue;
|
||||
const m = (data[i - W - 1] + data[i - W] + data[i - W + 1] + data[i - 1] + data[i] + data[i + 1] + data[i + W - 1] + data[i + W] + data[i + W + 1]) / 9;
|
||||
const d = data[i] - m;
|
||||
sum += d;
|
||||
sum2 += d * d;
|
||||
n++;
|
||||
}
|
||||
}
|
||||
return Math.sqrt(Math.max(0, sum2 / n - (sum / n) ** 2));
|
||||
};
|
||||
const groundPlane = new Uint8Array(W * H);
|
||||
for (let i = 0; i < W * H; i++) groundPlane[i] = noisyGround.rgba[i * 4];
|
||||
// matchGrain reads the source the way the server is fed it: three planes, no alpha.
|
||||
const src3 = new Uint8Array(3 * W * H);
|
||||
for (let i = 0; i < W * H; i++) {
|
||||
src3[i] = noisyGround.rgba[i * 4];
|
||||
src3[W * H + i] = noisyGround.rgba[i * 4 + 1];
|
||||
src3[2 * W * H + i] = noisyGround.rgba[i * 4 + 2];
|
||||
}
|
||||
const patchPlane = new Uint8Array(W * H);
|
||||
for (let i = 0; i < W * H; i++) patchPlane[i] = smoothPatch[i * 4];
|
||||
const groundSigma = hp(groundPlane, noisyGround.hole, false, 1);
|
||||
const smoothSigma = hp(patchPlane, noisyGround.hole, true, 2);
|
||||
const added = matchGrain(smoothPatch, src3, noisyGround.hole, W, H);
|
||||
for (let i = 0; i < W * H; i++) patchPlane[i] = smoothPatch[i * 4];
|
||||
const nowSigma = hp(patchPlane, noisyGround.hole, true, 2);
|
||||
let touchedOutside = 0;
|
||||
for (let i = 0; i < W * H; i++) {
|
||||
if (noisyGround.hole[i]) continue;
|
||||
const p = i * 4;
|
||||
if (smoothPatch[p] !== before[p]) touchedOutside++;
|
||||
}
|
||||
console.log(
|
||||
`ground hp sigma ${groundSigma.toFixed(2)}, model patch ${smoothSigma.toFixed(2)}, ` +
|
||||
`after grain ${nowSigma.toFixed(2)} (added ${added.toFixed(2)})`
|
||||
);
|
||||
assert.ok(groundSigma > 3, 'the synthetic ground has to be noisy for this to mean anything');
|
||||
assert.ok(nowSigma > groundSigma * 0.5, 'the model patch must end up carrying the ground grain');
|
||||
assert.ok(nowSigma < groundSigma * 2.2, 'and not more of it than the ground has');
|
||||
assert.equal(touchedOutside, 0, 'grain goes under the mask only');
|
||||
|
||||
// 7. ...and a patch that already has the ground's grain is not given a second helping.
|
||||
const already = noisyGround.rgba.slice();
|
||||
const addedAgain = matchGrain(already, src3, noisyGround.hole, W, H);
|
||||
console.log(`already-grained patch, added ${addedAgain.toFixed(2)}`);
|
||||
assert.ok(addedAgain < 1.5, `a grained patch is left alone (added ${addedAgain.toFixed(2)})`);
|
||||
|
||||
// 8. The fourth routing rule, the one only the engine knows: a mask no translation of the photo
|
||||
// can clear. There is no patch to copy, so the mask is the model's whatever its area — and
|
||||
// what the fast engine lays down instead has to be the smooth field of its own rim, with no
|
||||
// direction in it. That is what the fan (radial rays converging on the centre) and the
|
||||
// repeated stamp of a walked translation both are, and neither may come back from here.
|
||||
const wide = frame(flat, { r: 250 }); // wider than half the crop in every direction: no shift clears it
|
||||
// The mark the brush left behind, so "rebuilt" can be told from "left standing": everything under
|
||||
// the brush is painted dark before the fill is asked for.
|
||||
const marked = frame(flat, { r: 250 });
|
||||
for (let i = 0; i < W * H; i++) {
|
||||
if (!marked.hole[i]) continue;
|
||||
const p = i * 4;
|
||||
marked.rgba[p] = marked.rgba[p + 1] = marked.rgba[p + 2] = 30;
|
||||
}
|
||||
const wideFill = fastLocalInpaint(marked.rgba, W, H, marked.hole);
|
||||
assert.equal(localFillWasFlat(), true, 'a hole that no translation clears must report no donor');
|
||||
assert.equal(localFillWasFlat(), true, 'the flag has to survive the call the router reads it after');
|
||||
let leftStanding = 0, farFromRim = 0;
|
||||
for (let i = 0; i < W * H; i++) {
|
||||
if (!marked.hole[i]) continue;
|
||||
if (wideFill[i * 4] <= 31) leftStanding++;
|
||||
if (Math.abs(wideFill[i * 4] - 128) > 60) farFromRim++;
|
||||
}
|
||||
assert.equal(leftStanding, 0, `${leftStanding} pixels under the brush still carry the mark`);
|
||||
assert.equal(farFromRim, 0, `${farFromRim} filled pixels land away from the ground's own tone`);
|
||||
|
||||
// ...and the fill itself, on a ground with structure in it. What is asked of it is not accuracy
|
||||
// — a hole this size is the model's, and SOR on a 500px disc converges short of the rim's own
|
||||
// gradient, which is a smooth flattening and not an artifact — but that it invents no structure:
|
||||
// it stays monotone across a ramp (no ridges, no crumpled planes) and it carries no fan.
|
||||
const ramp = frame((x) => 30 + x * 0.4, { r: 250 });
|
||||
const rampFill = fastLocalInpaint(ramp.rgba, W, H, ramp.hole);
|
||||
let worstRamp = 0, drops = 0, prev = null;
|
||||
for (let y = 0; y < H; y++) {
|
||||
for (let x = 0; x < W; x++) {
|
||||
if (!ramp.hole[y * W + x]) continue;
|
||||
const got = rampFill[(y * W + x) * 4];
|
||||
worstRamp = Math.max(worstRamp, Math.abs(got - luma(30 + x * 0.4)));
|
||||
if (prev !== null && got < prev - 1) drops++;
|
||||
prev = got;
|
||||
}
|
||||
prev = null;
|
||||
}
|
||||
console.log(`wide ramp fill: worst ${worstRamp.toFixed(1)} levels off, ${drops} non-monotone pixels`);
|
||||
assert.equal(drops, 0, `the flat fill must not ripple across a ramp (${drops} pixels go backwards)`);
|
||||
assert.ok(worstRamp < 40, `the residual of an unconverged wide solve stays a flattening (${worstRamp.toFixed(1)} levels)`);
|
||||
|
||||
const striped = frame((x, y) => 128 + Math.sin(y * 0.6) * 24, { r: 250 });
|
||||
const stripedFill = fastLocalInpaint(striped.rgba, W, H, striped.hole);
|
||||
const bins = new Array(72).fill(0), cnt = new Array(72).fill(0);
|
||||
for (let y = 0; y < H; y++) {
|
||||
for (let x = 0; x < W; x++) {
|
||||
const i = y * W + x;
|
||||
if (!striped.hole[i]) continue;
|
||||
const dx = x - 256, dy = y - 256;
|
||||
const d = Math.hypot(dx, dy) / 250;
|
||||
if (d < 0.25 || d > 0.8) continue;
|
||||
const a = Math.round(((Math.atan2(dy, dx) + Math.PI) / (2 * Math.PI)) * 72) % 72;
|
||||
bins[a] += stripedFill[i * 4];
|
||||
cnt[a]++;
|
||||
}
|
||||
}
|
||||
const prof = bins.filter((_, i) => cnt[i]).map((v, i) => v / cnt[i]);
|
||||
const pm = prof.reduce((a, b) => a + b, 0) / prof.length;
|
||||
const fan = (100 * Math.sqrt(prof.reduce((a, b) => a + (b - pm) ** 2, 0) / prof.length)) / pm;
|
||||
console.log(`wide striped fill: angular spread ${fan.toFixed(1)}% of the mean (the fan measure)`);
|
||||
assert.ok(fan < 3, `a wide hole comes back without fan sectors (${fan.toFixed(1)}% — the old fill ran 9-39%)`);
|
||||
|
||||
console.log('PASS heal-route-check');
|
||||
@@ -0,0 +1,91 @@
|
||||
// The hole sits in the busy half of the frame, and other places to take a donor from are a short
|
||||
// hop away in the calm half, at the same tone. What the repair has to take from the photo is the
|
||||
// texture it will sit among, so three things must hold: every pixel under the brush is rebuilt,
|
||||
// the repair carries the surrounding texture rather than a slab laid over it (a donor picked on
|
||||
// brightness and gradient alone lands a smooth dome in the middle of a ripple — the patch looks
|
||||
// right against the rim it touches and wrong against everything else), and the tone the repair
|
||||
// leaves behind is the photo's own, not the mark's.
|
||||
//
|
||||
// node scripts/heal-texture-check.mjs
|
||||
// INPAINT_SRC=/tmp/old-inpaint.ts node scripts/heal-texture-check.mjs (must fail on the old engine)
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import ts from 'typescript';
|
||||
|
||||
const SRC = new URL(process.env.INPAINT_SRC ?? '../src/engine/inpaint.ts', import.meta.url);
|
||||
const dir = mkdtempSync(join(tmpdir(), 'heal-texture-'));
|
||||
writeFileSync(
|
||||
join(dir, 'inpaint.mjs'),
|
||||
ts.transpileModule(readFileSync(SRC, 'utf8'), {
|
||||
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText
|
||||
);
|
||||
const { fastLocalInpaint } = await import(pathToFileURL(join(dir, 'inpaint.mjs')).href);
|
||||
|
||||
const W = 256, H = 192, CX = 170, CY = 96, R = 32;
|
||||
const BUSY_FROM = 96; // everything left of this column is calm, and it is only a hop away
|
||||
const truth = new Uint8Array(W * H * 4);
|
||||
const dirty = new Uint8Array(W * H * 4);
|
||||
const hole = new Uint8Array(W * H);
|
||||
const tone = (x, y) => 120 + 30 * Math.sin((x + y) * 0.05);
|
||||
for (let y = 0; y < H; y++) {
|
||||
for (let x = 0; x < W; x++) {
|
||||
const i = y * W + x;
|
||||
const busy = x >= BUSY_FROM
|
||||
? 22 * Math.sin(x * 1.3 + y * 1.7) + 12 * Math.sin(x * 2.1 - y * 0.9)
|
||||
: 0;
|
||||
const v = Math.min(255, Math.max(0, tone(x, y) + busy));
|
||||
for (const px of [truth, dirty]) {
|
||||
px[i * 4] = px[i * 4 + 1] = px[i * 4 + 2] = v;
|
||||
px[i * 4 + 3] = 255;
|
||||
}
|
||||
// the mark: a red the grey photo cannot produce, so a pixel still wearing it was never rebuilt
|
||||
if ((x - CX) ** 2 + (y - CY) ** 2 <= R * R) {
|
||||
hole[i] = 1;
|
||||
dirty[i * 4] = 200;
|
||||
dirty[i * 4 + 1] = 20;
|
||||
dirty[i * 4 + 2] = 20;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const luma = (px, i) => 0.299 * px[i * 4] + 0.587 * px[i * 4 + 1] + 0.114 * px[i * 4 + 2];
|
||||
// High-pass energy: the grain and small structure, with the slow shading a patch match is
|
||||
// supposed to ignore taken out.
|
||||
const spread = (px, from, to) => {
|
||||
let s = 0, ss = 0, n = 0;
|
||||
for (let y = 1; y < H - 1; y++) {
|
||||
for (let x = 1; x < W - 1; x++) {
|
||||
const d = Math.hypot(x - CX, y - CY);
|
||||
if (d < from || d > to) continue;
|
||||
const i = y * W + x;
|
||||
const hp = luma(px, i) - (luma(px, i - 1) + luma(px, i + 1) + luma(px, i - W) + luma(px, i + W)) / 4;
|
||||
s += hp; ss += hp * hp; n++;
|
||||
}
|
||||
}
|
||||
return Math.sqrt(Math.max(0, ss / n - (s / n) ** 2));
|
||||
};
|
||||
|
||||
const out = fastLocalInpaint(dirty, W, H, hole);
|
||||
|
||||
let untouched = 0, sum = 0, n = 0, truthSum = 0;
|
||||
for (let i = 0; i < W * H; i++) {
|
||||
if (!hole[i]) continue;
|
||||
if (out[i * 4] === 200 && out[i * 4 + 1] === 20) untouched++;
|
||||
sum += luma(out, i);
|
||||
truthSum += luma(truth, i);
|
||||
n++;
|
||||
}
|
||||
|
||||
const inner = spread(out, 0, R * 0.8), ring = spread(out, R * 1.3, R * 2.2);
|
||||
const ratio = inner / ring;
|
||||
const toneMiss = Math.abs(sum / n - truthSum / n);
|
||||
console.log(`{ untouched: ${untouched}, texture: ${ratio.toFixed(2)}, inner: ${inner.toFixed(2)}, ring: ${ring.toFixed(2)}, toneMiss: ${toneMiss.toFixed(2)} }`);
|
||||
|
||||
assert.equal(untouched, 0, 'every pixel under the brush must be rebuilt');
|
||||
assert.ok(ratio > 0.6, `the repair must carry the surrounding texture, not a dome (texture ${ratio.toFixed(2)})`);
|
||||
assert.ok(toneMiss < 3, `the repair must land on the photo's own tone (off by ${toneMiss.toFixed(2)})`);
|
||||
console.log('heal texture match: ok');
|
||||
@@ -519,7 +519,7 @@ export function Workspace() {
|
||||
const [brushTool, setBrushTool] = useState<'heal' | 'mosaic' | null>(null);
|
||||
const [healR, setHealR] = useState(HEAL_DEFAULT_R);
|
||||
const [mosaicR, setMosaicR] = useState(MOSAIC_DEFAULT_R);
|
||||
const [aiInpaint, setAiInpaint] = useState<boolean>(() => recall('recipescam.heal.ai') === 'true');
|
||||
const [aiInpaint, setAiInpaint] = useState<boolean>(() => recall('recipescam.heal.ai') !== 'false');
|
||||
useEffect(() => {
|
||||
setAiInpaintMode(aiInpaint);
|
||||
keep('recipescam.heal.ai', String(aiInpaint));
|
||||
|
||||
@@ -1079,13 +1079,20 @@ async function applyHealToBase(
|
||||
const cSpanW = mx1 - mx0;
|
||||
const cSpanH = my1 - my0;
|
||||
const cSpan = Math.max(cSpanW, cSpanH);
|
||||
const margin = Math.max(48, Math.min(192, Math.round(Math.max(cWidest * 2.5, cSpan * 0.4) + 24)));
|
||||
const srcLeft = Math.max(boxLeft, Math.floor(mx0 - margin));
|
||||
const srcTop = Math.max(boxTop, Math.floor(my0 - margin));
|
||||
const srcRight = Math.min(boxRight, Math.ceil(mx1 + margin));
|
||||
const srcBottom = Math.min(boxBottom, Math.ceil(my1 + margin));
|
||||
const srcW = srcRight - srcLeft;
|
||||
const srcH = srcBottom - srcTop;
|
||||
// Provide 60%–80% context padding (min 64px) around the cluster with dimensions divisible by 8
|
||||
// so both the Fast engine and LaMa ONNX have ample surrounding context without boundary distortion.
|
||||
const padX = Math.max(64, Math.round(cSpanW * 0.75));
|
||||
const padY = Math.max(64, Math.round(cSpanH * 0.75));
|
||||
let srcLeft = Math.max(boxLeft, Math.floor(mx0 - padX));
|
||||
let srcTop = Math.max(boxTop, Math.floor(my0 - padY));
|
||||
let srcRight = Math.min(boxRight, Math.ceil(mx1 + padX));
|
||||
let srcBottom = Math.min(boxBottom, Math.ceil(my1 + padY));
|
||||
let srcW = srcRight - srcLeft;
|
||||
let srcH = srcBottom - srcTop;
|
||||
srcW = Math.max(8, srcW - (srcW % 8));
|
||||
srcH = Math.max(8, srcH - (srcH % 8));
|
||||
if (srcLeft + srcW > boxRight) srcLeft = Math.max(boxLeft, boxRight - srcW);
|
||||
if (srcTop + srcH > boxBottom) srcTop = Math.max(boxTop, boxBottom - srcH);
|
||||
|
||||
if (srcW >= 8 && srcH >= 8) {
|
||||
const { w: mw, h: mh } = inpaintCropSize(srcW, srcH);
|
||||
@@ -1128,7 +1135,11 @@ async function applyHealToBase(
|
||||
}
|
||||
const patchKey = `${mw}x${mh}_` + cluster.map((s) => `${s.x.toFixed(4)},${s.y.toFixed(4)},${s.r.toFixed(4)}`).join(';');
|
||||
(hole as { patchKey?: string }).patchKey = patchKey;
|
||||
const patch = await inpaintCrop(cropPixels, mw, mh, hole).catch(() => null);
|
||||
// What the engine router is told: how much of the frame is under this cluster's
|
||||
// brush. A crop is a tight window around its hole, so its own area says nothing
|
||||
// about whether the mark is a speck or an object (see engine/inpaint.ts).
|
||||
const brushArea = cluster.reduce((sum, s) => sum + Math.PI * (s.r * width) ** 2, 0);
|
||||
const patch = await inpaintCrop(cropPixels, mw, mh, hole, brushArea / (width * height)).catch(() => null);
|
||||
if (patch) {
|
||||
const patchSurface = tempOwn(createSurface(mw, mh));
|
||||
if (patchSurface && patchSurface.getCanvas().writePixels(patch, mw, mh, 0, 0)) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3056,6 +3056,52 @@ form[data-key='immich-modal'] {
|
||||
box-sizing: border-box !important;
|
||||
border-right: none !important;
|
||||
}
|
||||
/* Two things a desktop column is and a phone strip is not. Its height is the
|
||||
frame's (`.col` above), so a strip that holds one row of chips stood 46vh
|
||||
tall with that row floating in the middle of the empty bar. And every chip
|
||||
in it takes the column's own width (`.col .chip` above), which in a row is
|
||||
the row's width read once per chip: each chip came out as wide as the whole
|
||||
bar, so the strip was one chip wide and eight chips long, the rest of them
|
||||
off-screen, and what the visitor saw of a chip was its label alone in an
|
||||
empty pill. A chip here is as wide as its own label, and a strip as tall as
|
||||
the chips it holds. */
|
||||
.workspace.is-mobile .col,
|
||||
.workspace.is-mobile .col-main,
|
||||
.workspace.is-mobile .col-sub,
|
||||
.workspace.is-mobile .col[data-col="slider"],
|
||||
.workspace.is-mobile .col-create,
|
||||
.workspace.is-mobile .col-slider {
|
||||
height: auto !important;
|
||||
}
|
||||
.workspace.is-mobile .col .chip {
|
||||
width: auto !important;
|
||||
}
|
||||
/* The roll, in the bar's own system rather than the stage's. The chip bar
|
||||
insets itself by 8px, spaces its chips 6px apart, and a chip is a pill on
|
||||
`--bg-elev`; the strip already sat on that same 8px edge, but it wore the
|
||||
stage's square frame and floated in the stage's centred cluster, so the row
|
||||
of frames read as a foreign row hovering above the bar instead of the bar's
|
||||
own first line. Same gap, same pill, and anchored to the bottom: the roll
|
||||
ends up the row above the toolbar, which is the row above the chip bar, with
|
||||
the photo giving way above it. Frames are 44px so the eight of a folder fit
|
||||
the 414px a phone has between the insets and the roll does not have to be
|
||||
scrolled to be read. */
|
||||
.workspace.is-mobile .studio-strip {
|
||||
gap: 6px !important;
|
||||
/* The strip is centred when it is narrower than the stage, and a centred row
|
||||
starts where its own width leaves it — 18px in, against the bar's 8px. It
|
||||
takes the whole width so its first frame lands on the bar's own inset. */
|
||||
width: 100% !important;
|
||||
margin-top: auto !important;
|
||||
padding-bottom: 0 !important;
|
||||
scroll-padding-inline: 8px !important;
|
||||
}
|
||||
.workspace.is-mobile .strip-frame {
|
||||
width: 44px !important;
|
||||
height: 44px !important;
|
||||
border-radius: 999px !important;
|
||||
background: var(--bg-elev) !important;
|
||||
}
|
||||
.workspace.is-mobile .col-main {
|
||||
flex-direction: row !important;
|
||||
align-items: center !important;
|
||||
|
||||
@@ -86,6 +86,11 @@
|
||||
/* --- base elements ------------------------------------------------------ */
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #root { height: 100%; margin: 0; }
|
||||
/* iOS Safari inflates the text of any block wider than the viewport (the phone's
|
||||
chip strips are `width: max-content` under .col-main, so a selected chip can
|
||||
blow up to ~3x and take the whole bar). Every size in this app is written in
|
||||
px, so the boost is off: 100% keeps the sizes as declared. */
|
||||
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
|
||||
@@ -1150,7 +1150,17 @@ export function ImageStage({
|
||||
const selBox =
|
||||
box && selSpot !== null && selSpot < brushSpots.length
|
||||
? (() => {
|
||||
const s = brushSpots[selSpot];
|
||||
const targetStroke = brushSpots[selSpot]?.stroke;
|
||||
let anchorSpot = brushSpots[selSpot];
|
||||
if (typeof targetStroke === 'number') {
|
||||
for (let i = 0; i < brushSpots.length; i++) {
|
||||
const sp = brushSpots[i];
|
||||
if (sp.stroke === targetStroke && sp.y < anchorSpot.y) {
|
||||
anchorSpot = sp;
|
||||
}
|
||||
}
|
||||
}
|
||||
const s = anchorSpot;
|
||||
const off = (s.r * box.width + 14 / view.s) * Math.SQRT1_2;
|
||||
return {
|
||||
left: `calc(${s.x * 100}% + ${off}px)`,
|
||||
|
||||
@@ -1 +1 @@
|
||||
6d5c70d163ece1a8cc3225dac50848fbbafd65820bcafbaac3ecb5cca7d07ca2 recipescam-images.tar.gz
|
||||
7cd97bf92bc1bf2446e53c10ff3e486b94e9ce0f841ebf9ba6a6fbaf81ef79ae recipescam-images.tar.gz
|
||||
|
||||
Reference in New Issue
Block a user