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

+154
View File
@@ -0,0 +1,154 @@
// Thin wrapper over Tauri IPC so components never import tauri directly.
import { invoke as tauriInvoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { mockInvoke } from "./mockLibrary.js";
// mockInvoke is inert (returns undefined) unless localStorage.mockLibrary === "1".
const invoke = (cmd, args) => mockInvoke(cmd, args) ?? tauriInvoke(cmd, args);
// Artist-page payloads are keyed by a Deezer id all the way through the
// backend. Reject a mismatched response at the IPC boundary so a provider,
// cache, or future command regression cannot populate another artist's page.
// String comparison deliberately accepts route-string ids and numeric Tauri
// ids as the same identity without coercing the u64 argument sent to Rust.
const artistIdKey = (value) => (value == null ? "" : String(value).trim());
const sameArtistId = (left, right) => {
const leftKey = artistIdKey(left);
return !!leftKey && leftKey === artistIdKey(right);
};
const checkedArtistPayload = (command, artistId) =>
invoke(command, { artistId }).then((payload) => {
if (!sameArtistId(payload?.artist?.id, artistId)) {
throw new Error(`${command} returned a mismatched artist identity`);
}
return payload;
});
const checkedArtistIdentity = (artistId, artist) =>
invoke("catalog_artist_identity", { artistId, artist }).then((payload) => {
if (!sameArtistId(payload?.artist_id ?? payload?.artistId, artistId)) {
throw new Error("catalog_artist_identity returned a mismatched artist identity");
}
return payload;
});
const checkedAlbumPayload = (albumId) =>
invoke("catalog_album", { albumId }).then((payload) => {
if (!sameArtistId(payload?.album?.id, albumId)) {
throw new Error("catalog_album returned a mismatched album identity");
}
return payload;
});
export const appStatus = () => invoke("app_status");
export const getCredentials = () => invoke("get_credentials");
export const connectAndLogin = (username, password, remember) =>
invoke("connect_and_login", { username, password, remember });
export const connectStored = () => invoke("connect_stored");
export const disconnect = () => invoke("disconnect");
export const startSearch = (query) => invoke("start_search", { query });
export const excludedSearchPhrases = () => invoke("excluded_search_phrases");
export const stopSearch = (token) => invoke("stop_search", { token });
export const downloadFile = (username, path, size) =>
invoke("download_file", { username, path, size });
export const copyStreamToFolder = (files, folder) =>
invoke("copy_stream_to_folder", { files, folder });
export const saveToLibrary = (files) => invoke("save_to_library", { files });
export const cancelDownload = (id) => invoke("cancel_download", { id });
export const showInFolder = (path) => invoke("show_in_folder", { path });
export const refreshArtistArt = () => invoke("refresh_artist_art");
export const playStream = (username, path, size, duration) =>
invoke("play_stream", { username, path, size, duration });
export const playLocal = (path, duration) => invoke("play_local", { path, duration });
export const preloadLocal = (path, duration) => invoke("preload_local", { path, duration });
export const playerControl = (action, value, rate) =>
invoke("player_control", { action, value: value ?? null, rate: rate ?? null });
export const waveform = (path, buckets) => invoke("waveform", { path, buckets });
export const libraryAlbums = () => invoke("library_albums");
export const albumTracks = (albumId) => invoke("album_tracks", { albumId });
export const recordPlay = (artist, title) => invoke("record_play", { artist, title });
export const addPlaySeconds = (playId, secs) => invoke("add_play_seconds", { playId, secs });
export const artistPlayStats = (artist) => invoke("artist_play_stats", { artist });
// The WebView blocks target="_blank"; external links open via the backend.
export const openExternal = (url) => invoke("open_external", { url });
export const albumArt = (path) => invoke("album_art", { path });
export const refreshAlbumArt = (albumId) => invoke("refresh_album_art", { albumId });
export const refreshAllAlbumArt = () => invoke("refresh_all_album_art");
export const getSettings = () => invoke("get_settings");
export const setSettings = (patch) => invoke("set_settings", patch);
export const forgetCredentials = () => invoke("forget_credentials");
export const pickFolder = () => invoke("pick_folder");
export const listPlaylists = () => invoke("list_playlists");
export const createPlaylist = (name) => invoke("create_playlist", { name });
export const deletePlaylist = (id) => invoke("delete_playlist", { id });
export const playlistTracks = (id) => invoke("playlist_tracks", { id });
export const addToPlaylist = (playlistId, tracks) =>
invoke("add_to_playlist", { playlistId, tracks });
export const removeFromPlaylist = (playlistId, position) =>
invoke("remove_from_playlist", { playlistId, position });
export const catalogSearch = (query) => invoke("catalog_search", { query });
export const catalogArtist = (artistId) => checkedArtistPayload("catalog_artist", artistId);
export const catalogArtistArt = (artistId) =>
checkedArtistPayload("catalog_artist_art", artistId);
export const catalogArtistLogos = (artistId) => invoke("catalog_artist_logos", { artistId });
// Deferred MusicBrainz pass (socials + MB-only releases). Rate-limited at
// 1 req/s upstream, so it is fetched after the page renders, never before.
export const catalogArtistSocials = (artistId) =>
invoke("catalog_artist_socials", { artistId });
export const catalogArtistBio = (artistId, artist) =>
invoke("catalog_artist_bio", { artistId: artistId ?? null, artist });
export const catalogArtistIdentity = (artistId, artist) =>
checkedArtistIdentity(artistId, artist);
export const catalogCachedCoverLogo = (artistId) =>
invoke("catalog_cached_cover_logo", { artistId });
export const catalogCoverLogo = (artistId, artist, covers) =>
invoke("catalog_cover_logo", { artistId, artist, covers });
export const catalogArtistHeroSeed = (artistId) =>
invoke("catalog_artist_hero_seed", { artistId });
export const catalogArtistOverview = (artistId) =>
checkedArtistPayload("catalog_artist_overview", artistId);
export const pickArtistHero = (artistId) => invoke("pick_artist_hero", { artistId });
export const clearArtistHero = (artistId) => invoke("clear_artist_hero", { artistId });
export const catalogRelated = (artistId) => invoke("catalog_related", { artistId });
export const catalogAlbum = (albumId) => checkedAlbumPayload(albumId);
export const catalogAlbumInfo = (artistId, artist, title) =>
invoke("catalog_album_info", { artistId: artistId ?? null, artist, title });
export const catalogArt = (url) => invoke("catalog_art", { url });
export const catalogCover = (artistId, artist, title, cover) =>
invoke("catalog_cover", { artistId: artistId ?? null, artist, title, cover });
export const invalidateCatalogCover = (artistId, artist, title, cover) =>
invoke("invalidate_catalog_cover", { artistId: artistId ?? null, artist, title, cover });
export const catalogHeroPreview = (url) => invoke("catalog_hero_preview", { url });
export const catalogHeroGpuSource = (url, plan = null) =>
invoke("catalog_hero_gpu_source", { url, plan });
export const catalogCachedHeroArt = (url) => invoke("catalog_cached_hero_art", { url });
export const catalogHeroArt = (url, plan = null) =>
invoke("catalog_hero_art", { url, plan });
export const preloadStream = (username, path, size) =>
invoke("preload_stream", { username, path, size });
export const preloadCachedStream = (username, path, size, duration) =>
invoke("preload_cached_stream", { username, path, size, duration });
export const streamWaveform = (username, path, size, buckets) =>
invoke("stream_waveform", { username, path, size, buckets });
// Window chrome (the app draws its own titlebar; decorations are off).
import { getCurrentWindow } from "@tauri-apps/api/window";
export const win = {
minimize: () => getCurrentWindow().minimize(),
toggleMaximize: () => getCurrentWindow().toggleMaximize(),
close: () => getCurrentWindow().close(),
isMinimized: () => getCurrentWindow().isMinimized(),
isFocused: () => getCurrentWindow().isFocused(),
isMaximized: () => getCurrentWindow().isMaximized(),
// returns Promise<unlisten>
onResized: (handler) => getCurrentWindow().onResized(handler),
onFocusChanged: (handler) => getCurrentWindow().onFocusChanged(handler),
};
// Subscribe to a backend event; returns an unlisten function.
// No-ops outside Tauri (e.g. plain-browser preview).
export function onEvent(name, handler) {
const un = listen(name, (e) => handler(e.payload)).catch(() => () => {});
return () => un.then((f) => f());
}
+130
View File
@@ -0,0 +1,130 @@
// Dominant-color extraction from album art, tuned to produce a usable
// accent on a dark UI: prefers saturated mid-lightness pixels, then clamps
// the result into a safe saturation/lightness band.
export function hexToRgb(hex) {
const n = parseInt(hex.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
export function rgbToHex([r, g, b]) {
const c = (v) => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, "0");
return `#${c(r)}${c(g)}${c(b)}`;
}
export function lerpRgb(a, b, t) {
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
}
function rgbToHsl(r, g, b) {
r /= 255; g /= 255; b /= 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
const l = (max + min) / 2;
if (max === min) return [0, 0, l];
const d = max - min;
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
let h;
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
else if (max === g) h = ((b - r) / d + 2) / 6;
else h = ((r - g) / d + 4) / 6;
return [h, s, l];
}
function hslToRgb(h, s, l) {
if (s === 0) return [l * 255, l * 255, l * 255];
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
const f = (t) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
return [f(h + 1 / 3) * 255, f(h) * 255, f(h - 1 / 3) * 255];
}
/**
* Extract an accent color from an image data URL.
* Returns { accent, hover } hex strings, or null if nothing usable.
*/
export async function accentFromImage(dataUrl) {
const img = new Image();
img.src = dataUrl;
try {
await img.decode();
} catch {
return null;
}
const S = 48;
const canvas = document.createElement("canvas");
canvas.width = S;
canvas.height = S;
const ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0, S, S);
let data;
try {
data = ctx.getImageData(0, 0, S, S).data;
} catch {
return null;
}
// "True tone": the color FAMILY that covers the most of the artwork wins
// (chroma-weighted hue histogram), and the result keeps that family's
// real saturation/lightness character — a mossy cover yields moss, not
// neon. Legibility clamps are gentle floors, not vibrancy boosts.
const BINS = 24;
const binWeight = new Array(BINS).fill(0);
const samples = [];
const pixelCount = data.length / 4;
for (let i = 0; i < data.length; i += 4) {
const [h, s, l] = rgbToHsl(data[i], data[i + 1], data[i + 2]);
if (l < 0.08 || l > 0.95 || s < 0.1) continue;
const w = s * (1 - Math.abs(2 * l - 1)); // ~chroma
if (w <= 0) continue;
const bin = Math.floor(h * BINS) % BINS;
binWeight[bin] += w;
samples.push({ h, s, l, w, bin });
}
// Neutral guard: effectively grayscale art (or art with only a faint
// color cast) stays a deliberate silver, not an invented hue.
const totalWeight = samples.reduce((sum, p) => sum + p.w, 0);
if (!samples.length || totalWeight / pixelCount < 0.03) {
return {
accent: rgbToHex(hslToRgb(0, 0.05, 0.72)),
hover: rgbToHex(hslToRgb(0, 0.05, 0.8)),
};
}
// Smooth adjacent bins so a family split across a boundary still wins.
let bestBin = 0;
let bestScore = -1;
for (let i = 0; i < BINS; i++) {
const score = binWeight[i]
+ 0.5 * (binWeight[(i + 1) % BINS] + binWeight[(i + BINS - 1) % BINS]);
if (score > bestScore) { bestScore = score; bestBin = i; }
}
// Weighted circular mean over the winning family (bin ± 1).
let x = 0, y = 0, ws = 0, sSum = 0, lSum = 0;
for (const p of samples) {
const d = Math.min((p.bin - bestBin + BINS) % BINS, (bestBin - p.bin + BINS) % BINS);
if (d > 1) continue;
x += Math.cos(p.h * 2 * Math.PI) * p.w;
y += Math.sin(p.h * 2 * Math.PI) * p.w;
sSum += p.s * p.w;
lSum += p.l * p.w;
ws += p.w;
}
if (!ws) return null;
let h = Math.atan2(y, x) / (2 * Math.PI);
if (h < 0) h += 1;
const s = Math.min(0.88, Math.max(0.3, (sSum / ws) * 1.05));
const l = Math.min(0.66, Math.max(0.46, lSum / ws));
const accent = rgbToHex(hslToRgb(h, s, l));
const hover = rgbToHex(hslToRgb(h, s, Math.min(0.74, l + 0.08)));
return { accent, hover };
}
+543
View File
@@ -0,0 +1,543 @@
export const HERO_MASTER_WIDTH = 3840;
export const HERO_MASTER_HEIGHT = 2160;
const HEADER_BYTES = 16;
const MAGIC = [0x53, 0x48, 0x47, 0x31]; // SHG1
const VS = `#version 300 es
precision highp float;
const vec2 pos[3] = vec2[3](vec2(-1.,-1.), vec2(3.,-1.), vec2(-1.,3.));
void main(){ gl_Position = vec4(pos[gl_VertexID], 0., 1.); }`;
// FidelityFX FSR 1 EASU, transliterated from AMD's reference implementation.
// `flipInput` is used only for the first pass because Rust's RGBA rows arrive
// top-first while render-target textures use conventional GL bottom-first rows.
const FS_EASU = `#version 300 es
precision highp float;
uniform sampler2D src;
uniform vec2 inSize;
uniform vec2 outSize;
uniform float flipInput;
out vec4 frag;
float lumaOf(vec3 c){ return c.b*0.5 + (c.r*0.5 + c.g); }
vec3 tex(ivec2 p){ return texelFetch(src, clamp(p, ivec2(0), ivec2(inSize)-1), 0).rgb; }
void easuSet(inout vec2 dir, inout float len, vec2 pp, bool biS, bool biT, bool biU, bool biV,
float lA, float lB, float lC, float lD, float lE){
float w = 0.0;
if (biS) w = (1.0-pp.x)*(1.0-pp.y);
if (biT) w = pp.x*(1.0-pp.y);
if (biU) w = (1.0-pp.x)*pp.y;
if (biV) w = pp.x*pp.y;
float dc = lD-lC, cb = lC-lB;
float lenX = max(abs(dc), abs(cb));
lenX = 1.0/max(lenX, 1e-5);
float dirX = lD-lB;
dir.x += dirX*w;
lenX = clamp(abs(dirX)*lenX, 0.0, 1.0);
lenX *= lenX;
len += lenX*w;
float ec = lE-lC, ca = lC-lA;
float lenY = max(abs(ec), abs(ca));
lenY = 1.0/max(lenY, 1e-5);
float dirY = lE-lA;
dir.y += dirY*w;
lenY = clamp(abs(dirY)*lenY, 0.0, 1.0);
lenY *= lenY;
len += lenY*w;
}
void easuTap(inout vec3 aC, inout float aW, vec2 off, vec2 dir, vec2 len, float lob, float clp, vec3 c){
vec2 v = vec2(off.x*dir.x + off.y*dir.y, off.x*(-dir.y) + off.y*dir.x);
v *= len;
float d2 = min(v.x*v.x + v.y*v.y, clp);
float wB = 0.4*d2 - 1.0;
float wA = lob*d2 - 1.0;
wB *= wB; wA *= wA;
wB = (25.0/16.0)*wB - (25.0/16.0 - 1.0);
float w = wB*wA;
aC += c*w; aW += w;
}
void main(){
vec2 ip = floor(gl_FragCoord.xy);
if (flipInput > 0.5) ip.y = outSize.y - 1.0 - ip.y;
vec2 pp = (ip + 0.5)*(inSize/outSize) - 0.5;
vec2 fp = floor(pp);
pp -= fp;
ivec2 F = ivec2(fp);
vec3 bC = tex(F+ivec2(0,-1)), cC = tex(F+ivec2(1,-1));
vec3 eC = tex(F+ivec2(-1,0)), fC = tex(F), gC = tex(F+ivec2(1,0)), hC = tex(F+ivec2(2,0));
vec3 iC = tex(F+ivec2(-1,1)), jC = tex(F+ivec2(0,1)), kC = tex(F+ivec2(1,1)), lC2 = tex(F+ivec2(2,1));
vec3 nC = tex(F+ivec2(0,2)), oC = tex(F+ivec2(1,2));
float bL = lumaOf(bC), cL = lumaOf(cC), eL = lumaOf(eC), fL = lumaOf(fC);
float gL = lumaOf(gC), hL = lumaOf(hC), iL = lumaOf(iC), jL = lumaOf(jC);
float kL = lumaOf(kC), lL = lumaOf(lC2), nL = lumaOf(nC), oL = lumaOf(oC);
vec2 dir = vec2(0.0); float len = 0.0;
easuSet(dir, len, pp, true,false,false,false, bL, eL, fL, gL, jL);
easuSet(dir, len, pp, false,true,false,false, cL, fL, gL, hL, kL);
easuSet(dir, len, pp, false,false,true,false, fL, iL, jL, kL, nL);
easuSet(dir, len, pp, false,false,false,true, gL, jL, kL, lL, oL);
vec2 dir2 = dir*dir;
float dirR = dir2.x + dir2.y;
bool zro = dirR < (1.0/32768.0);
dirR = inversesqrt(max(dirR, 1e-12));
dirR = zro ? 1.0 : dirR;
dir.x = zro ? 1.0 : dir.x;
dir *= dirR;
len = len*0.5;
len *= len;
float stretch = (dir.x*dir.x + dir.y*dir.y)/max(max(abs(dir.x), abs(dir.y)), 1e-5);
vec2 len2 = vec2(1.0 + (stretch - 1.0)*len, 1.0 - 0.5*len);
float lob = 0.5 + ((1.0/4.0 - 0.04) - 0.5)*len;
float clp = 1.0/max(lob, 1e-5);
vec3 min4 = min(min(fC, gC), min(jC, kC));
vec3 max4 = max(max(fC, gC), max(jC, kC));
vec3 aC = vec3(0.0); float aW = 0.0;
easuTap(aC, aW, vec2( 0.,-1.)-pp, dir, len2, lob, clp, bC);
easuTap(aC, aW, vec2( 1.,-1.)-pp, dir, len2, lob, clp, cC);
easuTap(aC, aW, vec2(-1., 1.)-pp, dir, len2, lob, clp, iC);
easuTap(aC, aW, vec2( 0., 1.)-pp, dir, len2, lob, clp, jC);
easuTap(aC, aW, vec2( 0., 0.)-pp, dir, len2, lob, clp, fC);
easuTap(aC, aW, vec2(-1., 0.)-pp, dir, len2, lob, clp, eC);
easuTap(aC, aW, vec2( 1., 1.)-pp, dir, len2, lob, clp, kC);
easuTap(aC, aW, vec2( 1., 0.)-pp, dir, len2, lob, clp, gC);
easuTap(aC, aW, vec2( 0., 2.)-pp, dir, len2, lob, clp, nC);
easuTap(aC, aW, vec2( 1., 2.)-pp, dir, len2, lob, clp, oC);
easuTap(aC, aW, vec2( 2., 1.)-pp, dir, len2, lob, clp, lC2);
easuTap(aC, aW, vec2( 2., 0.)-pp, dir, len2, lob, clp, hC);
frag = vec4(min(max4, max(min4, aC/aW)), 1.0);
}`;
const FS_RCAS = `#version 300 es
precision highp float;
uniform sampler2D src;
uniform vec2 size;
uniform float con;
out vec4 frag;
vec3 tex(ivec2 p){ return texelFetch(src, clamp(p, ivec2(0), ivec2(size)-1), 0).rgb; }
void main(){
ivec2 sp = ivec2(gl_FragCoord.xy);
vec3 b = tex(sp+ivec2(0,-1)), d = tex(sp+ivec2(-1,0)), e = tex(sp), f = tex(sp+ivec2(1,0)), h = tex(sp+ivec2(0,1));
vec3 mn4 = min(min(b,d),min(f,h)), mx4 = max(max(b,d),max(f,h));
vec3 hitMin = min(mn4,e)/(4.0*mx4+1e-5);
vec3 hitMax = (1.0-max(mx4,e))/(4.0*mn4-4.0-1e-5);
vec3 lobeRGB = max(-hitMin,hitMax);
float lobe = max(-0.1875, min(max(lobeRGB.r,max(lobeRGB.g,lobeRGB.b)),0.0))*con;
frag = vec4(((b+d+f+h)*lobe+e)/(4.0*lobe+1.0), 1.0);
}`;
// Exact shipped Ruby Chrome 30% + vintage fade 100% + native-4K grain 70%.
// `trunc`, not `floor`, matches Rust's signed integer division toward zero.
const FS_GRADE = `#version 300 es
precision highp float;
uniform sampler2D src;
uniform float grainAmp;
uniform float seed;
out vec4 frag;
float ss(float e0,float e1,float v){ float t=clamp((v-e0)/(e1-e0),0.,1.); return t*t*(3.-2.*t); }
uint grainHash(uint x){
x ^= x >> 16; x *= 0x7feb352du;
x ^= x >> 15; x *= 0x846ca68bu;
return x ^ (x >> 16);
}
float grainByte(ivec2 p,uint salt){
uint key = uint(p.x)*0x9e3779b1u ^ uint(p.y)*0x85ebca6bu ^ salt;
return float(grainHash(key) & 255u);
}
void main(){
ivec2 pixel = ivec2(gl_FragCoord.xy);
vec3 c = texelFetch(src, pixel, 0).rgb;
float luma = c.r*0.2126+c.g*0.7152+c.b*0.0722;
float sh = 1.0-ss(0.16,0.60,luma), hi = ss(0.38,0.90,luma);
vec3 g = mix(vec3(luma), c, 1.24);
g = (g-0.5)*1.12+0.5;
g = mix(g, g*g*(3.-2.*g), 0.18);
g.r += hi*0.070+sh*0.018; g.g -= hi*0.028; g.b += sh*0.045+hi*0.018;
g = clamp(g,0.,1.);
vec3 color = mix(c, g, 0.30);
float l2 = color.r*0.2126+color.g*0.7152+color.b*0.0722;
vec3 faded = clamp(mix(vec3(l2*0.78+0.078), color*0.78+vec3(0.085,0.078,0.068), 0.88),0.,1.);
uint grainSeed = uint(seed);
float r1 = grainByte(pixel,grainSeed)-128.;
float r2 = grainByte(pixel,grainSeed^0xa511e9b3u)-128.;
float delta = clamp(trunc((r1+r2)*grainAmp/256.), -grainAmp, grainAmp)/255.;
frag = vec4(clamp(faded+delta,0.,1.), 1.0);
}`;
const FS_BLIT = `#version 300 es
precision highp float;
uniform sampler2D src;
uniform vec2 outSize;
uniform vec2 uvScale;
uniform vec2 uvOffset;
uniform float flipInput;
out vec4 frag;
void main(){
vec2 uv = gl_FragCoord.xy/outSize;
uv = uvOffset + uv*uvScale;
if (flipInput > 0.5) uv.y = 1.0-uv.y;
frag = vec4(texture(src, uv).rgb, 1.0);
}`;
// Preserve the 16:9 master like CSS object-fit cover, aligned center/top.
export function heroCoverUv(outputWidth, outputHeight) {
const sourceAspect = HERO_MASTER_WIDTH / HERO_MASTER_HEIGHT;
const outputAspect = outputWidth / outputHeight;
if (outputAspect > sourceAspect) {
const visibleHeight = sourceAspect / outputAspect;
return {
scale: [1, visibleHeight],
offset: [0, 1 - visibleHeight],
};
}
const visibleWidth = outputAspect / sourceAspect;
return {
scale: [visibleWidth, 1],
offset: [(1 - visibleWidth) / 2, 0],
};
}
function parsePayload(payload) {
const bytes = payload instanceof Uint8Array
? payload
: new Uint8Array(payload);
if (bytes.byteLength < HEADER_BYTES || !MAGIC.every((v, i) => bytes[i] === v)) {
throw new Error("invalid live hero payload");
}
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const width = view.getUint32(4, true);
const height = view.getUint32(8, true);
const seed = view.getUint32(12, true);
const expected = HEADER_BYTES + width * height * 4;
if (!width || !height || !Number.isSafeInteger(expected) || expected !== bytes.byteLength) {
throw new Error("invalid live hero dimensions");
}
return { width, height, seed, rgba: bytes.subarray(HEADER_BYTES) };
}
function compile(gl, source, type) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
const error = gl.getShaderInfoLog(shader) || "shader compilation failed";
gl.deleteShader(shader);
throw new Error(error);
}
return shader;
}
function program(gl, fragment) {
const result = gl.createProgram();
const vertex = compile(gl, VS, gl.VERTEX_SHADER);
const pixel = compile(gl, fragment, gl.FRAGMENT_SHADER);
gl.attachShader(result, vertex);
gl.attachShader(result, pixel);
gl.linkProgram(result);
gl.deleteShader(vertex);
gl.deleteShader(pixel);
if (!gl.getProgramParameter(result, gl.LINK_STATUS)) {
const error = gl.getProgramInfoLog(result) || "shader link failed";
gl.deleteProgram(result);
throw new Error(error);
}
return result;
}
function mipLevels(width, height) {
return 1 + Math.floor(Math.log2(Math.max(width, height)));
}
export function createHeroGpuRenderer(canvas, bloomCanvas, previousCanvas, previousBloomCanvas, onLost) {
const gl = canvas.getContext("webgl2", {
alpha: false,
antialias: false,
depth: false,
stencil: false,
premultipliedAlpha: false,
preserveDrawingBuffer: true,
powerPreference: "high-performance",
});
if (!gl) throw new Error("WebGL2 is unavailable");
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
gl.pixelStorei(gl.UNPACK_COLORSPACE_CONVERSION_WEBGL, gl.NONE);
if ("drawingBufferColorSpace" in gl) gl.drawingBufferColorSpace = "srgb";
const maxTexture = gl.getParameter(gl.MAX_TEXTURE_SIZE);
if (maxTexture < HERO_MASTER_WIDTH) {
throw new Error(`GPU texture limit ${maxTexture}px is below 4K`);
}
const programs = {
easu: program(gl, FS_EASU),
rcas: program(gl, FS_RCAS),
grade: program(gl, FS_GRADE),
blit: program(gl, FS_BLIT),
};
let master = null;
let destroyed = false;
const contextLost = (event) => {
event.preventDefault();
onLost?.(new Error("WebGL context lost"));
};
canvas.addEventListener("webglcontextlost", contextLost);
function texture(width, height, levels = 1) {
const value = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, value);
gl.texStorage2D(gl.TEXTURE_2D, levels, gl.RGBA8, width, height);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(
gl.TEXTURE_2D,
gl.TEXTURE_MIN_FILTER,
levels > 1 ? gl.LINEAR_MIPMAP_LINEAR : gl.LINEAR,
);
return value;
}
function target(width, height, levels = 1) {
const tx = texture(width, height, levels);
const fb = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tx, 0);
if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE) {
gl.deleteFramebuffer(fb);
gl.deleteTexture(tx);
throw new Error("GPU hero framebuffer is incomplete");
}
return { tx, fb, width, height, levels };
}
function disposeTarget(value, keepTexture = false) {
if (!value) return;
if (value.fb) gl.deleteFramebuffer(value.fb);
if (!keepTexture && value.tx) gl.deleteTexture(value.tx);
}
function draw(prog, src, output, uniforms = {}) {
gl.useProgram(prog);
gl.bindFramebuffer(gl.FRAMEBUFFER, output?.fb ?? null);
const width = output?.width ?? canvas.width;
const height = output?.height ?? canvas.height;
gl.viewport(0, 0, width, height);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, src);
gl.uniform1i(gl.getUniformLocation(prog, "src"), 0);
for (const [name, value] of Object.entries(uniforms)) {
const location = gl.getUniformLocation(prog, name);
if (location === null) continue;
if (Array.isArray(value)) gl.uniform2f(location, value[0], value[1]);
else gl.uniform1f(location, value);
}
gl.drawArrays(gl.TRIANGLES, 0, 3);
const error = gl.getError();
if (error !== gl.NO_ERROR) throw new Error(`WebGL hero draw failed (${error})`);
}
function copyCanvas(from, to) {
if (!from.width || !from.height) return;
to.width = from.width;
to.height = from.height;
const context = to.getContext("2d", { alpha: false });
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = "high";
context.drawImage(from, 0, 0, to.width, to.height);
}
function snapshot() {
if (!master || !canvas.width) return false;
copyCanvas(canvas, previousCanvas);
copyCanvas(bloomCanvas, previousBloomCanvas);
return true;
}
function buildMaster(payload) {
const { width, height, seed, rgba } = parsePayload(payload);
if (width > maxTexture || height > maxTexture) {
throw new Error(`hero source ${width}x${height} exceeds the GPU texture limit`);
}
const downsample = width > HERO_MASTER_WIDTH || height > HERO_MASTER_HEIGHT;
const sourceLevels = downsample ? mipLevels(width, height) : 1;
const source = texture(width, height, sourceLevels);
const temporaries = [];
let scaled = null;
let sharpened = null;
let finished = null;
try {
gl.bindTexture(gl.TEXTURE_2D, source);
gl.texSubImage2D(
gl.TEXTURE_2D,
0,
0,
0,
width,
height,
gl.RGBA,
gl.UNSIGNED_BYTE,
rgba,
);
if (downsample) gl.generateMipmap(gl.TEXTURE_2D);
if (downsample || (width === HERO_MASTER_WIDTH && height === HERO_MASTER_HEIGHT)) {
scaled = target(HERO_MASTER_WIDTH, HERO_MASTER_HEIGHT);
temporaries.push(scaled);
draw(programs.blit, source, scaled, {
outSize: [HERO_MASTER_WIDTH, HERO_MASTER_HEIGHT],
uvScale: [1, 1],
uvOffset: [0, 0],
flipInput: 1,
});
} else {
let currentTexture = source;
let currentWidth = width;
let currentHeight = height;
let firstPass = true;
const firstWidth = Math.min(width * 2, HERO_MASTER_WIDTH);
if (firstWidth < HERO_MASTER_WIDTH) {
const intermediate = target(
firstWidth,
Math.max(1, Math.round((height * firstWidth) / width)),
);
temporaries.push(intermediate);
draw(programs.easu, currentTexture, intermediate, {
inSize: [currentWidth, currentHeight],
outSize: [intermediate.width, intermediate.height],
flipInput: 1,
});
currentTexture = intermediate.tx;
currentWidth = intermediate.width;
currentHeight = intermediate.height;
firstPass = false;
}
scaled = target(HERO_MASTER_WIDTH, HERO_MASTER_HEIGHT);
temporaries.push(scaled);
draw(programs.easu, currentTexture, scaled, {
inSize: [currentWidth, currentHeight],
outSize: [HERO_MASTER_WIDTH, HERO_MASTER_HEIGHT],
flipInput: firstPass ? 1 : 0,
});
}
sharpened = target(HERO_MASTER_WIDTH, HERO_MASTER_HEIGHT);
temporaries.push(sharpened);
draw(programs.rcas, scaled.tx, sharpened, {
size: [HERO_MASTER_WIDTH, HERO_MASTER_HEIGHT],
con: Math.pow(2, -0.25),
});
finished = target(
HERO_MASTER_WIDTH,
HERO_MASTER_HEIGHT,
mipLevels(HERO_MASTER_WIDTH, HERO_MASTER_HEIGHT),
);
draw(programs.grade, sharpened.tx, finished, {
grainAmp: 20,
seed,
});
gl.bindTexture(gl.TEXTURE_2D, finished.tx);
gl.generateMipmap(gl.TEXTURE_2D);
gl.finish();
disposeTarget(finished, true);
return { tx: finished.tx, width: HERO_MASTER_WIDTH, height: HERO_MASTER_HEIGHT };
} catch (error) {
disposeTarget(finished);
throw error;
} finally {
for (const value of temporaries) disposeTarget(value);
gl.deleteTexture(source);
}
}
function copyBloom() {
if (!canvas.width || !canvas.height) return;
const cssHeight = 106;
const rect = canvas.getBoundingClientRect();
const scale = rect.height > 0 ? canvas.height / rect.height : 1;
const sourceHeight = Math.max(1, Math.min(canvas.height, Math.round(cssHeight * scale)));
bloomCanvas.width = canvas.width;
bloomCanvas.height = sourceHeight;
const context = bloomCanvas.getContext("2d", { alpha: false });
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = "high";
context.drawImage(
canvas,
0,
0,
canvas.width,
sourceHeight,
0,
0,
bloomCanvas.width,
bloomCanvas.height,
);
}
function resize() {
if (destroyed || !master) return;
const rect = canvas.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return;
const dpr = Math.max(1, window.devicePixelRatio || 1);
const scale = Math.min(
dpr,
HERO_MASTER_WIDTH / rect.width,
HERO_MASTER_HEIGHT / rect.height,
);
const width = Math.max(1, Math.round(rect.width * scale));
const height = Math.max(1, Math.round(rect.height * scale));
if (canvas.width !== width) canvas.width = width;
if (canvas.height !== height) canvas.height = height;
const cover = heroCoverUv(width, height);
draw(programs.blit, master.tx, null, {
outSize: [width, height],
uvScale: cover.scale,
uvOffset: cover.offset,
flipInput: 0,
});
gl.finish();
copyBloom();
}
function render(payload) {
if (destroyed) throw new Error("GPU hero renderer is closed");
const hadPrevious = snapshot();
const started = performance.now();
const next = buildMaster(payload);
const previous = master;
master = next;
resize();
if (previous?.tx) gl.deleteTexture(previous.tx);
return {
hadPrevious,
renderMs: performance.now() - started,
renderer: gl.getParameter(gl.RENDERER),
};
}
// Keep the compiled programs/context warm between artist navigations, but
// release the 4K master texture and canvas backing stores while there is no
// selected GPU hero. A stale master is both visually wrong and roughly
// 44 MiB once its mip chain is included.
function clear() {
if (destroyed) return;
if (master?.tx && !gl.isContextLost()) gl.deleteTexture(master.tx);
master = null;
for (const target of [canvas, bloomCanvas, previousCanvas, previousBloomCanvas]) {
target.width = 1;
target.height = 1;
}
}
function destroy() {
if (destroyed) return;
destroyed = true;
canvas.removeEventListener("webglcontextlost", contextLost);
if (master?.tx && !gl.isContextLost()) gl.deleteTexture(master.tx);
if (!gl.isContextLost()) {
for (const value of Object.values(programs)) gl.deleteProgram(value);
}
master = null;
}
return { render, resize, clear, destroy };
}
+69
View File
@@ -0,0 +1,69 @@
// Dev-only mock library data, for exercising the Library UI on a machine
// without a populated music library. Inert unless explicitly enabled by
// setting localStorage.mockLibrary = "1" in the webview (then reload);
// remove the key to go back to real backend data.
const ARTISTS = ["Aphex Twin","Boards of Canada","Caribou","Daft Punk","Eno Brian","Four Tet","Grimes","Holly Herndon","Jon Hopkins","Kraftwerk","LCD Soundsystem","Moderat","Nils Frahm","Oneohtrix","Portishead","Radiohead","Squarepusher","Tycho","Underworld","Vangelis","Wilco","Yello","Zero 7","Air","Bonobo","Burial","Clark","Deadmau5","Emancipator","Floating Points","Gold Panda","Helios","Kiasmos","Lorn","Moby","Nujabes","Ott","Plaid","Rival Consoles","Shpongle"];
const albums = [];
let aid = 1;
for (let i = 0; i < ARTISTS.length; i++) {
const n = 2 + (i % 3); // 2-4 albums per artist
for (let j = 0; j < n; j++) {
albums.push({
id: aid,
artist: ARTISTS[i],
title: `Album ${j + 1} of ${ARTISTS[i]}`,
year: 1995 + ((i + j) % 30),
track_count: 10,
art_path: `mock://art/${aid}`,
artist_art_path: `mock://artist/${i}`,
path: `C:\\mock\\${i}\\${j}`,
});
aid++;
}
}
function svgArt(hue, label) {
const s =
`<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256">` +
`<rect width="256" height="256" fill="hsl(${hue},45%,35%)"/>` +
`<text x="128" y="140" font-size="90" text-anchor="middle" fill="rgba(255,255,255,.75)" font-family="sans-serif">${label}</text></svg>`;
return "data:image/svg+xml;base64," + btoa(s);
}
function tracksFor(albumId) {
const artist = albums.find((a) => a.id === albumId)?.artist ?? "?";
return Array.from({ length: 10 }, (_, k) => ({
id: albumId * 100 + k,
title: `Track ${k + 1}`,
artist,
track_no: k + 1,
duration_secs: 120 + k * 17,
bitrate_kbps: 320,
path: `C:\\mock\\t${albumId}-${k}.mp3`,
}));
}
const enabled = (() => {
try {
return localStorage.getItem("mockLibrary") === "1";
} catch {
return false;
}
})();
/** Returns a Promise for mocked commands, or undefined to pass through. */
export function mockInvoke(cmd, args) {
if (!enabled) return undefined;
if (cmd === "library_albums") return Promise.resolve(albums);
if (cmd === "album_tracks")
return new Promise((r) => setTimeout(() => r(tracksFor(args.albumId)), 60));
if (cmd === "album_art") {
const m = /(\d+)$/.exec(args.path || "");
const num = m ? +m[1] : 0;
return Promise.resolve(svgArt((num * 47) % 360, String.fromCharCode(65 + (num % 26))));
}
if (cmd === "list_playlists") return Promise.resolve([]);
return undefined;
}
+211
View File
@@ -0,0 +1,211 @@
// Floating overlay scrollbar, applied with `use:floatScroll` on any element
// that scrolls. It hides the native scrollbar entirely — so the bar never
// reserves layout width and can never reflow the content when it appears —
// and draws a thin thumb that floats over the container's right padding,
// fading in while scrolling and out again when idle. No track, no arrow
// buttons; just the one bar. Reusable across the whole app.
//
// When the scroller has a floating glass header pinned to its top (marked
// `[data-scrollhead]`, either a descendant or a direct sibling), the thumb's
// travel starts one PAD *below* that header, so it never rides up over the
// header and shows the same PAD gap top and bottom.
//
// ARCHITECTURE — read before changing how the thumb is positioned.
// The thumb lives inside a zero-height `position: sticky` rail, so the
// COMPOSITOR keeps it pinned in the scrollport; JS only slides it along the
// track. The obvious simpler design — an absolute thumb counter-scrolled per
// frame (top = scrollTop + trackPos) — jitters as soon as the scroller gets
// composited (threaded) scrolling, which the artist page's sticky
// backdrop-filter header forces: the compositor scrolls the content ahead of
// the main thread, the counter-scroll commits one frame late, and the thumb
// visibly oscillates by the full per-frame scroll delta. Pages that scroll on
// the main thread never show this, which is why the bug looked artist-page-
// only. With the sticky rail, a late frame only mis-places the thumb along the
// track by scrollDelta × (trackH / scrollH) — a few percent — imperceptible.
const PAD = 8; // gap between the thumb's travel and the top header / bottom edge
const HOVER_ZONE = 22; // px in from the right edge that reveals the thumb on hover
export function floatScroll(node) {
node.classList.add("float-scroll");
node.style.scrollbarWidth = "none"; // inline: beats scoped component rules
if (getComputedStyle(node).position === "static") node.style.position = "relative";
// Zero-height sticky rail as the scroller's FIRST child (sticky can only
// hold an element back from scrolling up past `top: 0`, so it must sit at
// the top of the flow). The thumb hangs off it, absolutely positioned.
const rail = document.createElement("div");
rail.className = "float-rail";
const thumb = document.createElement("div");
thumb.className = "float-thumb";
rail.appendChild(thumb);
node.prepend(rail);
let hideTimer = 0;
let dragging = false;
let measureRaf = 0;
// Cached layout, refreshed only by measure() (never during a scroll).
let active = false;
let topInset = PAD;
let trackH = 0;
let thumbH = 24;
let maxScroll = 0;
let padTop = 0;
let boxRight = 0; // viewport x of the scroller's right edge (for hover)
let boxTop = 0; // viewport y of the scroller's top edge
// The `show` state (scrolling) fades on its own after a beat; the separate
// `hover` state below is independent and controls its own visibility, so a
// deliberate grab only needs `dragging` to hold the thumb up.
function scheduleHide() {
clearTimeout(hideTimer);
hideTimer = setTimeout(() => {
if (!dragging) thumb.classList.remove("show");
}, 500);
}
function show() {
thumb.classList.add("show");
scheduleHide();
}
// Recompute the cached geometry. Runs on init and whenever the layout might
// have changed (resize, content grow/shrink, a view swapping a header in or
// out) — but deliberately NOT on scroll: the per-frame path below reads
// only scrollTop and forces no layout.
function measure() {
measureRaf = 0;
const viewH = node.clientHeight;
const scrollH = node.scrollHeight;
const box = node.getBoundingClientRect();
boxRight = box.right;
boxTop = box.top;
const cs = getComputedStyle(node);
padTop = parseFloat(cs.paddingTop) || 0;
// The rail is an in-flow (zero-height) child. In a flex/grid scroller
// it would still contribute one row-gap before the real first child —
// cancel it so adding the rail never shifts the content.
const rowGap = parseFloat(cs.rowGap) || 0;
rail.style.marginBottom = rowGap ? `-${rowGap}px` : "0";
// The rail spans the content box; the thumb should sit 3px inside the
// PADDING box's right edge (where it always lived), so pull it back out
// through the right padding.
const padRight = parseFloat(cs.paddingRight) || 0;
thumb.style.right = `${3 - padRight}px`;
// A `[data-scrollhead]` glass header pinned to the top of this scroller:
// a descendant (the artist page's Results bar inside the content pane)
// or a direct sibling (the Now Playing header sits beside its scroller).
const head =
node.querySelector("[data-scrollhead]") ||
node.parentElement?.querySelector(":scope > [data-scrollhead]");
const headH = head ? head.getBoundingClientRect().height : 0;
active = scrollH > viewH + 1;
if (!active) {
thumb.style.display = "none";
return;
}
topInset = headH + PAD;
trackH = Math.max(0, viewH - topInset - PAD);
thumbH = Math.max(24, Math.min(trackH, (viewH / scrollH) * trackH));
maxScroll = scrollH - viewH;
thumb.style.display = "block";
thumb.style.height = `${thumbH}px`;
place();
}
function scheduleMeasure() {
if (!measureRaf) measureRaf = requestAnimationFrame(measure);
}
// Per-frame, cheap: read only scrollTop, write one composited transform.
// The engine pins the sticky rail at the scroller's CONTENT-box top (i.e.
// `padTop` below the visual top edge, verified empirically in WebView2) for
// the entire scroll, so the rail-relative offset is a constant correction.
function place() {
if (!active) return;
const ratio = maxScroll > 0 ? node.scrollTop / maxScroll : 0;
const y = topInset - padTop + ratio * (trackH - thumbH);
thumb.style.transform = `translateY(${y}px)`;
}
function onScroll() {
place();
show();
}
// The thumb appears while scrolling; separately, moving the pointer into the
// narrow strip along the scroller's right edge reveals it faintly (the
// `hover` state) so it can be found and grabbed without scrolling first.
node.addEventListener("scroll", onScroll, { passive: true });
let nearEdge = false;
function setHover(on) {
if (on === nearEdge) return;
nearEdge = on;
thumb.classList.toggle("hover", on);
}
function onPointerMove(e) {
setHover(
active &&
e.clientX >= boxRight - HOVER_ZONE &&
e.clientX <= boxRight + 2 &&
e.clientY >= boxTop + topInset - 4,
);
}
function onPointerLeave() {
setHover(false);
}
node.addEventListener("pointermove", onPointerMove, { passive: true });
node.addEventListener("pointerleave", onPointerLeave);
const ro = new ResizeObserver(scheduleMeasure);
ro.observe(node);
// Content growing/shrinking (queue changes, sections toggling) changes
// scrollHeight; childList catches that — and a view swapping a header in or
// out — without firing on every text tick.
const mo = new MutationObserver(scheduleMeasure);
mo.observe(node, { childList: true, subtree: true });
// Drag the thumb.
let dragY0 = 0;
let scroll0 = 0;
thumb.addEventListener("pointerdown", (e) => {
dragging = true;
dragY0 = e.clientY;
scroll0 = node.scrollTop;
thumb.classList.add("dragging");
thumb.setPointerCapture(e.pointerId);
e.preventDefault();
});
thumb.addEventListener("pointermove", (e) => {
if (!dragging) return;
const track = trackH - thumbH;
node.scrollTop = scroll0 + (track > 0 ? ((e.clientY - dragY0) / track) * maxScroll : 0);
});
function endDrag(e) {
if (!dragging) return;
dragging = false;
thumb.classList.remove("dragging");
if (thumb.hasPointerCapture(e.pointerId)) thumb.releasePointerCapture(e.pointerId);
scheduleHide();
}
thumb.addEventListener("pointerup", endDrag);
thumb.addEventListener("pointercancel", endDrag);
measure();
return {
destroy() {
clearTimeout(hideTimer);
if (measureRaf) cancelAnimationFrame(measureRaf);
node.removeEventListener("scroll", onScroll);
node.removeEventListener("pointermove", onPointerMove);
node.removeEventListener("pointerleave", onPointerLeave);
// The thumb's own pointer listeners go away with rail.remove().
ro.disconnect();
mo.disconnect();
rail.remove();
},
};
}
File diff suppressed because it is too large. Load diff
+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)}`;
}