├── .eslintrc.json ├── jsconfig.json ├── .gitattributes ├── public ├── teaser.jpg ├── favicon.ico └── opengraph.jpg ├── postcss.config.js ├── .env.example ├── pages ├── _app.js ├── api │ └── predictions │ │ ├── [id].js │ │ └── index.js ├── about.js └── index.js ├── .github └── workflows │ └── test.yml ├── .gitignore ├── tailwind.config.js ├── components ├── message.js ├── dropzone.js ├── prompt-form.js ├── footer.js └── messages.js ├── next.config.js ├── lib ├── seeds.js └── prepare-image-file-for-upload.js ├── package.json ├── styles └── globals.css ├── LICENSE └── README.md /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "." 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /public/teaser.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/replicate/paint-by-text/HEAD/public/teaser.jpg -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/replicate/paint-by-text/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/opengraph.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/replicate/paint-by-text/HEAD/public/opengraph.jpg -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Find your Replicate API token at https://www.replicate.com/account 2 | REPLICATE_API_TOKEN= 3 | 4 | # Set this to any value if to run from a Replicate deployment instead of a public model 5 | # USE_REPLICATE_DEPLOYMENT= -------------------------------------------------------------------------------- /pages/_app.js: -------------------------------------------------------------------------------- 1 | import "../styles/globals.css"; 2 | import { Analytics } from '@vercel/analytics/react'; 3 | 4 | function MyApp({ Component, pageProps }) { 5 | return ( 6 | <> 7 | 8 | 9 | 10 | ); 11 | } 12 | 13 | export default MyApp; 14 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: actions/setup-node@v4 15 | with: 16 | node-version: 18 17 | cache: npm 18 | - run: npm ci 19 | - run: npm test 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | .env 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | const colors = require("tailwindcss/colors"); 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | module.exports = { 5 | content: [ 6 | "./pages/**/*.{js,ts,jsx,tsx}", 7 | "./components/**/*.{js,ts,jsx,tsx}", 8 | ], 9 | theme: { 10 | extend: { 11 | colors: { 12 | brand: "rgb(255, 247, 145)", 13 | black: colors.black, 14 | white: colors.white, 15 | shade: "rgba(0, 0, 0, 0.45)", 16 | bgshade: "rgba(0, 0, 0, 0.05)", 17 | }, 18 | }, 19 | }, 20 | plugins: [require("tailwindcss-animate")], 21 | }; 22 | -------------------------------------------------------------------------------- /components/message.js: -------------------------------------------------------------------------------- 1 | export default function Message({ 2 | sender, 3 | shouldFillWidth = false, 4 | isSameSender = false, 5 | children, 6 | }) { 7 | return ( 8 |
9 |
16 | {children} 17 |
18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | swcMinify: true, 5 | images: { 6 | unoptimized: true, 7 | }, 8 | async redirects() { 9 | return [ 10 | { 11 | source: "/github", 12 | destination: "https://github.com/replicate/paint-by-text", 13 | permanent: false, 14 | }, 15 | { 16 | source: "/deploy", 17 | destination: "https://vercel.com/templates/next.js/paint-by-text", 18 | permanent: false, 19 | }, 20 | ] 21 | } 22 | }; 23 | 24 | module.exports = nextConfig; 25 | -------------------------------------------------------------------------------- /lib/seeds.js: -------------------------------------------------------------------------------- 1 | const seeds = [ 2 | { 3 | image: 4 | "https://user-images.githubusercontent.com/2289/215219780-cb4a0cdb-6fea-46fe-ae22-12d68e5ba79f.jpg", 5 | prompt: "make his jacket out of leather", 6 | }, 7 | // { 8 | // image: 9 | // "https://user-images.githubusercontent.com/2289/215241066-654c5acf-8293-4fb1-a85d-c87a0297a30b.jpg", 10 | // prompt: "what would it look like if it were snowing?", 11 | // }, 12 | { 13 | image: 14 | "https://user-images.githubusercontent.com/2289/215248577-bdf7c342-e65c-4b11-bc53-cdb2c0c52d8b.jpg", 15 | prompt: "add fireworks to the sky", 16 | }, 17 | { 18 | image: 19 | "https://user-images.githubusercontent.com/2289/215248708-80787623-fff4-4b22-a548-e5c46b055244.png", 20 | prompt: "swap sunflowers with roses", 21 | }, 22 | ]; 23 | 24 | export function getRandomSeed() { 25 | return seeds[Math.floor(Math.random() * seeds.length)]; 26 | } 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "paint-by-text", 3 | "description": "Modify images by chatting with a generative AI model.", 4 | "version": "1.0.0", 5 | "private": true, 6 | "scripts": { 7 | "dev": "next dev", 8 | "build": "next build", 9 | "start": "next start", 10 | "lint": "next lint", 11 | "test": "npm run lint && npm run build" 12 | }, 13 | "dependencies": { 14 | "@vercel/analytics": "^0.1.8", 15 | "lodash": "^4.17.21", 16 | "lucide-react": "^0.88.0", 17 | "next": "^13.5.4", 18 | "react": "18.2.0", 19 | "react-dom": "18.2.0", 20 | "react-dropzone": "^14.2.2", 21 | "react-spinners": "^0.13.8", 22 | "replicate": "^1.0.1" 23 | }, 24 | "devDependencies": { 25 | "autoprefixer": "^10.4.8", 26 | "eslint": "8.23.0", 27 | "eslint-config-next": "12.2.5", 28 | "postcss": "^8.4.16", 29 | "tailwindcss": "^3.1.8", 30 | "tailwindcss-animate": "^1.0.5" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /pages/api/predictions/[id].js: -------------------------------------------------------------------------------- 1 | const API_HOST = process.env.REPLICATE_API_HOST || "https://api.replicate.com"; 2 | 3 | export default async function handler(req, res) { 4 | const token = req.headers["x-replicate-api-token"]; 5 | if (!token) { 6 | res.statusCode = 401; 7 | res.end(JSON.stringify({ detail: "Missing Replicate API token. Please provide your token in the x-replicate-api-token header." })); 8 | return; 9 | } 10 | const response = await fetch(`${API_HOST}/v1/predictions/${req.query.id}`, { 11 | headers: { 12 | Authorization: `Token ${token}`, 13 | "Content-Type": "application/json", 14 | }, 15 | }); 16 | if (response.status !== 200) { 17 | let error = await response.json(); 18 | res.statusCode = 500; 19 | res.end(JSON.stringify({ detail: error.detail })); 20 | return; 21 | } 22 | 23 | const prediction = await response.json(); 24 | res.end(JSON.stringify(prediction)); 25 | } 26 | -------------------------------------------------------------------------------- /components/dropzone.js: -------------------------------------------------------------------------------- 1 | import { Upload as UploadIcon } from "lucide-react"; 2 | import { useCallback } from "react"; 3 | import { useDropzone } from "react-dropzone"; 4 | 5 | export default function Dropzone(props) { 6 | const onImageDropped = props.onImageDropped; 7 | const onDrop = useCallback( 8 | (acceptedFiles) => { 9 | onImageDropped(acceptedFiles[0]); 10 | }, 11 | [onImageDropped] 12 | ); 13 | const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop }); 14 | 15 | return ( 16 |
17 |
18 | 19 | {isDragActive ? ( 20 |

Drop the image here ...

21 | ) : ( 22 |

23 | 24 | Upload image 25 |

26 | )} 27 |
28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | input { 6 | @apply border; 7 | @apply border-solid; 8 | @apply border-gray-300; 9 | /* @apply rounded-md; */ 10 | @apply px-4; 11 | @apply py-2; 12 | } 13 | 14 | .border-hairline { 15 | @apply border border-black border-opacity-10; 16 | } 17 | 18 | .lil-button { 19 | @apply inline-block; 20 | @apply text-sm; 21 | @apply text-gray-500; 22 | @apply rounded-md; 23 | @apply py-2; 24 | @apply mx-3; 25 | } 26 | 27 | .lil-text { 28 | @apply text-sm; 29 | @apply text-gray-500; 30 | } 31 | 32 | .lil-text a { 33 | @apply text-gray-800; 34 | @apply underline; 35 | } 36 | 37 | .icon { 38 | @apply inline relative mr-1; 39 | top: -0.1em; 40 | width: 1.1em; 41 | height: 1.1em; 42 | } 43 | 44 | .prose { 45 | @apply mt-8; 46 | @apply text-lg; 47 | @apply text-gray-500; 48 | @apply leading-7; 49 | } 50 | 51 | .prose a { 52 | @apply text-gray-600; 53 | @apply underline; 54 | } 55 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2022 Zeke Sikelianos 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /pages/api/predictions/index.js: -------------------------------------------------------------------------------- 1 | import Replicate from "replicate"; 2 | import packageData from "../../../package.json"; 3 | 4 | const API_HOST = process.env.REPLICATE_API_HOST || "https://api.replicate.com"; 5 | 6 | export default async function handler(req, res) { 7 | const token = req.headers["x-replicate-api-token"]; 8 | if (!token) { 9 | res.statusCode = 401; 10 | res.end(JSON.stringify({ detail: "Missing Replicate API token. Please provide your token in the x-replicate-api-token header." })); 11 | return; 12 | } 13 | // remove null and undefined values 14 | req.body = Object.entries(req.body).reduce( 15 | (a, [k, v]) => (v == null ? a : ((a[k] = v), a)), 16 | {} 17 | ); 18 | let prediction; 19 | const model = "black-forest-labs/flux-kontext-pro"; 20 | const replicate = new Replicate({ 21 | auth: token, 22 | userAgent: `${packageData.name}/${packageData.version}` 23 | }); 24 | prediction = await replicate.predictions.create({ 25 | model, 26 | input: req.body 27 | }); 28 | res.statusCode = 201; 29 | res.end(JSON.stringify(prediction)); 30 | } 31 | 32 | export const config = { 33 | api: { 34 | bodyParser: { 35 | sizeLimit: "10mb", 36 | }, 37 | }, 38 | }; 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 👩‍🎨 Paint by Text 2 | 3 | Modify images by chatting with a generative AI model. 4 | 5 | Try it out at [paintbytext.chat](http://paintbytext.chat) 6 | 7 | ## How it works 8 | 9 | This app is powered by: 10 | 11 | 🚀 [Replicate](https://replicate.com/?utm_source=project&utm_campaign=paintbytext), a platform for running machine learning models in the cloud. 12 | 13 | 🎨 [Kontext](https://replicate.com/black-forest-labs/flux-kontext-pro?utm_source=project&utm_campaign=paintbytext), an open-source machine learning model that edits images using text. 14 | 15 | ▲ [Vercel](https://vercel.com/), a platform for running web apps. 16 | 17 | ⚡️ Next.js [server-side API routes](pages/api), for talking to the Replicate API. 18 | 19 | 👀 Next.js React components, for the browser UI. 20 | 21 | 🍃 [Tailwind CSS](https://tailwindcss.com/), for styles. 22 | 23 | 24 | ## Usage 25 | 26 | 1. Open the app in your browser. 27 | 1. When prompted, enter your [Replicate API token](https://replicate.com/account/api-tokens?new-token-name=paint-by-text-kontext). 28 | 1. You can generate a free token at the link above (requires a Replicate account). 29 | 1. Your token is stored securely in your browser and used only for your requests. 30 | 31 | ## Development 32 | 33 | 1. Install a recent version of [Node.js](https://nodejs.org/) 34 | 1. Install dependencies and run the server: 35 | ``` 36 | npm install 37 | npm run dev 38 | ``` 39 | 1. Open [localhost:3000](http://localhost:3000) in your browser. That's it! 40 | -------------------------------------------------------------------------------- /lib/prepare-image-file-for-upload.js: -------------------------------------------------------------------------------- 1 | export default function prepareImageFileForUpload(file) { 2 | return new Promise((resolve, reject) => { 3 | const fr = new FileReader(); 4 | fr.onerror = reject; 5 | fr.onload = (e) => { 6 | const img = document.createElement("img"); 7 | img.onload = function () { 8 | const MAX_WIDTH = 512; 9 | const MAX_HEIGHT = 512; 10 | 11 | let width = img.width; 12 | let height = img.height; 13 | // Calculate the scaling factor to fit within the max dimensions while preserving aspect ratio 14 | const widthRatio = MAX_WIDTH / width; 15 | const heightRatio = MAX_HEIGHT / height; 16 | const scale = Math.min(widthRatio, heightRatio, 1); // Don't upscale 17 | width = Math.round(width * scale); 18 | height = Math.round(height * scale); 19 | 20 | const canvas = document.createElement("canvas"); 21 | canvas.width = width; 22 | canvas.height = height; 23 | 24 | const ctx = canvas.getContext("2d"); 25 | ctx.mozImageSmoothingEnabled = false; 26 | ctx.webkitImageSmoothingEnabled = false; 27 | ctx.msImageSmoothingEnabled = false; 28 | ctx.imageSmoothingEnabled = false; 29 | 30 | ctx.drawImage(img, 0, 0, width, height); 31 | 32 | const dataURL = canvas.toDataURL(file.type); 33 | 34 | resolve(dataURL); 35 | }; 36 | img.src = e.target.result; 37 | }; 38 | fr.readAsDataURL(file); 39 | }); 40 | } 41 | -------------------------------------------------------------------------------- /components/prompt-form.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import Message from "./message"; 3 | 4 | export default function PromptForm({ 5 | initialPrompt, 6 | isFirstPrompt, 7 | onSubmit, 8 | disabled = false, 9 | }) { 10 | const [prompt, setPrompt] = useState(initialPrompt); 11 | 12 | useEffect(() => { 13 | setPrompt(initialPrompt); 14 | }, [initialPrompt]); 15 | 16 | const handleSubmit = (e) => { 17 | e.preventDefault(); 18 | setPrompt(""); 19 | onSubmit(e); 20 | }; 21 | 22 | if (disabled) { 23 | return; 24 | } 25 | 26 | return ( 27 |
28 | 29 | 34 | 35 | 36 |
37 | setPrompt(e.target.value)} 43 | placeholder="Your message..." 44 | className={`block w-full flex-grow${ 45 | disabled ? " rounded-md" : " rounded-l-md" 46 | }`} 47 | disabled={disabled} 48 | /> 49 | 50 | {disabled || ( 51 | 57 | )} 58 |
59 |
60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /pages/about.js: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | import Link from "next/link"; 3 | import { ArrowLeft as ArrowLeftIcon } from "lucide-react"; 4 | 5 | import appName from "./index"; 6 | 7 | export default function About() { 8 | return ( 9 |
10 | 11 | {appName} 12 | 13 | 14 |
15 |

{appName}

16 | 17 |

18 | This open-source website provides a simple interface for modifying 19 | images using text-based instructions. You can upload an image, provide 20 | a text prompt describing how to change that image, and generate new 21 | images based on the prompt. 22 |

23 | 24 |

25 | The model is hosted on{" "} 26 | 27 | Replicate 28 | 29 | , which exposes a cloud API for running predictions. This website is 30 | built with Next.js and hosted on{" "} 31 | Vercel, and uses 32 | Replicate's API to run the Kontext Pro model. The source code 33 | is publicly available on{" "} 34 | 35 | GitHub 36 | 37 | . Pull requests welcome! 38 |

39 | 40 |
41 | 44 | 45 | Back to painting 46 | 47 |
48 |
49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /components/footer.js: -------------------------------------------------------------------------------- 1 | import Dropzone from "components/dropzone"; 2 | import { 3 | Code as CodeIcon, 4 | Download as DownloadIcon, 5 | Info as InfoIcon, 6 | XCircle as StartOverIcon, 7 | } from "lucide-react"; 8 | import Link from "next/link"; 9 | 10 | export default function Footer({ events, startOver, handleImageDropped }) { 11 | return ( 12 |
13 |
14 | 15 | 16 | What is this? 17 | 18 | 19 | {events.length > 1 && ( 20 | 24 | )} 25 | 26 | 27 | 28 | {events.length > 2 && ( 29 | ( ev.image).image} 31 | className="lil-button" 32 | target="_blank" 33 | rel="noopener noreferrer"> 34 | 35 | Download image 36 | ) 37 | )} 38 | 39 | 44 | 45 | Fork repo 46 | 47 |
48 | 49 |
50 |
51 | 🤔 Are you a developer and want to learn how to build this? Check out the{" "} 52 | 55 | README 56 | . 57 |
58 |
59 | 60 |
61 | Powered by{" "} 62 | 63 | Black Forest Labs 64 | 65 | ,{" "} 66 | 69 | Replicate 70 | 71 | ,{" "} 72 | 73 | Vercel 74 | 75 | , and{" "} 76 | 77 | GitHub 78 | 79 |
80 |
81 | ); 82 | } 83 | -------------------------------------------------------------------------------- /components/messages.js: -------------------------------------------------------------------------------- 1 | import { RotateCcw as UndoIcon } from "lucide-react"; 2 | import Image from "next/image"; 3 | import { Fragment, useEffect, useRef } from "react"; 4 | import PulseLoader from "react-spinners/PulseLoader"; 5 | import Message from "./message"; 6 | 7 | export default function Messages({ events, isProcessing, onUndo }) { 8 | const messagesEndRef = useRef(null); 9 | 10 | useEffect(() => { 11 | if (events.length > 2) { 12 | messagesEndRef.current.scrollIntoView({ behavior: "smooth" }); 13 | } 14 | }, [events.length]); 15 | 16 | return ( 17 |
18 | {events.map((ev, index) => { 19 | if (ev.image) { 20 | return ( 21 | 22 | 23 | { 35 | 36 | {onUndo && index > 0 && index === events.length - 1 && ( 37 |
38 | 47 |
48 | )} 49 |
50 | 51 | {(isProcessing || index < events.length - 1) && ( 52 | 53 | {index === 0 54 | ? "What should we change?" 55 | : "What should we change now?"} 56 | 57 | )} 58 |
59 | ); 60 | } 61 | 62 | if (ev.prompt) { 63 | return ( 64 | 65 | {ev.prompt} 66 | 67 | ); 68 | } 69 | })} 70 | 71 | {isProcessing && ( 72 | 73 | 74 | 75 | )} 76 | 77 |
78 |
79 | ); 80 | } 81 | -------------------------------------------------------------------------------- /pages/index.js: -------------------------------------------------------------------------------- 1 | import Messages from "components/messages"; 2 | import PromptForm from "components/prompt-form"; 3 | import Head from "next/head"; 4 | import { useEffect, useState } from "react"; 5 | 6 | import Footer from "components/footer"; 7 | 8 | import prepareImageFileForUpload from "lib/prepare-image-file-for-upload"; 9 | import { getRandomSeed } from "lib/seeds"; 10 | 11 | const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); 12 | 13 | export const appName = "Paint by Text"; 14 | export const appSubtitle = "Edit your photos using written instructions, with the help of an AI."; 15 | export const appMetaDescription = "Edit your photos using written instructions, with the help of an AI."; 16 | 17 | function TokenModal({ onTokenSet }) { 18 | const [token, setToken] = useState(""); 19 | return ( 20 |
21 |
{ 24 | e.preventDefault(); 25 | if (token) { 26 | localStorage.setItem("replicateApiToken", token); 27 | onTokenSet(token); 28 | } 29 | }} 30 | aria-modal="true" 31 | role="dialog" 32 | > 33 |

Enter your Replicate API token

34 |

35 | Get a free token at {" "} 36 | 42 | replicate.com/account/api-tokens 43 | 44 |

45 | setToken(e.target.value)} 50 | placeholder="r8_..." 51 | required 52 | autoFocus 53 | /> 54 | 57 |
58 |
59 | ); 60 | } 61 | 62 | export default function Home() { 63 | const [events, setEvents] = useState([]); 64 | const [predictions, setPredictions] = useState([]); 65 | const [error, setError] = useState(null); 66 | const [isProcessing, setIsProcessing] = useState(false); 67 | const [seed] = useState(getRandomSeed()); 68 | const [initialPrompt, setInitialPrompt] = useState(seed.prompt); 69 | const [apiToken, setApiToken] = useState(null); 70 | // Removed showTokenForm state 71 | 72 | // set the initial image from a random seed 73 | useEffect(() => { 74 | setEvents([{ image: seed.image }]); 75 | const storedToken = localStorage.getItem("replicateApiToken"); 76 | if (storedToken) setApiToken(storedToken); 77 | // Removed setShowTokenForm 78 | }, [seed.image]); 79 | 80 | const handleImageDropped = async (image) => { 81 | try { 82 | image = await prepareImageFileForUpload(image); 83 | } catch (error) { 84 | setError(error.message); 85 | return; 86 | } 87 | setEvents(events.concat([{ image }])); 88 | }; 89 | 90 | const handleSubmit = async (e) => { 91 | e.preventDefault(); 92 | if (!apiToken) return; 93 | 94 | const prompt = e.target.prompt.value; 95 | const lastImage = events.findLast((ev) => ev.image)?.image; 96 | 97 | setError(null); 98 | setIsProcessing(true); 99 | setInitialPrompt(""); 100 | 101 | // make a copy so that the second call to setEvents here doesn't blow away the first. Why? 102 | const myEvents = [...events, { prompt }]; 103 | setEvents(myEvents); 104 | 105 | const body = { 106 | prompt, 107 | input_image: lastImage, 108 | }; 109 | 110 | const response = await fetch("/api/predictions", { 111 | method: "POST", 112 | headers: { 113 | "Content-Type": "application/json", 114 | "x-replicate-api-token": apiToken, 115 | }, 116 | body: JSON.stringify(body), 117 | }); 118 | let prediction = await response.json(); 119 | 120 | if (response.status !== 201) { 121 | setError(prediction.detail); 122 | return; 123 | } 124 | 125 | while ( 126 | prediction.status !== "succeeded" && 127 | prediction.status !== "failed" 128 | ) { 129 | await sleep(500); 130 | const response = await fetch("/api/predictions/" + prediction.id, { 131 | headers: { "x-replicate-api-token": apiToken }, 132 | }); 133 | prediction = await response.json(); 134 | if (response.status !== 200) { 135 | setError(prediction.detail); 136 | return; 137 | } 138 | 139 | // just for bookkeeping 140 | setPredictions(predictions.concat([prediction])); 141 | 142 | if (prediction.status === "succeeded") { 143 | setEvents( 144 | myEvents.concat([ 145 | { image: prediction.output }, 146 | ]) 147 | ); 148 | } 149 | } 150 | 151 | setIsProcessing(false); 152 | }; 153 | 154 | const startOver = async (e) => { 155 | e.preventDefault(); 156 | setEvents(events.slice(0, 1)); 157 | setError(null); 158 | setIsProcessing(false); 159 | setInitialPrompt(seed.prompt); 160 | }; 161 | 162 | const handleTokenSet = (token) => { 163 | setApiToken(token); 164 | // Removed setShowTokenForm 165 | }; 166 | 167 | const handleLogout = () => { 168 | localStorage.removeItem("replicateApiToken"); 169 | setApiToken(null); 170 | // Removed setShowTokenForm 171 | }; 172 | 173 | return ( 174 |
175 | 176 | {appName} 177 | 178 | 179 | 180 | 181 | 182 | 183 |
184 | {!apiToken ? null : ( 185 | <> 186 |
187 | 190 |
191 |
192 |

{appName}

193 |

194 | {appSubtitle} 195 |

196 |
197 | 198 | { 202 | setInitialPrompt(events[index - 1].prompt); 203 | setEvents( 204 | events.slice(0, index - 1).concat(events.slice(index + 1)) 205 | ); 206 | }} 207 | /> 208 | 209 | 215 | 216 |
217 | {error &&

{error}

} 218 |
219 | 220 |
225 | 226 | )} 227 |
228 | {!apiToken && } 229 |
230 | ); 231 | } 232 | --------------------------------------------------------------------------------