Baseline: full tree snapshot after git repository loss
The previous .git directory was found gutted (only info/ remained), so history is unrecoverable locally. This commit restores version control with the tree exactly as it stands: the working app, the preview-tracks launcher on the artist hero, the scrollable-track-previews design bundle (docs + approved mockups), and the Phase 0 validation artifacts.
This commit is contained in:
commit
a667b4b28e
161 files changed
+53170
No files matched your search
@@ -0,0 +1,338 @@
|
||||
// Silhouette tracer: anti-aliased alpha raster -> SVG path. No dependencies.
|
||||
//
|
||||
// Fanart's ClearLOGOs are a fixed 800x310 canvas, so a logo whose ink is short
|
||||
// (Weezer's is only 131px tall) cannot be enlarged without visibly blurring. We
|
||||
// only ever paint these as a flat accent-coloured mask, so the colour data is
|
||||
// irrelevant -- all we need is the ALPHA SILHOUETTE, and that traces cleanly to
|
||||
// vector: crisp at any size, and free of the source's resolution ceiling.
|
||||
//
|
||||
// Marching squares with INTERPOLATION is what makes this work. Thresholding
|
||||
// alpha to a hard in/out mask and walking pixel edges would yield a 1px
|
||||
// staircase that no amount of smoothing fully hides (and whose 90-degree steps
|
||||
// are indistinguishable from real corners in a letterform). Instead we treat
|
||||
// alpha as a scalar field and place each vertex where it actually crosses 50%,
|
||||
// which recovers sub-pixel edge positions from the source's anti-aliasing --
|
||||
// the curve of an "o" comes out smooth because the anti-aliased pixels told us
|
||||
// where the true edge lay.
|
||||
|
||||
const ISO = 127.5; // the 50%-coverage isoline we trace
|
||||
|
||||
// --- separating the ink from a baked-in drop shadow -------------------------
|
||||
// Many ClearLOGOs paint a drop shadow into the artwork itself, and it is often
|
||||
// FULLY OPAQUE (Weezer's is ~5k pixels at alpha 255), so tracing the alpha
|
||||
// channel picks the shadow up as if it were part of the letterform and the
|
||||
// glyphs come out bloated and tailed. Alpha cannot separate them at any
|
||||
// threshold -- but colour can: we only ever mask logos that scored as
|
||||
// monochrome, so the ink is one colour and the shadow is a different (darker)
|
||||
// one. Model each pixel as a linear blend of ink C and shadow S and keep the
|
||||
// ink's share; that also un-blends the anti-aliased pixels along the boundary,
|
||||
// which is what keeps the traced edge sub-pixel accurate.
|
||||
|
||||
const LUM = (r, g, b) => r * 0.299 + g * 0.587 + b * 0.114;
|
||||
|
||||
/** Mean colour of the dominant 3-bit bucket among solid pixels: the ink. */
|
||||
function inkColor(px, w, h) {
|
||||
const buckets = new Map();
|
||||
for (let p = 0; p < w * h; p++) {
|
||||
const i = p * 4;
|
||||
if (px[i + 3] < 200) continue;
|
||||
const key = ((px[i] >> 5) << 6) | ((px[i + 1] >> 5) << 3) | (px[i + 2] >> 5);
|
||||
const b = buckets.get(key);
|
||||
if (b) {
|
||||
b[0] += px[i];
|
||||
b[1] += px[i + 1];
|
||||
b[2] += px[i + 2];
|
||||
b[3]++;
|
||||
} else buckets.set(key, [px[i], px[i + 1], px[i + 2], 1]);
|
||||
}
|
||||
let top = null;
|
||||
for (const b of buckets.values()) if (!top || b[3] > top[3]) top = b;
|
||||
if (!top) return null;
|
||||
return [top[0] / top[3], top[1] / top[3], top[2] / top[3]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-pixel ink coverage in 0..255 -- the field we actually trace.
|
||||
*
|
||||
* With no shadow present this is just the alpha channel, so clean logos trace
|
||||
* exactly as before. When a dark opaque population IS found, each pixel is
|
||||
* un-blended against the ink/shadow axis so only the letterform survives.
|
||||
*/
|
||||
function inkField(px, w, h) {
|
||||
const n = w * h;
|
||||
const field = new Float32Array(n);
|
||||
const C = inkColor(px, w, h);
|
||||
if (!C) return field;
|
||||
|
||||
// The un-blend below models the artwork as exactly two colours, but a drop
|
||||
// shadow is a soft GRADIENT. Where a letter edge sits over its own shadow
|
||||
// the model is slightly off, and the 50% isoline jitters by a fraction of a
|
||||
// pixel -- which reads as a serrated edge once the logo is scaled up. A mild
|
||||
// blur of the coverage field removes that high-frequency jitter while
|
||||
// leaving a straight edge exactly where it was.
|
||||
const soften = () => {
|
||||
const tmp = new Float32Array(n);
|
||||
const at = (x, y) => field[Math.min(h - 1, Math.max(0, y)) * w + Math.min(w - 1, Math.max(0, x))];
|
||||
for (let y = 0; y < h; y++)
|
||||
for (let x = 0; x < w; x++)
|
||||
tmp[y * w + x] = (at(x - 1, y) + 2 * at(x, y) + at(x + 1, y)) / 4;
|
||||
const at2 = (x, y) => tmp[Math.min(h - 1, Math.max(0, y)) * w + x];
|
||||
for (let y = 0; y < h; y++)
|
||||
for (let x = 0; x < w; x++)
|
||||
field[y * w + x] = (at2(x, y - 1) + 2 * at2(x, y) + at2(x, y + 1)) / 4;
|
||||
};
|
||||
|
||||
// Is there a shadow? Look for solid pixels markedly darker than the ink.
|
||||
const darkCut = LUM(C[0], C[1], C[2]) - 60;
|
||||
let sr = 0, sg = 0, sb = 0, dark = 0, solid = 0;
|
||||
for (let p = 0; p < n; p++) {
|
||||
const i = p * 4;
|
||||
if (px[i + 3] < 200) continue;
|
||||
solid++;
|
||||
if (LUM(px[i], px[i + 1], px[i + 2]) < darkCut) {
|
||||
sr += px[i];
|
||||
sg += px[i + 1];
|
||||
sb += px[i + 2];
|
||||
dark++;
|
||||
}
|
||||
}
|
||||
|
||||
// Ink darker than its own shadow would be, or barely any dark pixels: no
|
||||
// shadow to strip, so fall back to plain alpha (previous behaviour).
|
||||
const S = dark ? [sr / dark, sg / dark, sb / dark] : null;
|
||||
const dx = S ? C[0] - S[0] : 0;
|
||||
const dy = S ? C[1] - S[1] : 0;
|
||||
const dz = S ? C[2] - S[2] : 0;
|
||||
const len2 = dx * dx + dy * dy + dz * dz;
|
||||
if (!S || dark / solid < 0.02 || len2 < 900) {
|
||||
for (let p = 0; p < n; p++) field[p] = px[p * 4 + 3];
|
||||
return field;
|
||||
}
|
||||
|
||||
// coverage = how far along the shadow->ink axis this pixel sits, times its
|
||||
// own opacity. Pure ink -> 1, pure shadow -> 0, anti-aliased edge -> the
|
||||
// true fractional coverage.
|
||||
for (let p = 0; p < n; p++) {
|
||||
const i = p * 4;
|
||||
const a = px[i + 3];
|
||||
if (!a) continue;
|
||||
const t =
|
||||
((px[i] - S[0]) * dx + (px[i + 1] - S[1]) * dy + (px[i + 2] - S[2]) * dz) /
|
||||
len2;
|
||||
field[p] = a * Math.max(0, Math.min(1, t));
|
||||
}
|
||||
soften(); // only on the shadow path -- clean logos need no smoothing
|
||||
return field;
|
||||
}
|
||||
|
||||
// Marching-squares cases -> contour segments, as [fromEdge, toEdge] pairs.
|
||||
// Corner bits: 1 top-left, 2 top-right, 4 bottom-right, 8 bottom-left.
|
||||
// Edges: 0 top, 1 right, 2 bottom, 3 left. Every segment is directed so the ink
|
||||
// lies to its LEFT, which makes all loops close with consistent winding.
|
||||
const CASES = [
|
||||
[], // 0 ....
|
||||
[[3, 0]], // 1 tl
|
||||
[[0, 1]], // 2 tr
|
||||
[[3, 1]], // 3 tl tr
|
||||
[[1, 2]], // 4 br
|
||||
null, // 5 tl br -- saddle, resolved by the cell centre
|
||||
[[0, 2]], // 6 tr br
|
||||
[[3, 2]], // 7 tl tr br
|
||||
[[2, 3]], // 8 bl
|
||||
[[2, 0]], // 9 tl bl
|
||||
null, // 10 tr bl -- saddle
|
||||
[[2, 1]], // 11 tl tr bl
|
||||
[[1, 3]], // 12 br bl
|
||||
[[1, 0]], // 13 tl br bl
|
||||
[[0, 3]], // 14 tr br bl
|
||||
[], // 15 ....
|
||||
];
|
||||
|
||||
/**
|
||||
* Trace an ink-coverage field into closed sub-pixel contours.
|
||||
* Returns loops of [x, y] points in source-pixel coordinates.
|
||||
*/
|
||||
function contours(cov, w, h) {
|
||||
// Padded by one empty pixel, so ink touching the image border still closes
|
||||
// into a loop instead of running off the edge.
|
||||
const W = w + 2;
|
||||
const H = h + 2;
|
||||
const a = new Float32Array(W * H);
|
||||
for (let y = 0; y < h; y++)
|
||||
for (let x = 0; x < w; x++) a[(y + 1) * W + (x + 1)] = cov[y * w + x];
|
||||
|
||||
const at = (x, y) => a[y * W + x];
|
||||
|
||||
// Where along a cell edge does alpha cross ISO? This is the whole trick:
|
||||
// the fractional position recovers the true edge from anti-aliased pixels.
|
||||
const lerp = (v0, v1) => {
|
||||
const d = v1 - v0;
|
||||
return Math.abs(d) < 1e-6 ? 0.5 : (ISO - v0) / d;
|
||||
};
|
||||
|
||||
// Endpoint of `edge` for the cell whose top-left corner is (x, y).
|
||||
const point = (x, y, edge) => {
|
||||
const tl = at(x, y);
|
||||
const tr = at(x + 1, y);
|
||||
const br = at(x + 1, y + 1);
|
||||
const bl = at(x, y + 1);
|
||||
switch (edge) {
|
||||
case 0: return [x + lerp(tl, tr), y];
|
||||
case 1: return [x + 1, y + lerp(tr, br)];
|
||||
case 2: return [x + lerp(bl, br), y + 1];
|
||||
default: return [x, y + lerp(tl, bl)];
|
||||
}
|
||||
};
|
||||
|
||||
// Chain segments by their endpoints. Two cells sharing an edge compute that
|
||||
// crossing from the same pair of corner values, so the coordinates are
|
||||
// bit-identical and hash to the same key -- no tolerance needed.
|
||||
const key = ([x, y]) => `${x.toFixed(4)},${y.toFixed(4)}`;
|
||||
const next = new Map(); // start key -> {to, toKey}
|
||||
|
||||
for (let y = 0; y < H - 1; y++) {
|
||||
for (let x = 0; x < W - 1; x++) {
|
||||
const tl = at(x, y) >= ISO ? 1 : 0;
|
||||
const tr = at(x + 1, y) >= ISO ? 2 : 0;
|
||||
const br = at(x + 1, y + 1) >= ISO ? 4 : 0;
|
||||
const bl = at(x, y + 1) >= ISO ? 8 : 0;
|
||||
const c = tl | tr | br | bl;
|
||||
let segs = CASES[c];
|
||||
if (segs && !segs.length) continue;
|
||||
|
||||
if (!segs) {
|
||||
// Saddle: the cell centre decides whether the two ink corners
|
||||
// join through the middle or stay separate blobs.
|
||||
const mid =
|
||||
(at(x, y) + at(x + 1, y) + at(x + 1, y + 1) + at(x, y + 1)) / 4;
|
||||
const joined = mid >= ISO;
|
||||
if (c === 5)
|
||||
segs = joined ? [[1, 0], [3, 2]] : [[3, 0], [1, 2]];
|
||||
else segs = joined ? [[0, 3], [2, 1]] : [[0, 1], [2, 3]];
|
||||
}
|
||||
|
||||
for (const [from, to] of segs) {
|
||||
const p0 = point(x, y, from);
|
||||
const p1 = point(x, y, to);
|
||||
next.set(key(p0), { to: p1, toKey: key(p1) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const loops = [];
|
||||
const seen = new Set();
|
||||
for (const start of next.keys()) {
|
||||
if (seen.has(start)) continue;
|
||||
const loop = [];
|
||||
let k = start;
|
||||
while (!seen.has(k)) {
|
||||
const step = next.get(k);
|
||||
if (!step) break; // open contour (shouldn't happen once padded)
|
||||
seen.add(k);
|
||||
loop.push(step.to);
|
||||
k = step.toKey;
|
||||
}
|
||||
if (loop.length >= 3) loops.push(loop);
|
||||
}
|
||||
// Undo the 1px pad so coordinates are back in source-image space.
|
||||
return loops.map((l) => l.map(([x, y]) => [x - 1, y - 1]));
|
||||
}
|
||||
|
||||
/** Ramer-Douglas-Peucker: drop vertices that stay within `eps` of the chord. */
|
||||
function simplify(pts, eps) {
|
||||
if (pts.length < 3) return pts;
|
||||
const keep = new Uint8Array(pts.length);
|
||||
keep[0] = keep[pts.length - 1] = 1;
|
||||
const stack = [[0, pts.length - 1]];
|
||||
while (stack.length) {
|
||||
const [i, j] = stack.pop();
|
||||
const [x1, y1] = pts[i];
|
||||
const [x2, y2] = pts[j];
|
||||
const dx = x2 - x1;
|
||||
const dy = y2 - y1;
|
||||
const len = Math.hypot(dx, dy) || 1;
|
||||
let far = -1;
|
||||
let best = eps;
|
||||
for (let k = i + 1; k < j; k++) {
|
||||
const [px, py] = pts[k];
|
||||
const d = Math.abs(dy * px - dx * py + x2 * y1 - y2 * x1) / len;
|
||||
if (d > best) {
|
||||
best = d;
|
||||
far = k;
|
||||
}
|
||||
}
|
||||
if (far > 0) {
|
||||
keep[far] = 1;
|
||||
stack.push([i, far], [far, j]);
|
||||
}
|
||||
}
|
||||
return pts.filter((_, i) => keep[i]);
|
||||
}
|
||||
|
||||
/** Signed area -- used to discard specks by size rather than point count. */
|
||||
function area(pts) {
|
||||
let s = 0;
|
||||
for (let i = 0, n = pts.length; i < n; i++) {
|
||||
const [x1, y1] = pts[i];
|
||||
const [x2, y2] = pts[(i + 1) % n];
|
||||
s += x1 * y2 - x2 * y1;
|
||||
}
|
||||
return Math.abs(s) / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace RGBA pixels into an SVG whose viewBox is exactly the ink's bounding box.
|
||||
*
|
||||
* Because the viewBox hugs the ink, this needs none of the sprite-cropping the
|
||||
* raster path required: `mask: url(...) center / contain` fills its box, and
|
||||
* `aspect` is the artwork's true aspect ratio.
|
||||
*
|
||||
* @returns {{svg:string, d:string, aspect:number, ink:{w:number,h:number}}|null}
|
||||
*/
|
||||
export function traceToSvg(px, w, h, opts = {}) {
|
||||
const {
|
||||
eps = 0.22, // simplify tolerance in source px; sub-pixel, so keep small
|
||||
minArea = 6, // drop specks (source px^2)
|
||||
} = opts;
|
||||
|
||||
const loops = contours(inkField(px, w, h), w, h)
|
||||
.filter((l) => area(l) >= minArea)
|
||||
.map((l) => simplify(l, eps));
|
||||
if (!loops.length) return null;
|
||||
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
for (const l of loops)
|
||||
for (const [x, y] of l) {
|
||||
if (x < minX) minX = x;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (y > maxY) maxY = y;
|
||||
}
|
||||
const bw = maxX - minX;
|
||||
const bh = maxY - minY;
|
||||
if (!(bw > 0 && bh > 0)) return null;
|
||||
|
||||
const n = (v) => Math.round(v * 100) / 100;
|
||||
const d = loops
|
||||
.map(
|
||||
(l) =>
|
||||
"M" + l.map(([x, y]) => `${n(x - minX)} ${n(y - minY)}`).join("L") + "Z",
|
||||
)
|
||||
.join("");
|
||||
|
||||
// Filled black: a CSS mask consumes the SVG's alpha, so only coverage
|
||||
// matters. evenodd keeps letter counters (the hole in an "o") open.
|
||||
const svg =
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${n(bw)} ${n(bh)}">` +
|
||||
`<path fill="#000" fill-rule="evenodd" d="${d}"/></svg>`;
|
||||
|
||||
return { svg, d, aspect: bw / bh, ink: { w: bw, h: bh } };
|
||||
}
|
||||
|
||||
/** SVG source -> a data: URL usable in `mask-image`. */
|
||||
export function svgDataUrl(svg) {
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
||||
}
|
||||
Reference in new issue
Block a user