├── .eslintrc.json ├── .gitignore ├── .prettierignore ├── .prettierrc ├── README.md ├── app ├── api │ ├── image │ │ └── route.ts │ ├── prompt │ │ └── route.ts │ └── translate │ │ └── route.ts ├── components │ ├── App.tsx │ ├── Painting.tsx │ └── Prompt.tsx ├── favicon.ico ├── global.css ├── layout.tsx ├── lib │ ├── config.ts │ ├── tasks.ts │ ├── types.ts │ ├── useImageSize.tsx │ ├── useZustand.tsx │ └── utils.ts ├── page.tsx └── widgets │ ├── Config.tsx │ ├── Images.tsx │ └── Tasks.tsx ├── bun.lockb ├── license ├── next.config.mjs ├── package.json ├── postcss.config.mjs ├── public └── avatar.jpg ├── readme ├── 1.png ├── 2.png ├── 3.png └── 4.png ├── tsconfig.json └── workers ├── README.md ├── bun.lock ├── package.json ├── src ├── index.ts └── routes │ ├── painter_generate.ts │ ├── painter_genprompt_v4.ts │ └── painter_translate.ts └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "next/typescript", "prettier"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | workers/wrangler.toml 3 | workers/node_modules 4 | workers/dist 5 | workers/.wrangler 6 | # dependencies 7 | /node_modules 8 | /.pnp 9 | .pnp.* 10 | .yarn/* 11 | !.yarn/patches 12 | !.yarn/plugins 13 | !.yarn/releases 14 | !.yarn/versions 15 | 16 | # testing 17 | /coverage 18 | 19 | # next.js 20 | /.next/ 21 | /out/ 22 | 23 | # production 24 | /build 25 | 26 | # misc 27 | .DS_Store 28 | *.pem 29 | 30 | # debug 31 | npm-debug.log* 32 | yarn-debug.log* 33 | yarn-error.log* 34 | 35 | # env files (can opt-in for committing if needed) 36 | .env* 37 | 38 | # vercel 39 | .vercel 40 | 41 | # typescript 42 | *.tsbuildinfo 43 | next-env.d.ts 44 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore artifacts: 2 | node_modules 3 | .next 4 | public 5 | dist -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": false, 4 | "jsxSingleQuote": true 5 | } 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **Please deploy your own server cause mine exceeds the free plan limit.** (`2025.03.18` I've added an optional "server password" feature to prevent unauthorized access.) 2 | 3 | | ![](./readme/3.png) | ![](./readme/4.png) | 4 | | :-----------------: | :-----------------: | 5 | 6 | # Painter Leaf 7 | 8 | A image creator based on **free** `Cloudflare AI` and `HuggingFace` APIs. Features include: 9 | 10 | - Prompt-to-image: supports a variety of models (e.g. `Flux.1`、`StableDiffusion 3.5`) 11 | - Image-to-Prompt: convert local images to prompts 12 | - AI Translation: use Chinese prompts in any model 13 | - Store: save your creations to `IndexedDB` 14 | 15 | ![](./readme/1.png) 16 | 17 | ![](./readme/2.png) 18 | 19 | ## 1 Deployment 20 | 21 | ### 1.1 Config Environment Variables 22 | 23 | You can use either `Fullstack` or `Client-Server` mode and set the corresponding environment variables. 24 | 25 | > You may need to initialize `Cloudflare AI` `llama3.2 11B vision` model before using `Image-to-Prompt` feature. See [here](https://developers.cloudflare.com/workers-ai/models/llama-3.2-11b-vision-instruct/) for more information. 26 | 27 | #### 1.1.1 Fullstack 28 | 29 | Set following environment variables in `.env` file or `Vercel`. 30 | 31 | | Key | Value | Required | 32 | | :---------------: | :-------------------------------: | :------: | 33 | | `CF_USER_ID` | `Cloudflare` user id | ✅ | 34 | | `CF_AI_API_KEY` | `Cloudflare AI` api key | ✅ | 35 | | `HF_API_KEY` | `HuggingFace` api key | | 36 | | `SERVER_PASSWORD` | Password for accessing the server | | 37 | 38 | > The free plan of `Vercel` has a limit of 10s for each request, which may cause `504` error (especially when using `HuggingFace` models). You can subscribe to a `Vercel` paid plan, run the server locally, or use `Client-Server` mode. 39 | 40 | #### 1.1.2 Client-Server 41 | 42 | Deploy the server (see `workers` folder) to `Cloudflare Workers` and set following environment variables in `.env` file or `Vercel`. 43 | 44 | | Key | Value | Required | 45 | | :--------------------------: | :-----------------------------------------------: | :------: | 46 | | `NEXT_PUBLIC_WORKERS_SERVER` | `Server` url (e.g. `https://api.xxx.workers.dev`) | ✅ | 47 | 48 | > Once `NEXT_PUBLIC_WORKERS_SERVER` is set, all the other environment variables will be ignored. 49 | 50 | ### 1.2 Deploy to Vercel 51 | 52 | Deploy this project to `Vercel` (remember to set environment variables when deploying). 53 | 54 | ### 1.3 Common Issues 55 | 56 | - `429` error: You may have exceeded the `HuggingFace` api request limit. Please wait for a while, reduce the frequency of requests, and consider subscribing to a paid plan. 57 | - `504` error: The request may have exceeded the `Vercel` time limit. See [above](#vervel-limit-resolution) for resolution. 58 | 59 | ## 2 Development 60 | 61 | ### 2.1 Clone the repository 62 | 63 | ```bash 64 | git clone https://github.com/LeafYeeXYZ/PainterLeaf.git 65 | cd PainterLeaf 66 | ``` 67 | 68 | ### 2.2 Install dependencies 69 | 70 | ```bash 71 | bun install # or use other package manager you like 72 | ``` 73 | 74 | ### 2.3 Local Development 75 | 76 | ```bash 77 | bun run dev 78 | ``` 79 | 80 | ### 2.4 Build 81 | 82 | ```bash 83 | bun run build 84 | ``` 85 | -------------------------------------------------------------------------------- /app/api/image/route.ts: -------------------------------------------------------------------------------- 1 | import { InferenceClient } from '@huggingface/inference' 2 | 3 | async function getImageFromHuggingFace( 4 | model: string, 5 | prompt: string, 6 | env: { HF_API_KEY: string }, 7 | ): Promise { 8 | const client = new InferenceClient(env.HF_API_KEY) 9 | const image = (await client.textToImage( 10 | { 11 | provider: 'auto', 12 | model: model.replace(/^@hf\//, ''), // 移除 @hf/ 前缀 13 | inputs: prompt, 14 | }, 15 | { outputType: 'blob' }, 16 | )) as Blob 17 | return new Response(image, { 18 | status: 200, 19 | headers: { 20 | 'content-type': 'image/png', 21 | }, 22 | }) 23 | } 24 | 25 | async function getImageFromCloudflare( 26 | model: string, 27 | prompt: string, 28 | env: { CF_USER_ID: string; CF_AI_API_KEY: string }, 29 | ): Promise { 30 | const url = `https://api.cloudflare.com/client/v4/accounts/${env.CF_USER_ID}/ai/run/${model}` 31 | const options = { 32 | method: 'POST', 33 | headers: { 34 | 'content-type': 'application/json', 35 | Authorization: `Bearer ${env.CF_AI_API_KEY}`, 36 | }, 37 | body: JSON.stringify({ 38 | prompt: prompt, 39 | negative_prompt: 40 | 'lowres, bad, text, error, missing, extra, fewer, cropped, jpeg artifacts, worst quality, bad quality, watermark, bad aesthetic, unfinished, chromatic aberration, scan, scan artifacts', 41 | }), 42 | } 43 | // 针对 Cloudflare 的 FLUX.1 Schnell 模型的特殊处理 44 | if (model === '@cf/black-forest-labs/flux-1-schnell') { 45 | options.body = JSON.stringify({ 46 | num_steps: 8, 47 | ...JSON.parse(options.body), 48 | }) 49 | } 50 | const response = await fetch(url, options) 51 | if (!response.ok) { 52 | const errorText = await response.text() 53 | throw new Error(`Cloudflare API Error: ${errorText}`) 54 | } 55 | // 针对 Cloudflare 的 FLUX.1 Schnell 模型的特殊处理 56 | if (model === '@cf/black-forest-labs/flux-1-schnell') { 57 | const res = await response.json() 58 | const base64 = res.result.image as string 59 | const buffer = new Uint8Array( 60 | atob(base64) 61 | .split('') 62 | .map((c) => c.charCodeAt(0)), 63 | ) 64 | return new Response(buffer, { 65 | status: response.status, 66 | headers: { 67 | 'content-type': 'image/png', 68 | }, 69 | }) 70 | } 71 | return new Response(response.body, { 72 | status: response.status, 73 | headers: { 74 | 'content-type': response.headers.get('content-type') ?? 'text/plain', 75 | }, 76 | }) 77 | } 78 | 79 | export async function POST(req: Request): Promise { 80 | try { 81 | const { model, prompt, password } = await req.json() 82 | if ( 83 | process.env.SERVER_PASSWORD && 84 | password !== process.env.SERVER_PASSWORD 85 | ) { 86 | return new Response('Unauthorized - Invalid Server Password', { 87 | status: 401, 88 | statusText: 'Unauthorized - Invalid Server Password', 89 | }) 90 | } 91 | const cfReg = /^@cf\// 92 | const hfReg = /^@hf\// 93 | if (cfReg.test(model)) { 94 | const response = await getImageFromCloudflare(model, prompt, { 95 | CF_USER_ID: process.env.CF_USER_ID ?? '', 96 | CF_AI_API_KEY: process.env.CF_AI_API_KEY ?? '', 97 | }) 98 | return response 99 | } 100 | if (hfReg.test(model)) { 101 | const response = await getImageFromHuggingFace(model, prompt, { 102 | HF_API_KEY: process.env.HF_API_KEY ?? '', 103 | }) 104 | return response 105 | } 106 | return new Response('Unsupported model', { 107 | status: 400, 108 | statusText: 'Unsupported model', 109 | }) 110 | } catch (e) { 111 | return new Response( 112 | e instanceof Error ? e.message : 'Unkown Server Error', 113 | { status: 500 }, 114 | ) 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /app/api/prompt/route.ts: -------------------------------------------------------------------------------- 1 | export async function POST(req: Request): Promise { 2 | try { 3 | const { image, password } = await req.json() 4 | if ( 5 | process.env.SERVER_PASSWORD && 6 | password !== process.env.SERVER_PASSWORD 7 | ) { 8 | return new Response('Unauthorized - Invalid Server Password', { 9 | status: 401, 10 | statusText: 'Unauthorized - Invalid Server Password', 11 | }) 12 | } 13 | const url = `https://api.cloudflare.com/client/v4/accounts/${process.env.CF_USER_ID}/ai/run/@cf/meta/llama-3.2-11b-vision-instruct` 14 | const body = { 15 | image: image as number[], 16 | max_tokens: 4096, 17 | prompt: 18 | 'Analyze the given image and provide a detailed (but less than 8 sentences) description. Include details about the main subject/people/characters, background, colors, composition, and mood. Ensure the description is vivid and suitable for input into a text-to-image generation model (which means it should be in only one paragraph and not contain any bullet points or lists).', 19 | } 20 | const response = await fetch(url, { 21 | method: 'POST', 22 | headers: { 23 | 'content-type': 'application/json', 24 | Authorization: `Bearer ${process.env.CF_AI_API_KEY}`, 25 | }, 26 | body: JSON.stringify(body), 27 | }) 28 | const result = await response.json() 29 | return Response.json(result) 30 | } catch { 31 | return new Response('Failed to generate prompt', { status: 500 }) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/api/translate/route.ts: -------------------------------------------------------------------------------- 1 | export async function POST(req: Request): Promise { 2 | try { 3 | const url = `https://api.cloudflare.com/client/v4/accounts/${process.env.CF_USER_ID}/ai/run/@cf/meta/m2m100-1.2b` 4 | const { zh, password } = await req.json() 5 | if ( 6 | process.env.SERVER_PASSWORD && 7 | password !== process.env.SERVER_PASSWORD 8 | ) { 9 | return new Response('Unauthorized - Invalid Server Password', { 10 | status: 401, 11 | statusText: 'Unauthorized - Invalid Server Password', 12 | }) 13 | } 14 | const res = await fetch(url, { 15 | method: 'POST', 16 | headers: { 17 | 'content-type': 'application/json', 18 | Authorization: `Bearer ${process.env.CF_AI_API_KEY}`, 19 | }, 20 | body: JSON.stringify({ 21 | text: zh, 22 | source_lang: 'zh', 23 | target_lang: 'en', 24 | }), 25 | }) 26 | const result = await res.json() 27 | if (result?.success) { 28 | return Response.json(result) 29 | } else { 30 | return new Response('Translate Failed', { status: 500 }) 31 | } 32 | } catch (e) { 33 | return new Response( 34 | e instanceof Error ? e.message : 'Unkown Server Error', 35 | { status: 500 }, 36 | ) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /app/components/App.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import Painting from './Painting' 4 | import Prompt from './Prompt' 5 | import { useState, useEffect } from 'react' 6 | import { ConfigProvider, type ThemeConfig, message } from 'antd' 7 | import { ANTD_THEME_DARK, ANTD_THEME_LIGHT } from '../lib/config' 8 | import { getStaredImages } from '../lib/utils' 9 | import { useZustand } from '../lib/useZustand' 10 | import { handleTasks } from '../lib/tasks' 11 | 12 | export default function App() { 13 | // 动态设置主题 14 | const [config, setConfig] = useState(ANTD_THEME_LIGHT) 15 | useEffect(() => { 16 | const getTheme = () => 17 | window.matchMedia('(prefers-color-scheme: dark)').matches 18 | const subTheme = () => 19 | setConfig(getTheme() ? ANTD_THEME_DARK : ANTD_THEME_LIGHT) 20 | setConfig(getTheme() ? ANTD_THEME_DARK : ANTD_THEME_LIGHT) 21 | window 22 | .matchMedia('(prefers-color-scheme: dark)') 23 | .addEventListener('change', subTheme) 24 | return () => 25 | window 26 | .matchMedia('(prefers-color-scheme: dark)') 27 | .removeEventListener('change', subTheme) 28 | }, []) 29 | // 初始化图片 30 | const tasks = useZustand((state) => state.tasks) 31 | const setTasks = useZustand((state) => state.setTasks) 32 | const setImages = useZustand((state) => state.setImages) 33 | const hasImage = useZustand((state) => state.hasImage) 34 | const setMessageApi = useZustand((state) => state.setMessageApi) 35 | useEffect(() => { 36 | getStaredImages().then((images) => setImages(() => images)) 37 | }, [setImages]) 38 | // 处理任务 39 | useEffect(() => { 40 | handleTasks(tasks, setTasks, setImages, hasImage) 41 | }, [tasks, setTasks, setImages, hasImage]) 42 | // 消息提示 43 | const [messageApi, contextHolder] = message.useMessage() 44 | useEffect(() => { 45 | setMessageApi(messageApi) 46 | }, [messageApi, setMessageApi]) 47 | 48 | return ( 49 | 50 |
51 |
52 |
53 | 54 |
55 |
56 |
57 |
58 | 59 |
60 |
61 |
62 | {contextHolder} 63 |
64 | ) 65 | } 66 | -------------------------------------------------------------------------------- /app/components/Painting.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Segmented, Badge } from 'antd' 4 | import { 5 | PictureOutlined, 6 | BarsOutlined, 7 | SettingOutlined, 8 | } from '@ant-design/icons' 9 | import { useState } from 'react' 10 | import Images from '../widgets/Images' 11 | import Tasks from '../widgets/Tasks' 12 | import Config from '../widgets/Config' 13 | import { useZustand } from '../lib/useZustand' 14 | 15 | export default function Painting() { 16 | const tasks = useZustand((state) => state.tasks) 17 | const containerID = 'images-container' 18 | const [page, setPage] = useState( 19 | , 20 | ) 21 | return ( 22 |
23 |
24 | task.status !== 'success' && task.status !== 'error', 29 | ).length 30 | } 31 | > 32 | , label: 'Config' }, 36 | { value: 'Images', icon: , label: 'Images' }, 37 | { value: 'Tasks', icon: , label: 'Tasks' }, 38 | ]} 39 | onChange={(value) => { 40 | switch (value) { 41 | case 'Config': 42 | setPage() 43 | break 44 | case 'Images': 45 | setPage() 46 | break 47 | case 'Tasks': 48 | setPage() 49 | break 50 | } 51 | }} 52 | defaultValue='Images' 53 | /> 54 | 55 |
56 |
60 | {page} 61 |
62 |
63 | ) 64 | } 65 | -------------------------------------------------------------------------------- /app/components/Prompt.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Models } from '../lib/config' 4 | import { Form, Button, Select, Input, Space, Upload, Popover } from 'antd' 5 | import { FileImageOutlined, FileAddOutlined } from '@ant-design/icons' 6 | import { useState } from 'react' 7 | import { flushSync } from 'react-dom' 8 | import type { Task } from '../lib/types' 9 | import { getPromptLanguage, getPassword } from '../lib/utils' 10 | import { useZustand } from '../lib/useZustand' 11 | 12 | type FormValues = { 13 | prompt: string 14 | model: string 15 | } 16 | 17 | export default function Prompt() { 18 | const setTasks = useZustand((state) => state.setTasks) 19 | const messageApi = useZustand((state) => state.messageApi) 20 | const [disabled, setDisabled] = useState(false) 21 | const handleFinish = (value: FormValues) => { 22 | flushSync(() => setDisabled(true)) 23 | const model = Models.find((m) => m.value === value.model)! 24 | const task = { 25 | prompt: value.prompt, 26 | trigger: model.trigger, 27 | model: model.value, 28 | promptLanguage: getPromptLanguage(), 29 | status: 'waiting' as Task['status'], 30 | createTimestamp: Date.now(), 31 | } 32 | setTasks((prev) => [task, ...prev]) 33 | messageApi?.success('Added to tasks', 1) 34 | setTimeout(() => setDisabled(false), 10) 35 | } 36 | const [form] = Form.useForm() 37 | 38 | return ( 39 |
40 |
50 | ({ 55 | validator(_, value) { 56 | if (!value) return Promise.resolve() 57 | const promptLanguage = getPromptLanguage() 58 | if ( 59 | promptLanguage === 'en' && 60 | (value as string).match(/[\u4e00-\u9fa5]/) 61 | ) { 62 | return Promise.reject( 63 | new Error( 64 | 'Chinese characters are not allowed in English prompt', 65 | ), 66 | ) 67 | } else if ( 68 | promptLanguage === 'zh' && 69 | !(value as string).match(/[\u4e00-\u9fa5]/) 70 | ) { 71 | return Promise.reject( 72 | new Error('请输入中文提示词, 或在设置中切换至英文'), 73 | ) 74 | } 75 | return Promise.resolve() 76 | }, 77 | }), 78 | ]} 79 | > 80 | 84 | 85 | 86 | 91 |