From 9f0807b0fcf522e928eb4a040238f3bc78145854 Mon Sep 17 00:00:00 2001 From: Citali Date: Sat, 28 Jun 2025 00:12:24 +0200 Subject: [PATCH] Change image edit to recreate palettes --- src/actions/images/extractColors.ts | 330 ++++++++++++++++++ src/actions/images/generateExtractColors.ts | 54 +++ src/actions/images/generateImageColors.ts | 40 +++ src/actions/images/generatePalette.ts | 58 +++ src/actions/images/uploadImage.ts | 121 +------ src/app/images/edit/[id]/page.tsx | 16 +- .../images/edit/EditImageColors.tsx | 20 -- src/components/images/edit/EditImageForm.tsx | 13 +- .../images/edit/EditImagePalettes.tsx | 34 -- src/components/images/edit/ExtractColors.tsx | 41 +++ src/components/images/edit/ImageColors.tsx | 47 +++ src/components/images/edit/ImagePalettes.tsx | 63 ++++ ...ditImageVariants.tsx => ImageVariants.tsx} | 5 +- src/utils/determineWatermarkPosition.ts | 51 +++ src/utils/formatFileSize.ts | 9 + src/utils/getImageBufferFromS3.ts | 20 ++ 16 files changed, 737 insertions(+), 185 deletions(-) create mode 100644 src/actions/images/extractColors.ts create mode 100644 src/actions/images/generateExtractColors.ts create mode 100644 src/actions/images/generateImageColors.ts create mode 100644 src/actions/images/generatePalette.ts delete mode 100644 src/components/images/edit/EditImageColors.tsx delete mode 100644 src/components/images/edit/EditImagePalettes.tsx create mode 100644 src/components/images/edit/ExtractColors.tsx create mode 100644 src/components/images/edit/ImageColors.tsx create mode 100644 src/components/images/edit/ImagePalettes.tsx rename src/components/images/edit/{EditImageVariants.tsx => ImageVariants.tsx} (63%) create mode 100644 src/utils/determineWatermarkPosition.ts create mode 100644 src/utils/formatFileSize.ts create mode 100644 src/utils/getImageBufferFromS3.ts diff --git a/src/actions/images/extractColors.ts b/src/actions/images/extractColors.ts new file mode 100644 index 0000000..8d5c88c --- /dev/null +++ b/src/actions/images/extractColors.ts @@ -0,0 +1,330 @@ +"use server" + +import prisma from "@/lib/prisma"; +import { s3 } from "@/lib/s3"; +import { imageUploadSchema } from "@/schemas/images/imageSchema"; +import { VibrantSwatch } from "@/types/VibrantSwatch"; +import { extractPaletteTones, rgbToHex, upsertPalettes } from "@/utils/uploadHelper"; +import { PutObjectCommand } from "@aws-sdk/client-s3"; +import { argbFromHex, themeFromSourceColor } from "@material/material-color-utilities"; +import { extractColors } from "extract-colors"; +import getPixels from "get-pixels"; +import { NdArray } from "ndarray"; +import { Vibrant } from "node-vibrant/node"; +import path from "path"; +import sharp from "sharp"; +import { v4 as uuidv4 } from 'uuid'; +import * as z from "zod/v4"; + +export async function uploadImage(values: z.infer) { + const imageFile = values.file[0]; + const imageName = values.imageName; + + if (!(imageFile instanceof File)) { + console.log("No image or invalid type"); + return null; + } + + if (!imageName) { + console.log("No name for the image provided"); + return null; + } + + const fileName = imageFile.name; + const fileType = imageFile.type; + const fileSize = imageFile.size; + const lastModified = new Date(imageFile.lastModified); + const year = lastModified.getUTCFullYear(); + const month = lastModified.getUTCMonth() + 1; + + const fileKey = uuidv4(); + + const arrayBuffer = await imageFile.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + const imageDataUrl = `data:${imageFile.type};base64,${buffer.toString("base64")}`; + + const originalKey = `original/${fileKey}.webp`; + const watermarkedKey = `watermarked/${fileKey}.webp`; + const resizedKey = `resized/${fileKey}.webp`; + const thumbnailKey = `thumbnails/${fileKey}.webp`; + + const sharpData = sharp(buffer); + const metadata = await sharpData.metadata(); + const stats = await sharpData.stats(); + + const palette = await Vibrant.from(buffer).getPalette(); + + const vibrantHexes = Object.fromEntries( + Object.entries(palette).map(([key, swatch]) => { + const castSwatch = swatch as VibrantSwatch | null; + const rgb = castSwatch?._rgb; + const hex = castSwatch?.hex || (rgb ? rgbToHex(rgb) : undefined); + return [key, hex]; + }) + ); + + for (const [type, hex] of Object.entries(vibrantHexes)) { + if (!hex) continue; + const [r, g, b] = hex.match(/\w\w/g)!.map((h) => parseInt(h, 16)); + await prisma.imageColor.create({ + data: { + type, + hex, + red: r, + green: g, + blue: b, + imageId: image.id, + }, + }); + } + + const seedHex = + vibrantHexes.Vibrant ?? + vibrantHexes.Muted ?? + vibrantHexes.DarkVibrant ?? + vibrantHexes.DarkMuted ?? + vibrantHexes.LightVibrant ?? + vibrantHexes.LightMuted ?? + "#dfffff"; + + const theme = themeFromSourceColor(argbFromHex(seedHex)); + const primaryTones = extractPaletteTones(theme.palettes.primary); + const secondaryTones = extractPaletteTones(theme.palettes.secondary); + const tertiaryTones = extractPaletteTones(theme.palettes.tertiary); + const neutralTones = extractPaletteTones(theme.palettes.neutral); + const neutralVariantTones = extractPaletteTones(theme.palettes.neutralVariant); + const errorTones = extractPaletteTones(theme.palettes.error); + + const pixels = await new Promise>((resolve, reject) => { + getPixels(imageDataUrl, 'image/' + metadata.format || "image/jpeg", (err, pixels) => { + if (err) reject(err); + else resolve(pixels); + }); + }); + + const extracted = await extractColors({ + data: Array.from(pixels.data), + width: pixels.shape[0], + height: pixels.shape[1] + }); + + //--- Original file + await s3.send( + new PutObjectCommand({ + Bucket: "felliesartapp", + Key: originalKey, + Body: buffer, + ContentType: "image/" + metadata.format, + }) + ); + //--- Watermarked file + const watermarkPath = path.join(process.cwd(), 'public/watermark/fellies-watermark.svg'); + const watermarkWidth = Math.round(metadata.width * 0.25); + const watermarkBuffer = await sharp(watermarkPath) + .resize({ width: watermarkWidth }) + .png() + .toBuffer(); + const watermarkedBuffer = await sharp(buffer) + .composite([{ input: watermarkBuffer, gravity: 'southwest', blend: 'atop' }]) + .toFormat('webp') + .toBuffer() + const watermarkedMetadata = await sharp(watermarkedBuffer).metadata(); + await s3.send( + new PutObjectCommand({ + Bucket: "felliesartapp", + Key: watermarkedKey, + Body: watermarkedBuffer, + ContentType: "image/" + watermarkedMetadata.format, + }) + ); + //--- Resized file + const resizedWidth = Math.min(watermarkedMetadata.width || 400, 400); + const resizedBuffer = await sharp(watermarkedBuffer) + .resize({ width: resizedWidth, withoutEnlargement: true }) + .toFormat('webp') + .toBuffer(); + const resizedMetadata = await sharp(resizedBuffer).metadata(); + await s3.send( + new PutObjectCommand({ + Bucket: "felliesartapp", + Key: resizedKey, + Body: resizedBuffer, + ContentType: "image/" + resizedMetadata.format, + }) + ); + //--- Thumbnail file + const thumbnailWidth = Math.min(watermarkedMetadata.width || 200, 200); + const thumbnailBuffer = await sharp(watermarkedBuffer) + .resize({ width: thumbnailWidth, withoutEnlargement: true }) + .toFormat('webp') + .toBuffer(); + const thumbnailMetadata = await sharp(thumbnailBuffer).metadata(); + await s3.send( + new PutObjectCommand({ + Bucket: "felliesartapp", + Key: thumbnailKey, + Body: thumbnailBuffer, + ContentType: "image/" + thumbnailMetadata.format, + }) + ); + + const image = await prisma.image.create({ + data: { + imageName, + fileKey, + originalFile: fileName, + uploadDate: new Date(), + + creationDate: lastModified, + creationMonth: month, + creationYear: year, + imageData: imageDataUrl, + fileType: fileType, + fileSize: fileSize, + altText: "", + description: "", + }, + }); + + await prisma.imageMetadata.create({ + data: { + imageId: image.id, + format: metadata.format || "unknown", + width: metadata.width || 0, + height: metadata.height || 0, + space: metadata.space || "unknown", + channels: metadata.channels || 0, + depth: metadata.depth || "unknown", + density: metadata.density ?? undefined, + bitsPerSample: metadata.bitsPerSample ?? undefined, + isProgressive: metadata.isProgressive ?? undefined, + isPalette: metadata.isPalette ?? undefined, + hasProfile: metadata.hasProfile ?? undefined, + hasAlpha: metadata.hasAlpha ?? undefined, + autoOrientW: metadata.autoOrient?.width ?? undefined, + autoOrientH: metadata.autoOrient?.height ?? undefined, + }, + }); + + await prisma.imageStats.create({ + data: { + imageId: image.id, + isOpaque: stats.isOpaque, + entropy: stats.entropy, + sharpness: stats.sharpness, + dominantR: stats.dominant.r, + dominantG: stats.dominant.g, + dominantB: stats.dominant.b, + }, + }); + + await prisma.imageVariant.createMany({ + data: [ + { + s3Key: originalKey, + type: "original", + height: metadata.height, + width: metadata.width, + fileExtension: metadata.format, + mimeType: "image/" + metadata.format, + sizeBytes: metadata.size, + imageId: image.id + }, + { + s3Key: watermarkedKey, + type: "watermarked", + height: watermarkedMetadata.height, + width: watermarkedMetadata.width, + fileExtension: watermarkedMetadata.format, + mimeType: "image/" + watermarkedMetadata.format, + sizeBytes: watermarkedMetadata.size, + imageId: image.id + }, + { + s3Key: resizedKey, + type: "resized", + height: resizedMetadata.height, + width: resizedMetadata.width, + fileExtension: resizedMetadata.format, + mimeType: "image/" + resizedMetadata.format, + sizeBytes: resizedMetadata.size, + imageId: image.id + }, + { + s3Key: thumbnailKey, + type: "thumbnail", + height: thumbnailMetadata.height, + width: thumbnailMetadata.width, + fileExtension: thumbnailMetadata.format, + mimeType: "image/" + thumbnailMetadata.format, + sizeBytes: thumbnailMetadata.size, + imageId: image.id + } + ], + }); + + await upsertPalettes(primaryTones, image.id, "primary"); + await upsertPalettes(secondaryTones, image.id, "secondary"); + await upsertPalettes(tertiaryTones, image.id, "tertiary"); + await upsertPalettes(neutralTones, image.id, "neutral"); + await upsertPalettes(neutralVariantTones, image.id, "neutralVariant"); + await upsertPalettes(errorTones, image.id, "error"); + + for (const [type, hex] of Object.entries(vibrantHexes)) { + if (!hex) continue; + const [r, g, b] = hex.match(/\w\w/g)!.map((h) => parseInt(h, 16)); + await prisma.imageColor.create({ + data: { + type, + hex, + red: r, + green: g, + blue: b, + imageId: image.id, + }, + }); + } + + for (const c of extracted) { + await prisma.extractColor.create({ + data: { + hex: c.hex, + red: c.red, + green: c.green, + blue: c.blue, + hue: c.hue, + saturation: c.saturation, + // value: c.value, + area: c.area, + // isLight: c.isLight, + imageId: image.id, + }, + }); + } + + await prisma.themeSeed.create({ + data: { + seedHex, + imageId: image.id, + }, + }); + + await prisma.pixelSummary.create({ + data: { + width: pixels.shape[0], + height: pixels.shape[1], + channels: pixels.shape[2], + imageId: image.id, + }, + }); + + return image + // return await prisma.gallery.create({ + // data: { + // name: values.name, + // slug: values.slug, + // description: values.description, + // } + // }) +} \ No newline at end of file diff --git a/src/actions/images/generateExtractColors.ts b/src/actions/images/generateExtractColors.ts new file mode 100644 index 0000000..042a945 --- /dev/null +++ b/src/actions/images/generateExtractColors.ts @@ -0,0 +1,54 @@ +"use server" + +import prisma from "@/lib/prisma"; +import { getImageBufferFromS3 } from "@/utils/getImageBufferFromS3"; +import { extractColors } from "extract-colors"; +import getPixels from "get-pixels"; +import { NdArray } from "ndarray"; + +export async function generateExtractColors(imageId: string, fileKey: string) { + const image = await prisma.image.findUnique({ + where: { + id: imageId + }, + include: { + metadata: true + } + }) + const buffer = await getImageBufferFromS3(fileKey); + if(!image) throw new Error("Image not found"); + + const imageDataUrl = `data:${image.fileType};base64,${buffer.toString("base64")}`; + + const pixels = await new Promise>((resolve, reject) => { + getPixels(imageDataUrl, 'image/' + image.metadata[0].format || "image/jpeg", (err, pixels) => { + if (err) reject(err); + else resolve(pixels); + }); + }); + + const extracted = await extractColors({ + data: Array.from(pixels.data), + width: pixels.shape[0], + height: pixels.shape[1] + }); + + for (const c of extracted) { + await prisma.extractColor.create({ + data: { + hex: c.hex, + red: c.red, + green: c.green, + blue: c.blue, + hue: c.hue, + saturation: c.saturation, + area: c.area, + imageId: imageId, + }, + }); + } + + return await prisma.extractColor.findMany({ + where: { imageId: imageId } + }); +} \ No newline at end of file diff --git a/src/actions/images/generateImageColors.ts b/src/actions/images/generateImageColors.ts new file mode 100644 index 0000000..905ccea --- /dev/null +++ b/src/actions/images/generateImageColors.ts @@ -0,0 +1,40 @@ +"use server" + +import prisma from "@/lib/prisma"; +import { VibrantSwatch } from "@/types/VibrantSwatch"; +import { getImageBufferFromS3 } from "@/utils/getImageBufferFromS3"; +import { rgbToHex } from "@/utils/uploadHelper"; +import { Vibrant } from "node-vibrant/node"; + +export async function generateImageColors(imageId: string, fileKey: string) { + const buffer = await getImageBufferFromS3(fileKey); + const palette = await Vibrant.from(buffer).getPalette(); + + const vibrantHexes = Object.fromEntries( + Object.entries(palette).map(([key, swatch]) => { + const castSwatch = swatch as VibrantSwatch | null; + const rgb = castSwatch?._rgb; + const hex = castSwatch?.hex || (rgb ? rgbToHex(rgb) : undefined); + return [key, hex]; + }) + ); + + for (const [type, hex] of Object.entries(vibrantHexes)) { + if (!hex) continue; + const [r, g, b] = hex.match(/\w\w/g)!.map((h) => parseInt(h, 16)); + await prisma.imageColor.create({ + data: { + type, + hex, + red: r, + green: g, + blue: b, + imageId: imageId, + }, + }); + } + + return await prisma.imageColor.findMany({ + where: { imageId: imageId } + }); +} \ No newline at end of file diff --git a/src/actions/images/generatePalette.ts b/src/actions/images/generatePalette.ts new file mode 100644 index 0000000..e69b0e6 --- /dev/null +++ b/src/actions/images/generatePalette.ts @@ -0,0 +1,58 @@ +"use server" + +import prisma from "@/lib/prisma"; +import { VibrantSwatch } from "@/types/VibrantSwatch"; +import { getImageBufferFromS3 } from "@/utils/getImageBufferFromS3"; +import { extractPaletteTones, rgbToHex, upsertPalettes } from "@/utils/uploadHelper"; +import { argbFromHex, themeFromSourceColor } from "@material/material-color-utilities"; +import { Vibrant } from "node-vibrant/node"; + +export async function generatePaletteAction(imageId: string, fileKey: string) { + const buffer = await getImageBufferFromS3(fileKey); + const palette = await Vibrant.from(buffer).getPalette(); + + const vibrantHexes = Object.fromEntries( + Object.entries(palette).map(([key, swatch]) => { + const castSwatch = swatch as VibrantSwatch | null; + const rgb = castSwatch?._rgb; + const hex = castSwatch?.hex || (rgb ? rgbToHex(rgb) : undefined); + return [key, hex]; + }) + ); + + const seedHex = + vibrantHexes.Vibrant ?? + vibrantHexes.Muted ?? + vibrantHexes.DarkVibrant ?? + vibrantHexes.DarkMuted ?? + vibrantHexes.LightVibrant ?? + vibrantHexes.LightMuted ?? + "#dfffff"; + + const theme = themeFromSourceColor(argbFromHex(seedHex)); + const primaryTones = extractPaletteTones(theme.palettes.primary); + const secondaryTones = extractPaletteTones(theme.palettes.secondary); + const tertiaryTones = extractPaletteTones(theme.palettes.tertiary); + const neutralTones = extractPaletteTones(theme.palettes.neutral); + const neutralVariantTones = extractPaletteTones(theme.palettes.neutralVariant); + const errorTones = extractPaletteTones(theme.palettes.error); + + await upsertPalettes(primaryTones, imageId, "primary"); + await upsertPalettes(secondaryTones, imageId, "secondary"); + await upsertPalettes(tertiaryTones, imageId, "tertiary"); + await upsertPalettes(neutralTones, imageId, "neutral"); + await upsertPalettes(neutralVariantTones, imageId, "neutralVariant"); + await upsertPalettes(errorTones, imageId, "error"); + + await prisma.themeSeed.create({ + data: { + seedHex, + imageId, + }, + }); + + return await prisma.colorPalette.findMany({ + where: { images: { some: { id: imageId } } }, + include: { items: true }, + }); +} \ No newline at end of file diff --git a/src/actions/images/uploadImage.ts b/src/actions/images/uploadImage.ts index d745a35..3073a29 100644 --- a/src/actions/images/uploadImage.ts +++ b/src/actions/images/uploadImage.ts @@ -3,14 +3,8 @@ import prisma from "@/lib/prisma"; import { s3 } from "@/lib/s3"; import { imageUploadSchema } from "@/schemas/images/imageSchema"; -import { VibrantSwatch } from "@/types/VibrantSwatch"; -import { extractPaletteTones, rgbToHex, upsertPalettes } from "@/utils/uploadHelper"; +import { determineBestWatermarkPosition } from "@/utils/determineWatermarkPosition"; import { PutObjectCommand } from "@aws-sdk/client-s3"; -import { argbFromHex, themeFromSourceColor } from "@material/material-color-utilities"; -import { extractColors } from "extract-colors"; -import getPixels from "get-pixels"; -import { NdArray } from "ndarray"; -import { Vibrant } from "node-vibrant/node"; import path from "path"; import sharp from "sharp"; import { v4 as uuidv4 } from 'uuid'; @@ -42,8 +36,6 @@ export async function uploadImage(values: z.infer) { const arrayBuffer = await imageFile.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); - const imageDataUrl = `data:${imageFile.type};base64,${buffer.toString("base64")}`; - const originalKey = `original/${fileKey}.webp`; const watermarkedKey = `watermarked/${fileKey}.webp`; const resizedKey = `resized/${fileKey}.webp`; @@ -52,47 +44,6 @@ export async function uploadImage(values: z.infer) { const sharpData = sharp(buffer); const metadata = await sharpData.metadata(); const stats = await sharpData.stats(); - - const palette = await Vibrant.from(buffer).getPalette(); - - const vibrantHexes = Object.fromEntries( - Object.entries(palette).map(([key, swatch]) => { - const castSwatch = swatch as VibrantSwatch | null; - const rgb = castSwatch?._rgb; - const hex = castSwatch?.hex || (rgb ? rgbToHex(rgb) : undefined); - return [key, hex]; - }) - ); - - const seedHex = - vibrantHexes.Vibrant ?? - vibrantHexes.Muted ?? - vibrantHexes.DarkVibrant ?? - vibrantHexes.DarkMuted ?? - vibrantHexes.LightVibrant ?? - vibrantHexes.LightMuted ?? - "#dfffff"; - - const theme = themeFromSourceColor(argbFromHex(seedHex)); - const primaryTones = extractPaletteTones(theme.palettes.primary); - const secondaryTones = extractPaletteTones(theme.palettes.secondary); - const tertiaryTones = extractPaletteTones(theme.palettes.tertiary); - const neutralTones = extractPaletteTones(theme.palettes.neutral); - const neutralVariantTones = extractPaletteTones(theme.palettes.neutralVariant); - const errorTones = extractPaletteTones(theme.palettes.error); - - const pixels = await new Promise>((resolve, reject) => { - getPixels(imageDataUrl, 'image/' + metadata.format || "image/jpeg", (err, pixels) => { - if (err) reject(err); - else resolve(pixels); - }); - }); - - const extracted = await extractColors({ - data: Array.from(pixels.data), - width: pixels.shape[0], - height: pixels.shape[1] - }); //--- Original file await s3.send( @@ -106,12 +57,13 @@ export async function uploadImage(values: z.infer) { //--- Watermarked file const watermarkPath = path.join(process.cwd(), 'public/watermark/fellies-watermark.svg'); const watermarkWidth = Math.round(metadata.width * 0.25); + const gravity = await determineBestWatermarkPosition(buffer); const watermarkBuffer = await sharp(watermarkPath) .resize({ width: watermarkWidth }) .png() .toBuffer(); const watermarkedBuffer = await sharp(buffer) - .composite([{ input: watermarkBuffer, gravity: 'southwest', blend: 'atop' }]) + .composite([{ input: watermarkBuffer, gravity, blend: 'over' }]) .toFormat('webp') .toBuffer() const watermarkedMetadata = await sharp(watermarkedBuffer).metadata(); @@ -164,11 +116,8 @@ export async function uploadImage(values: z.infer) { creationDate: lastModified, creationMonth: month, creationYear: year, - imageData: imageDataUrl, fileType: fileType, - fileSize: fileSize, - altText: "", - description: "", + fileSize: fileSize }, }); @@ -249,67 +198,5 @@ export async function uploadImage(values: z.infer) { ], }); - await upsertPalettes(primaryTones, image.id, "primary"); - await upsertPalettes(secondaryTones, image.id, "secondary"); - await upsertPalettes(tertiaryTones, image.id, "tertiary"); - await upsertPalettes(neutralTones, image.id, "neutral"); - await upsertPalettes(neutralVariantTones, image.id, "neutralVariant"); - await upsertPalettes(errorTones, image.id, "error"); - - for (const [type, hex] of Object.entries(vibrantHexes)) { - if (!hex) continue; - const [r, g, b] = hex.match(/\w\w/g)!.map((h) => parseInt(h, 16)); - await prisma.imageColor.create({ - data: { - type, - hex, - red: r, - green: g, - blue: b, - imageId: image.id, - }, - }); - } - - for (const c of extracted) { - await prisma.extractColor.create({ - data: { - hex: c.hex, - red: c.red, - green: c.green, - blue: c.blue, - hue: c.hue, - saturation: c.saturation, - // value: c.value, - area: c.area, - // isLight: c.isLight, - imageId: image.id, - }, - }); - } - - await prisma.themeSeed.create({ - data: { - seedHex, - imageId: image.id, - }, - }); - - await prisma.pixelSummary.create({ - data: { - width: pixels.shape[0], - height: pixels.shape[1], - channels: pixels.shape[2], - imageId: image.id, - }, - }); - return image - // return await prisma.gallery.create({ - // data: { - // name: values.name, - // slug: values.slug, - // description: values.description, - // } - // }) } \ No newline at end of file diff --git a/src/app/images/edit/[id]/page.tsx b/src/app/images/edit/[id]/page.tsx index ab401d4..358beb0 100644 --- a/src/app/images/edit/[id]/page.tsx +++ b/src/app/images/edit/[id]/page.tsx @@ -1,7 +1,8 @@ -import EditImageColors from "@/components/images/edit/EditImageColors"; import EditImageForm from "@/components/images/edit/EditImageForm"; -import EditImagePalettes from "@/components/images/edit/EditImagePalettes"; -import EditImageVariants from "@/components/images/edit/EditImageVariants"; +import ExtractColors from "@/components/images/edit/ExtractColors"; +import ImageColors from "@/components/images/edit/ImageColors"; +import ImagePalettes from "@/components/images/edit/ImagePalettes"; +import ImageVariants from "@/components/images/edit/ImageVariants"; import prisma from "@/lib/prisma"; export default async function ImagesEditPage({ params }: { params: { id: string } }) { @@ -38,16 +39,19 @@ export default async function ImagesEditPage({ params }: { params: { id: string
{image ? : 'Image not found...'} +
+ {image && } +
- {image && } + {image && }
- {image && } + {image && }
- {image && } + {image && }
diff --git a/src/components/images/edit/EditImageColors.tsx b/src/components/images/edit/EditImageColors.tsx deleted file mode 100644 index 526ae9a..0000000 --- a/src/components/images/edit/EditImageColors.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { ExtractColor, ImageColor } from "@/generated/prisma"; - -export default function EditImageColors({ extractColors, colors }: { extractColors: ExtractColor[], colors: ImageColor[] }) { - return ( - <> -

Extracted Colors

-
- {extractColors.map((color, index) => ( -
- ))} -
-

Image Colors

-
- {colors.map((color, index) => ( -
- ))} -
- - ); -} \ No newline at end of file diff --git a/src/components/images/edit/EditImageForm.tsx b/src/components/images/edit/EditImageForm.tsx index 6cd101b..3fffc3b 100644 --- a/src/components/images/edit/EditImageForm.tsx +++ b/src/components/images/edit/EditImageForm.tsx @@ -19,8 +19,8 @@ import { toast } from "sonner"; import * as z from "zod/v4"; type ImageWithItems = Image & { - album: Album, - artist: Artist, + album: Album | null, + artist: Artist | null, colors: ImageColor[], extractColors: ExtractColor[], metadata: ImageMetadata[], @@ -28,9 +28,11 @@ type ImageWithItems = Image & { stats: ImageStats[], theme: ThemeSeed[], variants: ImageVariant[], - palettes: ColorPalette[] & { - items: ColorPaletteItem[] - } + palettes: ( + ColorPalette & { + items: ColorPaletteItem[] + } + )[] }; export default function EditImageForm({ image, artists, albums }: { image: ImageWithItems, artists: Artist[], albums: Album[] }) { @@ -64,7 +66,6 @@ export default function EditImageForm({ image, artists, albums }: { image: Image const updatedImage = await updateImage(values, image.id) if (updatedImage) { toast.success("Image updated") - router.push(`/images`) } } diff --git a/src/components/images/edit/EditImagePalettes.tsx b/src/components/images/edit/EditImagePalettes.tsx deleted file mode 100644 index 07d8e8a..0000000 --- a/src/components/images/edit/EditImagePalettes.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { ColorPalette, ColorPaletteItem } from "@/generated/prisma"; - -type PaletteWithItems = ColorPalette & { - items: ColorPaletteItem[]; -}; - -export default function EditImagePalettes({ palettes }: { palettes: PaletteWithItems[] }) { - // console.log(JSON.stringify(palettes, null, 4)); - return ( - <> -

Palettes

-
- {palettes.map((palette) => ( -
-
{palette.type}
-
- {palette.items - .filter((item) => item.tone !== null && item.hex !== null) - .sort((a, b) => (a.tone ?? 0) - (b.tone ?? 0)) - .map((item) => ( -
- ))} -
-
- ))} -
- - ); -} \ No newline at end of file diff --git a/src/components/images/edit/ExtractColors.tsx b/src/components/images/edit/ExtractColors.tsx new file mode 100644 index 0000000..2077870 --- /dev/null +++ b/src/components/images/edit/ExtractColors.tsx @@ -0,0 +1,41 @@ +"use client" + +import { generateExtractColors } from "@/actions/images/generateExtractColors"; +import { Button } from "@/components/ui/button"; +import { ExtractColor } from "@/generated/prisma"; +import { useState, useTransition } from "react"; +import { toast } from "sonner"; + +export default function ExtractColors({ colors: initialColors, imageId, fileKey }: { colors: ExtractColor[], imageId: string, fileKey: string }) { + const [colors, setColors] = useState(initialColors); + const [isPending, startTransition] = useTransition(); + + const handleGenerate = () => { + startTransition(async () => { + try { + const newColors = await generateExtractColors(imageId, fileKey); + setColors(newColors); + toast.success("Colors extracted successfully"); + } catch (err) { + toast.error("Failed to extract colors"); + console.error(err); + } + }); + }; + + return ( + <> +
+

Extracted Colors

+ +
+
+ {colors.map((color, index) => ( +
+ ))} +
+ + ); +} \ No newline at end of file diff --git a/src/components/images/edit/ImageColors.tsx b/src/components/images/edit/ImageColors.tsx new file mode 100644 index 0000000..5b191d9 --- /dev/null +++ b/src/components/images/edit/ImageColors.tsx @@ -0,0 +1,47 @@ +"use client" + +import { generateImageColors } from "@/actions/images/generateImageColors"; +import { Button } from "@/components/ui/button"; +import { ImageColor } from "@/generated/prisma"; +import { useState, useTransition } from "react"; +import { toast } from "sonner"; + +export default function ImageColors({ colors: initialColors, imageId, fileKey }: { colors: ImageColor[], imageId: string, fileKey: string }) { + const [colors, setColors] = useState(initialColors); + const [isPending, startTransition] = useTransition(); + + const handleGenerate = () => { + startTransition(async () => { + try { + const newColors = await generateImageColors(imageId, fileKey); + setColors(newColors); + toast.success("Colors extracted successfully"); + } catch (err) { + toast.error("Failed to extract colors"); + console.error(err); + } + }); + }; + + return ( + <> +
+

Image Colors

+ +
+ {/*

Extracted Colors

+
+ {extractColors.map((color, index) => ( +
+ ))} +
*/} +
+ {colors.map((color, index) => ( +
+ ))} +
+ + ); +} \ No newline at end of file diff --git a/src/components/images/edit/ImagePalettes.tsx b/src/components/images/edit/ImagePalettes.tsx new file mode 100644 index 0000000..3cb9336 --- /dev/null +++ b/src/components/images/edit/ImagePalettes.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { generatePaletteAction } from "@/actions/images/generatePalette"; +import { Button } from "@/components/ui/button"; +import { ColorPalette, ColorPaletteItem } from "@/generated/prisma"; +import { useState, useTransition } from "react"; +import { toast } from "sonner"; + +type PaletteWithItems = ColorPalette & { + items: ColorPaletteItem[]; +}; + +export default function ImagePalettes({ palettes: initialPalettes, imageId, fileKey }: { palettes: PaletteWithItems[], imageId: string, fileKey: string }) { + const [palettes, setPalettes] = useState(initialPalettes); + const [isPending, startTransition] = useTransition(); + + const handleGenerate = () => { + startTransition(async () => { + try { + const newPalettes = await generatePaletteAction(imageId, fileKey); + setPalettes(newPalettes); + toast.success("Palette extracted successfully"); + } catch (err) { + toast.error("Failed to extract palette"); + console.error(err); + } + }); + }; + + return ( + <> +
+

Palettes

+ +
+ +
+ {palettes.map((palette) => ( + palette.type != 'error' ? +
+
{palette.type}
+
+ {palette.items + .filter((item) => item.tone !== null && item.hex !== null) + .sort((a, b) => (a.tone ?? 0) - (b.tone ?? 0)) + .map((item) => ( +
+ ))} +
+
+ : null + ))} +
+ + ); +} \ No newline at end of file diff --git a/src/components/images/edit/EditImageVariants.tsx b/src/components/images/edit/ImageVariants.tsx similarity index 63% rename from src/components/images/edit/EditImageVariants.tsx rename to src/components/images/edit/ImageVariants.tsx index d5a3c93..7aef266 100644 --- a/src/components/images/edit/EditImageVariants.tsx +++ b/src/components/images/edit/ImageVariants.tsx @@ -1,14 +1,15 @@ import { ImageVariant } from "@/generated/prisma"; +import { formatFileSize } from "@/utils/formatFileSize"; import NextImage from "next/image"; -export default function EditImageVariants({ variants }: { variants: ImageVariant[] }) { +export default function ImageVariants({ variants }: { variants: ImageVariant[] }) { return ( <>

Variants

{variants.map((variant) => (
-
{variant.type}
+
{variant.type} | {variant.width}x{variant.height}px | {variant.sizeBytes ? formatFileSize(variant.sizeBytes) : "-"}
{variant.s3Key && ( )} diff --git a/src/utils/determineWatermarkPosition.ts b/src/utils/determineWatermarkPosition.ts new file mode 100644 index 0000000..e2d14f3 --- /dev/null +++ b/src/utils/determineWatermarkPosition.ts @@ -0,0 +1,51 @@ +import sharp from "sharp"; + +export async function determineBestWatermarkPosition(imageBuffer: Buffer): Promise { + const positions = [ + { name: "center", left: 0.25, top: 0.25 }, + { name: "northwest", left: 0, top: 0 }, + { name: "northeast", left: 0.5, top: 0 }, + { name: "southwest", left: 0, top: 0.5 }, + { name: "southeast", left: 0.5, top: 0.5 }, + ] as const; + + const lowResSize = 64; + const image = sharp(imageBuffer).removeAlpha().greyscale(); + // const { width = lowResSize, height = lowResSize } = await image.metadata(); + + const resized = await image + .resize(lowResSize, lowResSize, { fit: "inside" }) + .raw() + .toBuffer(); + + const getBrightness = (x0: number, y0: number, boxW: number, boxH: number) => { + let sum = 0; + let count = 0; + for (let y = y0; y < y0 + boxH; y++) { + for (let x = x0; x < x0 + boxW; x++) { + const idx = y * lowResSize + x; + sum += resized[idx]; + count++; + } + } + return sum / count; + }; + + const boxW = Math.floor(lowResSize * 0.5); + const boxH = Math.floor(lowResSize * 0.5); + + let bestPos: sharp.Gravity = "center"; + let bestBrightness = -1; + + for (const pos of positions) { + const x0 = Math.floor(pos.left * lowResSize); + const y0 = Math.floor(pos.top * lowResSize); + const brightness = getBrightness(x0, y0, boxW, boxH); + if (brightness > bestBrightness) { + bestBrightness = brightness; + bestPos = pos.name; + } + } + + return bestPos; +} \ No newline at end of file diff --git a/src/utils/formatFileSize.ts b/src/utils/formatFileSize.ts new file mode 100644 index 0000000..51a4a8a --- /dev/null +++ b/src/utils/formatFileSize.ts @@ -0,0 +1,9 @@ +export function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + const kb = bytes / 1024; + if (kb < 1024) return `${kb.toFixed(1)} KB`; + const mb = kb / 1024; + if (mb < 1024) return `${mb.toFixed(1)} MB`; + const gb = mb / 1024; + return `${gb.toFixed(2)} GB`; +} \ No newline at end of file diff --git a/src/utils/getImageBufferFromS3.ts b/src/utils/getImageBufferFromS3.ts new file mode 100644 index 0000000..660e66a --- /dev/null +++ b/src/utils/getImageBufferFromS3.ts @@ -0,0 +1,20 @@ +import { s3 } from "@/lib/s3"; +import { GetObjectCommand } from "@aws-sdk/client-s3"; +import { Readable } from "stream"; + +export async function getImageBufferFromS3(fileKey: string): Promise { + const command = new GetObjectCommand({ + Bucket: "felliesartapp", + Key: `original/${fileKey}.webp`, + }); + + const response = await s3.send(command); + const stream = response.Body as Readable; + + const chunks: Uint8Array[] = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + + return Buffer.concat(chunks); +} \ No newline at end of file