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)
|
thumbnailUrl String? @db.VarChar(500)
|
||||||
width Int?
|
width Int?
|
||||||
height 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?
|
exifData Json?
|
||||||
caption String? @db.Text
|
caption String? @db.Text
|
||||||
displayOrder Int @default(0)
|
displayOrder Int @default(0)
|
||||||
|
|
@ -127,6 +130,9 @@ model Media {
|
||||||
thumbnailUrl String? @db.Text
|
thumbnailUrl String? @db.Text
|
||||||
width Int?
|
width Int?
|
||||||
height 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
|
exifData Json? // EXIF data for photos
|
||||||
description String? @db.Text // Description (used for alt text and captions)
|
description String? @db.Text // Description (used for alt text and captions)
|
||||||
isPhotography Boolean @default(false) // Star for photos experience
|
isPhotography Boolean @default(false) // Star for photos experience
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import type { UploadApiResponse, UploadApiErrorResponse } from 'cloudinary'
|
||||||
import { logger } from './logger'
|
import { logger } from './logger'
|
||||||
import { uploadFileLocally } from './local-storage'
|
import { uploadFileLocally } from './local-storage'
|
||||||
import { dev } from '$app/environment'
|
import { dev } from '$app/environment'
|
||||||
|
import { selectBestDominantColor } from './color-utils'
|
||||||
|
|
||||||
// Configure Cloudinary
|
// Configure Cloudinary
|
||||||
cloudinary.config({
|
cloudinary.config({
|
||||||
|
|
@ -67,6 +68,9 @@ export interface UploadResult {
|
||||||
height?: number
|
height?: number
|
||||||
format?: string
|
format?: string
|
||||||
size?: number
|
size?: number
|
||||||
|
dominantColor?: string
|
||||||
|
colors?: any
|
||||||
|
aspectRatio?: number
|
||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -92,6 +96,10 @@ export async function uploadFile(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const aspectRatio = localResult.width && localResult.height
|
||||||
|
? localResult.width / localResult.height
|
||||||
|
: undefined
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
publicId: `local/${localResult.filename}`,
|
publicId: `local/${localResult.filename}`,
|
||||||
|
|
@ -101,7 +109,8 @@ export async function uploadFile(
|
||||||
width: localResult.width,
|
width: localResult.width,
|
||||||
height: localResult.height,
|
height: localResult.height,
|
||||||
format: file.type.split('/')[1],
|
format: file.type.split('/')[1],
|
||||||
size: localResult.size
|
size: localResult.size,
|
||||||
|
aspectRatio
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -122,7 +131,9 @@ export async function uploadFile(
|
||||||
...customOptions,
|
...customOptions,
|
||||||
public_id: `${Date.now()}-${fileNameWithoutExt}`,
|
public_id: `${Date.now()}-${fileNameWithoutExt}`,
|
||||||
// For SVG files, explicitly set format to preserve extension
|
// 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
|
// Log upload attempt for debugging
|
||||||
|
|
@ -151,6 +162,20 @@ export async function uploadFile(
|
||||||
secure: true
|
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)
|
logger.mediaUpload(file.name, file.size, file.type, true)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -162,7 +187,10 @@ export async function uploadFile(
|
||||||
width: result.width,
|
width: result.width,
|
||||||
height: result.height,
|
height: result.height,
|
||||||
format: result.format,
|
format: result.format,
|
||||||
size: result.bytes
|
size: result.bytes,
|
||||||
|
dominantColor,
|
||||||
|
colors: result.colors,
|
||||||
|
aspectRatio
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Cloudinary upload failed', error as Error)
|
logger.error('Cloudinary upload failed', error as Error)
|
||||||
|
|
@ -273,8 +301,14 @@ export function extractPublicId(url: string): string | null {
|
||||||
try {
|
try {
|
||||||
// Cloudinary URLs typically follow this pattern:
|
// Cloudinary URLs typically follow this pattern:
|
||||||
// https://res.cloudinary.com/{cloud_name}/image/upload/{version}/{public_id}.{format}
|
// https://res.cloudinary.com/{cloud_name}/image/upload/{version}/{public_id}.{format}
|
||||||
const match = url.match(/\/v\d+\/(.+)\.[a-zA-Z]+$/)
|
// First decode the URL to handle encoded characters
|
||||||
return match ? match[1] : null
|
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 {
|
} catch {
|
||||||
return null
|
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
|
caption?: string
|
||||||
width: number
|
width: number
|
||||||
height: number
|
height: number
|
||||||
|
dominantColor?: string
|
||||||
|
colors?: any
|
||||||
|
aspectRatio?: number
|
||||||
exif?: ExifData
|
exif?: ExifData
|
||||||
createdAt?: string
|
createdAt?: string
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -161,6 +161,9 @@ export const POST: RequestHandler = async (event) => {
|
||||||
thumbnailUrl: uploadResult.thumbnailUrl,
|
thumbnailUrl: uploadResult.thumbnailUrl,
|
||||||
width: uploadResult.width,
|
width: uploadResult.width,
|
||||||
height: uploadResult.height,
|
height: uploadResult.height,
|
||||||
|
dominantColor: uploadResult.dominantColor,
|
||||||
|
colors: uploadResult.colors,
|
||||||
|
aspectRatio: uploadResult.aspectRatio,
|
||||||
exifData: exifData,
|
exifData: exifData,
|
||||||
description: description?.trim() || null,
|
description: description?.trim() || null,
|
||||||
isPhotography: isPhotography
|
isPhotography: isPhotography
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,9 @@ export const GET: RequestHandler = async (event) => {
|
||||||
thumbnailUrl: true,
|
thumbnailUrl: true,
|
||||||
width: true,
|
width: true,
|
||||||
height: true,
|
height: true,
|
||||||
|
dominantColor: true,
|
||||||
|
colors: true,
|
||||||
|
aspectRatio: true,
|
||||||
photoCaption: true,
|
photoCaption: true,
|
||||||
exifData: true
|
exifData: true
|
||||||
}
|
}
|
||||||
|
|
@ -55,6 +58,9 @@ export const GET: RequestHandler = async (event) => {
|
||||||
thumbnailUrl: true,
|
thumbnailUrl: true,
|
||||||
width: true,
|
width: true,
|
||||||
height: true,
|
height: true,
|
||||||
|
dominantColor: true,
|
||||||
|
colors: true,
|
||||||
|
aspectRatio: true,
|
||||||
photoCaption: true,
|
photoCaption: true,
|
||||||
photoTitle: true,
|
photoTitle: true,
|
||||||
photoDescription: true,
|
photoDescription: true,
|
||||||
|
|
@ -116,7 +122,10 @@ export const GET: RequestHandler = async (event) => {
|
||||||
alt: firstMedia.photoCaption || album.title,
|
alt: firstMedia.photoCaption || album.title,
|
||||||
caption: firstMedia.photoCaption || undefined,
|
caption: firstMedia.photoCaption || undefined,
|
||||||
width: firstMedia.width || 400,
|
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) => ({
|
photos: album.media.map((albumMedia) => ({
|
||||||
id: `media-${albumMedia.media.id}`,
|
id: `media-${albumMedia.media.id}`,
|
||||||
|
|
@ -124,7 +133,10 @@ export const GET: RequestHandler = async (event) => {
|
||||||
alt: albumMedia.media.photoCaption || albumMedia.media.filename,
|
alt: albumMedia.media.photoCaption || albumMedia.media.filename,
|
||||||
caption: albumMedia.media.photoCaption || undefined,
|
caption: albumMedia.media.photoCaption || undefined,
|
||||||
width: albumMedia.media.width || 400,
|
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()
|
createdAt: albumDate.toISOString()
|
||||||
}
|
}
|
||||||
|
|
@ -142,6 +154,9 @@ export const GET: RequestHandler = async (event) => {
|
||||||
caption: media.photoCaption || undefined,
|
caption: media.photoCaption || undefined,
|
||||||
width: media.width || 400,
|
width: media.width || 400,
|
||||||
height: media.height || 400,
|
height: media.height || 400,
|
||||||
|
dominantColor: media.dominantColor || undefined,
|
||||||
|
colors: media.colors || undefined,
|
||||||
|
aspectRatio: media.aspectRatio || undefined,
|
||||||
createdAt: photoDate.toISOString()
|
createdAt: photoDate.toISOString()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue