From edc1cef5c6cd7760b530ed3eed50a02cd5b00b81 Mon Sep 17 00:00:00 2001 From: zzggo Date: Mon, 19 May 2025 15:05:48 +1000 Subject: [PATCH 1/8] feat: change canvas size --- app/components/pixel-grid.tsx | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/app/components/pixel-grid.tsx b/app/components/pixel-grid.tsx index b8ca102..9271acf 100644 --- a/app/components/pixel-grid.tsx +++ b/app/components/pixel-grid.tsx @@ -24,9 +24,8 @@ export default function PixelGrid({ }; return ( -
-
-

The Canvas

+
+
{(soldPercentage * 100).toFixed(1)}% @@ -37,22 +36,27 @@ export default function PixelGrid({
+
+ Click on any available white space to purchase +
+
{gridData.map((cell) => (
- -
- Click on any available white space to purchase -
); } From c3461a8ac71a96ff640bbf551356366ee14e7b1c Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Mon, 19 May 2025 18:47:56 +1000 Subject: [PATCH 2/8] Added key --- app/components/purchase-panel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/purchase-panel.tsx b/app/components/purchase-panel.tsx index cb4190f..64b27a8 100644 --- a/app/components/purchase-panel.tsx +++ b/app/components/purchase-panel.tsx @@ -166,7 +166,7 @@ export default function PurchasePanel({ onChange={(e) => setStyle(e.target.value as CuteArtStyle)} > {CUTE_ART_STYLES.map((artStyle: CuteArtStyle) => ( - ))} From 19660c2239d3cbb9227a8bf17b9116526e3c3b51 Mon Sep 17 00:00:00 2001 From: zzggo Date: Mon, 19 May 2025 21:03:45 +1000 Subject: [PATCH 3/8] feat: new update canvas api --- .gitignore | 3 + app/api/update-canvas/route.ts | 295 +++++++++++++++++++++++++ app/components/ai-pixel-canvas.tsx | 121 ++++++++-- app/components/pixel-grid.tsx | 262 +++++++++++++++++----- app/components/purchase-panel.tsx | 341 +++++++++++++---------------- 5 files changed, 754 insertions(+), 268 deletions(-) create mode 100644 app/api/update-canvas/route.ts diff --git a/.gitignore b/.gitignore index c621226..4ad5b79 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,6 @@ imports .vercel .env*.local + +/public/canvas +/public/pixels diff --git a/app/api/update-canvas/route.ts b/app/api/update-canvas/route.ts new file mode 100644 index 0000000..90e3464 --- /dev/null +++ b/app/api/update-canvas/route.ts @@ -0,0 +1,295 @@ +import { NextRequest, NextResponse } from "next/server"; +import fs from "fs"; +import path from "path"; +import sharp from "sharp"; +import OpenAI from "openai"; +import { generateStyledPrompt } from "@/lib/prompt-style"; +import axios from "axios"; + +// Initialize OpenAI client +const openai = new OpenAI({ + apiKey: process.env.OPENAI_API_KEY, +}); + +export async function POST(req: NextRequest) { + try { + const { prompt, style, x, y, previousCanvasUrl } = await req.json(); + + if (!prompt || x === undefined || y === undefined) { + return NextResponse.json( + { success: false, error: "Prompt, x, and y coordinates are required" }, + { status: 400 } + ); + } + + const timestamp = Date.now(); + const publicCanvasPath = path.join(process.cwd(), "public", "canvas"); + const publicPixelsPath = path.join(process.cwd(), "public", "pixels"); + + // Ensure directories exist + if (!fs.existsSync(publicCanvasPath)) { + fs.mkdirSync(publicCanvasPath, { recursive: true }); + } + if (!fs.existsSync(publicPixelsPath)) { + fs.mkdirSync(publicPixelsPath, { recursive: true }); + } + + // File paths for canvas and individual pixel + const canvasFilePath = path.join( + publicCanvasPath, + `canvas-${timestamp}.png` + ); + const pixelFilePath = path.join( + publicPixelsPath, + `pixel-${x}-${y}-${timestamp}.png` + ); + + // Canvas dimensions - 1024x1024 with 16x16 grid of 64px cells + const cellSize = 64; + const gridSize = 16; + const canvasWidth = 1024; + const canvasHeight = 1024; + + // Force generate a new background if none exists + let baseCanvas; + const generateNewBackground = + !previousCanvasUrl || !previousCanvasUrl.startsWith("/canvas/"); + + if (!generateNewBackground) { + const previousPath = path.join( + process.cwd(), + "public", + previousCanvasUrl.replace(/^\//, "") + ); + + if (fs.existsSync(previousPath)) { + baseCanvas = sharp(previousPath); + console.log("Using existing canvas:", previousPath); + } else { + console.log("Previous canvas not found, generating new background"); + baseCanvas = await createEmptyCanvas(canvasWidth, canvasHeight, prompt); + } + } else { + console.log("Generating new background with prompt:", prompt); + baseCanvas = await createEmptyCanvas(canvasWidth, canvasHeight, prompt); + } + + console.log("Saving canvas to:", canvasFilePath); + + let contextPrompt = prompt; + if (previousCanvasUrl) { + contextPrompt = `Continue the existing image at coordinates (${x}, ${y}) with: ${prompt}`; + } + + const pixelImageBuffer = await generatePixelImage( + contextPrompt, + style, + x, + y, + previousCanvasUrl + ); + + await sharp(pixelImageBuffer).toFile(pixelFilePath); + + const pixelUrl = `/pixels/pixel-${x}-${y}-${timestamp}.png`; + + const posX = x * cellSize; + const posY = y * cellSize; + + const resizedPixelBuffer = await sharp(pixelImageBuffer) + .resize(cellSize, cellSize, { + fit: "contain", + position: "center", + }) + .toBuffer(); + + await baseCanvas + .composite([ + { + input: resizedPixelBuffer, + top: posY, + left: posX, + }, + ]) + .toFile(canvasFilePath); + + const canvasUrl = `/canvas/canvas-${timestamp}.png`; + + return NextResponse.json({ + success: true, + message: "Canvas updated successfully", + canvasUrl, + pixelUrl, + coordinates: { x, y }, + }); + } catch (error) { + console.error("Canvas update failed:", error); + return NextResponse.json( + { success: false, error: (error as Error).message }, + { status: 500 } + ); + } +} + +/** + * Create an empty canvas with background content related to the prompt + */ +async function createEmptyCanvas( + width: number, + height: number, + userPrompt?: string +): Promise { + try { + const backgroundPrompt = userPrompt + ? `Create a background image containing ${userPrompt}.` + : "Create a background image with a landscape."; + + console.log("Generating background with prompt:", backgroundPrompt); + + const response = await openai.images.generate({ + model: "gpt-image-1", + prompt: backgroundPrompt, + n: 1, + size: "1024x1024", + }); + + if (!response.data || !response.data[0]) { + throw new Error("No background image generated by OpenAI"); + } + + // Get image data + let imageBuffer: Buffer; + if (response.data[0].url) { + const imageResponse = await axios.get(response.data[0].url, { + responseType: "arraybuffer", + }); + imageBuffer = Buffer.from(imageResponse.data); + } else if (response.data[0].b64_json) { + imageBuffer = Buffer.from(response.data[0].b64_json, "base64"); + } else { + throw new Error("No image data returned from OpenAI"); + } + + return sharp(imageBuffer).modulate({ + brightness: 1.3, + saturation: 0.4, + }); + } catch (error) { + console.error("Failed to create background canvas:", error); + + return sharp({ + create: { + width, + height, + channels: 4, + background: { r: 252, g: 252, b: 253, alpha: 1 }, + }, + }).composite([ + { + input: Buffer.from( + ` + + + + + + + + + + + + + ` + ), + top: 0, + left: 0, + }, + ]); + } +} + +/** + * Generate a pixel image based on the prompt using OpenAI + */ +async function generatePixelImage( + prompt: string, + style?: string, + x?: number, + y?: number, + previousCanvasUrl?: string | null +): Promise { + try { + // Map style string to expected format + const styleMap: { + [key: string]: + | "pixelArt" + | "chibi" + | "kawaiiPastel" + | "softBlob" + | "sanrio" + | "vinylToy" + | "storybook" + | "flatDesign" + | "y2kBubble" + | "crochetAmigurumi"; + } = { + "pixel-art": "pixelArt", + "kawaii-pastel": "kawaiiPastel", + "soft-blob": "softBlob", + "vinyl-toy": "vinylToy", + "flat-design": "flatDesign", + "y2k-bubble": "y2kBubble", + "crochet-amigurumi": "crochetAmigurumi", + }; + + // Map style or default to pixelArt if not found + const mappedStyle = style ? styleMap[style] || "pixelArt" : "pixelArt"; + + // Enhanced prompt with stronger continuity instructions + let enhancedPrompt = prompt; + + if (previousCanvasUrl && x !== undefined && y !== undefined) { + enhancedPrompt = `Create a pixel art image of: ${prompt}.`; + } else { + enhancedPrompt = `Create a vibrant, detailed pixel art image of: ${prompt}.`; + } + + if (style) { + enhancedPrompt = generateStyledPrompt(mappedStyle, enhancedPrompt); + } + + // Generate image using OpenAI + const response = await openai.images.generate({ + model: "gpt-image-1", + prompt: enhancedPrompt, + n: 1, + size: "1024x1024", + }); + + if (!response.data || !response.data[0]) { + throw new Error("No image generated by OpenAI"); + } + + // Get image data - either from URL or base64 + let imageBuffer: Buffer; + + if (response.data[0].url) { + // Download image from URL + const imageResponse = await axios.get(response.data[0].url, { + responseType: "arraybuffer", + }); + imageBuffer = Buffer.from(imageResponse.data); + } else if (response.data[0].b64_json) { + // Convert base64 to buffer + imageBuffer = Buffer.from(response.data[0].b64_json, "base64"); + } else { + throw new Error("No image data returned from OpenAI"); + } + + return imageBuffer; + } catch (error) { + console.error("Error generating pixel image with OpenAI:", error); + throw error; + } +} diff --git a/app/components/ai-pixel-canvas.tsx b/app/components/ai-pixel-canvas.tsx index 106e6ae..26044d5 100644 --- a/app/components/ai-pixel-canvas.tsx +++ b/app/components/ai-pixel-canvas.tsx @@ -2,8 +2,8 @@ import React, { useState, useEffect, useCallback } from "react"; import Header from "./header"; -import PixelGrid from "./pixel-grid"; -import PurchasePanel from "./purchase-panel"; +import { PixelGrid } from "./pixel-grid"; +import { PurchasePanel } from "./purchase-panel"; import { useCurrentFlowUser } from "@onflow/kit"; import { PixelData, CanvasOverview, PixelOnChainData } from "@/lib/pixel-types"; import { @@ -21,13 +21,15 @@ interface CanvasSectionParams { height: number; } -const DEFAULT_GRID_SIZE = 16; +const DEFAULT_GRID_SIZE = 16; // 1024/64 = 16 cells export default function AIPixelCanvas() { const [selectedSpace, setSelectedSpace] = useState( null ); const [gridSize, setGridSize] = useState(DEFAULT_GRID_SIZE); + const [canvasUrl, setCanvasUrl] = useState(null); + const [isUpdatingCanvas, setIsUpdatingCanvas] = useState(false); const { data: canvasOverview, @@ -99,6 +101,16 @@ export default function AIPixelCanvas() { } }, [gridParams, refetchPixelData, refetchAllPixels]); + // Load canvas URL from localStorage on mount + useEffect(() => { + if (typeof window !== 'undefined') { + const savedCanvasUrl = localStorage.getItem('canvasUrl'); + if (savedCanvasUrl) { + setCanvasUrl(savedCanvasUrl); + } + } + }, []); + const handlePurchaseSuccess = useCallback(async () => { console.log("Purchase successful, refreshing canvas data..."); await fetchCanvasOverview(); @@ -129,6 +141,47 @@ export default function AIPixelCanvas() { } }; + const handleGeneratePixel = async (prompt: string, style: string) => { + if (!selectedSpace) return; + + setIsUpdatingCanvas(true); + + try { + const response = await fetch('/api/update-canvas', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + prompt, + style, + x: selectedSpace.x, + y: selectedSpace.y, + previousCanvasUrl: canvasUrl + }) + }); + + if (!response.ok) { + throw new Error('Failed to update canvas'); + } + + const data = await response.json(); + console.log('Generated pixel:', data); + + // Update canvas URL with the new one from the response + if (data.canvasUrl) { + setCanvasUrl(data.canvasUrl); + // Save to localStorage for persistence + localStorage.setItem('canvasUrl', data.canvasUrl); + } + + // Return the data so the Purchase panel can access it + return data; + } catch (error) { + console.error('Error generating pixel:', error); + } finally { + setIsUpdatingCanvas(false); + } + }; + if (sectionError || overviewError) { return (
@@ -146,28 +199,52 @@ export default function AIPixelCanvas() { } return ( -
-
- - -
- +
+
+ {/* Canvas background image */} + {canvasUrl && ( +
+ )} + + {/* Grid overlay */} + setSelectedSpace(null)} - onPurchaseSuccess={handlePurchaseSuccess} + backgroundUrl={canvasUrl} /> + +
+ setSelectedSpace(null)} + onPurchaseSuccess={handlePurchaseSuccess} + onGenerate={handleGeneratePixel} + isUpdatingCanvas={isUpdatingCanvas} + canvasUrl={canvasUrl} + /> +
diff --git a/app/components/pixel-grid.tsx b/app/components/pixel-grid.tsx index 954b918..293ef2a 100644 --- a/app/components/pixel-grid.tsx +++ b/app/components/pixel-grid.tsx @@ -1,69 +1,225 @@ -import React, { useState } from "react"; -import { PixelData } from "@/lib/pixel-types"; +"use client"; -type PixelGridProps = { +import { useState, useEffect } from "react"; +import { PurchasePanel } from "./purchase-panel"; + +interface PixelGridProps { gridSize: number; - gridData: PixelData[]; - onCellClick: (cell: any) => void; - soldPercentage: number; - currentPrice: number; - selectedSpace: PixelData | null; -}; - -export default function PixelGrid({ - gridSize, - gridData, + gridData?: any[]; + onCellClick?: (cell: any) => void; + selectedSpace?: any; + setSelectedSpace: (space: any) => void; + soldPercentage?: number; + currentPrice?: number; + initialSoldPercentage?: number; + initialPrice?: number; + backgroundUrl?: string | null; +} + +export function PixelGrid({ + gridSize = 32, + gridData = [], onCellClick, + selectedSpace = null, + setSelectedSpace, soldPercentage, currentPrice, - selectedSpace, + initialSoldPercentage = 0, + initialPrice = 1.0, + backgroundUrl }: PixelGridProps) { - const handleCellClick = (cell: any) => { - console.log("handleCellClick", cell); - onCellClick(cell); + const [localGridData, setLocalGridData] = useState(gridData.length > 0 ? gridData : []); + const [localSoldPercentage, setLocalSoldPercentage] = useState(soldPercentage || initialSoldPercentage); + const [localCurrentPrice, setLocalCurrentPrice] = useState(currentPrice || initialPrice); + const [canvasUrl, setCanvasUrl] = useState(null); + const [isUpdatingCanvas, setIsUpdatingCanvas] = useState(false); + + // Load canvas URL from localStorage on mount + useEffect(() => { + if (typeof window !== 'undefined') { + const savedCanvasUrl = localStorage.getItem('canvasUrl'); + if (savedCanvasUrl) { + setCanvasUrl(savedCanvasUrl); + } + } + }, []); + + // Initialize grid data + useEffect(() => { + if (gridData.length === 0) { + // Create a grid of cells + const cells = Array.from({ length: gridSize * gridSize }, (_, index) => { + const x = index % gridSize; + const y = Math.floor(index / gridSize); + return { + id: `cell-${x}-${y}`, + x, + y, + ownerId: null, // null means available + }; + }); + + setLocalGridData(cells); + } + }, [gridSize, gridData]); + + // Handle cell click + const handleLocalCellClick = (cell: any) => { + if (onCellClick) { + onCellClick(cell); + } else { + if (cell.ownerId) { + // Already owned + return; + } + + setSelectedSpace(cell); + } }; - return ( -
-
-
- {soldPercentage.toFixed(1)}% sold • - price from:{" "} - {currentPrice.toFixed(2)} FLOW -
-
+ // Handle canvas update + const updateCanvas = async (prompt: string, style: string) => { + if (!selectedSpace) return; -
- Click on any available white space to purchase -
+ setIsUpdatingCanvas(true); + + try { + const response = await fetch('/api/update-canvas', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + prompt, + style, + x: selectedSpace.x, + y: selectedSpace.y, + previousCanvasUrl: canvasUrl + }) + }); + + if (!response.ok) { + throw new Error('Failed to update canvas'); + } -
+ const data = await response.json(); + + if (data.success) { + const newCanvasUrl = data.canvasUrl; + setCanvasUrl(newCanvasUrl); + localStorage.setItem('canvasUrl', newCanvasUrl); + + // Mark the cell as owned + setLocalGridData(prevGrid => + prevGrid.map(cell => { + if (cell.x === selectedSpace.x && cell.y === selectedSpace.y) { + return { + ...cell, + ownerId: 'current-user' // Replace with actual user ID + }; + } + return cell; + }) + ); + + // Update sold percentage + const newSoldPercentage = localSoldPercentage + (1 / (gridSize * gridSize)); + setLocalSoldPercentage(newSoldPercentage); + + // Increase price (example formula) + const newPrice = initialPrice * (1 + (newSoldPercentage * 0.5)); + setLocalCurrentPrice(newPrice); + } + } catch (error) { + console.error('Error updating canvas:', error); + } finally { + setIsUpdatingCanvas(false); + setSelectedSpace(null); + } + }; + + return ( +
+ {/* If you want to show the background image here too */} + {backgroundUrl && (
- {gridData.map((cell) => ( -
handleCellClick(cell)} - /> - ))} + /> + )} + +
+
+
+ + {(localSoldPercentage * 100).toFixed(1)}% + {" "} + sold • Current price:{" "} + {localCurrentPrice.toFixed(2)} FLOW per + cell +
+
+ +
+ Click on any available white space to purchase +
+ +
+ {/* Canvas background */} + {canvasUrl && ( +
+ Canvas +
+ )} + + {/* Loading overlay */} + {isUpdatingCanvas && ( +
+
+ + + + + Generating... +
+
+ )} + + {/* Grid overlay */} +
+ {localGridData.map((cell) => ( +
handleLocalCellClick(cell)} + /> + ))} +
diff --git a/app/components/purchase-panel.tsx b/app/components/purchase-panel.tsx index 64b27a8..ad6c269 100644 --- a/app/components/purchase-panel.tsx +++ b/app/components/purchase-panel.tsx @@ -1,233 +1,188 @@ "use client"; -import React, { useEffect, useState } from "react"; -import { Image, Camera, PlusSquare, Wallet } from "lucide-react"; -import { useFlowMutate } from "@onflow/kit"; -import { useCurrentFlowUser } from "@onflow/kit"; -import * as fcl from "@onflow/fcl"; -import { useAcquirePixelSpace, usePixelPrice } from "../hooks/pixel-hooks"; -import { PixelOnChainData } from "@/lib/pixel-types"; -import AIImageGenerator from "./ai-image-generator"; -import { - CUTE_ART_STYLE_LABELS, - CUTE_ART_STYLES, - CuteArtStyle, -} from "@/lib/prompt-style"; - -type PurchasePanelProps = { - selectedSpace: PixelOnChainData | null; - currentPrice: number; +import { useState } from "react"; + +interface GenerateResult { + canvasUrl?: string; + pixelUrl?: string; + success?: boolean; +} + +interface PurchasePanelProps { + selectedSpace: { x: number; y: number; id: number } | null; onCancel: () => void; - onPurchaseSuccess: () => void; -}; + currentPrice?: number; + pixelPrice?: number; + onPurchaseSuccess?: () => Promise; + onGenerate?: (prompt: string, style: string) => Promise; + isUpdatingCanvas?: boolean; + canvasUrl?: string | null; +} -export default function PurchasePanel({ +export function PurchasePanel({ selectedSpace, - currentPrice, onCancel, + currentPrice, + pixelPrice = currentPrice, onPurchaseSuccess, + onGenerate, + isUpdatingCanvas = false, + canvasUrl = null }: PurchasePanelProps) { const [prompt, setPrompt] = useState(""); - const [style, setStyle] = useState("pixelArt"); - const [imageURL, setImageURL] = useState(""); - const [isGenerating, setIsGenerating] = useState(false); - const [isSubmitting, setIsSubmitting] = useState(false); - const { user, authenticate, unauthenticate } = useCurrentFlowUser(); - - const x = selectedSpace?.x; - const y = selectedSpace?.y; - const { price: pixelPrice, refetch: refetchPixelPrice } = usePixelPrice({ - x: x, - y: y, - }); - useEffect(() => { - // refetch the pixel price when the space changes - refetchPixelPrice(); - }, [x, y, refetchPixelPrice]); - - const { - acquire, - isLoading: isAcquiringPixel, - error: acquirePixelError, - } = useAcquirePixelSpace({ - onSuccess: () => { - console.log("Pixel acquired successfully"); - - onPurchaseSuccess(); - }, - onError: (error) => { - console.error("Error acquiring pixel:", error); - }, - }); + const [style, setStyle] = useState("pixel-art"); + const [imageGenerated, setImageGenerated] = useState(false); + const [showPreview, setShowPreview] = useState(false); + const [pixelUrl, setPixelUrl] = useState(null); + + const handlePromptChange = (e: React.ChangeEvent) => { + setPrompt(e.target.value); + setImageGenerated(false); // Reset generated state when prompt changes + }; + + const handleStyleChange = (e: React.ChangeEvent) => { + setStyle(e.target.value); + setImageGenerated(false); // Reset generated state when style changes + }; const handleGenerate = async () => { - if (!selectedSpace || !user.loggedIn || !user.addr) { - console.error( - "User not logged in or address not available, or no space selected." - ); - return; - } + if (!prompt || !onGenerate) return; + setShowPreview(false); // Reset preview while generating + setPixelUrl(null); // Reset pixel URL try { - setIsSubmitting(true); - await acquire({ - x: selectedSpace.x, - y: selectedSpace.y, - prompt: prompt, - style: style, - imageURL: imageURL, - imageMediaType: "image/jpeg", - flowPaymentAmount: pixelPrice === null ? "0" : pixelPrice.toFixed(8), - backendPaymentAmount: pixelPrice === null ? 0 : pixelPrice, - userId: user.addr, - }); + const result = await onGenerate(prompt, style); + // Check if the result includes a pixelUrl + if (result && result.pixelUrl) { + setPixelUrl(result.pixelUrl); + } + setImageGenerated(true); + setShowPreview(true); } catch (error) { - console.error("Error during pixel acquisition process:", error); - } finally { - setIsSubmitting(false); - onCancel(); + console.error("Error generating image:", error); } }; - if (!selectedSpace) { - return ( -
- -

- Select a Space -

-

- Click on any available space on the canvas to purchase and create your - AI-generated image. -

-
- ); - } - - if (!user.loggedIn) { - return ( -
- - -
- ); - } + const handlePurchase = async () => { + if (!imageGenerated || !onPurchaseSuccess) return; + await onPurchaseSuccess(); + }; return ( -
-

- {selectedSpace.isTaken ? "This space is taken" : "Purchase this Space"} -

-
-
- {!imageURL && ( -
- -
- )} - {imageURL && ( -
-

Preview:

- AI Generated Preview -
- )} -

- Position: ({selectedSpace.x}, {selectedSpace.y}) -

-
-
+
+

Purchase Pixel

+

+ Position: ({selectedSpace?.x}, {selectedSpace?.y}) +

-
-