Change image edit to recreate palettes

This commit is contained in:
2025-06-28 00:12:24 +02:00
parent c7a9c68605
commit 9f0807b0fc
16 changed files with 737 additions and 185 deletions

View File

@ -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<typeof imageUploadSchema>) {
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<NdArray<Uint8Array>>((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,
// }
// })
}

View File

@ -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<NdArray<Uint8Array>>((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 }
});
}

View File

@ -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 }
});
}

View File

@ -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 },
});
}

View File

@ -3,14 +3,8 @@
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { s3 } from "@/lib/s3"; import { s3 } from "@/lib/s3";
import { imageUploadSchema } from "@/schemas/images/imageSchema"; import { imageUploadSchema } from "@/schemas/images/imageSchema";
import { VibrantSwatch } from "@/types/VibrantSwatch"; import { determineBestWatermarkPosition } from "@/utils/determineWatermarkPosition";
import { extractPaletteTones, rgbToHex, upsertPalettes } from "@/utils/uploadHelper";
import { PutObjectCommand } from "@aws-sdk/client-s3"; 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 path from "path";
import sharp from "sharp"; import sharp from "sharp";
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
@ -42,8 +36,6 @@ export async function uploadImage(values: z.infer<typeof imageUploadSchema>) {
const arrayBuffer = await imageFile.arrayBuffer(); const arrayBuffer = await imageFile.arrayBuffer();
const buffer = Buffer.from(arrayBuffer); const buffer = Buffer.from(arrayBuffer);
const imageDataUrl = `data:${imageFile.type};base64,${buffer.toString("base64")}`;
const originalKey = `original/${fileKey}.webp`; const originalKey = `original/${fileKey}.webp`;
const watermarkedKey = `watermarked/${fileKey}.webp`; const watermarkedKey = `watermarked/${fileKey}.webp`;
const resizedKey = `resized/${fileKey}.webp`; const resizedKey = `resized/${fileKey}.webp`;
@ -53,47 +45,6 @@ export async function uploadImage(values: z.infer<typeof imageUploadSchema>) {
const metadata = await sharpData.metadata(); const metadata = await sharpData.metadata();
const stats = await sharpData.stats(); 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<NdArray<Uint8Array>>((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 //--- Original file
await s3.send( await s3.send(
new PutObjectCommand({ new PutObjectCommand({
@ -106,12 +57,13 @@ export async function uploadImage(values: z.infer<typeof imageUploadSchema>) {
//--- Watermarked file //--- Watermarked file
const watermarkPath = path.join(process.cwd(), 'public/watermark/fellies-watermark.svg'); const watermarkPath = path.join(process.cwd(), 'public/watermark/fellies-watermark.svg');
const watermarkWidth = Math.round(metadata.width * 0.25); const watermarkWidth = Math.round(metadata.width * 0.25);
const gravity = await determineBestWatermarkPosition(buffer);
const watermarkBuffer = await sharp(watermarkPath) const watermarkBuffer = await sharp(watermarkPath)
.resize({ width: watermarkWidth }) .resize({ width: watermarkWidth })
.png() .png()
.toBuffer(); .toBuffer();
const watermarkedBuffer = await sharp(buffer) const watermarkedBuffer = await sharp(buffer)
.composite([{ input: watermarkBuffer, gravity: 'southwest', blend: 'atop' }]) .composite([{ input: watermarkBuffer, gravity, blend: 'over' }])
.toFormat('webp') .toFormat('webp')
.toBuffer() .toBuffer()
const watermarkedMetadata = await sharp(watermarkedBuffer).metadata(); const watermarkedMetadata = await sharp(watermarkedBuffer).metadata();
@ -164,11 +116,8 @@ export async function uploadImage(values: z.infer<typeof imageUploadSchema>) {
creationDate: lastModified, creationDate: lastModified,
creationMonth: month, creationMonth: month,
creationYear: year, creationYear: year,
imageData: imageDataUrl,
fileType: fileType, fileType: fileType,
fileSize: fileSize, fileSize: fileSize
altText: "",
description: "",
}, },
}); });
@ -249,67 +198,5 @@ export async function uploadImage(values: z.infer<typeof imageUploadSchema>) {
], ],
}); });
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 image
// return await prisma.gallery.create({
// data: {
// name: values.name,
// slug: values.slug,
// description: values.description,
// }
// })
} }

View File

@ -1,7 +1,8 @@
import EditImageColors from "@/components/images/edit/EditImageColors";
import EditImageForm from "@/components/images/edit/EditImageForm"; import EditImageForm from "@/components/images/edit/EditImageForm";
import EditImagePalettes from "@/components/images/edit/EditImagePalettes"; import ExtractColors from "@/components/images/edit/ExtractColors";
import EditImageVariants from "@/components/images/edit/EditImageVariants"; 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"; import prisma from "@/lib/prisma";
export default async function ImagesEditPage({ params }: { params: { id: string } }) { export default async function ImagesEditPage({ params }: { params: { id: string } }) {
@ -38,16 +39,19 @@ export default async function ImagesEditPage({ params }: { params: { id: string
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div> <div>
{image ? <EditImageForm image={image} artists={artists} albums={albums} /> : 'Image not found...'} {image ? <EditImageForm image={image} artists={artists} albums={albums} /> : 'Image not found...'}
<div>
{image && <ImageVariants variants={image.variants} />}
</div>
</div> </div>
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
{image && <EditImageColors extractColors={image.extractColors} colors={image.colors} />} {image && <ImagePalettes palettes={image.palettes} imageId={image.id} fileKey={image.fileKey} />}
</div> </div>
<div> <div>
{image && <EditImagePalettes palettes={image.palettes} />} {image && <ImageColors colors={image.colors} imageId={image.id} fileKey={image.fileKey} />}
</div> </div>
<div> <div>
{image && <EditImageVariants variants={image.variants} />} {image && <ExtractColors colors={image.extractColors} imageId={image.id} fileKey={image.fileKey} />}
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,20 +0,0 @@
import { ExtractColor, ImageColor } from "@/generated/prisma";
export default function EditImageColors({ extractColors, colors }: { extractColors: ExtractColor[], colors: ImageColor[] }) {
return (
<>
<h2 className="font-semibold text-lg mb-2">Extracted Colors</h2>
<div className="flex flex-wrap gap-2">
{extractColors.map((color, index) => (
<div key={index} className="w-10 h-10 rounded" style={{ backgroundColor: color.hex }} title={color.hex}></div>
))}
</div>
<h2 className="font-semibold text-lg mb-2">Image Colors</h2>
<div className="flex flex-wrap gap-2">
{colors.map((color, index) => (
<div key={index} className="w-10 h-10 rounded" style={{ backgroundColor: color.hex ?? "#000000" }} title={`Tone ${color.type} - ${color.hex}`}></div>
))}
</div>
</>
);
}

View File

@ -19,8 +19,8 @@ import { toast } from "sonner";
import * as z from "zod/v4"; import * as z from "zod/v4";
type ImageWithItems = Image & { type ImageWithItems = Image & {
album: Album, album: Album | null,
artist: Artist, artist: Artist | null,
colors: ImageColor[], colors: ImageColor[],
extractColors: ExtractColor[], extractColors: ExtractColor[],
metadata: ImageMetadata[], metadata: ImageMetadata[],
@ -28,9 +28,11 @@ type ImageWithItems = Image & {
stats: ImageStats[], stats: ImageStats[],
theme: ThemeSeed[], theme: ThemeSeed[],
variants: ImageVariant[], variants: ImageVariant[],
palettes: ColorPalette[] & { palettes: (
ColorPalette & {
items: ColorPaletteItem[] items: ColorPaletteItem[]
} }
)[]
}; };
export default function EditImageForm({ image, artists, albums }: { image: ImageWithItems, artists: Artist[], albums: Album[] }) { 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) const updatedImage = await updateImage(values, image.id)
if (updatedImage) { if (updatedImage) {
toast.success("Image updated") toast.success("Image updated")
router.push(`/images`) router.push(`/images`)
} }
} }

View File

@ -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 (
<>
<h2 className="font-semibold text-lg mb-2">Palettes</h2>
<div className="space-y-4">
{palettes.map((palette) => (
<div key={palette.id}>
<div className="text-sm font-medium mb-1">{palette.type}</div>
<div className="flex gap-1">
{palette.items
.filter((item) => item.tone !== null && item.hex !== null)
.sort((a, b) => (a.tone ?? 0) - (b.tone ?? 0))
.map((item) => (
<div
key={item.id}
className="w-6 h-6 rounded"
style={{ backgroundColor: item.hex ?? "#000000" }}
title={`Tone ${item.tone} - ${item.hex}`}
/>
))}
</div>
</div>
))}
</div>
</>
);
}

View File

@ -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 (
<>
<div className="flex items-center justify-between mb-2">
<h2 className="font-semibold text-lg">Extracted Colors</h2>
<Button size="sm" onClick={handleGenerate} disabled={isPending}>
{isPending ? "Extracting..." : "Generate Palette"}
</Button>
</div >
<div className="flex flex-wrap gap-2">
{colors.map((color, index) => (
<div key={index} className="w-10 h-10 rounded" style={{ backgroundColor: color.hex }} title={color.hex}></div>
))}
</div>
</>
);
}

View File

@ -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 (
<>
<div className="flex items-center justify-between mb-2">
<h2 className="font-semibold text-lg">Image Colors</h2>
<Button size="sm" onClick={handleGenerate} disabled={isPending}>
{isPending ? "Extracting..." : "Generate Palette"}
</Button>
</div >
{/* <h2 className="font-semibold text-lg mb-2">Extracted Colors</h2>
<div className="flex flex-wrap gap-2">
{extractColors.map((color, index) => (
<div key={index} className="w-10 h-10 rounded" style={{ backgroundColor: color.hex }} title={color.hex}></div>
))}
</div> */}
<div className="flex flex-wrap gap-2">
{colors.map((color, index) => (
<div key={index} className="w-10 h-10 rounded" style={{ backgroundColor: color.hex ?? "#000000" }} title={`Tone ${color.type} - ${color.hex}`}></div>
))}
</div>
</>
);
}

View File

@ -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 (
<>
<div className="flex items-center justify-between mb-2">
<h2 className="font-semibold text-lg">Palettes</h2>
<Button size="sm" onClick={handleGenerate} disabled={isPending}>
{isPending ? "Extracting..." : "Generate Palette"}
</Button>
</div >
<div className="space-y-4">
{palettes.map((palette) => (
palette.type != 'error' ?
<div key={palette.id}>
<div className="text-sm font-medium mb-1">{palette.type}</div>
<div className="flex gap-1">
{palette.items
.filter((item) => item.tone !== null && item.hex !== null)
.sort((a, b) => (a.tone ?? 0) - (b.tone ?? 0))
.map((item) => (
<div
key={item.id}
className="w-6 h-6 rounded"
style={{ backgroundColor: item.hex ?? "#000000" }}
title={`Tone ${item.tone} - ${item.hex}`}
/>
))}
</div>
</div>
: null
))}
</div>
</>
);
}

View File

@ -1,14 +1,15 @@
import { ImageVariant } from "@/generated/prisma"; import { ImageVariant } from "@/generated/prisma";
import { formatFileSize } from "@/utils/formatFileSize";
import NextImage from "next/image"; import NextImage from "next/image";
export default function EditImageVariants({ variants }: { variants: ImageVariant[] }) { export default function ImageVariants({ variants }: { variants: ImageVariant[] }) {
return ( return (
<> <>
<h2 className="font-semibold text-lg mb-2">Variants</h2> <h2 className="font-semibold text-lg mb-2">Variants</h2>
<div> <div>
{variants.map((variant) => ( {variants.map((variant) => (
<div key={variant.id}> <div key={variant.id}>
<div className="text-sm mb-1">{variant.type}</div> <div className="text-sm mb-1">{variant.type} | {variant.width}x{variant.height}px | {variant.sizeBytes ? formatFileSize(variant.sizeBytes) : "-"}</div>
{variant.s3Key && ( {variant.s3Key && (
<NextImage src={`/api/image/${variant.s3Key}`} alt={variant.s3Key} width={variant.width} height={variant.height} className="rounded shadow max-w-md" /> <NextImage src={`/api/image/${variant.s3Key}`} alt={variant.s3Key} width={variant.width} height={variant.height} className="rounded shadow max-w-md" />
)} )}

View File

@ -0,0 +1,51 @@
import sharp from "sharp";
export async function determineBestWatermarkPosition(imageBuffer: Buffer): Promise<sharp.Gravity> {
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;
}

View File

@ -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`;
}

View File

@ -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<Buffer> {
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);
}