diff --git a/prisma/migrations/20240618_add_color_fields/migration.sql b/prisma/migrations/20240618_add_color_fields/migration.sql new file mode 100644 index 0000000..45d2003 --- /dev/null +++ b/prisma/migrations/20240618_add_color_fields/migration.sql @@ -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; \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 42ef646..3c98db4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/src/lib/server/cloudinary.ts b/src/lib/server/cloudinary.ts index f0ec7b7..384767d 100644 --- a/src/lib/server/cloudinary.ts +++ b/src/lib/server/cloudinary.ts @@ -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 } diff --git a/src/lib/server/color-utils.ts b/src/lib/server/color-utils.ts new file mode 100644 index 0000000..e081459 --- /dev/null +++ b/src/lib/server/color-utils.ts @@ -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 + } +} \ No newline at end of file diff --git a/src/lib/types/photos.ts b/src/lib/types/photos.ts index 3acbbc0..4a6da12 100644 --- a/src/lib/types/photos.ts +++ b/src/lib/types/photos.ts @@ -16,6 +16,9 @@ export interface Photo { caption?: string width: number height: number + dominantColor?: string + colors?: any + aspectRatio?: number exif?: ExifData createdAt?: string } diff --git a/src/routes/api/media/upload/+server.ts b/src/routes/api/media/upload/+server.ts index 2106f0c..412ac11 100644 --- a/src/routes/api/media/upload/+server.ts +++ b/src/routes/api/media/upload/+server.ts @@ -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 diff --git a/src/routes/api/photos/+server.ts b/src/routes/api/photos/+server.ts index 9327a5f..428fa6d 100644 --- a/src/routes/api/photos/+server.ts +++ b/src/routes/api/photos/+server.ts @@ -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() } })