Initial release v1.0.0

This commit is contained in:
Kevin O'Neill
2025-12-25 18:58:06 -06:00
commit 021aec7a63
439 changed files with 116588 additions and 0 deletions

View File

@@ -0,0 +1,466 @@
import { useEffect, useState } from "react";
export interface ColorPalette {
vibrant: string;
darkVibrant: string;
lightVibrant: string;
muted: string;
darkMuted: string;
lightMuted: string;
}
/**
* Extract dominant colors from an image using Canvas API
*/
function extractColorsFromImage(imageUrl: string): Promise<ColorPalette> {
return new Promise((resolve, reject) => {
const img = new Image();
// ALWAYS set crossOrigin for images that need color extraction
// This is required for Canvas.getImageData() to work
// The backend must send Access-Control-Allow-Origin header
img.crossOrigin = "anonymous";
img.onload = () => {
try {
// Create canvas and get context
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("Could not get canvas context");
}
// Scale down for performance
const scaleFactor = 0.1;
canvas.width = img.width * scaleFactor;
canvas.height = img.height * scaleFactor;
// Draw image
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
// Get image data
const imageData = ctx.getImageData(
0,
0,
canvas.width,
canvas.height
);
const pixels = imageData.data;
// Count color frequencies, prioritizing saturated colors
const colorMap = new Map<
string,
{ count: number; saturation: number }
>();
for (let i = 0; i < pixels.length; i += 4) {
const r = pixels[i];
const g = pixels[i + 1];
const b = pixels[i + 2];
const a = pixels[i + 3];
// Skip transparent pixels
if (a < 125) continue;
// Skip extremely dark and extremely light pixels
const brightness = (r + g + b) / 3;
if (brightness < 15 || brightness > 240) continue;
// Calculate saturation to favor colorful pixels
const saturation = getSaturation(r, g, b);
// Reduce color precision for better grouping
const reducedR = Math.floor(r / 15) * 15;
const reducedG = Math.floor(g / 15) * 15;
const reducedB = Math.floor(b / 15) * 15;
const key = `${reducedR},${reducedG},${reducedB}`;
const existing = colorMap.get(key);
if (existing) {
existing.count += 1;
existing.saturation = Math.max(
existing.saturation,
saturation
);
} else {
colorMap.set(key, { count: 1, saturation });
}
}
// Sort colors by a weighted score (frequency + saturation)
const sortedColors = Array.from(colorMap.entries())
.sort((a, b) => {
// Weight both frequency and saturation
const scoreA = a[1].count * (1 + a[1].saturation * 2);
const scoreB = b[1].count * (1 + b[1].saturation * 2);
return scoreB - scoreA;
})
.map(([color]) => {
const [r, g, b] = color.split(",").map(Number);
return { r, g, b };
});
if (sortedColors.length === 0) {
// Fallback colors
resolve({
vibrant: "#1db954",
darkVibrant: "#121212",
lightVibrant: "#181818",
muted: "#535353",
darkMuted: "#121212",
lightMuted: "#b3b3b3",
});
return;
}
// Find vibrant color (highest saturation) and boost if too dark
const vibrantColor = sortedColors.reduce((prev, curr) => {
const prevSat = getSaturation(prev.r, prev.g, prev.b);
const currSat = getSaturation(curr.r, curr.g, curr.b);
return currSat > prevSat ? curr : prev;
});
// Boost vibrant color if it's too dark - be VERY aggressive
const vibrantBrightness =
(vibrantColor.r + vibrantColor.g + vibrantColor.b) / 3;
let boostFactor: number;
if (vibrantBrightness < 30) {
// Extremely dark - boost by 8-10x
boostFactor = 10.0;
} else if (vibrantBrightness < 60) {
// Very dark - boost by 4-5x
boostFactor = 5.0;
} else if (vibrantBrightness < 100) {
// Dark - boost by 2-3x
boostFactor = 3.0;
} else if (vibrantBrightness < 140) {
// Somewhat dark - boost by 1.5-2x
boostFactor = 2.0;
} else {
// Normal brightness
boostFactor = 1.3;
}
let boostedVibrant = {
r: Math.min(255, Math.floor(vibrantColor.r * boostFactor)),
g: Math.min(255, Math.floor(vibrantColor.g * boostFactor)),
b: Math.min(255, Math.floor(vibrantColor.b * boostFactor)),
};
// Ensure minimum brightness - if still too dark, add a base amount
const boostedBrightness =
(boostedVibrant.r + boostedVibrant.g + boostedVibrant.b) /
3;
if (boostedBrightness < 80) {
const addAmount = 80 - boostedBrightness;
boostedVibrant = {
r: Math.min(255, boostedVibrant.r + addAmount),
g: Math.min(255, boostedVibrant.g + addAmount),
b: Math.min(255, boostedVibrant.b + addAmount),
};
}
// Find dark vibrant (vibrant but darker than original, not boosted)
const darkVibrantColor = {
r: Math.floor(vibrantColor.r * 0.6),
g: Math.floor(vibrantColor.g * 0.6),
b: Math.floor(vibrantColor.b * 0.6),
};
// Find light vibrant (from boosted vibrant)
const lightVibrantColor = {
r: Math.min(255, Math.floor(boostedVibrant.r * 1.2)),
g: Math.min(255, Math.floor(boostedVibrant.g * 1.2)),
b: Math.min(255, Math.floor(boostedVibrant.b * 1.2)),
};
// Find muted color (low saturation)
const mutedColor = sortedColors.reduce((prev, curr) => {
const prevSat = getSaturation(prev.r, prev.g, prev.b);
const currSat = getSaturation(curr.r, curr.g, curr.b);
return currSat < prevSat ? curr : prev;
});
// Dark muted
const darkMutedColor = {
r: Math.floor(mutedColor.r * 0.4),
g: Math.floor(mutedColor.g * 0.4),
b: Math.floor(mutedColor.b * 0.4),
};
// Light muted
const lightMutedColor = {
r: Math.min(255, Math.floor(mutedColor.r * 1.5)),
g: Math.min(255, Math.floor(mutedColor.g * 1.5)),
b: Math.min(255, Math.floor(mutedColor.b * 1.5)),
};
resolve({
vibrant: rgbToHex(
boostedVibrant.r,
boostedVibrant.g,
boostedVibrant.b
),
darkVibrant: rgbToHex(
darkVibrantColor.r,
darkVibrantColor.g,
darkVibrantColor.b
),
lightVibrant: rgbToHex(
lightVibrantColor.r,
lightVibrantColor.g,
lightVibrantColor.b
),
muted: rgbToHex(mutedColor.r, mutedColor.g, mutedColor.b),
darkMuted: rgbToHex(
darkMutedColor.r,
darkMutedColor.g,
darkMutedColor.b
),
lightMuted: rgbToHex(
lightMutedColor.r,
lightMutedColor.g,
lightMutedColor.b
),
});
} catch (error) {
reject(error);
}
};
img.onerror = () => {
reject(new Error("Failed to load image"));
};
img.src = imageUrl;
});
}
/**
* Calculate saturation of an RGB color
*/
function getSaturation(r: number, g: number, b: number): number {
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const delta = max - min;
return max === 0 ? 0 : delta / max;
}
/**
* Convert RGB to hex color
*/
function rgbToHex(r: number, g: number, b: number): string {
return (
"#" +
[r, g, b]
.map((x) => {
const hex = x.toString(16);
return hex.length === 1 ? "0" + hex : hex;
})
.join("")
);
}
export function useImageColor(imageUrl: string | null | undefined) {
const [colors, setColors] = useState<ColorPalette | null>(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (!imageUrl) {
setColors(null);
return;
}
// Handle placeholder images immediately
if (
imageUrl.includes("placeholder") ||
imageUrl.startsWith("/placeholder")
) {
setColors({
vibrant: "#1db954",
darkVibrant: "#121212",
lightVibrant: "#181818",
muted: "#535353",
darkMuted: "#121212",
lightMuted: "#b3b3b3",
});
setIsLoading(false);
return;
}
// Check cache first
const cacheKey = `color_cache_${imageUrl}`;
try {
const cached = localStorage.getItem(cacheKey);
if (cached) {
const cachedPalette = JSON.parse(cached) as ColorPalette;
setColors(cachedPalette);
setIsLoading(false);
return;
}
} catch (error) {
// Ignore cache read errors
}
setIsLoading(true);
// Extract colors client-side using canvas
extractColorsFromImage(imageUrl)
.then((palette: ColorPalette) => {
setColors(palette);
setIsLoading(false);
// Cache the result in localStorage
try {
localStorage.setItem(cacheKey, JSON.stringify(palette));
} catch (error) {
// Ignore cache write errors
}
})
.catch((error) => {
console.error("[useImageColor] Failed to extract colors:", error.message || error);
// Use fallback colors on error
setColors({
vibrant: "#1db954",
darkVibrant: "#121212",
lightVibrant: "#181818",
muted: "#535353",
darkMuted: "#121212",
lightMuted: "#b3b3b3",
});
setIsLoading(false);
// Remove any cached failures so it can retry later
try {
localStorage.removeItem(cacheKey);
} catch (e) {
// Ignore
}
});
}, [imageUrl]);
return { colors, isLoading };
}
/**
* Generate a gradient style object from extracted colors
*/
export function createGradient(
colors: ColorPalette | null,
fallbackColor: string = "#1db954"
): React.CSSProperties {
if (!colors) {
return {
background: `linear-gradient(180deg, ${fallbackColor}40 0%, rgba(0, 0, 0, 0) 100%)`,
};
}
// Create a gradient from dark vibrant to transparent black
return {
background: `linear-gradient(180deg, ${colors.darkVibrant} 0%, ${colors.darkMuted} 40%, rgba(0, 0, 0, 0.8) 70%, #000000 100%)`,
};
}
/**
* Generate a hero gradient (for the top section)
*/
export function createHeroGradient(
colors: ColorPalette | null,
fallbackColor: string = "#1db954"
): React.CSSProperties {
if (!colors) {
return {
background: `linear-gradient(180deg, ${fallbackColor}60 0%, rgba(18, 18, 18, 0.9) 100%)`,
};
}
// Create a more vibrant gradient for the hero section
return {
background: `linear-gradient(180deg, ${colors.vibrant}40 0%, ${colors.darkVibrant}80 50%, rgba(18, 18, 18, 0.95) 100%)`,
};
}
/**
* Calculate relative luminance for contrast ratio
* Based on WCAG 2.0 formula: https://www.w3.org/TR/WCAG20/#relativeluminancedef
*/
function getLuminance(r: number, g: number, b: number): number {
const [rs, gs, bs] = [r, g, b].map((c) => {
c = c / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
});
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
}
/**
* Calculate contrast ratio between two colors
* Returns a value between 1 and 21 (higher is better contrast)
* WCAG AA requires 4.5:1 for normal text, 3:1 for large text
*/
function getContrastRatio(hex1: string, hex2: string): number {
const rgb1 = hexToRgb(hex1);
const rgb2 = hexToRgb(hex2);
if (!rgb1 || !rgb2) return 1;
const lum1 = getLuminance(rgb1.r, rgb1.g, rgb1.b);
const lum2 = getLuminance(rgb2.r, rgb2.g, rgb2.b);
const lighter = Math.max(lum1, lum2);
const darker = Math.min(lum1, lum2);
return (lighter + 0.05) / (darker + 0.05);
}
/**
* Convert hex color to RGB
*/
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
}
: null;
}
/**
* Get the best icon color (black or white) for a given background color
* Returns 'black' or 'white' based on contrast ratio
*/
export function getIconColor(backgroundColor: string): "black" | "white" {
const whiteContrast = getContrastRatio(backgroundColor, "#ffffff");
const blackContrast = getContrastRatio(backgroundColor, "#000000");
// Return the color with better contrast
// Use white if contrast is close (within 1.5), as it looks better on colorful backgrounds
return whiteContrast > blackContrast - 1.5 ? "white" : "black";
}
/**
* Get play button styles based on vibrant color
* Returns background color and icon color with proper contrast
*/
export function getPlayButtonStyles(colors: ColorPalette | null): {
backgroundColor: string;
iconColor: "black" | "white";
} {
if (!colors) {
return {
backgroundColor: "#facc15", // Yellow fallback
iconColor: "black",
};
}
// Use the vibrant color for the button
const bgColor = colors.vibrant;
const iconColor = getIconColor(bgColor);
return {
backgroundColor: bgColor,
iconColor,
};
}