├── .example.env ├── .prettierrc ├── app ├── api │ ├── s3-upload │ │ └── route.ts │ └── validate-key │ │ └── route.ts ├── favicon.ico ├── Fieldset.tsx ├── SubmitButton.tsx ├── components │ ├── PlusIcon.tsx │ ├── TwitterIcon.tsx │ ├── DownloadIcon.tsx │ └── GitHubIcon.tsx ├── Logo.tsx ├── Spinner.tsx ├── SampleImages.tsx ├── actions.ts ├── suggested-prompts │ ├── SuggestedPrompts.tsx │ └── actions.ts ├── UserAPIKey.tsx ├── ImageUploader.tsx ├── globals.css ├── layout.tsx └── page.tsx ├── public ├── og-image.png ├── vercel.svg ├── window.svg ├── file.svg ├── globe.svg └── next.svg ├── pnpm-workspace.yaml ├── postcss.config.mjs ├── lib ├── utils.ts ├── get-together.ts ├── get-adjusted-dimentions.ts ├── rate-limiter.ts └── preload-next-image.ts ├── eslint.config.mjs ├── next.config.ts ├── components.json ├── components └── ui │ └── sonner.tsx ├── .gitignore ├── README.md ├── tsconfig.json └── package.json /.example.env: -------------------------------------------------------------------------------- 1 | TOGETHER_API_KEY= 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | {"plugins": ["prettier-plugin-tailwindcss"]} 2 | -------------------------------------------------------------------------------- /app/api/s3-upload/route.ts: -------------------------------------------------------------------------------- 1 | export { POST } from 'next-s3-upload/route'; 2 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nutlope/easyedit/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /public/og-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nutlope/easyedit/HEAD/public/og-image.png -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | onlyBuiltDependencies: 2 | - '@tailwindcss/oxide' 3 | - lzo 4 | - sharp 5 | - unrs-resolver 6 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: ["@tailwindcss/postcss"], 3 | }; 4 | 5 | export default config; 6 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /app/Fieldset.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentProps } from "react"; 2 | import { useFormStatus } from "react-dom"; 3 | 4 | export function Fieldset({ children, ...rest }: ComponentProps<"fieldset">) { 5 | const { pending } = useFormStatus(); 6 | 7 | return ( 8 |
9 | {children} 10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [ 13 | ...compat.extends("next/core-web-vitals", "next/typescript"), 14 | ]; 15 | 16 | export default eslintConfig; 17 | -------------------------------------------------------------------------------- /app/SubmitButton.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentProps } from "react"; 2 | import { useFormStatus } from "react-dom"; 3 | import Spinner from "./Spinner"; 4 | 5 | export function SubmitButton({ 6 | children, 7 | disabled, 8 | ...props 9 | }: ComponentProps<"button">) { 10 | const { pending } = useFormStatus(); 11 | return ( 12 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | images: { 5 | remotePatterns: [ 6 | { 7 | protocol: "https", 8 | hostname: "napkinsdev.s3.us-east-1.amazonaws.com", 9 | }, 10 | { 11 | protocol: "https", 12 | hostname: "api.together.ai", 13 | }, 14 | { 15 | protocol: "https", 16 | hostname: "fal.media", 17 | }, 18 | ], 19 | }, 20 | }; 21 | 22 | export default nextConfig; 23 | -------------------------------------------------------------------------------- /app/components/PlusIcon.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentProps } from "react"; 2 | 3 | export function PlusIcon(props: ComponentProps<"svg">) { 4 | return ( 5 | 13 | 19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /components/ui/sonner.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Toaster as Sonner, ToasterProps } from "sonner"; 4 | 5 | const Toaster = ({ ...props }: ToasterProps) => { 6 | return ( 7 | 20 | ); 21 | }; 22 | 23 | export { Toaster }; 24 | -------------------------------------------------------------------------------- /.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.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | -------------------------------------------------------------------------------- /app/components/TwitterIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import type { SVGProps } from "react"; 3 | const XformerlyTwitter = (props: SVGProps) => ( 4 | 12 | 16 | 17 | ); 18 | export default XformerlyTwitter; 19 | -------------------------------------------------------------------------------- /lib/get-together.ts: -------------------------------------------------------------------------------- 1 | import Together from "together-ai"; 2 | 3 | export function getTogether(userAPIKey: string | null) { 4 | const options: ConstructorParameters[0] = {}; 5 | 6 | if (process.env.HELICONE_API_KEY) { 7 | options.baseURL = "https://together.helicone.ai/v1"; 8 | options.defaultHeaders = { 9 | "Helicone-Auth": `Bearer ${process.env.HELICONE_API_KEY}`, 10 | "Helicone-Property-BYOK": userAPIKey ? "true" : "false", 11 | "Helicone-Property-appname": "EasyEdit", 12 | "Helicone-Property-environment": 13 | process.env.VERCEL_ENV === "production" ? "prod" : "dev", 14 | }; 15 | } 16 | 17 | if (userAPIKey) { 18 | options.apiKey = userAPIKey; 19 | } 20 | 21 | const together = new Together(options); 22 | 23 | return together; 24 | } 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | EasyEdit 3 |

EasyEdit

4 |
5 | 6 |

7 | Edit images with a single prompt. Powered by Flux through Together.ai. 8 |

9 | 10 | ## Tech stack 11 | 12 | - [Flux.1 Kontext](https://www.together.ai/blog/flux-1-kontext) from BFL for the image model 13 | - [Together AI](https://togetherai.link) for inference 14 | - Next.js app router with Tailwind 15 | - Helicone for observability 16 | - Plausible for website analytics 17 | 18 | ## Cloning & running 19 | 20 | 1. Clone the repo: `git clone https://github.com/Nutlope/easyedit` 21 | 2. Create a `.env.local` file and add your [Together AI API key](https://togetherai.link): `TOGETHER_API_KEY=` 22 | 3. Run `npm install` and `npm run dev` to install dependencies and run locally 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "noEmit": true, 13 | "esModuleInterop": true, 14 | "module": "esnext", 15 | "moduleResolution": "bundler", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "jsx": "react-jsx", 19 | "incremental": true, 20 | "plugins": [ 21 | { 22 | "name": "next" 23 | } 24 | ], 25 | "paths": { 26 | "@/*": [ 27 | "./*" 28 | ] 29 | } 30 | }, 31 | "include": [ 32 | "next-env.d.ts", 33 | "**/*.ts", 34 | "**/*.tsx", 35 | ".next/types/**/*.ts", 36 | ".next/dev/types/**/*.ts" 37 | ], 38 | "exclude": [ 39 | "node_modules" 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /app/components/DownloadIcon.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentProps } from "react"; 2 | 3 | export function DownloadIcon(props: ComponentProps<"svg">) { 4 | return ( 5 | 13 | 17 | 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/get-adjusted-dimentions.ts: -------------------------------------------------------------------------------- 1 | export function getAdjustedDimensions( 2 | width: number, 3 | height: number, 4 | ): { width: number; height: number } { 5 | const maxDim = 1024; 6 | const minDim = 64; 7 | 8 | const roundToMultipleOf16 = (n: number) => Math.round(n / 16) * 16; 9 | 10 | const aspectRatio = width / height; 11 | 12 | let scaledWidth = width; 13 | let scaledHeight = height; 14 | 15 | if (width > maxDim || height > maxDim) { 16 | if (aspectRatio >= 1) { 17 | scaledWidth = maxDim; 18 | scaledHeight = Math.round(maxDim / aspectRatio); 19 | } else { 20 | scaledHeight = maxDim; 21 | scaledWidth = Math.round(maxDim * aspectRatio); 22 | } 23 | } 24 | 25 | const adjustedWidth = Math.min( 26 | maxDim, 27 | Math.max(minDim, roundToMultipleOf16(scaledWidth)), 28 | ); 29 | const adjustedHeight = Math.min( 30 | maxDim, 31 | Math.max(minDim, roundToMultipleOf16(scaledHeight)), 32 | ); 33 | 34 | return { width: adjustedWidth, height: adjustedHeight }; 35 | } 36 | -------------------------------------------------------------------------------- /lib/rate-limiter.ts: -------------------------------------------------------------------------------- 1 | import { Ratelimit } from "@upstash/ratelimit"; 2 | import { Redis } from "@upstash/redis"; 3 | import { headers } from "next/headers"; 4 | import "server-only"; 5 | 6 | export function getRateLimiter() { 7 | let ratelimit: Ratelimit | undefined; 8 | 9 | // Add rate limiting if Upstash API keys are set, otherwise skip 10 | if (process.env.UPSTASH_REDIS_REST_URL) { 11 | ratelimit = new Ratelimit({ 12 | redis: Redis.fromEnv(), 13 | // Allow 5 requests per day 14 | limiter: Ratelimit.fixedWindow(5, "1 d"), 15 | analytics: true, 16 | prefix: "easyedit", 17 | }); 18 | } 19 | return ratelimit; 20 | } 21 | export async function getIPAddress() { 22 | const FALLBACK_IP_ADDRESS = "0.0.0.0"; 23 | const headersList = await headers(); 24 | const forwardedFor = headersList.get("x-forwarded-for"); 25 | 26 | if (forwardedFor) { 27 | return forwardedFor.split(",")[0] ?? FALLBACK_IP_ADDRESS; 28 | } 29 | 30 | return headersList.get("x-real-ip") ?? FALLBACK_IP_ADDRESS; 31 | } 32 | -------------------------------------------------------------------------------- /lib/preload-next-image.ts: -------------------------------------------------------------------------------- 1 | import { getImageProps, ImageProps } from "next/image"; 2 | 3 | export async function preloadNextImage(imageProps: Omit) { 4 | try { 5 | // Get the same props that Next.js Image would use 6 | const { props } = getImageProps({ ...imageProps, alt: "" }); 7 | 8 | // Extract the srcSet from the props 9 | const { srcSet } = props; 10 | 11 | if (srcSet) { 12 | // Parse the srcSet to get the URLs 13 | const srcSetUrls = srcSet.split(", ").map((s) => s.split(" ")[0]); 14 | 15 | // Preload each URL in the srcSet 16 | await Promise.all( 17 | srcSetUrls.map((url) => { 18 | return new Promise((resolve) => { 19 | const img = new window.Image(); 20 | img.onload = () => resolve(undefined); 21 | img.onerror = () => resolve(undefined); 22 | img.src = url; 23 | }); 24 | }), 25 | ); 26 | } 27 | } catch (error) { 28 | console.error("Error preloading Next.js image:", error); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/Logo.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentProps } from "react"; 2 | 3 | export function Logo(props: ComponentProps<"svg">) { 4 | return ( 5 | 13 | 23 | 33 | 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /app/components/GitHubIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import type { SVGProps } from "react"; 3 | const GitHub = (props: SVGProps) => ( 4 | 12 | 19 | 20 | ); 21 | export default GitHub; 22 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "easyedit", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@fal-ai/client": "^1.5.0", 13 | "@upstash/ratelimit": "^2.0.5", 14 | "@upstash/redis": "^1.34.9", 15 | "class-variance-authority": "^0.7.1", 16 | "clsx": "^2.1.1", 17 | "dedent": "^1.6.0", 18 | "lucide-react": "^0.511.0", 19 | "next": "16.0.10", 20 | "next-plausible": "^3.12.5", 21 | "next-s3-upload": "^0.3.4", 22 | "next-themes": "^0.4.6", 23 | "react": "^19.0.0", 24 | "react-dom": "^19.0.0", 25 | "server-only": "^0.0.1", 26 | "sonner": "^2.0.5", 27 | "tailwind-merge": "^3.3.0", 28 | "tiny-invariant": "^1.3.3", 29 | "together-ai": "^0.16.0", 30 | "zod": "^3.25.48", 31 | "zod-to-json-schema": "^3.24.5" 32 | }, 33 | "devDependencies": { 34 | "@eslint/eslintrc": "^3", 35 | "@tailwindcss/postcss": "^4", 36 | "@types/node": "^20", 37 | "@types/react": "^19", 38 | "@types/react-dom": "^19", 39 | "eslint": "^9", 40 | "eslint-config-next": "16.0.7", 41 | "prettier": "^3.5.3", 42 | "prettier-plugin-tailwindcss": "^0.6.11", 43 | "tailwindcss": "^4", 44 | "tw-animate-css": "^1.3.3", 45 | "typescript": "^5" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /app/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | const keyframes = ` 4 | @keyframes spinner { 5 | 0% { opacity: 1; } 6 | 100% { opacity: 0.25; } 7 | } 8 | `; 9 | 10 | export default function Spinner({ 11 | loading = true, 12 | children, 13 | className, 14 | }: { 15 | loading?: boolean; 16 | children?: ReactNode; 17 | className?: string; 18 | }) { 19 | if (!loading) return children; 20 | 21 | const spinner = ( 22 | 23 | 24 | {Array.from(Array(8).keys()).map((i) => ( 25 | 33 | ))} 34 | 35 | ); 36 | 37 | if (!children) return spinner; 38 | 39 | return ( 40 | 41 | {children} 42 | 43 | 44 | {spinner} 45 | 46 | 47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /app/SampleImages.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | 3 | const sampleImages = [ 4 | { 5 | url: "https://napkinsdev.s3.us-east-1.amazonaws.com/next-s3-uploads/39f8ea0e-000f-49bc-9f52-ed1cfb4e4230/free-photo-of-casual-man-working-on-laptop-in-cozy-cafe.jpeg", 6 | height: 1752, 7 | width: 986, 8 | }, 9 | { 10 | url: "https://napkinsdev.s3.us-east-1.amazonaws.com/next-s3-uploads/4e742e29-ddbc-4493-b2a3-95c15d8d0a2c/image-(6).png", 11 | height: 1365, 12 | width: 2048, 13 | }, 14 | { 15 | url: "https://napkinsdev.s3.us-east-1.amazonaws.com/next-s3-uploads/8dcc41ed-b7e4-473d-b30f-19c8790a4293/style_transfer_1.png", 16 | width: 1408, 17 | height: 792, 18 | }, 19 | ]; 20 | 21 | export function SampleImages({ 22 | onSelect, 23 | }: { 24 | onSelect: ({ 25 | url, 26 | width, 27 | height, 28 | }: { 29 | url: string; 30 | width: number; 31 | height: number; 32 | }) => void; 33 | }) { 34 | return ( 35 |
36 |

37 | Nothing to upload?{" "} 38 | Try a sample image: 39 |

40 |
41 | {sampleImages.map((sample) => ( 42 | 63 | ))} 64 |
65 |
66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /app/api/validate-key/route.ts: -------------------------------------------------------------------------------- 1 | import { getTogether } from "@/lib/get-together"; 2 | 3 | export async function POST(request: Request) { 4 | try { 5 | const { apiKey } = await request.json(); 6 | 7 | if (!apiKey) { 8 | return new Response( 9 | JSON.stringify({ 10 | success: false, 11 | message: "API key is required", 12 | }), 13 | { status: 200, headers: { "Content-Type": "application/json" } }, 14 | ); 15 | } 16 | 17 | const together = getTogether(apiKey); 18 | 19 | try { 20 | // Make a simple chat completion call to validate the API key 21 | await together.chat.completions.create({ 22 | model: "Qwen/Qwen2.5-72B-Instruct-Turbo", 23 | messages: [ 24 | { 25 | role: "user", 26 | content: "Hello, how are you?", 27 | }, 28 | ], 29 | max_tokens: 1, // Minimal tokens for validation 30 | }); 31 | 32 | return new Response( 33 | JSON.stringify({ 34 | success: true, 35 | message: "API key is valid", 36 | }), 37 | { status: 200, headers: { "Content-Type": "application/json" } }, 38 | ); 39 | } catch (error) { 40 | console.error("API key validation failed:", error); 41 | 42 | const errorCode = 43 | typeof error === "object" && error !== null && "code" in error 44 | ? String((error as { code?: unknown }).code) 45 | : undefined; 46 | 47 | return new Response( 48 | JSON.stringify({ 49 | success: false, 50 | message: "Invalid API key or service unavailable", 51 | code: errorCode || "VALIDATION_ERROR", 52 | }), 53 | { status: 200, headers: { "Content-Type": "application/json" } }, 54 | ); 55 | } 56 | } catch (error) { 57 | console.error("Request processing failed:", error); 58 | return new Response( 59 | JSON.stringify({ 60 | success: false, 61 | message: "Invalid request format", 62 | code: "INVALID_REQUEST", 63 | }), 64 | { status: 200, headers: { "Content-Type": "application/json" } }, 65 | ); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /app/actions.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | "use server"; 3 | 4 | import { getAdjustedDimensions } from "@/lib/get-adjusted-dimentions"; 5 | import { getTogether } from "@/lib/get-together"; 6 | import { getIPAddress, getRateLimiter } from "@/lib/rate-limiter"; 7 | import { z } from "zod"; 8 | 9 | const ratelimit = getRateLimiter(); 10 | 11 | const schema = z.object({ 12 | imageUrl: z.string(), 13 | prompt: z.string(), 14 | width: z.number(), 15 | height: z.number(), 16 | userAPIKey: z.string().nullable(), 17 | model: z 18 | .enum([ 19 | "black-forest-labs/FLUX.1-kontext-dev", 20 | "black-forest-labs/FLUX.1-kontext-pro", 21 | ]) 22 | .default("black-forest-labs/FLUX.1-kontext-dev"), 23 | }); 24 | 25 | export async function generateImage( 26 | unsafeData: z.infer, 27 | ): Promise<{ success: true; url: string } | { success: false; error: string }> { 28 | const { imageUrl, prompt, width, height, userAPIKey, model } = 29 | schema.parse(unsafeData); 30 | 31 | if (ratelimit && !userAPIKey) { 32 | const ipAddress = await getIPAddress(); 33 | 34 | const { success } = await ratelimit.limit(ipAddress); 35 | if (!success) { 36 | return { 37 | success: false, 38 | error: 39 | "No requests left. Please add your own API key or try again in 24h.", 40 | }; 41 | } 42 | } 43 | 44 | const together = getTogether(userAPIKey); 45 | const adjustedDimensions = getAdjustedDimensions(width, height); 46 | 47 | let url; 48 | try { 49 | const json = await together.images.create({ 50 | model, 51 | prompt, 52 | width: adjustedDimensions.width, 53 | height: adjustedDimensions.height, 54 | image_url: imageUrl, 55 | }); 56 | 57 | url = json.data[0].url; 58 | } catch (e: any) { 59 | console.log(e); 60 | // if the error contains "403", then it's a rate limit error 61 | if (e.toString().includes("403")) { 62 | return { 63 | success: false, 64 | error: 65 | "You need a paid Together AI account to use the Pro model. Please upgrade by purchasing credits here: https://api.together.xyz/settings/billing.", 66 | }; 67 | } 68 | } 69 | 70 | if (url) { 71 | return { success: true, url }; 72 | } else { 73 | return { 74 | success: false, 75 | error: "Image could not be generated. Please try again.", 76 | }; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /app/suggested-prompts/SuggestedPrompts.tsx: -------------------------------------------------------------------------------- 1 | import { startTransition, useActionState, useEffect } from "react"; 2 | import { getSuggestions } from "./actions"; 3 | 4 | const cache = new Map(); 5 | 6 | const shimmer = ` 7 | @keyframes shimmer { 8 | 0% { 9 | background-position: -200% 0; 10 | } 11 | 100% { 12 | background-position: 200% 0; 13 | } 14 | } 15 | `; 16 | 17 | export function SuggestedPrompts({ 18 | imageUrl, 19 | onSelect, 20 | }: { 21 | imageUrl: string; 22 | onSelect: (v: string) => void; 23 | }) { 24 | const [state, action, pending] = useActionState<{ 25 | url: string; 26 | suggestions: string[]; 27 | } | null>(async () => { 28 | let cachedSuggestions = cache.get(imageUrl); 29 | 30 | if (!cachedSuggestions) { 31 | const newSuggestions = await getSuggestions( 32 | imageUrl, 33 | localStorage.getItem("togetherApiKey"), 34 | ); 35 | cache.set(imageUrl, newSuggestions); 36 | cachedSuggestions = newSuggestions; 37 | } 38 | 39 | return { url: imageUrl, suggestions: cachedSuggestions }; 40 | }, null); 41 | 42 | useEffect(() => { 43 | if (!pending && state?.url !== imageUrl) { 44 | setTimeout(() => { 45 | startTransition(() => { 46 | action(); 47 | }); 48 | }, 50); 49 | } 50 | }, [action, imageUrl, pending, state?.url]); 51 | 52 | return ( 53 |
54 | 55 | 56 | {/* {true ? ( */} 57 | {pending || !state ? ( 58 |
59 | {Array.from(Array(3).keys()).map((i) => ( 60 |
64 | ))} 65 |
66 | ) : ( 67 |
68 | {state?.suggestions.map((suggestion, i) => ( 69 | 77 | ))} 78 |
79 | )} 80 |
81 | ); 82 | } 83 | -------------------------------------------------------------------------------- /app/UserAPIKey.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useState, useRef } from "react"; 4 | import { toast } from "sonner"; 5 | 6 | export function UserAPIKey() { 7 | const [userAPIKey, setUserAPIKey] = useState(""); 8 | const [isValidating, setIsValidating] = useState(false); 9 | const debounceTimeoutRef = useRef(null); 10 | 11 | // Initialize from sessionStorage 12 | useEffect(() => { 13 | const storedKey = sessionStorage.getItem("togetherApiKey"); 14 | if (storedKey) { 15 | setUserAPIKey(storedKey); 16 | } 17 | }, []); 18 | 19 | const validateAndSaveApiKey = async (apiKey: string) => { 20 | if (!apiKey) { 21 | sessionStorage.removeItem("togetherApiKey"); 22 | return false; 23 | } 24 | 25 | setIsValidating(true); 26 | try { 27 | const response = await fetch("/api/validate-key", { 28 | method: "POST", 29 | headers: { 30 | "Content-Type": "application/json", 31 | }, 32 | body: JSON.stringify({ apiKey }), 33 | }); 34 | 35 | const result = await response.json(); 36 | 37 | if (result.success) { 38 | sessionStorage.setItem("togetherApiKey", apiKey); 39 | toast.success("API key validated and saved!"); 40 | return true; 41 | } else { 42 | toast.error(result.message || "Invalid API key"); 43 | return false; 44 | } 45 | } catch (error) { 46 | console.error("Error validating API key:", error); 47 | toast.error("Failed to validate API key. Please try again."); 48 | return false; 49 | } finally { 50 | setIsValidating(false); 51 | } 52 | }; 53 | 54 | const handleApiKeyChange = (e: React.ChangeEvent) => { 55 | const value = e.target.value; 56 | setUserAPIKey(value); 57 | 58 | if (value.length === 0) { 59 | sessionStorage.removeItem("togetherApiKey"); 60 | return; 61 | } 62 | 63 | if (debounceTimeoutRef.current) { 64 | clearTimeout(debounceTimeoutRef.current); 65 | } 66 | 67 | debounceTimeoutRef.current = setTimeout(() => { 68 | validateAndSaveApiKey(value); 69 | }, 500); 70 | }; 71 | 72 | return ( 73 |
74 |
75 |

[Optional] Add your

76 | 82 | Together API Key: 83 | 84 |
85 |
86 | 94 | {isValidating && ( 95 |
96 |
97 |
98 | )} 99 |
100 |
101 | ); 102 | } 103 | -------------------------------------------------------------------------------- /app/ImageUploader.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { getImageData, useS3Upload } from "next-s3-upload"; 3 | import { useRef, useState, useTransition } from "react"; 4 | import Spinner from "./Spinner"; 5 | 6 | export function ImageUploader({ 7 | onUpload, 8 | }: { 9 | onUpload: ({ 10 | url, 11 | width, 12 | height, 13 | }: { 14 | url: string; 15 | width: number; 16 | height: number; 17 | }) => void; 18 | }) { 19 | const fileInputRef = useRef(null); 20 | const [isDragging, setIsDragging] = useState(false); 21 | const { uploadToS3 } = useS3Upload(); 22 | const [pending, startTransition] = useTransition(); 23 | 24 | async function handleUpload(file: File) { 25 | startTransition(async () => { 26 | const [result, data] = await Promise.all([ 27 | uploadToS3(file), 28 | getImageData(file), 29 | ]); 30 | 31 | console.log(result.url); 32 | console.log(data); 33 | 34 | onUpload({ 35 | url: result.url, 36 | width: data.width ?? 1024, 37 | height: data.height ?? 768, 38 | }); 39 | }); 40 | } 41 | 42 | return ( 43 | 118 | ); 119 | } 120 | -------------------------------------------------------------------------------- /app/suggested-prompts/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { getTogether } from "@/lib/get-together"; 4 | import { getIPAddress, getRateLimiter } from "@/lib/rate-limiter"; 5 | import dedent from "dedent"; 6 | import invariant from "tiny-invariant"; 7 | import { z } from "zod"; 8 | // import { zodToJsonSchema } from "zod-to-json-schema"; 9 | 10 | const schema = z.array(z.string()); 11 | // const jsonSchema = zodToJsonSchema(schema, { target: "openAi" }); 12 | 13 | const ratelimit = getRateLimiter(); 14 | 15 | export async function getSuggestions( 16 | imageUrl: string, 17 | userAPIKey: string | null, 18 | ) { 19 | invariant(typeof imageUrl === "string"); 20 | 21 | if (ratelimit && !userAPIKey) { 22 | const ipAddress = await getIPAddress(); 23 | 24 | const { success } = await ratelimit.limit(`${ipAddress}-suggestions`); 25 | if (!success) { 26 | return []; 27 | } 28 | } 29 | 30 | const together = getTogether(userAPIKey); 31 | 32 | const response = await together.chat.completions.create({ 33 | model: "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8", 34 | messages: [ 35 | { 36 | role: "system", 37 | content: dedent` 38 | # General Instructions 39 | You will be shown an image that a user wants to edit using AI-powered prompting. Analyze the image and suggest exactly 3 simple, practical edits that would improve or meaningfully change the image. Each suggestion should be: 40 | 41 | - Specific and actionable (not vague) 42 | - Achievable with standard image editing AI 43 | - Varied in type (e.g., lighting, objects, style, composition) 44 | 45 | Please keep the suggestions short and concise, about 5-8 words each. 46 | 47 | Format your response as valid JSON with this structure: 48 | [ 49 | "specific description of edit 1", 50 | "specific description of edit 2", 51 | "specific description of edit 3" 52 | ] 53 | 54 | Provide only the JSON response, no additional text. 55 | 56 | # Additional Context 57 | 58 | Here's some additional information about the image model that will be used to edit the image based on the prompt: 59 | 60 | With FLUX.1 Kontext you can modify an input image via simple text instructions, enabling flexible and instant image editing - no need for finetuning or complex editing workflows. The core capabilities of the the FLUX.1 Kontext suite are: 61 | 62 | - Character consistency: Preserve unique elements of an image, such as a reference character or object in a picture, across multiple scenes and environments. 63 | - Local editing: Make targeted modifications of specific elements in an image without affecting the rest. 64 | - Style Reference: Generate novel scenes while preserving unique styles from a reference image, directed by text prompts. 65 | - Interactive Speed: Minimal latency for both image generation and editing. 66 | - Iterate: modify step by step 67 | 68 | Flux.1 Kontext allows you to iteratively add more instructions and build on previous edits, refining your creation step-by-step with minimal latency, while preserving image quality and character consistency. 69 | 70 | # Final instructions. 71 | 72 | ONLY RESPOND IN JSON. NOTHING ELSE. 73 | `, 74 | }, 75 | { 76 | role: "user", 77 | content: [ 78 | { 79 | type: "image_url", 80 | image_url: { 81 | url: imageUrl, 82 | }, 83 | }, 84 | ], 85 | }, 86 | ], 87 | // response_format: { type: "json_schema", schema: jsonSchema }, 88 | }); 89 | 90 | if (!response?.choices?.[0]?.message?.content) return []; 91 | 92 | const json = JSON.parse(response?.choices?.[0]?.message?.content); 93 | const result = schema.safeParse(json); 94 | 95 | if (result.error) return []; 96 | 97 | return result.data; 98 | } 99 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @import "tw-animate-css"; 3 | 4 | @theme { 5 | --color-gray-50: oklch(0.985 0 0); 6 | --color-gray-100: oklch(0.97 0 0); 7 | --color-gray-200: oklch(0.922 0 0); 8 | --color-gray-300: oklch(0.87 0 0); 9 | --color-gray-400: oklch(0.708 0 0); 10 | --color-gray-500: oklch(0.556 0 0); 11 | --color-gray-600: oklch(0.439 0 0); 12 | --color-gray-700: oklch(0.371 0 0); 13 | --color-gray-800: oklch(0.269 0 0); 14 | --color-gray-900: oklch(0.205 0 0); 15 | --color-gray-950: oklch(0.145 0 0); 16 | 17 | --default-font-family: var(--font-kulim-park); 18 | --default-mono-font-family: var(--font-syne-mono); 19 | } 20 | 21 | @theme inline { 22 | --radius-sm: calc(var(--radius) - 4px); 23 | --radius-md: calc(var(--radius) - 2px); 24 | --radius-lg: var(--radius); 25 | --radius-xl: calc(var(--radius) + 4px); 26 | --color-background: var(--background); 27 | --color-foreground: var(--foreground); 28 | --color-card: var(--card); 29 | --color-card-foreground: var(--card-foreground); 30 | --color-popover: var(--popover); 31 | --color-popover-foreground: var(--popover-foreground); 32 | --color-primary: var(--primary); 33 | --color-primary-foreground: var(--primary-foreground); 34 | --color-secondary: var(--secondary); 35 | --color-secondary-foreground: var(--secondary-foreground); 36 | --color-muted: var(--muted); 37 | --color-muted-foreground: var(--muted-foreground); 38 | --color-accent: var(--accent); 39 | --color-accent-foreground: var(--accent-foreground); 40 | --color-destructive: var(--destructive); 41 | --color-border: var(--border); 42 | --color-input: var(--input); 43 | --color-ring: var(--ring); 44 | --color-chart-1: var(--chart-1); 45 | --color-chart-2: var(--chart-2); 46 | --color-chart-3: var(--chart-3); 47 | --color-chart-4: var(--chart-4); 48 | --color-chart-5: var(--chart-5); 49 | --color-sidebar: var(--sidebar); 50 | --color-sidebar-foreground: var(--sidebar-foreground); 51 | --color-sidebar-primary: var(--sidebar-primary); 52 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); 53 | --color-sidebar-accent: var(--sidebar-accent); 54 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); 55 | --color-sidebar-border: var(--sidebar-border); 56 | --color-sidebar-ring: var(--sidebar-ring); 57 | } 58 | 59 | :root { 60 | --radius: 0.625rem; 61 | --background: oklch(0.145 0 0); 62 | --foreground: oklch(0.985 0 0); 63 | --card: oklch(0.205 0 0); 64 | --card-foreground: oklch(0.985 0 0); 65 | --popover: oklch(0.205 0 0); 66 | --popover-foreground: oklch(0.985 0 0); 67 | --primary: oklch(0.922 0 0); 68 | --primary-foreground: oklch(0.205 0 0); 69 | --secondary: oklch(0.269 0 0); 70 | --secondary-foreground: oklch(0.985 0 0); 71 | --muted: oklch(0.269 0 0); 72 | --muted-foreground: oklch(0.708 0 0); 73 | --accent: oklch(0.269 0 0); 74 | --accent-foreground: oklch(0.985 0 0); 75 | --destructive: oklch(0.704 0.191 22.216); 76 | --border: oklch(1 0 0 / 10%); 77 | --input: oklch(1 0 0 / 15%); 78 | --ring: oklch(0.556 0 0); 79 | --chart-1: oklch(0.488 0.243 264.376); 80 | --chart-2: oklch(0.696 0.17 162.48); 81 | --chart-3: oklch(0.769 0.188 70.08); 82 | --chart-4: oklch(0.627 0.265 303.9); 83 | --chart-5: oklch(0.645 0.246 16.439); 84 | --sidebar: oklch(0.205 0 0); 85 | --sidebar-foreground: oklch(0.985 0 0); 86 | --sidebar-primary: oklch(0.488 0.243 264.376); 87 | --sidebar-primary-foreground: oklch(0.985 0 0); 88 | --sidebar-accent: oklch(0.269 0 0); 89 | --sidebar-accent-foreground: oklch(0.985 0 0); 90 | --sidebar-border: oklch(1 0 0 / 10%); 91 | --sidebar-ring: oklch(0.556 0 0); 92 | } 93 | 94 | @layer base { 95 | * { 96 | @apply border-border outline-ring/50; 97 | } 98 | body { 99 | @apply bg-background text-foreground; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata, Viewport } from "next"; 2 | import { Kulim_Park, Syne_Mono } from "next/font/google"; 3 | import "./globals.css"; 4 | import { Logo } from "./Logo"; 5 | import PlausibleProvider from "next-plausible"; 6 | import { UserAPIKey } from "./UserAPIKey"; 7 | import { Toaster } from "@/components/ui/sonner"; 8 | import GitHub from "./components/GitHubIcon"; 9 | import XformerlyTwitter from "./components/TwitterIcon"; 10 | import { PlusIcon } from "./components/PlusIcon"; 11 | 12 | const kulimPark = Kulim_Park({ 13 | variable: "--font-kulim-park", 14 | subsets: ["latin"], 15 | weight: ["200", "300", "400", "600", "700"], 16 | }); 17 | 18 | const syneMono = Syne_Mono({ 19 | variable: "--font-syne-mono", 20 | subsets: ["latin"], 21 | weight: ["400"], 22 | }); 23 | 24 | const title = "EasyEdit – Edit images with one prompt"; 25 | const description = "The easiest way to edit images in one prompt"; 26 | const url = "https://www.easyedit.io/"; 27 | const ogimage = "https://www.easyedit.io/og-image.png"; 28 | const sitename = "easyedit.io"; 29 | 30 | export const metadata: Metadata = { 31 | metadataBase: new URL(url), 32 | title, 33 | description, 34 | icons: { 35 | icon: "/favicon.ico", 36 | }, 37 | openGraph: { 38 | images: [ogimage], 39 | title, 40 | description, 41 | url: url, 42 | siteName: sitename, 43 | locale: "en_US", 44 | type: "website", 45 | }, 46 | twitter: { 47 | card: "summary_large_image", 48 | images: [ogimage], 49 | title, 50 | description, 51 | }, 52 | }; 53 | 54 | export const viewport: Viewport = { 55 | colorScheme: "dark", 56 | }; 57 | 58 | export default function RootLayout({ 59 | children, 60 | }: Readonly<{ 61 | children: React.ReactNode; 62 | }>) { 63 | return ( 64 | 65 | 66 | 67 | 68 | 69 |
70 | 71 | 77 | 78 | EasyEdit 79 | 80 | 81 | 110 |
111 | 112 |
113 |
{children}
114 |
115 | 116 | 117 | 118 | 156 | 157 | 158 | ); 159 | } 160 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Image, { getImageProps } from "next/image"; 4 | import { useRef, useState, useTransition, useEffect, useMemo } from "react"; 5 | import { generateImage } from "./actions"; 6 | import { ImageUploader } from "./ImageUploader"; 7 | import { Fieldset } from "./Fieldset"; 8 | import Spinner from "./Spinner"; 9 | import { preloadNextImage } from "@/lib/preload-next-image"; 10 | import clsx from "clsx"; 11 | import { SampleImages } from "./SampleImages"; 12 | import { getAdjustedDimensions } from "@/lib/get-adjusted-dimentions"; 13 | import { DownloadIcon } from "./components/DownloadIcon"; 14 | import { toast } from "sonner"; 15 | import { SuggestedPrompts } from "./suggested-prompts/SuggestedPrompts"; 16 | import { flushSync } from "react-dom"; 17 | 18 | // Helper to slugify the prompt for filenames 19 | function slugifyPrompt(prompt?: string): string { 20 | if (!prompt) return "image"; 21 | // Take first 8 words, join with dashes, remove non-alphanum, limit to 40 chars 22 | const words = prompt.split(/\s+/).slice(0, 8); 23 | let slug = words.join("-").toLowerCase(); 24 | slug = slug.replace(/[^a-z0-9\-]/g, ""); 25 | if (slug.length > 40) slug = slug.slice(0, 40); 26 | return slug || "image"; 27 | } 28 | 29 | export default function Home() { 30 | const [images, setImages] = useState< 31 | { url: string; version: number; prompt?: string }[] 32 | >([]); 33 | const [imageData, setImageData] = useState<{ 34 | width: number; 35 | height: number; 36 | }>({ width: 1024, height: 768 }); 37 | const [activeImageUrl, setActiveImageUrl] = useState(null); 38 | const [pending, startTransition] = useTransition(); 39 | const scrollRef = useRef(null); 40 | const [prompt, setPrompt] = useState(""); 41 | const formRef = useRef(null); 42 | const [selectedModel, setSelectedModel] = useState< 43 | | "black-forest-labs/FLUX.1-kontext-dev" 44 | | "black-forest-labs/FLUX.1-kontext-pro" 45 | >("black-forest-labs/FLUX.1-kontext-dev"); 46 | const [hasApiKey, setHasApiKey] = useState(false); 47 | 48 | const activeImage = useMemo( 49 | () => images.find((i) => i.url === activeImageUrl), 50 | [images, activeImageUrl], 51 | ); 52 | 53 | const adjustedImageDimensions = getAdjustedDimensions( 54 | imageData.width, 55 | imageData.height, 56 | ); 57 | 58 | useEffect(() => { 59 | function handleNewSession() { 60 | setImages([]); 61 | setActiveImageUrl(null); 62 | } 63 | window.addEventListener("new-image-session", handleNewSession); 64 | return () => { 65 | window.removeEventListener("new-image-session", handleNewSession); 66 | }; 67 | }, []); 68 | 69 | useEffect(() => { 70 | const checkApiKey = () => { 71 | const apiKey = localStorage.getItem("togetherApiKey"); 72 | const hasKey = !!apiKey; 73 | setHasApiKey(hasKey); 74 | 75 | // If Pro model is selected but no API key, switch to Dev model 76 | if (!hasKey && selectedModel === "black-forest-labs/FLUX.1-kontext-pro") { 77 | setSelectedModel("black-forest-labs/FLUX.1-kontext-dev"); 78 | } 79 | }; 80 | 81 | checkApiKey(); 82 | 83 | // Listen for storage events (when localStorage changes in other tabs) 84 | window.addEventListener("storage", checkApiKey); 85 | 86 | // Poll for API key changes every 500ms to catch changes in the same tab 87 | const interval = setInterval(checkApiKey, 500); 88 | 89 | return () => { 90 | window.removeEventListener("storage", checkApiKey); 91 | clearInterval(interval); 92 | }; 93 | }, [selectedModel]); 94 | 95 | async function handleDownload() { 96 | if (!activeImage) return; 97 | 98 | const imageProps = getImageProps({ 99 | src: activeImage.url, 100 | alt: "Generated image", 101 | height: imageData.height, 102 | width: imageData.width, 103 | quality: 100, 104 | }); 105 | 106 | // Fetch the image 107 | const response = await fetch(imageProps.props.src); 108 | const blob = await response.blob(); 109 | 110 | const extension = blob.type.includes("jpeg") 111 | ? "jpg" 112 | : blob.type.includes("png") 113 | ? "png" 114 | : blob.type.includes("webp") 115 | ? "webp" 116 | : "bin"; 117 | 118 | // Create a download link 119 | const url = window.URL.createObjectURL(blob); 120 | const link = document.createElement("a"); 121 | link.href = url; 122 | // Use slugified prompt as filename prefix 123 | const slug = slugifyPrompt(activeImage.prompt); 124 | link.download = `v${activeImage.version}-${slug}.${extension}`; 125 | document.body.appendChild(link); 126 | link.click(); 127 | 128 | // Cleanup 129 | document.body.removeChild(link); 130 | window.URL.revokeObjectURL(url); 131 | } 132 | 133 | return ( 134 | <> 135 |
136 |
140 | {images 141 | .slice() 142 | .reverse() 143 | .map((image) => ( 144 |
148 | 156 | v{image.version} 157 | 158 | 178 |
179 | ))} 180 |
181 | 182 |
183 | {!activeImage ? ( 184 | <> 185 |

186 | Edit any image with a simple prompt 187 |

188 | 189 |
190 | { 192 | setImageData({ width, height }); 193 | setImages([{ url, version: 0 }]); 194 | setActiveImageUrl(url); 195 | }} 196 | /> 197 |
198 | 199 |
200 | { 202 | setImageData({ width, height }); 203 | setImages([{ url, version: 0 }]); 204 | setActiveImageUrl(url); 205 | }} 206 | /> 207 |
208 | 209 | ) : ( 210 |
211 |
212 | uploaded image 224 | 225 |
226 |
227 |
228 |
229 | v{activeImage.version} 230 |
231 |
232 | {activeImage.prompt && ( 233 |
234 |

Prompt used:

235 |

236 | {activeImage.prompt} 237 |

238 |
239 | )} 240 |
241 | 248 |
249 | 250 |
251 | 252 | {pending && ( 253 |
254 | 255 |

256 | Editing your image... 257 |

258 |

259 | This can take up to 15 seconds. 260 |

261 |
262 | )} 263 |
264 | 265 |
266 | {activeImage ? ( 267 |
{ 272 | startTransition(async () => { 273 | const prompt = formData.get("prompt") as string; 274 | 275 | const generation = await generateImage({ 276 | imageUrl: activeImage.url, // Use the currently active image 277 | prompt, 278 | width: imageData.width, 279 | height: imageData.height, 280 | userAPIKey: localStorage.getItem("togetherApiKey"), 281 | model: selectedModel, 282 | }); 283 | 284 | if (generation.success) { 285 | await preloadNextImage({ 286 | src: generation.url, 287 | width: imageData.width, 288 | height: imageData.height, 289 | }); 290 | setImages((current) => [ 291 | ...current, 292 | { 293 | url: generation.url, 294 | prompt, 295 | version: current.length, 296 | }, 297 | ]); 298 | setActiveImageUrl(generation.url); 299 | setPrompt(""); 300 | } else { 301 | toast(generation.error); 302 | } 303 | }); 304 | }} 305 | > 306 |
307 | 313 |
314 | 336 |
337 | 343 | 349 | 350 |
351 |
352 | {!hasApiKey && 353 | selectedModel === 354 | "black-forest-labs/FLUX.1-kontext-pro" && ( 355 |

356 | Pro model requires an API key. Please add your 357 | Together AI API key to use this model. 358 |

359 | )} 360 |
361 | 362 |
363 |
364 | {/* Mobile: no autofocus */} 365 | setPrompt(e.target.value)} 371 | placeholder="Tell us the changes you want..." 372 | required 373 | /> 374 | 375 | {/* Desktop: autofocus */} 376 | setPrompt(e.target.value)} 383 | placeholder="Tell us the changes you want..." 384 | required 385 | /> 386 | 387 | 400 | 401 | { 404 | flushSync(() => { 405 | setPrompt(suggestion); 406 | }); 407 | formRef.current?.requestSubmit(); 408 | }} 409 | /> 410 |
411 |
412 | ) : ( 413 |

414 | Select an image to make more edits. 415 |

416 | )} 417 |
418 |
419 | )} 420 |
421 | 422 |
423 |
424 | 425 | ); 426 | } 427 | --------------------------------------------------------------------------------