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 { 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<typeof imageUploadSchema>) {
|
||||
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`;
|
||||
@ -53,47 +45,6 @@ export async function uploadImage(values: z.infer<typeof imageUploadSchema>) {
|
||||
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<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({
|
||||
@ -106,12 +57,13 @@ export async function uploadImage(values: z.infer<typeof imageUploadSchema>) {
|
||||
//--- 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<typeof imageUploadSchema>) {
|
||||
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<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 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 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
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<div>
|
||||
{image ? <EditImageForm image={image} artists={artists} albums={albums} /> : 'Image not found...'}
|
||||
<div>
|
||||
{image && <ImageVariants variants={image.variants} />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
{image && <EditImageColors extractColors={image.extractColors} colors={image.colors} />}
|
||||
{image && <ImagePalettes palettes={image.palettes} imageId={image.id} fileKey={image.fileKey} />}
|
||||
</div>
|
||||
<div>
|
||||
{image && <EditImagePalettes palettes={image.palettes} />}
|
||||
{image && <ImageColors colors={image.colors} imageId={image.id} fileKey={image.fileKey} />}
|
||||
</div>
|
||||
<div>
|
||||
{image && <EditImageVariants variants={image.variants} />}
|
||||
{image && <ExtractColors colors={image.extractColors} imageId={image.id} fileKey={image.fileKey} />}
|
||||
</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";
|
||||
|
||||
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`)
|
||||
}
|
||||
}
|
||||
|
@ -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 { formatFileSize } from "@/utils/formatFileSize";
|
||||
import NextImage from "next/image";
|
||||
|
||||
export default function EditImageVariants({ variants }: { variants: ImageVariant[] }) {
|
||||
export default function ImageVariants({ variants }: { variants: ImageVariant[] }) {
|
||||
return (
|
||||
<>
|
||||
<h2 className="font-semibold text-lg mb-2">Variants</h2>
|
||||
<div>
|
||||
{variants.map((variant) => (
|
||||
<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 && (
|
||||
<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