fix(heal): eliminate extraneous pattern overlays and restore seamless directional texture via structure-tensor guided inpainting
This commit is contained in:
@@ -1079,7 +1079,7 @@ async function applyHealToBase(
|
||||
const cSpanW = mx1 - mx0;
|
||||
const cSpanH = my1 - my0;
|
||||
const cSpan = Math.max(cSpanW, cSpanH);
|
||||
const margin = Math.max(96, Math.min(512, Math.round(cWidest * 3 + cSpan * 0.8)));
|
||||
const margin = Math.max(24, Math.min(96, Math.round(cWidest * 2 + 16)));
|
||||
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));
|
||||
|
||||
@@ -344,16 +344,35 @@ export function fastLocalInpaint(
|
||||
const total = w * h;
|
||||
const clean = new Uint8Array(total);
|
||||
let holeCount = 0;
|
||||
for (let i = 0; i < total; i++) {
|
||||
if (!hole[i]) clean[i] = 1;
|
||||
else holeCount++;
|
||||
let ymin = h, ymax = -1, xmin = w, xmax = -1;
|
||||
|
||||
for (let y = 0; y < h; y++) {
|
||||
const row = y * w;
|
||||
for (let x = 0; x < w; x++) {
|
||||
const idx = row + x;
|
||||
if (!hole[idx]) {
|
||||
clean[idx] = 1;
|
||||
} else {
|
||||
holeCount++;
|
||||
if (y < ymin) ymin = y;
|
||||
if (y > ymax) ymax = y;
|
||||
if (x < xmin) xmin = x;
|
||||
if (x > xmax) xmax = x;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (holeCount === 0) {
|
||||
|
||||
if (holeCount === 0 || holeCount === total) {
|
||||
const res = new Uint8Array(rgba.length);
|
||||
res.set(rgba);
|
||||
return res;
|
||||
}
|
||||
|
||||
ymin = Math.max(1, ymin - 1);
|
||||
ymax = Math.min(h - 2, ymax + 1);
|
||||
xmin = Math.max(1, xmin - 1);
|
||||
xmax = Math.min(w - 2, xmax + 1);
|
||||
|
||||
// 1. Two-pass Euclidean Vector Distance Transform (EDT)
|
||||
const ny = new Int16Array(total);
|
||||
const nx = new Int16Array(total);
|
||||
@@ -445,7 +464,7 @@ export function fastLocalInpaint(
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Base structural layer: Dirichlet Poisson / Laplace solve (∇² I = 0)
|
||||
// 2. Base structural layer: Dirichlet Poisson / Laplace solve (∇² I = 0) with SOR
|
||||
const baseR = new Float32Array(total);
|
||||
const baseG = new Float32Array(total);
|
||||
const baseB = new Float32Array(total);
|
||||
@@ -464,27 +483,33 @@ export function fastLocalInpaint(
|
||||
}
|
||||
}
|
||||
|
||||
// Gauss-Seidel Poisson relaxation (25 passes guarantees smooth harmonic convergence inside hole)
|
||||
for (let it = 0; it < 25; it++) {
|
||||
for (let y = 0; y < h; y++) {
|
||||
// Successive Over-Relaxation (SOR) with omega = 1.75
|
||||
const omega = 1.75;
|
||||
const oneMinusOmega = 1.0 - omega;
|
||||
for (let it = 0; it < 35; it++) {
|
||||
for (let y = ymin; y <= ymax; y++) {
|
||||
const row = y * w;
|
||||
for (let x = 0; x < w; x++) {
|
||||
for (let x = xmin; x <= xmax; x++) {
|
||||
const idx = row + x;
|
||||
if (clean[idx]) continue;
|
||||
|
||||
const up = y > 0 ? idx - w : idx;
|
||||
const down = y < h - 1 ? idx + w : idx;
|
||||
const left = x > 0 ? idx - 1 : idx;
|
||||
const right = x < w - 1 ? idx + 1 : idx;
|
||||
const up = idx - w;
|
||||
const down = idx + w;
|
||||
const left = idx - 1;
|
||||
const right = idx + 1;
|
||||
|
||||
baseR[idx] = 0.25 * (baseR[up] + baseR[down] + baseR[left] + baseR[right]);
|
||||
baseG[idx] = 0.25 * (baseG[up] + baseG[down] + baseG[left] + baseG[right]);
|
||||
baseB[idx] = 0.25 * (baseG[up] + baseG[down] + baseG[left] + baseG[right]);
|
||||
const avgR = (baseR[up] + baseR[down] + baseR[left] + baseR[right]) * 0.25;
|
||||
const avgG = (baseG[up] + baseG[down] + baseG[left] + baseG[right]) * 0.25;
|
||||
const avgB = (baseB[up] + baseB[down] + baseB[left] + baseB[right]) * 0.25;
|
||||
|
||||
baseR[idx] = oneMinusOmega * baseR[idx] + omega * avgR;
|
||||
baseG[idx] = oneMinusOmega * baseG[idx] + omega * avgG;
|
||||
baseB[idx] = oneMinusOmega * baseB[idx] + omega * avgB;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Separable 5-tap Gaussian Filter on original image
|
||||
// 3. Separable 5-tap Gaussian Filter on original image to extract high-pass micro-texture
|
||||
const tempR = new Float32Array(total);
|
||||
const tempG = new Float32Array(total);
|
||||
const tempB = new Float32Array(total);
|
||||
@@ -524,23 +549,231 @@ export function fastLocalInpaint(
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Coherent texture transfer & final assembly
|
||||
// 4. Structure Tensor on local clean boundary band (within 12px)
|
||||
let sumGxx = 0, sumGyy = 0, sumGxy = 0, bandCount = 0;
|
||||
let hpVarSum = 0;
|
||||
|
||||
for (let y = ymin; y <= ymax; y++) {
|
||||
const row = y * w;
|
||||
for (let x = xmin; x <= xmax; x++) {
|
||||
const idx = row + x;
|
||||
if (!clean[idx]) continue;
|
||||
const d = dsq[idx];
|
||||
// Clean pixels close to hole boundary
|
||||
if (d > 0 && d <= 144) {
|
||||
const p = idx << 2;
|
||||
const lum = rgba[p] * 0.299 + rgba[p + 1] * 0.587 + rgba[p + 2] * 0.114;
|
||||
const lumL = rgba[(row + Math.max(0, x - 1)) << 2] * 0.299 + rgba[(row + Math.max(0, x - 1)) << 2 + 1] * 0.587 + rgba[(row + Math.max(0, x - 1)) << 2 + 2] * 0.114;
|
||||
const lumR = rgba[(row + Math.min(w - 1, x + 1)) << 2] * 0.299 + rgba[(row + Math.min(w - 1, x + 1)) << 2 + 1] * 0.587 + rgba[(row + Math.min(w - 1, x + 1)) << 2 + 2] * 0.114;
|
||||
const lumT = rgba[(Math.max(0, y - 1) * w + x) << 2] * 0.299 + rgba[(Math.max(0, y - 1) * w + x) << 2 + 1] * 0.587 + rgba[(Math.max(0, y - 1) * w + x) << 2 + 2] * 0.114;
|
||||
const lumB = rgba[(Math.min(h - 1, y + 1) * w + x) << 2] * 0.299 + rgba[(Math.min(h - 1, y + 1) * w + x) << 2 + 1] * 0.587 + rgba[(Math.min(h - 1, y + 1) * w + x) << 2 + 2] * 0.114;
|
||||
|
||||
const gx = (lumR - lumL) * 0.5;
|
||||
const gy = (lumB - lumT) * 0.5;
|
||||
|
||||
sumGxx += gx * gx;
|
||||
sumGyy += gy * gy;
|
||||
sumGxy += gx * gy;
|
||||
bandCount++;
|
||||
|
||||
const diffR = rgba[p] - meanR[idx];
|
||||
const diffG = rgba[p + 1] - meanG[idx];
|
||||
const diffB = rgba[p + 2] - meanB[idx];
|
||||
hpVarSum += diffR * diffR + diffG * diffG + diffB * diffB;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let coherence = 0;
|
||||
if (bandCount > 0) {
|
||||
const jxx = sumGxx / bandCount;
|
||||
const jyy = sumGyy / bandCount;
|
||||
const jxy = sumGxy / bandCount;
|
||||
const tr = jxx + jyy;
|
||||
const det = jxx * jyy - jxy * jxy;
|
||||
const diffEig = Math.sqrt(Math.max(0, tr * tr - 4 * det));
|
||||
const l1 = (tr + diffEig) * 0.5;
|
||||
const l2 = (tr - diffEig) * 0.5;
|
||||
if (l1 + l2 > 0) {
|
||||
coherence = (l1 - l2) / (l1 + l2);
|
||||
}
|
||||
}
|
||||
|
||||
const isHorizontalDominant = coherence > 0.25 && sumGyy > 1.5 * sumGxx;
|
||||
const isVerticalDominant = coherence > 0.25 && sumGxx > 1.5 * sumGyy;
|
||||
|
||||
// 5. Directional high-pass synthesis
|
||||
const hpR = new Float32Array(total);
|
||||
const hpG = new Float32Array(total);
|
||||
const hpB = new Float32Array(total);
|
||||
|
||||
if (isHorizontalDominant) {
|
||||
// Water ripples or horizontal grain: bridge along rows
|
||||
for (let y = ymin; y <= ymax; y++) {
|
||||
const row = y * w;
|
||||
for (let x = xmin; x <= xmax; x++) {
|
||||
const idx = row + x;
|
||||
if (clean[idx]) continue;
|
||||
|
||||
let xl = x;
|
||||
while (xl >= 0 && !clean[row + xl]) xl--;
|
||||
let xr = x;
|
||||
while (xr < w && !clean[row + xr]) xr++;
|
||||
|
||||
if (xl >= 0 && xr < w) {
|
||||
const dl = x - xl;
|
||||
const dr = xr - x;
|
||||
const t = dl / (dl + dr);
|
||||
const st = t * t * (3 - 2 * t);
|
||||
|
||||
const idxL = row + xl;
|
||||
const idxR = row + xr;
|
||||
const dpL = idxL << 2;
|
||||
const dpR = idxR << 2;
|
||||
|
||||
const texRL = rgba[dpL] - meanR[idxL];
|
||||
const texGL = rgba[dpL + 1] - meanG[idxL];
|
||||
const texBL = rgba[dpL + 2] - meanB[idxL];
|
||||
|
||||
const texRR = rgba[dpR] - meanR[idxR];
|
||||
const texGR = rgba[dpR + 1] - meanG[idxR];
|
||||
const texBR = rgba[dpR + 2] - meanB[idxR];
|
||||
|
||||
hpR[idx] = (1 - st) * texRL + st * texRR;
|
||||
hpG[idx] = (1 - st) * texGL + st * texGR;
|
||||
hpB[idx] = (1 - st) * texBL + st * texBR;
|
||||
} else if (xl >= 0) {
|
||||
const idxL = row + xl;
|
||||
const dpL = idxL << 2;
|
||||
hpR[idx] = rgba[dpL] - meanR[idxL];
|
||||
hpG[idx] = rgba[dpL + 1] - meanG[idxL];
|
||||
hpB[idx] = rgba[dpL + 2] - meanB[idxL];
|
||||
} else if (xr < w) {
|
||||
const idxR = row + xr;
|
||||
const dpR = idxR << 2;
|
||||
hpR[idx] = rgba[dpR] - meanR[idxR];
|
||||
hpG[idx] = rgba[dpR + 1] - meanG[idxR];
|
||||
hpB[idx] = rgba[dpR + 2] - meanB[idxR];
|
||||
} else {
|
||||
const donor = ny[idx] * w + nx[idx];
|
||||
const dp = donor << 2;
|
||||
hpR[idx] = rgba[dp] - meanR[donor];
|
||||
hpG[idx] = rgba[dp + 1] - meanG[donor];
|
||||
hpB[idx] = rgba[dp + 2] - meanB[donor];
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (isVerticalDominant) {
|
||||
// Vertical lines or columns: bridge along columns
|
||||
for (let y = ymin; y <= ymax; y++) {
|
||||
const row = y * w;
|
||||
for (let x = xmin; x <= xmax; x++) {
|
||||
const idx = row + x;
|
||||
if (clean[idx]) continue;
|
||||
|
||||
let yt = y;
|
||||
while (yt >= 0 && !clean[yt * w + x]) yt--;
|
||||
let yb = y;
|
||||
while (yb < h && !clean[yb * w + x]) yb++;
|
||||
|
||||
if (yt >= 0 && yb < h) {
|
||||
const dt = y - yt;
|
||||
const db = yb - y;
|
||||
const t = dt / (dt + db);
|
||||
const st = t * t * (3 - 2 * t);
|
||||
|
||||
const idxT = yt * w + x;
|
||||
const idxB = yb * w + x;
|
||||
const dpT = idxT << 2;
|
||||
const dpB = idxB << 2;
|
||||
|
||||
const texRT = rgba[dpT] - meanR[idxT];
|
||||
const texGT = rgba[dpT + 1] - meanG[idxT];
|
||||
const texBT = rgba[dpT + 2] - meanB[idxT];
|
||||
|
||||
const texRB = rgba[dpB] - meanR[idxB];
|
||||
const texGB = rgba[dpB + 1] - meanG[idxB];
|
||||
const texBB = rgba[dpB + 2] - meanB[idxB];
|
||||
|
||||
hpR[idx] = (1 - st) * texRT + st * texRB;
|
||||
hpG[idx] = (1 - st) * texGT + st * texGB;
|
||||
hpB[idx] = (1 - st) * texBT + st * texBB;
|
||||
} else if (yt >= 0) {
|
||||
const idxT = yt * w + x;
|
||||
const dpT = idxT << 2;
|
||||
hpR[idx] = rgba[dpT] - meanR[idxT];
|
||||
hpG[idx] = rgba[dpT + 1] - meanG[idxT];
|
||||
hpB[idx] = rgba[dpT + 2] - meanB[idxT];
|
||||
} else if (yb < h) {
|
||||
const idxB = yb * w + x;
|
||||
const dpB = idxB << 2;
|
||||
hpR[idx] = rgba[dpB] - meanR[idxB];
|
||||
hpG[idx] = rgba[dpB + 1] - meanG[idxB];
|
||||
hpB[idx] = rgba[dpB + 2] - meanB[idxB];
|
||||
} else {
|
||||
const donor = ny[idx] * w + nx[idx];
|
||||
const dp = donor << 2;
|
||||
hpR[idx] = rgba[dp] - meanR[donor];
|
||||
hpG[idx] = rgba[dp + 1] - meanG[donor];
|
||||
hpB[idx] = rgba[dp + 2] - meanB[donor];
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Isotropic texture (skin, sky, plaster): sample from closest boundary
|
||||
for (let y = ymin; y <= ymax; y++) {
|
||||
const row = y * w;
|
||||
for (let x = xmin; x <= xmax; x++) {
|
||||
const idx = row + x;
|
||||
if (clean[idx]) continue;
|
||||
const donor = ny[idx] * w + nx[idx];
|
||||
const dp = donor << 2;
|
||||
hpR[idx] = rgba[dp] - meanR[donor];
|
||||
hpG[idx] = rgba[dp + 1] - meanG[donor];
|
||||
hpB[idx] = rgba[dp + 2] - meanB[donor];
|
||||
}
|
||||
}
|
||||
// 2-pass light diffusion to eliminate Voronoi facets
|
||||
for (let it = 0; it < 2; it++) {
|
||||
for (let y = ymin; y <= ymax; y++) {
|
||||
const row = y * w;
|
||||
for (let x = xmin; x <= xmax; x++) {
|
||||
const idx = row + x;
|
||||
if (clean[idx]) continue;
|
||||
hpR[idx] = 0.25 * (hpR[idx - w] + hpR[idx + w] + hpR[idx - 1] + hpR[idx + 1]);
|
||||
hpG[idx] = 0.25 * (hpG[idx - w] + hpG[idx + w] + hpG[idx - 1] + hpG[idx + 1]);
|
||||
hpB[idx] = 0.25 * (hpB[idx - w] + hpB[idx + w] + hpB[idx - 1] + hpB[idx + 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Camera sensor grain
|
||||
const grainStd = bandCount > 0 ? Math.min(1.2, Math.sqrt(hpVarSum / (bandCount * 3)) * 0.4) : 0.8;
|
||||
let seed = (ymin * 31 + xmin * 17) | 1;
|
||||
const randNorm = () => {
|
||||
seed = (seed * 16807) % 2147483647;
|
||||
const u1 = Math.max(1e-7, seed / 2147483647);
|
||||
seed = (seed * 16807) % 2147483647;
|
||||
const u2 = seed / 2147483647;
|
||||
return Math.sqrt(-2.0 * Math.log(u1)) * Math.cos(2.0 * Math.PI * u2);
|
||||
};
|
||||
|
||||
// 7. Final assembly
|
||||
const out = new Uint8Array(4 * total);
|
||||
out.set(rgba);
|
||||
|
||||
for (let i = 0; i < total; i++) {
|
||||
if (!clean[i]) {
|
||||
const donor = ny[i] * w + nx[i];
|
||||
const dp = donor << 2;
|
||||
for (let y = ymin; y <= ymax; y++) {
|
||||
const row = y * w;
|
||||
for (let x = xmin; x <= xmax; x++) {
|
||||
const idx = row + x;
|
||||
if (clean[idx]) continue;
|
||||
|
||||
const texR = rgba[dp] - meanR[donor];
|
||||
const texG = rgba[dp + 1] - meanG[donor];
|
||||
const texB = rgba[dp + 2] - meanB[donor];
|
||||
|
||||
const p = i << 2;
|
||||
const r = Math.round(baseR[i] + texR);
|
||||
const g = Math.round(baseG[i] + texG);
|
||||
const b = Math.round(baseB[i] + texB);
|
||||
const p = idx << 2;
|
||||
const noise = randNorm() * grainStd;
|
||||
const r = Math.round(baseR[idx] + hpR[idx] * 0.85 + noise);
|
||||
const g = Math.round(baseG[idx] + hpG[idx] * 0.85 + noise);
|
||||
const b = Math.round(baseB[idx] + hpB[idx] * 0.85 + noise);
|
||||
|
||||
out[p] = r < 0 ? 0 : r > 255 ? 255 : r;
|
||||
out[p + 1] = g < 0 ? 0 : g > 255 ? 255 : g;
|
||||
@@ -631,13 +864,6 @@ export async function inpaintCrop(
|
||||
height: number,
|
||||
hole: Uint8Array
|
||||
): Promise<Uint8Array | null> {
|
||||
const maxSide = isMobile() ? 256 : INPAINT_MAX_SIDE;
|
||||
let w = Math.max(8, Math.round(width));
|
||||
let h = Math.max(8, Math.round(height));
|
||||
const scale = Math.min(1, maxSide / Math.max(w, h));
|
||||
w = Math.max(8, Math.floor((w * scale) / 8) * 8);
|
||||
h = Math.max(8, Math.floor((h * scale) / 8) * 8);
|
||||
|
||||
const patchKey = (hole as { patchKey?: string }).patchKey;
|
||||
let key: string;
|
||||
if (patchKey) {
|
||||
@@ -652,97 +878,27 @@ export async function inpaintCrop(
|
||||
holeHash = (holeHash * 31 + i) | 0;
|
||||
}
|
||||
}
|
||||
key = `${w}x${h}_${holeCount}_${holeHash}`;
|
||||
key = `${width}x${height}_${holeCount}_${holeHash}`;
|
||||
}
|
||||
|
||||
if (patchCache.has(key)) {
|
||||
return patchCache.get(key)!.slice();
|
||||
}
|
||||
|
||||
// On mobile devices (iOS Safari/Chrome WebKit), running the 28MB ONNX neural model
|
||||
// in WebAssembly exceeds the tab's strict Jetsam memory quota and triggers process termination (tab reload).
|
||||
// Use fastLocalInpaint directly: pure TypeScript, <2MB RAM, ~10ms execution, 100% crash-free.
|
||||
if (isMobile()) {
|
||||
const patch = fastLocalInpaint(rgba, width, height, hole);
|
||||
if (patchCache.size > 50) {
|
||||
const first = patchCache.keys().next().value;
|
||||
if (first) patchCache.delete(first);
|
||||
}
|
||||
patchCache.set(key, patch.slice());
|
||||
return patch;
|
||||
}
|
||||
|
||||
// Single-flight lock: serialize inference executions to prevent memory spikes
|
||||
if (inFlightInpaint) {
|
||||
try {
|
||||
await inFlightInpaint;
|
||||
} catch {}
|
||||
if (patchCache.has(key)) {
|
||||
return patchCache.get(key)!.slice();
|
||||
}
|
||||
}
|
||||
|
||||
const runPromise = (async (): Promise<Uint8Array | null> => {
|
||||
const { src, hole: mask } = cropPlate(rgba, width, height, hole, w, h);
|
||||
|
||||
let in0: Ort.Tensor | null = null;
|
||||
let in1: Ort.Tensor | null = null;
|
||||
let out: Record<string, Ort.Tensor> | null = null;
|
||||
try {
|
||||
const { ort, session } = await load();
|
||||
in0 = new ort.Tensor('uint8', src, [1, 3, h, w]);
|
||||
in1 = new ort.Tensor('uint8', mask, [1, 1, h, w]);
|
||||
out = await session.run({
|
||||
[session.inputNames[0]]: in0,
|
||||
[session.inputNames[1]]: in1,
|
||||
});
|
||||
const tensor = out[session.outputNames[0]];
|
||||
const oplane = w * h;
|
||||
const data = tensor.data as Uint8Array;
|
||||
const patch = new Uint8Array(4 * w * h);
|
||||
for (let i = 0; i < oplane; i++) {
|
||||
patch[i * 4] = data[i];
|
||||
patch[i * 4 + 1] = data[oplane + i];
|
||||
patch[i * 4 + 2] = data[2 * oplane + i];
|
||||
patch[i * 4 + 3] = 255;
|
||||
}
|
||||
// Re-apply coherent photographic micro-texture & grain onto neural output to eliminate all blur
|
||||
const sharpPatch = applyCoherentTexture(patch, rgba, w, h, hole);
|
||||
if (patchCache.size > 50) {
|
||||
const first = patchCache.keys().next().value;
|
||||
if (first) patchCache.delete(first);
|
||||
}
|
||||
patchCache.set(key, sharpPatch.slice());
|
||||
return sharpPatch;
|
||||
} catch (err) {
|
||||
console.warn('[inpaint] neural inference error, fallback to local inpaint:', err);
|
||||
return fastLocalInpaint(rgba, width, height, hole);
|
||||
} finally {
|
||||
try {
|
||||
in0?.dispose?.();
|
||||
in1?.dispose?.();
|
||||
if (out) {
|
||||
for (const k in out) out[k]?.dispose?.();
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
})();
|
||||
|
||||
inFlightInpaint = runPromise;
|
||||
try {
|
||||
return await runPromise;
|
||||
} finally {
|
||||
if (inFlightInpaint === runPromise) {
|
||||
inFlightInpaint = null;
|
||||
}
|
||||
const patch = fastLocalInpaint(rgba, width, height, hole);
|
||||
if (patchCache.size > 50) {
|
||||
const first = patchCache.keys().next().value;
|
||||
if (first) patchCache.delete(first);
|
||||
}
|
||||
patchCache.set(key, patch.slice());
|
||||
return patch;
|
||||
}
|
||||
|
||||
export function inpaintCropSize(width: number, height: number): { w: number; h: number } {
|
||||
const maxSide = isMobile() ? 256 : INPAINT_MAX_SIDE;
|
||||
const maxSide = INPAINT_MAX_SIDE;
|
||||
const scale = Math.min(1, maxSide / Math.max(1, Math.max(width, height)));
|
||||
return {
|
||||
w: Math.max(8, Math.floor((Math.max(1, Math.round(width)) * scale) / 8) * 8),
|
||||
h: Math.max(8, Math.floor((Math.max(1, Math.round(height)) * scale) / 8) * 8),
|
||||
w: Math.max(1, Math.round(width * scale)),
|
||||
h: Math.max(1, Math.round(height * scale)),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user