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:
parent
b4f76ab3f9
commit
dab7fdf3ac
7 changed files with 315 additions and 7 deletions
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
238
src/lib/server/color-utils.ts
Normal file
238
src/lib/server/color-utils.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,9 @@ export interface Photo {
|
|||
caption?: string
|
||||
width: number
|
||||
height: number
|
||||
dominantColor?: string
|
||||
colors?: any
|
||||
aspectRatio?: number
|
||||
exif?: ExifData
|
||||
createdAt?: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue