mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-12 05:10:52 +02:00
267 lines
7.9 KiB
TypeScript
267 lines
7.9 KiB
TypeScript
import type { CoverArtAccentMethod } from '@/stores/themeStore';
|
|
|
|
const MIN_ALPHA = 24;
|
|
const VIBRANT_MIN_ALPHA = 48;
|
|
const DOMINANT_BUCKET_SIZE = 24;
|
|
|
|
type Rgb = { r: number; g: number; b: number };
|
|
|
|
interface AccentNormalizationOptions {
|
|
saturationFloor: number;
|
|
saturationCeiling: number;
|
|
lightnessFloor: number;
|
|
lightnessCeiling: number;
|
|
}
|
|
|
|
interface AccentBucket {
|
|
count: number;
|
|
sumR: number;
|
|
sumG: number;
|
|
sumB: number;
|
|
saturationSum: number;
|
|
luminanceSum: number;
|
|
chromaSum: number;
|
|
}
|
|
|
|
interface PixelAnalysis extends Rgb {
|
|
max: number;
|
|
min: number;
|
|
saturation: number;
|
|
luminance: number;
|
|
chroma: number;
|
|
}
|
|
|
|
interface BucketExtractionOptions {
|
|
minAlpha: number;
|
|
normalizeColor: (color: Rgb) => Rgb;
|
|
isPixelAccepted: (pixel: PixelAnalysis) => boolean;
|
|
scoreBucket: (bucket: AccentBucket) => number;
|
|
fallback: () => string | null;
|
|
}
|
|
|
|
const DEFAULT_NORMALIZATION: AccentNormalizationOptions = {
|
|
saturationFloor: 0.32,
|
|
saturationCeiling: 0.9,
|
|
lightnessFloor: 0.33,
|
|
lightnessCeiling: 0.68,
|
|
};
|
|
|
|
const VIBRANT_NORMALIZATION: AccentNormalizationOptions = {
|
|
saturationFloor: 0.5,
|
|
saturationCeiling: 0.98,
|
|
lightnessFloor: 0.38,
|
|
lightnessCeiling: 0.62,
|
|
};
|
|
|
|
const clamp = (value: number, min: number, max: number) =>
|
|
Math.max(min, Math.min(max, value));
|
|
const clampByte = (value: number) => clamp(Math.round(value), 0, 255);
|
|
|
|
function rgbToHex({ r, g, b }: Rgb): string {
|
|
const toHex = (value: number) => clampByte(value).toString(16).padStart(2, '0');
|
|
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
|
}
|
|
|
|
function rgbToHsl({ r, g, b }: Rgb): { h: number; s: number; l: number } {
|
|
const nr = clampByte(r) / 255;
|
|
const ng = clampByte(g) / 255;
|
|
const nb = clampByte(b) / 255;
|
|
const max = Math.max(nr, ng, nb);
|
|
const min = Math.min(nr, ng, nb);
|
|
const delta = max - min;
|
|
let h = 0;
|
|
if (delta > 0) {
|
|
if (max === nr) h = ((ng - nb) / delta) % 6;
|
|
else if (max === ng) h = (nb - nr) / delta + 2;
|
|
else h = (nr - ng) / delta + 4;
|
|
h /= 6;
|
|
if (h < 0) h += 1;
|
|
}
|
|
const l = (max + min) / 2;
|
|
const s = delta === 0 ? 0 : delta / (1 - Math.abs(2 * l - 1));
|
|
return { h, s, l };
|
|
}
|
|
|
|
function hueToRgb(p: number, q: number, t: number): number {
|
|
let value = t;
|
|
if (value < 0) value += 1;
|
|
if (value > 1) value -= 1;
|
|
if (value < 1 / 6) return p + (q - p) * 6 * value;
|
|
if (value < 1 / 2) return q;
|
|
if (value < 2 / 3) return p + (q - p) * (2 / 3 - value) * 6;
|
|
return p;
|
|
}
|
|
|
|
function hslToRgb({ h, s, l }: { h: number; s: number; l: number }): Rgb {
|
|
const hue = ((h % 1) + 1) % 1;
|
|
const sat = clamp(s, 0, 1);
|
|
const light = clamp(l, 0, 1);
|
|
if (sat === 0) {
|
|
const gray = clampByte(light * 255);
|
|
return { r: gray, g: gray, b: gray };
|
|
}
|
|
const q = light < 0.5 ? light * (1 + sat) : light + sat - light * sat;
|
|
const p = 2 * light - q;
|
|
return {
|
|
r: clampByte(hueToRgb(p, q, hue + 1 / 3) * 255),
|
|
g: clampByte(hueToRgb(p, q, hue) * 255),
|
|
b: clampByte(hueToRgb(p, q, hue - 1 / 3) * 255),
|
|
};
|
|
}
|
|
|
|
function normalizeAccentColor(color: Rgb, options: AccentNormalizationOptions): Rgb {
|
|
const hsl = rgbToHsl(color);
|
|
return hslToRgb({
|
|
h: hsl.h,
|
|
s: clamp(Math.max(hsl.s, options.saturationFloor), 0, options.saturationCeiling),
|
|
l: clamp(hsl.l, options.lightnessFloor, options.lightnessCeiling),
|
|
});
|
|
}
|
|
|
|
function analyzePixel(r: number, g: number, b: number): PixelAnalysis {
|
|
const max = Math.max(r, g, b);
|
|
const min = Math.min(r, g, b);
|
|
return {
|
|
r,
|
|
g,
|
|
b,
|
|
max,
|
|
min,
|
|
saturation: max === 0 ? 0 : (max - min) / max,
|
|
luminance: (max + min) / (2 * 255),
|
|
chroma: (max - min) / 255,
|
|
};
|
|
}
|
|
|
|
function scoreDominantBucket(bucket: AccentBucket): number {
|
|
if (bucket.count <= 0) return -1;
|
|
const avgSaturation = bucket.saturationSum / bucket.count;
|
|
const avgLuminance = bucket.luminanceSum / bucket.count;
|
|
const midToneWeight = 1 - Math.min(1, Math.abs(avgLuminance - 0.52) / 0.52);
|
|
return bucket.count * (1 + avgSaturation * 0.9) * (0.55 + midToneWeight * 0.45);
|
|
}
|
|
|
|
function scoreVibrantBucket(bucket: AccentBucket): number {
|
|
if (bucket.count <= 0) return -1;
|
|
const avgSaturation = bucket.saturationSum / bucket.count;
|
|
const avgLuminance = bucket.luminanceSum / bucket.count;
|
|
const avgChroma = bucket.chromaSum / bucket.count;
|
|
const midToneWeight = 1 - Math.min(1, Math.abs(avgLuminance - 0.52) / 0.52);
|
|
return (
|
|
Math.pow(bucket.count, 0.55) *
|
|
(0.45 + avgSaturation * 1.6 + avgChroma * 0.9) *
|
|
(0.75 + midToneWeight * 0.35)
|
|
);
|
|
}
|
|
|
|
function extractAverageColor(pixels: Uint8Array): string | null {
|
|
let sumR = 0;
|
|
let sumG = 0;
|
|
let sumB = 0;
|
|
let totalWeight = 0;
|
|
for (let i = 0; i < pixels.length; i += 4) {
|
|
const alpha = pixels[i + 3];
|
|
if (alpha < MIN_ALPHA) continue;
|
|
const weight = alpha / 255;
|
|
totalWeight += weight;
|
|
sumR += pixels[i] * weight;
|
|
sumG += pixels[i + 1] * weight;
|
|
sumB += pixels[i + 2] * weight;
|
|
}
|
|
if (totalWeight <= 0) return null;
|
|
return rgbToHex(normalizeAccentColor({
|
|
r: sumR / totalWeight,
|
|
g: sumG / totalWeight,
|
|
b: sumB / totalWeight,
|
|
}, DEFAULT_NORMALIZATION));
|
|
}
|
|
|
|
function extractBucketedColor(
|
|
pixels: Uint8Array,
|
|
options: BucketExtractionOptions,
|
|
): string | null {
|
|
const buckets = new Map<string, AccentBucket>();
|
|
for (let i = 0; i < pixels.length; i += 4) {
|
|
const alpha = pixels[i + 3];
|
|
if (alpha < options.minAlpha) continue;
|
|
const pixel = analyzePixel(pixels[i], pixels[i + 1], pixels[i + 2]);
|
|
if (!options.isPixelAccepted(pixel)) continue;
|
|
const key = [
|
|
Math.round(pixel.r / DOMINANT_BUCKET_SIZE),
|
|
Math.round(pixel.g / DOMINANT_BUCKET_SIZE),
|
|
Math.round(pixel.b / DOMINANT_BUCKET_SIZE),
|
|
].join('-');
|
|
const existing = buckets.get(key);
|
|
if (existing) {
|
|
existing.count += 1;
|
|
existing.sumR += pixel.r;
|
|
existing.sumG += pixel.g;
|
|
existing.sumB += pixel.b;
|
|
existing.saturationSum += pixel.saturation;
|
|
existing.luminanceSum += pixel.luminance;
|
|
existing.chromaSum += pixel.chroma;
|
|
} else {
|
|
buckets.set(key, {
|
|
count: 1,
|
|
sumR: pixel.r,
|
|
sumG: pixel.g,
|
|
sumB: pixel.b,
|
|
saturationSum: pixel.saturation,
|
|
luminanceSum: pixel.luminance,
|
|
chromaSum: pixel.chroma,
|
|
});
|
|
}
|
|
}
|
|
if (buckets.size === 0) return options.fallback();
|
|
let winner: AccentBucket | null = null;
|
|
let bestScore = -1;
|
|
for (const bucket of buckets.values()) {
|
|
const score = options.scoreBucket(bucket);
|
|
if (score > bestScore) {
|
|
bestScore = score;
|
|
winner = bucket;
|
|
}
|
|
}
|
|
if (!winner || winner.count <= 0) return options.fallback();
|
|
return rgbToHex(options.normalizeColor({
|
|
r: winner.sumR / winner.count,
|
|
g: winner.sumG / winner.count,
|
|
b: winner.sumB / winner.count,
|
|
}));
|
|
}
|
|
|
|
function extractDominantColor(pixels: Uint8Array): string | null {
|
|
return extractBucketedColor(pixels, {
|
|
minAlpha: MIN_ALPHA,
|
|
normalizeColor: (color) => normalizeAccentColor(color, DEFAULT_NORMALIZATION),
|
|
isPixelAccepted: (pixel) => {
|
|
if (pixel.max < 24 || pixel.min > 240) return false;
|
|
return pixel.saturation >= 0.08;
|
|
},
|
|
scoreBucket: scoreDominantBucket,
|
|
fallback: () => extractAverageColor(pixels),
|
|
});
|
|
}
|
|
|
|
function extractVibrantColor(pixels: Uint8Array): string | null {
|
|
return extractBucketedColor(pixels, {
|
|
minAlpha: VIBRANT_MIN_ALPHA,
|
|
normalizeColor: (color) => normalizeAccentColor(color, VIBRANT_NORMALIZATION),
|
|
isPixelAccepted: (pixel) => {
|
|
if (pixel.saturation < 0.16 || pixel.luminance < 0.1) return false;
|
|
return !(pixel.luminance > 0.93 && pixel.saturation < 0.35);
|
|
},
|
|
scoreBucket: scoreVibrantBucket,
|
|
fallback: () => extractDominantColor(pixels),
|
|
});
|
|
}
|
|
|
|
export function extractArtworkAccentFromPixels(
|
|
pixels: Uint8Array,
|
|
method: CoverArtAccentMethod,
|
|
): string | null {
|
|
if (method === 'average') return extractAverageColor(pixels);
|
|
if (method === 'vibrant') return extractVibrantColor(pixels);
|
|
return extractDominantColor(pixels);
|
|
}
|