Change image edit to recreate palettes
This commit is contained in:
330
src/actions/images/extractColors.ts
Normal file
330
src/actions/images/extractColors.ts
Normal 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,
|
||||||
|
// }
|
||||||
|
// })
|
||||||
|
}
|
54
src/actions/images/generateExtractColors.ts
Normal file
54
src/actions/images/generateExtractColors.ts
Normal 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 }
|
||||||
|
});
|
||||||
|
}
|
40
src/actions/images/generateImageColors.ts
Normal file
40
src/actions/images/generateImageColors.ts
Normal 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 }
|
||||||
|
});
|
||||||
|
}
|
58
src/actions/images/generatePalette.ts
Normal file
58
src/actions/images/generatePalette.ts
Normal 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 },
|
||||||
|
});
|
||||||
|
}
|
@ -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`;
|
||||||
@ -52,47 +44,6 @@ export async function uploadImage(values: z.infer<typeof imageUploadSchema>) {
|
|||||||
const sharpData = sharp(buffer);
|
const sharpData = sharp(buffer);
|
||||||
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(
|
||||||
@ -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,
|
|
||||||
// }
|
|
||||||
// })
|
|
||||||
}
|
}
|
@ -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>
|
||||||
|
@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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: (
|
||||||
items: ColorPaletteItem[]
|
ColorPalette & {
|
||||||
}
|
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`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
41
src/components/images/edit/ExtractColors.tsx
Normal file
41
src/components/images/edit/ExtractColors.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
47
src/components/images/edit/ImageColors.tsx
Normal file
47
src/components/images/edit/ImageColors.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
63
src/components/images/edit/ImagePalettes.tsx
Normal file
63
src/components/images/edit/ImagePalettes.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -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" />
|
||||||
)}
|
)}
|
51
src/utils/determineWatermarkPosition.ts
Normal file
51
src/utils/determineWatermarkPosition.ts
Normal 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;
|
||||||
|
}
|
9
src/utils/formatFileSize.ts
Normal file
9
src/utils/formatFileSize.ts
Normal 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`;
|
||||||
|
}
|
20
src/utils/getImageBufferFromS3.ts
Normal file
20
src/utils/getImageBufferFromS3.ts
Normal 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);
|
||||||
|
}
|
Reference in New Issue
Block a user