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:
KeelrLeek committed 2026-07-22 17:57:00 -05:00
commit a667b4b28e
161 files changed
+53170

No files matched your search

+338
View File
@@ -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)}`;
}