diff --git a/.gitignore b/.gitignore index c621226..1b25401 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,7 @@ imports .vercel .env*.local + +/public/canvas +/public/pixels +public/pixels-metadata.json diff --git a/app/api/update-canvas/route.ts b/app/api/update-canvas/route.ts new file mode 100644 index 0000000..f1e4439 --- /dev/null +++ b/app/api/update-canvas/route.ts @@ -0,0 +1,330 @@ +import { NextRequest, NextResponse } from "next/server"; +import fs from "fs"; +import path from "path"; +import sharp from "sharp"; +import OpenAI from "openai"; +import axios from "axios"; +import { CuteArtStyle, stylePromptTemplates } from "@/lib/prompt-style"; + +const openai = new OpenAI({ + apiKey: process.env.OPENAI_API_KEY, +}); + +interface PixelData { + prompt: string; + timestamp: number; + position: { x: number; y: number }; + pixelUrl?: string; +} + +export async function POST(req: NextRequest) { + try { + const { prompt, style, x, y } = await req.json(); + + if (!prompt || x === undefined || y === undefined) { + return NextResponse.json( + { success: false, error: "Prompt, x, and y coordinates are required" }, + { status: 400 } + ); + } + + console.log("Processing placement request:", { prompt, style, x, y }); + + const timestamp = Date.now(); + const publicCanvasPath = path.join(process.cwd(), "public", "canvas"); + const pixelsMetadataPath = path.join( + process.cwd(), + "public", + "pixels-metadata.json" + ); + + // Ensure directories exist + if (!fs.existsSync(publicCanvasPath)) { + fs.mkdirSync(publicCanvasPath, { recursive: true }); + } + + // Canvas dimensions and cell calculation + const cellSize = 64; + const gridSize = 16; + const canvasWidth = gridSize * cellSize; + const canvasHeight = gridSize * cellSize; + + // Calculate cell position + const cellX = x * cellSize; + const cellY = y * cellSize; + + // File paths + const canvasFilePath = path.join( + publicCanvasPath, + `canvas-${timestamp}.png` + ); + const currentCanvasPath = path.join(publicCanvasPath, "current-canvas.png"); + const pixelFilePath = path.join( + publicCanvasPath, + `pixel-${x}-${y}-${timestamp}.png` + ); + + // Initialize metadata + let pixelsData: Record = {}; + if (fs.existsSync(pixelsMetadataPath)) { + const metadataContent = await fs.promises.readFile( + pixelsMetadataPath, + "utf8" + ); + pixelsData = JSON.parse(metadataContent); + } + + // Create initial canvas if it doesn't exist + if ( + !fs.existsSync(currentCanvasPath) || + req.nextUrl.searchParams.get("reset") === "true" + ) { + console.log("Creating initial canvas..."); + + try { + // Start with a simpler, less detailed landscape background + const response = await openai.images.generate({ + model: "dall-e-3", + prompt: `Create a highly detailed realistic landscape ${style} art style image of a plain with few trees and no other objects. Use a rich color palette with good definition. The image should be beautiful and immersive with clear details, but still function well as a background for other elements. Ensure the landscape has excellent definition while maintaining the distinct pixel art aesthetic.`, + n: 1, + size: "1024x1024", + }); + + if (response?.data?.[0]?.url) { + // Download the landscape + const imageResponse = await axios.get(response.data[0].url, { + responseType: "arraybuffer", + }); + + // Save the raw landscape + await fs.promises.writeFile( + currentCanvasPath, + Buffer.from(imageResponse.data) + ); + + // Add grid overlay + await sharp(currentCanvasPath) + .composite([ + { + input: Buffer.from(` + + + + + + + + + `), + blend: "over", + }, + ]) + .toFile(currentCanvasPath + ".tmp"); + + fs.renameSync(currentCanvasPath + ".tmp", currentCanvasPath); + console.log("Created initial canvas with DALL-E"); + + // Clear metadata if resetting + if (req.nextUrl.searchParams.get("reset") === "true") { + pixelsData = {}; + await fs.promises.writeFile( + pixelsMetadataPath, + JSON.stringify(pixelsData, null, 2) + ); + } + } + } catch (error) { + console.error("Failed to create initial canvas:", error); + // Fallback to a simple grid + await createGridCanvas( + canvasWidth, + canvasHeight, + gridSize, + cellSize + ).toFile(currentCanvasPath); + } + } + + // Add validation for style to ensure it exists in stylePromptTemplates + const validStyle = + style && Object.keys(stylePromptTemplates).includes(style) + ? (style as CuteArtStyle) + : "pixelArt"; // Default to pixelArt if invalid + + // Use the validated style + const styledPrompt = stylePromptTemplates[validStyle](prompt); + console.log("styledPrompt", styledPrompt); + const response = await openai.images.generate({ + model: "dall-e-3", + prompt: `Create ${styledPrompt}. ONLY the ${prompt} itself should be visible - no UI elements, no color palettes, no thumbnails, no multiple versions. Just a single isolated ${prompt} centered in the image.`, + n: 1, + size: "1024x1024", + }); + + if (!response?.data?.[0]?.url) { + throw new Error("Failed to generate image"); + } + + console.log("Downloading generated image..."); + const imageResponse = await axios.get(response.data[0].url, { + responseType: "arraybuffer", + }); + + // Save the raw pixel image + const rawPixelPath = path.join(publicCanvasPath, `raw-${timestamp}.png`); + await fs.promises.writeFile(rawPixelPath, Buffer.from(imageResponse.data)); + + console.log("Extracting main object from image..."); + await sharp(rawPixelPath) + .extract({ + left: Math.floor(1024 * 0.25), + top: Math.floor(1024 * 0.25), + width: Math.floor(1024 * 0.5), + height: Math.floor(1024 * 0.5), + }) + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }) + .then(({ data, info }) => { + const { width, height, channels } = info; + + const cornerSamples = [ + // Top-left + { r: data[0], g: data[1], b: data[2] }, + // Top-right + { + r: data[(width - 1) * channels], + g: data[(width - 1) * channels + 1], + b: data[(width - 1) * channels + 2], + }, + // Bottom-left + { + r: data[(height - 1) * width * channels], + g: data[(height - 1) * width * channels + 1], + b: data[(height - 1) * width * channels + 2], + }, + // Bottom-right + { + r: data[(height - 1) * width * channels + (width - 1) * channels], + g: data[ + (height - 1) * width * channels + (width - 1) * channels + 1 + ], + b: data[ + (height - 1) * width * channels + (width - 1) * channels + 2 + ], + }, + ]; + + // Find the most common corner color (simplified approach) + const bgColor = cornerSamples[0]; // Use first corner as reference + + for (let i = 0; i < width * height; i++) { + const r = data[i * channels]; + const g = data[i * channels + 1]; + const b = data[i * channels + 2]; + + // Very aggressive background detection - remove ALL light colors, grids, and patterns + const isBackground = + // Color is close to the sampled background color + (Math.abs(r - bgColor.r) < 30 && + Math.abs(g - bgColor.g) < 30 && + Math.abs(b - bgColor.b) < 30) || + // Very light colors (definitely background) + (r > 240 && g > 240 && b > 240); + + if (isBackground) { + // Make pixel transparent + data[i * channels + 3] = 0; + } else { + // Ensure object pixels are fully opaque + data[i * channels + 3] = 255; + } + } + + return sharp(data, { + raw: { width, height, channels }, + }) + .resize(cellSize - 10, cellSize - 10, { + fit: "inside", + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }) + .png() + .toFile(pixelFilePath); + }); + + // Place it on the canvas + console.log("Placing image on canvas..."); + await sharp(currentCanvasPath) + .composite([ + { + input: pixelFilePath, + top: cellY, + left: cellX, + }, + ]) + .toFile(canvasFilePath); + + // Update current canvas + fs.copyFileSync(canvasFilePath, currentCanvasPath); + + // Update metadata + pixelsData[`${x}-${y}`] = { + prompt, + timestamp, + position: { x, y }, + pixelUrl: `/canvas/pixel-${x}-${y}-${timestamp}.png`, + }; + + await fs.promises.writeFile( + pixelsMetadataPath, + JSON.stringify(pixelsData, null, 2) + ); + + console.log("Pixel successfully placed"); + return NextResponse.json({ + success: true, + canvasUrl: `/canvas/canvas-${timestamp}.png`, + pixelUrl: `/canvas/pixel-${x}-${y}-${timestamp}.png`, + coordinates: { x, y }, + }); + } catch (error) { + console.error("Error processing request:", error); + return NextResponse.json( + { success: false, error: (error as Error).message }, + { status: 500 } + ); + } +} + +// Helper function to create a grid canvas +function createGridCanvas( + width: number, + height: number, + gridSize: number, + cellSize: number +): sharp.Sharp { + // Create a visible grid background + return sharp({ + create: { + width: width, + height: height, + channels: 4, + background: { r: 240, g: 240, b: 245, alpha: 1 }, + }, + }).composite([ + { + input: Buffer.from(` + + + + + + + + + `), + top: 0, + left: 0, + }, + ]); +} diff --git a/app/components/ai-pixel-canvas.tsx b/app/components/ai-pixel-canvas.tsx index 106e6ae..852adb3 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,46 @@ export default function AIPixelCanvas() { } return ( -
-
- - -
- setSelectedSpace(null)} - onPurchaseSuccess={handlePurchaseSuccess} - /> +
+
+
+ {/* Use flex to position grid and panel side by side */} +
+ {/* Left side - Canvas and Grid */} +
+ {/* Canvas background image */} + + + {/* Grid overlay */} + +
+ + {/* Right side - Purchase Panel */} +
+ 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 bf1053f..b29622b 100644 --- a/app/components/pixel-grid.tsx +++ b/app/components/pixel-grid.tsx @@ -1,37 +1,148 @@ -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; import { PixelData } from "@/lib/pixel-types"; import { useCurrentBackgroundInfo } from "../hooks/pixel-hooks"; -type PixelGridProps = { + +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 backgroundImage = useCurrentBackgroundInfo(); - 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); + } + }; + + // Handle canvas update + const updateCanvas = 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(); + + 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 (
- {soldPercentage.toFixed(1)}% sold • + {soldPercentage?.toFixed(1)}% sold • price from:{" "} - {currentPrice.toFixed(2)} FLOW + {currentPrice?.toFixed(2)} FLOW
@@ -42,8 +153,8 @@ export default function PixelGrid({
(
handleCellClick(cell)} + onClick={() => handleLocalCellClick(cell)} /> ))}
diff --git a/app/components/purchase-panel.tsx b/app/components/purchase-panel.tsx index 64b27a8..28f4c05 100644 --- a/app/components/purchase-panel.tsx +++ b/app/components/purchase-panel.tsx @@ -1,154 +1,144 @@ "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 { useState } from "react"; 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 { CUTE_ART_STYLES, CUTE_ART_STYLE_LABELS } from "@/lib/prompt-style"; +import { CuteArtStyle } from "@/lib/prompt-style"; +import { useAcquirePixelSpace } from "@/app/hooks/pixel-hooks"; +import { recordNewCanvasBackgroundVersion } from "@/app/actions/canvas-background-actions"; + +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; + userId?: string; +} -export default function PurchasePanel({ +export function PurchasePanel({ selectedSpace, - currentPrice, onCancel, + currentPrice, + pixelPrice = currentPrice, onPurchaseSuccess, + onGenerate, + isUpdatingCanvas = false, + canvasUrl = null, + userId }: PurchasePanelProps) { const [prompt, setPrompt] = useState(""); - const [style, setStyle] = useState("pixelArt"); - const [imageURL, setImageURL] = useState(""); - const [isGenerating, setIsGenerating] = useState(false); + const [style, setStyle] = useState("pixel-art"); + const [imageGenerated, setImageGenerated] = useState(false); + const [showPreview, setShowPreview] = useState(false); + const [pixelUrl, setPixelUrl] = useState(null); 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, + isLoading: isAcquireLoading, + error: acquireError } = useAcquirePixelSpace({ - onSuccess: () => { - console.log("Pixel acquired successfully"); - - onPurchaseSuccess(); + onSuccess: (data) => { + if (onPurchaseSuccess) onPurchaseSuccess(); }, onError: (error) => { console.error("Error acquiring pixel:", error); - }, + } }); + 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); + setPixelUrl(null); + + try { + const result = await onGenerate(prompt, style); + + if (result && result.pixelUrl) { + setPixelUrl(result.pixelUrl); + } + setImageGenerated(true); + setShowPreview(true); + } catch (error) { + console.error("Error generating image:", error); } + }; + + const handlePurchase = async () => { + if (!selectedSpace || !pixelUrl) return; + setIsSubmitting(true); try { - setIsSubmitting(true); + const { x, y } = selectedSpace; + 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, + x, + y, + prompt: prompt || "", + style: style as CuteArtStyle, + imageURL: `${window.location.origin}${pixelUrl}`, + flowPaymentAmount: pixelPrice?.toString() || "10.0", + userId: userId || "", + imageMediaType: "image/png", + backendPaymentAmount: 0, }); + + + + if (onPurchaseSuccess) { + onPurchaseSuccess(); + } } catch (error) { - console.error("Error during pixel acquisition process:", error); + console.error("Error purchasing pixel:", error); } finally { setIsSubmitting(false); - onCancel(); } }; - 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 ( -
- - -
- ); - } - return ( -
-

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

+
+

Purchase Pixel

+

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

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

Preview:

- AI Generated Preview -
- )} -

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

-
+ +
-