Initial release v1.0.0
This commit is contained in:
466
frontend/hooks/useImageColor.ts
Normal file
466
frontend/hooks/useImageColor.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user