Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,17 @@
"dayjs": "^1.11.13",
"flatpickr": "^4.6.13",
"framer-motion": "^12.12.1",
"frog": "^0.18.3",
"gifenc": "^1.0.3",
"graphql": "^16.11.0",
"graphql-request": "^7.1.2",
"graphql-tag": "^2.12.6",
"hono": "^4.7.10",
"html-react-parser": "^5.2.5",
"ioredis": "^5.8.1",
"lanyard": "^1.1.2",
"lodash": "^4.17.21",
"multiformats": "^13.3.6",
"next": "^15.5.7",
"nextjs-progressbar": "^0.0.16",
"react": "^19.2.1",
Expand Down
279 changes: 260 additions & 19 deletions apps/web/src/pages/api/renderer/stack-images.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,250 @@
import { CACHE_TIMES } from '@buildeross/constants/cacheTimes'
import { getFetchableUrls } from '@buildeross/ipfs-service/gateway'
import { applyPalette, GIFEncoder, quantize } from 'gifenc'
import { NextApiRequest, NextApiResponse } from 'next'
import sharp from 'sharp'
import { withCors } from 'src/utils/api/cors'

const SVG_DEFAULT_SIZE = 1080
const REQUEST_TIMEOUT = 20000 // 20s

// Helper function to detect image format
const detectImageFormat = (buffer: Buffer): 'gif' | 'png' | 'svg' | 'unknown' => {
if (
buffer.length >= 3 &&
buffer[0] === 0x47 &&
buffer[1] === 0x49 &&
buffer[2] === 0x46
) {
return 'gif'
}
if (
buffer.length >= 8 &&
buffer[0] === 0x89 &&
buffer[1] === 0x50 &&
buffer[2] === 0x4e &&
buffer[3] === 0x47
) {
return 'png'
}
if (
buffer.toString('utf8', 0, 5) === '<?xml' ||
buffer.toString('utf8', 0, 4) === '<svg'
) {
return 'svg'
}
return 'unknown'
}

// Function to compose images - all images are the same type
const composeImages = async (
imageData: Buffer[]
): Promise<{ buffer: Buffer; contentType: string }> => {
// Detect format from first image (all are same type)
const format = detectImageFormat(imageData[0])

if (format === 'gif') {
// Handle GIFs - check for animation
const animationStatus = await Promise.all(
imageData.map(async (buffer, index) => {
try {
const metadata = await sharp(buffer).metadata()
const isAnimated = metadata.pages ? metadata.pages > 1 : false
return isAnimated
} catch (err) {
console.warn(`Error getting GIF metadata for image ${index}:`, err)
return false
}
})
)

const hasAnimatedGifs = animationStatus.some((isAnimated) => isAnimated)

if (hasAnimatedGifs) {
try {
// Prepare each layer as PNG with transparency for Sharp compositing
const layerPngs: Buffer[][] = []
const framesCounts: number[] = []

for (let i = 0; i < imageData.length; i++) {
if (animationStatus[i]) {
// Extract all frames from animated GIF as PNGs
const metadata = await sharp(imageData[i]).metadata()
const frameCount = metadata.pages || 1
framesCounts[i] = frameCount

const frames: Buffer[] = []
for (let frameIndex = 0; frameIndex < frameCount; frameIndex++) {
const framePng = await sharp(imageData[i], { page: frameIndex })
.resize(SVG_DEFAULT_SIZE, SVG_DEFAULT_SIZE, { fit: 'inside' })
.png()
.toBuffer()

Comment on lines +78 to +82
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Fix: Ensure uniform 1080x1080 canvas and correct GIF frame extraction

Current resize uses fit: 'inside', which produces variable frame dimensions. Later, GIFEncoder() is called with fixed SVG_DEFAULT_SIZE width/height, leading to mismatches. Also, extract frames with animated: true for reliability.

-              const framePng = await sharp(imageData[i], { page: frameIndex })
-                .resize(SVG_DEFAULT_SIZE, SVG_DEFAULT_SIZE, { fit: 'inside' })
-                .png()
-                .toBuffer()
+              const framePng = await sharp(imageData[i], { animated: true })
+                .extractFrame(frameIndex)
+                .resize(SVG_DEFAULT_SIZE, SVG_DEFAULT_SIZE, {
+                  fit: 'contain',
+                  background: { r: 0, g: 0, b: 0, alpha: 0 },
+                })
+                .png()
+                .toBuffer()
-            const staticPng = await sharp(imageData[i])
-              .resize(SVG_DEFAULT_SIZE, SVG_DEFAULT_SIZE, { fit: 'inside' })
-              .png()
-              .toBuffer()
+            const staticPng = await sharp(imageData[i])
+              .resize(SVG_DEFAULT_SIZE, SVG_DEFAULT_SIZE, {
+                fit: 'contain',
+                background: { r: 0, g: 0, b: 0, alpha: 0 },
+              })
+              .png()
+              .toBuffer()

Also applies to: 88-92

frames.push(framePng)
}

layerPngs[i] = frames
} else {
// Static GIF - convert to PNG
const staticPng = await sharp(imageData[i])
.resize(SVG_DEFAULT_SIZE, SVG_DEFAULT_SIZE, { fit: 'inside' })
.png()
.toBuffer()

layerPngs[i] = [staticPng]
framesCounts[i] = 1
}
}

// Find the maximum number of frames needed
const maxFrames = Math.max(...framesCounts)

// Check for reasonable frame count to prevent memory issues
if (maxFrames > 1000) {
throw new Error('Frame count exceeds maximum allowed (1000 frames)')
}

// Estimate memory usage and validate
const estimatedMemoryMB =
(maxFrames * imageData.length * SVG_DEFAULT_SIZE * SVG_DEFAULT_SIZE * 4) /
(1024 * 1024)
if (estimatedMemoryMB > 500) {
throw new Error(
`Estimated memory usage (${estimatedMemoryMB.toFixed(0)}MB) exceeds limit`
)
}

// Use Sharp to properly composite each frame with correct colors
const compositedFrames: Buffer[] = []

for (let frameIndex = 0; frameIndex < maxFrames; frameIndex++) {
// Get the base layer for this frame
const baseLayerIndex = 0
const baseFrameIndex = frameIndex % layerPngs[baseLayerIndex].length
let compositeImage = sharp(layerPngs[baseLayerIndex][baseFrameIndex])

// Build overlay list for Sharp composite
const overlays: sharp.OverlayOptions[] = []

for (let layerIndex = 1; layerIndex < imageData.length; layerIndex++) {
const layerFrameIndex = frameIndex % layerPngs[layerIndex].length
const layerFrame = layerPngs[layerIndex][layerFrameIndex]

overlays.push({
input: layerFrame,
gravity: 'center',
blend: 'over',
})
}

// Composite this frame using Sharp for accurate colors
const compositedFrame = await compositeImage
.composite(overlays)
.png()
.toBuffer()

compositedFrames.push(compositedFrame)
}

// Now convert the Sharp-composited PNG frames to an animated GIF

// Create GIF encoder
const encoder = GIFEncoder()

// Process all frames to get RGB data
const allRgbFrames: Uint8Array[] = []

for (const frame of compositedFrames) {
const rgbData = await sharp(frame).raw().toBuffer()
allRgbFrames.push(rgbData)
}

// Create global palette from all frames
const combinedRgb = new Uint8Array(
allRgbFrames.reduce((sum, frame) => sum + frame.length, 0)
)
let offset = 0
for (const frame of allRgbFrames) {
combinedRgb.set(frame, offset)
offset += frame.length
}

const globalPalette = quantize(combinedRgb, 256)

// Encode all frames
for (const rgbFrame of allRgbFrames) {
const indexedData = applyPalette(rgbFrame, globalPalette)

encoder.writeFrame(indexedData, SVG_DEFAULT_SIZE, SVG_DEFAULT_SIZE, {
palette: globalPalette,
delay: 100, // 100ms = 10fps
dispose: 2, // clear to background
})
}

encoder.finish()
const result = Buffer.from(encoder.bytesView())

Comment on lines +151 to +187
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Fix: Pass correct width/height to GIFEncoder, ensure RGBA, and set loop count

Encoder currently uses fixed SVG_DEFAULT_SIZE regardless of the actual frame size, which can corrupt output. Also add encoder.setRepeat(0) for infinite looping and ensure RGBA input to quantize/applyPalette.

-        // Create GIF encoder
-        const encoder = GIFEncoder()
+        // Create GIF encoder
+        const encoder = GIFEncoder()
+        // Loop forever
+        encoder.setRepeat(0)
 
-        // Process all frames to get RGB data
-        const allRgbFrames: Uint8Array[] = []
+        // Process all frames to get RGBA data and dimensions
+        const allRgbFrames: { data: Uint8Array; width: number; height: number }[] = []
 
-        for (const frame of compositedFrames) {
-          const rgbData = await sharp(frame).raw().toBuffer()
-          allRgbFrames.push(rgbData)
-        }
+        for (const frame of compositedFrames) {
+          const { data, info } = await sharp(frame)
+            .ensureAlpha()
+            .raw()
+            .toBuffer({ resolveWithObject: true })
+          // Avoid copying; wrap Buffer into a Uint8Array view
+          const view = new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
+          allRgbFrames.push({ data: view, width: info.width, height: info.height })
+        }
 
         // Create global palette from all frames
-        const combinedRgb = new Uint8Array(
-          allRgbFrames.reduce((sum, frame) => sum + frame.length, 0)
-        )
+        const combinedRgb = new Uint8Array(
+          allRgbFrames.reduce((sum, f) => sum + f.data.length, 0)
+        )
         let offset = 0
-        for (const frame of allRgbFrames) {
-          combinedRgb.set(frame, offset)
-          offset += frame.length
+        for (const f of allRgbFrames) {
+          combinedRgb.set(f.data, offset)
+          offset += f.data.length
         }
 
         const globalPalette = quantize(combinedRgb, 256)
 
         // Encode all frames
-        for (const rgbFrame of allRgbFrames) {
-          const indexedData = applyPalette(rgbFrame, globalPalette)
-
-          encoder.writeFrame(indexedData, SVG_DEFAULT_SIZE, SVG_DEFAULT_SIZE, {
+        for (const f of allRgbFrames) {
+          const indexedData = applyPalette(f.data, globalPalette)
+          encoder.writeFrame(indexedData, f.width, f.height, {
             palette: globalPalette,
             delay: 100, // 100ms = 10fps
             dispose: 2, // clear to background
           })
         }
 
         encoder.finish()
         const result = Buffer.from(encoder.bytesView())
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Create GIF encoder
const encoder = GIFEncoder()
// Process all frames to get RGB data
const allRgbFrames: Uint8Array[] = []
for (const frame of compositedFrames) {
const rgbData = await sharp(frame).raw().toBuffer()
allRgbFrames.push(rgbData)
}
// Create global palette from all frames
const combinedRgb = new Uint8Array(
allRgbFrames.reduce((sum, frame) => sum + frame.length, 0)
)
let offset = 0
for (const frame of allRgbFrames) {
combinedRgb.set(frame, offset)
offset += frame.length
}
const globalPalette = quantize(combinedRgb, 256)
// Encode all frames
for (const rgbFrame of allRgbFrames) {
const indexedData = applyPalette(rgbFrame, globalPalette)
encoder.writeFrame(indexedData, SVG_DEFAULT_SIZE, SVG_DEFAULT_SIZE, {
palette: globalPalette,
delay: 100, // 100ms = 10fps
dispose: 2, // clear to background
})
}
encoder.finish()
const result = Buffer.from(encoder.bytesView())
// Create GIF encoder
const encoder = GIFEncoder()
// Loop forever
encoder.setRepeat(0)
// Process all frames to get RGBA data and dimensions
const allRgbFrames: { data: Uint8Array; width: number; height: number }[] = []
for (const frame of compositedFrames) {
const { data, info } = await sharp(frame)
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true })
// Avoid copying; wrap Buffer into a Uint8Array view
const view = new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
allRgbFrames.push({ data: view, width: info.width, height: info.height })
}
// Create global palette from all frames
const combinedRgb = new Uint8Array(
allRgbFrames.reduce((sum, f) => sum + f.data.length, 0)
)
let offset = 0
for (const f of allRgbFrames) {
combinedRgb.set(f.data, offset)
offset += f.data.length
}
const globalPalette = quantize(combinedRgb, 256)
// Encode all frames
for (const f of allRgbFrames) {
const indexedData = applyPalette(f.data, globalPalette)
encoder.writeFrame(indexedData, f.width, f.height, {
palette: globalPalette,
delay: 100, // 100ms = 10fps
dispose: 2, // clear to background
})
}
encoder.finish()
const result = Buffer.from(encoder.bytesView())
🤖 Prompt for AI Agents
In apps/web/src/pages/api/renderer/stack-images.ts around lines 150-186, fix the
GIF encoding by creating the GIFEncoder with the actual frame width/height,
ensuring frames are RGBA before quantization, and setting the loop count to
infinite: obtain each frame's width/height (e.g., from sharp(frame).metadata()
or a known decoded size) and pass those values to GIFEncoder(width, height);
when reading pixels use sharp(frame).ensureAlpha().raw() so
quantize/applyPalette receive RGBA data; build the combinedRgb from RGBA buffers
accordingly; call encoder.setRepeat(0) before writing frames; and use the actual
width/height when calling encoder.writeFrame and when validating indexed frame
sizes.

return { buffer: result, contentType: 'image/gif' }
} catch (err) {
console.error('Frame-by-frame composition failed:', err)

// Fallback to single animated layer
const animatedIndex = animationStatus.findIndex((isAnimated) => isAnimated)

const fallbackBuffer = await sharp(imageData[animatedIndex], { animated: true })
.resize(SVG_DEFAULT_SIZE, SVG_DEFAULT_SIZE, { fit: 'inside' })
.gif({ loop: 0 })
.toBuffer()

return { buffer: fallbackBuffer, contentType: 'image/gif' }
}
} else {
// All GIFs are static
const resizedImages = await Promise.all(
imageData.map(async (buffer) =>
sharp(buffer)
.resize(SVG_DEFAULT_SIZE, SVG_DEFAULT_SIZE, { fit: 'inside' })
.gif()
.toBuffer()
)
)

const compositeParams = resizedImages.slice(1).map((buffer) => ({
input: buffer,
gravity: 'center' as const,
}))

const result = await sharp(resizedImages[0])
.composite(compositeParams)
.gif()
.toBuffer()
return { buffer: result, contentType: 'image/gif' }
Comment on lines +204 to +222
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Avoid double GIF encoding for static GIFs; composite as PNG then encode once

Each layer is first encoded to GIF, then decoded for composite, then encoded again to GIF, degrading quality and wasting CPU. Keep layers as PNG (RGBA) during resize/composite and GIF-encode only at the end.

-      const resizedImages = await Promise.all(
-        imageData.map(async (buffer) =>
-          sharp(buffer)
-            .resize(SVG_DEFAULT_SIZE, SVG_DEFAULT_SIZE, { fit: 'inside' })
-            .gif()
-            .toBuffer()
-        )
-      )
+      const resizedImages = await Promise.all(
+        imageData.map(async (buffer) =>
+          sharp(buffer)
+            .resize(SVG_DEFAULT_SIZE, SVG_DEFAULT_SIZE, {
+              fit: 'contain',
+              background: { r: 0, g: 0, b: 0, alpha: 0 },
+            })
+            .png()
+            .toBuffer()
+        )
+      )
 
       const compositeParams = resizedImages.slice(1).map((buffer) => ({
         input: buffer,
         gravity: 'center' as const,
       }))
 
       const result = await sharp(resizedImages[0])
         .composite(compositeParams)
         .gif()
         .toBuffer()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const resizedImages = await Promise.all(
imageData.map(async (buffer) =>
sharp(buffer)
.resize(SVG_DEFAULT_SIZE, SVG_DEFAULT_SIZE, { fit: 'inside' })
.gif()
.toBuffer()
)
)
const compositeParams = resizedImages.slice(1).map((buffer) => ({
input: buffer,
gravity: 'center' as const,
}))
const result = await sharp(resizedImages[0])
.composite(compositeParams)
.gif()
.toBuffer()
return { buffer: result, contentType: 'image/gif' }
const resizedImages = await Promise.all(
imageData.map(async (buffer) =>
sharp(buffer)
.resize(SVG_DEFAULT_SIZE, SVG_DEFAULT_SIZE, {
fit: 'contain',
background: { r: 0, g: 0, b: 0, alpha: 0 },
})
.png()
.toBuffer()
)
)
const compositeParams = resizedImages.slice(1).map((buffer) => ({
input: buffer,
gravity: 'center' as const,
}))
const result = await sharp(resizedImages[0])
.composite(compositeParams)
.gif()
.toBuffer()
return { buffer: result, contentType: 'image/gif' }

}
} else {
// Handle static images (PNG/SVG/other)
const resizedImages = await Promise.all(
imageData.map(async (buffer) =>
sharp(buffer)
.resize(SVG_DEFAULT_SIZE, SVG_DEFAULT_SIZE, { fit: 'inside' })
.toBuffer()
)
)

const compositeParams = resizedImages.slice(1).map((buffer) => ({
input: buffer,
gravity: 'center' as const,
}))

const result = await sharp(resizedImages[0])
.composite(compositeParams)
.webp({ quality: 75 })
.toBuffer()

return { buffer: result, contentType: 'image/webp' }
}
}

const handler = async (req: NextApiRequest, res: NextApiResponse) => {
let images = req.query.images

Expand All @@ -19,10 +257,27 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
'Cache-Control',
`public, s-maxage=${maxAge}, stale-while-revalidate=${swr}`
)
res.setHeader('Content-Type', 'image/webp')

// Handle HEAD request - return headers only
if (req.method === 'HEAD') {
if (typeof images === 'string') images = [images]

try {
// Get first image to detect format for content type
const firstImageData = await getImageData(images[0])
const format = detectImageFormat(firstImageData)

let contentType = 'image/webp' // default for static images
if (format === 'gif') {
contentType = 'image/gif'
}

res.setHeader('Content-Type', contentType)
} catch (err) {
// Fallback to default content type if image detection fails
res.setHeader('Content-Type', 'image/webp')
}

res.status(200).end()
return
}
Expand All @@ -33,24 +288,10 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
images.map((imageUrl) => getImageData(imageUrl))
)

// Resize all images to a default size
imageData = await Promise.all(
imageData.map(async (x) =>
sharp(x).resize(SVG_DEFAULT_SIZE, SVG_DEFAULT_SIZE, { fit: 'inside' }).toBuffer()
)
)

const compositeParams = imageData.slice(1).map((x) => ({
input: x,
gravity: 'center',
}))

const compositeRes = await sharp(imageData[0])
.composite(compositeParams)
.webp({ quality: 75 })
.toBuffer()

res.send(compositeRes)
// All images are the same type - handle composition based on detected format
const { buffer, contentType } = await composeImages(imageData)
res.setHeader('Content-Type', contentType)
res.send(buffer)
}

const getImageData = async (imageUrl: string): Promise<Buffer> => {
Expand Down
21 changes: 21 additions & 0 deletions apps/web/src/types/gifenc.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
declare module 'gifenc' {
export interface GIFEncoderInstance {
writeFrame(
indexedData: Uint8Array,
width: number,
height: number,
options?: {
palette?: Uint8Array[]
delay?: number
dispose?: number
transparent?: boolean | number
}
): void
finish(): void
bytesView(): Uint8Array
}

export function GIFEncoder(): GIFEncoderInstance
export function quantize(rgbData: Uint8Array, maxColors: number): Uint8Array[]
export function applyPalette(rgbData: Uint8Array, palette: Uint8Array[]): Uint8Array
}
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export const Artwork: React.FC<ArtworkProps> = ({ title }) => {
onChange={formik.handleChange}
onBlur={formik.handleBlur}
helperText={
'Builder uses folder hierarchy to organize your assets. Upload a single folder containing a subfolder for each trait. Each subfolder should contain every variant for that trait.\nMaximum directory size: 200MB\nSupported image types: PNG and SVG'
'Builder uses folder hierarchy to organize your assets. Upload a single folder containing a subfolder for each trait. Each subfolder should contain every variant for that trait.\nMaximum directory size: 200MB\nSupported image types: PNG, SVG, and GIF'
}
errorMessage={
formik.touched.artwork && formik.errors?.artwork
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ export const ArtworkUpload: React.FC<ArtworkFormProps> = ({
if (!fileInfo || !filesArray || !fileInfo.traits || !formik || uploadArtworkError)
return

setOrderedLayers([])
setSetUpArtwork({
...formik.values,
artwork: fileInfo.traits,
Expand Down
Loading
Loading