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`;
 | 
			
		||||
@ -52,47 +44,6 @@ export async function uploadImage(values: z.infer<typeof imageUploadSchema>) {
 | 
			
		||||
  const sharpData = sharp(buffer);
 | 
			
		||||
  const metadata = await sharpData.metadata();
 | 
			
		||||
  const stats = await sharpData.stats();
 | 
			
		||||
 | 
			
		||||
  const palette = await Vibrant.from(buffer).getPalette();
 | 
			
		||||
  
 | 
			
		||||
  const vibrantHexes = Object.fromEntries(
 | 
			
		||||
    Object.entries(palette).map(([key, swatch]) => {
 | 
			
		||||
      const castSwatch = swatch as VibrantSwatch | null;
 | 
			
		||||
      const rgb = castSwatch?._rgb;
 | 
			
		||||
      const hex = castSwatch?.hex || (rgb ? rgbToHex(rgb) : undefined);
 | 
			
		||||
      return [key, hex];
 | 
			
		||||
    })
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const seedHex = 
 | 
			
		||||
    vibrantHexes.Vibrant ??
 | 
			
		||||
    vibrantHexes.Muted ??
 | 
			
		||||
    vibrantHexes.DarkVibrant ??
 | 
			
		||||
    vibrantHexes.DarkMuted ??
 | 
			
		||||
    vibrantHexes.LightVibrant ??
 | 
			
		||||
    vibrantHexes.LightMuted ??
 | 
			
		||||
    "#dfffff";
 | 
			
		||||
 | 
			
		||||
  const theme = themeFromSourceColor(argbFromHex(seedHex));
 | 
			
		||||
  const primaryTones = extractPaletteTones(theme.palettes.primary);
 | 
			
		||||
  const secondaryTones = extractPaletteTones(theme.palettes.secondary);
 | 
			
		||||
  const tertiaryTones = extractPaletteTones(theme.palettes.tertiary);
 | 
			
		||||
  const neutralTones = extractPaletteTones(theme.palettes.neutral);
 | 
			
		||||
  const neutralVariantTones = extractPaletteTones(theme.palettes.neutralVariant);
 | 
			
		||||
  const errorTones = extractPaletteTones(theme.palettes.error);
 | 
			
		||||
 | 
			
		||||
  const pixels = await new Promise<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(
 | 
			
		||||
@ -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