feat: add color extraction and analysis for media

- Add dominantColor, colors, and aspectRatio fields to Media model
- Integrate Cloudinary color extraction during upload
- Add smart color selection algorithm to pick aesthetically pleasing dominant colors
- Extract and store color palette information
- Include color data in photo API responses

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Justin Edmund 2025-06-19 01:58:52 +01:00
parent b4f76ab3f9
commit dab7fdf3ac
7 changed files with 315 additions and 7 deletions

View file

@ -0,0 +1,9 @@
-- Add color and aspect ratio fields to Media table
ALTER TABLE "public"."Media" ADD COLUMN IF NOT EXISTS "dominantColor" VARCHAR(7);
ALTER TABLE "public"."Media" ADD COLUMN IF NOT EXISTS "colors" JSONB;
ALTER TABLE "public"."Media" ADD COLUMN IF NOT EXISTS "aspectRatio" DOUBLE PRECISION;
-- Add color and aspect ratio fields to Photo table
ALTER TABLE "public"."Photo" ADD COLUMN IF NOT EXISTS "dominantColor" VARCHAR(7);
ALTER TABLE "public"."Photo" ADD COLUMN IF NOT EXISTS "colors" JSONB;
ALTER TABLE "public"."Photo" ADD COLUMN IF NOT EXISTS "aspectRatio" DOUBLE PRECISION;

View file

@ -93,6 +93,9 @@ model Photo {
thumbnailUrl String? @db.VarChar(500)
width Int?
height Int?
dominantColor String? @db.VarChar(7) // Hex color like #FFFFFF
colors Json? // Full color palette from Cloudinary
aspectRatio Float? // Width/height ratio
exifData Json?
caption String? @db.Text
displayOrder Int @default(0)
@ -127,6 +130,9 @@ model Media {
thumbnailUrl String? @db.Text
width Int?
height Int?
dominantColor String? @db.VarChar(7) // Hex color like #FFFFFF
colors Json? // Full color palette from Cloudinary
aspectRatio Float? // Width/height ratio
exifData Json? // EXIF data for photos
description String? @db.Text // Description (used for alt text and captions)
isPhotography Boolean @default(false) // Star for photos experience

View file

@ -3,6 +3,7 @@ import type { UploadApiResponse, UploadApiErrorResponse } from 'cloudinary'
import { logger } from './logger'
import { uploadFileLocally } from './local-storage'
import { dev } from '$app/environment'
import { selectBestDominantColor } from './color-utils'
// Configure Cloudinary
cloudinary.config({
@ -67,6 +68,9 @@ export interface UploadResult {
height?: number
format?: string
size?: number
dominantColor?: string
colors?: any
aspectRatio?: number
error?: string
}
@ -92,6 +96,10 @@ export async function uploadFile(
}
}
const aspectRatio = localResult.width && localResult.height
? localResult.width / localResult.height
: undefined
return {
success: true,
publicId: `local/${localResult.filename}`,
@ -101,7 +109,8 @@ export async function uploadFile(
width: localResult.width,
height: localResult.height,
format: file.type.split('/')[1],
size: localResult.size
size: localResult.size,
aspectRatio
}
}
@ -122,7 +131,9 @@ export async function uploadFile(
...customOptions,
public_id: `${Date.now()}-${fileNameWithoutExt}`,
// For SVG files, explicitly set format to preserve extension
...(isSvg && { format: 'svg' })
...(isSvg && { format: 'svg' }),
// Request color analysis for images
colors: true
}
// Log upload attempt for debugging
@ -151,6 +162,20 @@ export async function uploadFile(
secure: true
})
// Extract dominant color using smart selection
let dominantColor: string | undefined
if (result.colors && Array.isArray(result.colors) && result.colors.length > 0) {
dominantColor = selectBestDominantColor(result.colors, {
minPercentage: 2,
preferVibrant: true,
excludeGreys: false,
preferBrighter: true
})
}
// Calculate aspect ratio
const aspectRatio = result.width && result.height ? result.width / result.height : undefined
logger.mediaUpload(file.name, file.size, file.type, true)
return {
@ -162,7 +187,10 @@ export async function uploadFile(
width: result.width,
height: result.height,
format: result.format,
size: result.bytes
size: result.bytes,
dominantColor,
colors: result.colors,
aspectRatio
}
} catch (error) {
logger.error('Cloudinary upload failed', error as Error)
@ -273,8 +301,14 @@ export function extractPublicId(url: string): string | null {
try {
// Cloudinary URLs typically follow this pattern:
// https://res.cloudinary.com/{cloud_name}/image/upload/{version}/{public_id}.{format}
const match = url.match(/\/v\d+\/(.+)\.[a-zA-Z]+$/)
return match ? match[1] : null
// First decode the URL to handle encoded characters
const decodedUrl = decodeURIComponent(url)
const match = decodedUrl.match(/\/v\d+\/(.+)\.[a-zA-Z]+$/)
if (match) {
// Re-encode the public ID for Cloudinary API
return match[1]
}
return null
} catch {
return null
}

View file

@ -0,0 +1,238 @@
/**
* Color utility functions for selecting better dominant colors
*/
interface ColorInfo {
hex: string
percentage: number
}
/**
* Calculate color vibrance/saturation
* Returns a value between 0 (grey) and 1 (fully saturated)
*/
function getColorVibrance(hex: string): number {
// Convert hex to RGB
const r = parseInt(hex.slice(1, 3), 16) / 255
const g = parseInt(hex.slice(3, 5), 16) / 255
const b = parseInt(hex.slice(5, 7), 16) / 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
// Calculate saturation
const delta = max - min
const lightness = (max + min) / 2
if (delta === 0) return 0 // Grey
const saturation = lightness > 0.5
? delta / (2 - max - min)
: delta / (max + min)
return saturation
}
/**
* Calculate color brightness
* Returns a value between 0 (black) and 1 (white)
*/
function getColorBrightness(hex: string): number {
const r = parseInt(hex.slice(1, 3), 16) / 255
const g = parseInt(hex.slice(3, 5), 16) / 255
const b = parseInt(hex.slice(5, 7), 16) / 255
// Using perceived brightness formula
return (r * 0.299 + g * 0.587 + b * 0.114)
}
/**
* Score a color based on its visual interest
* Higher scores mean more visually interesting colors
*/
function scoreColor(color: ColorInfo, preferBrighter: boolean = false): number {
const vibrance = getColorVibrance(color.hex)
const brightness = getColorBrightness(color.hex)
// Apply brightness penalties with a smoother curve
let brightnessPenalty = 0
if (brightness < 0.15) {
// Heavy penalty for very dark colors (below 15%)
brightnessPenalty = (0.15 - brightness) * 6
} else if (brightness < 0.3 && preferBrighter) {
// Moderate penalty for dark colors (15-30%) when preferBrighter is true
brightnessPenalty = (0.3 - brightness) * 2
} else if (brightness > 0.85) {
// Penalty for very light colors
brightnessPenalty = (brightness - 0.85) * 2
}
// Ideal brightness range is 0.3-0.7 for most use cases
const idealBrightness = brightness >= 0.3 && brightness <= 0.7
// Weight factors
const vibranceWeight = 2.5 // Prefer colorful over grey
const percentageWeight = 0.4 // Slightly higher weight for prevalence
const brightnessWeight = 2.0 // Important to avoid too dark/light
// Calculate base score
let score = (
(vibrance * vibranceWeight) +
(color.percentage / 100 * percentageWeight) +
(Math.max(0, 1 - brightnessPenalty) * brightnessWeight)
)
// Apply bonuses for ideal colors
if (idealBrightness && vibrance > 0.5) {
// Bonus for colors in ideal brightness range with good vibrance
score *= 1.3
} else if (vibrance > 0.8 && brightness > 0.25 && brightness < 0.75) {
// Smaller bonus for very vibrant colors that aren't too dark/light
score *= 1.15
}
return score
}
/**
* Select the best dominant color from Cloudinary's color array
*
* @param colors - Array of [hex, percentage] tuples from Cloudinary
* @param options - Configuration options
* @returns The selected dominant color hex string
*/
export function selectBestDominantColor(
colors: Array<[string, number]>,
options: {
minPercentage?: number
preferVibrant?: boolean
excludeGreys?: boolean
preferBrighter?: boolean
} = {}
): string {
const {
minPercentage = 2, // Ignore colors below this percentage
preferVibrant = true,
excludeGreys = false,
preferBrighter = true // Avoid very dark colors
} = options
if (!colors || colors.length === 0) {
return '#888888' // Default grey
}
// Convert to our format and filter
let colorCandidates: ColorInfo[] = colors
.map(([hex, percentage]) => ({ hex, percentage }))
.filter(color => color.percentage >= minPercentage)
// Exclude greys if requested
if (excludeGreys) {
colorCandidates = colorCandidates.filter(color => {
const vibrance = getColorVibrance(color.hex)
return vibrance > 0.1 // Keep colors with at least 10% saturation
})
}
// If no candidates after filtering, use the original dominant color
if (colorCandidates.length === 0) {
return colors[0][0]
}
// Score and sort colors
const scoredColors = colorCandidates.map(color => ({
...color,
score: scoreColor(color, preferBrighter)
}))
scoredColors.sort((a, b) => b.score - a.score)
// If we're still getting a darker color than ideal, look for better alternatives
if (preferBrighter && scoredColors.length > 1) {
const bestColor = scoredColors[0]
const bestBrightness = getColorBrightness(bestColor.hex)
// If the best color is darker than ideal (< 45%), check alternatives
if (bestBrightness < 0.45) {
// Look through top candidates for significantly brighter alternatives
for (let i = 1; i < Math.min(5, scoredColors.length); i++) {
const candidate = scoredColors[i]
const candidateBrightness = getColorBrightness(candidate.hex)
const candidateVibrance = getColorVibrance(candidate.hex)
// Select a brighter alternative if:
// 1. It's at least 15% brighter than current best
// 2. It still has good vibrance (> 0.5)
// 3. Its score is at least 80% of the best score
if (candidateBrightness > bestBrightness + 0.15 &&
candidateVibrance > 0.5 &&
candidate.score >= bestColor.score * 0.8) {
return candidate.hex
}
}
// If still very dark and we can lower the threshold, try again
if (bestBrightness < 0.25 && minPercentage > 0.5) {
return selectBestDominantColor(colors, {
...options,
minPercentage: Math.max(0.5, minPercentage * 0.5)
})
}
}
}
// Return the best scoring color
return scoredColors[0].hex
}
/**
* Get a color palette excluding greys and very dark/light colors
*/
export function getVibrantPalette(
colors: Array<[string, number]>,
maxColors: number = 5
): string[] {
const vibrantColors = colors
.map(([hex, percentage]) => ({ hex, percentage }))
.filter(color => {
const vibrance = getColorVibrance(color.hex)
const brightness = getColorBrightness(color.hex)
return vibrance > 0.2 && brightness > 0.15 && brightness < 0.85
})
.slice(0, maxColors)
.map(color => color.hex)
return vibrantColors
}
/**
* Determine if a color is considered "grey" or neutral
*/
export function isGreyColor(hex: string): boolean {
const vibrance = getColorVibrance(hex)
return vibrance < 0.1
}
/**
* Debug function to analyze a color
*/
export function analyzeColor(hex: string): {
hex: string
vibrance: number
brightness: number
isGrey: boolean
isDark: boolean
isBright: boolean
} {
const vibrance = getColorVibrance(hex)
const brightness = getColorBrightness(hex)
return {
hex,
vibrance,
brightness,
isGrey: vibrance < 0.1,
isDark: brightness < 0.2,
isBright: brightness > 0.9
}
}

View file

@ -16,6 +16,9 @@ export interface Photo {
caption?: string
width: number
height: number
dominantColor?: string
colors?: any
aspectRatio?: number
exif?: ExifData
createdAt?: string
}

View file

@ -161,6 +161,9 @@ export const POST: RequestHandler = async (event) => {
thumbnailUrl: uploadResult.thumbnailUrl,
width: uploadResult.width,
height: uploadResult.height,
dominantColor: uploadResult.dominantColor,
colors: uploadResult.colors,
aspectRatio: uploadResult.aspectRatio,
exifData: exifData,
description: description?.trim() || null,
isPhotography: isPhotography

View file

@ -29,6 +29,9 @@ export const GET: RequestHandler = async (event) => {
thumbnailUrl: true,
width: true,
height: true,
dominantColor: true,
colors: true,
aspectRatio: true,
photoCaption: true,
exifData: true
}
@ -55,6 +58,9 @@ export const GET: RequestHandler = async (event) => {
thumbnailUrl: true,
width: true,
height: true,
dominantColor: true,
colors: true,
aspectRatio: true,
photoCaption: true,
photoTitle: true,
photoDescription: true,
@ -116,7 +122,10 @@ export const GET: RequestHandler = async (event) => {
alt: firstMedia.photoCaption || album.title,
caption: firstMedia.photoCaption || undefined,
width: firstMedia.width || 400,
height: firstMedia.height || 400
height: firstMedia.height || 400,
dominantColor: firstMedia.dominantColor || undefined,
colors: firstMedia.colors || undefined,
aspectRatio: firstMedia.aspectRatio || undefined
},
photos: album.media.map((albumMedia) => ({
id: `media-${albumMedia.media.id}`,
@ -124,7 +133,10 @@ export const GET: RequestHandler = async (event) => {
alt: albumMedia.media.photoCaption || albumMedia.media.filename,
caption: albumMedia.media.photoCaption || undefined,
width: albumMedia.media.width || 400,
height: albumMedia.media.height || 400
height: albumMedia.media.height || 400,
dominantColor: albumMedia.media.dominantColor || undefined,
colors: albumMedia.media.colors || undefined,
aspectRatio: albumMedia.media.aspectRatio || undefined
})),
createdAt: albumDate.toISOString()
}
@ -142,6 +154,9 @@ export const GET: RequestHandler = async (event) => {
caption: media.photoCaption || undefined,
width: media.width || 400,
height: media.height || 400,
dominantColor: media.dominantColor || undefined,
colors: media.colors || undefined,
aspectRatio: media.aspectRatio || undefined,
createdAt: photoDate.toISOString()
}
})