diff --git a/src/client/pages/common/TaskList/index.tsx b/src/client/pages/common/TaskList/index.tsx index 263cf1f..b2a4e13 100644 --- a/src/client/pages/common/TaskList/index.tsx +++ b/src/client/pages/common/TaskList/index.tsx @@ -19,7 +19,6 @@ import { TaskItemDeleteButton } from './components/TaskItemDeleteButton' import { TaskItemDownloadButton } from './components/TaskItemDownloadButton' import { TaskItemTags } from './components/TaskItemTags' import { TaskListHeader } from './TaskListHeader' - import { useState } from 'react' const client = hc('/') @@ -30,6 +29,7 @@ export function TaskList() { { defaultValue: [] }, ) + const handleRetry = async (task: Task) => { await client.api.gptImage.generate.$post({ json: { @@ -137,7 +137,7 @@ export function TaskList() {
- {task.rawTemplate && ( + {task.rawTemplate && task.taskType !== 'edit' && ( + )} +
+ + {!sourceDataUrl ? ( +
+ { + handleUserUpload(file as File) + return false + }} + className="!rounded !border-dashed" + > +
+ + + 点击或拖拽上传要编辑的图片 + + + 支持 PNG / JPEG / WebP + +
+
+
+ +
+
+ ) : ( + <> + {/* Hidden img for load detection */} + setImageStatus('error')} + /> + + {imageStatus === 'loading' && ( +
+ 加载图片中... +
+ )} + + {imageStatus === 'error' && ( +
图片加载失败
+ )} + + {imageStatus === 'ready' && ( +
+ {/* ── Mask area ────────────────────────────────── */} +
+
+ 蒙版 + {hasAlpha && ( + + 图片已含透明区域,将作为编辑蒙版 + + )} +
+ + {hasAlpha ? ( +
+ 无需蒙版,图片自带透明度 +
+ ) : ( + <> + setMaskMode(e.target.value)} + size="small" + className="mb-2" + > + 画笔绘制 + 上传蒙版 + + + {maskMode === 'brush' && ( +
+ {/* Source image on top of canvas as reference */} +
+ + + {maskDataUrl && ( +
+
+ )} +
+ + {/* Brush size */} +
+ 画笔大小 + + {brushSize}px +
+ + {/* Source opacity — NEW */} +
+ 原图透明度 + + {sourceOpacity}% +
+
+ )} + + {maskMode === 'upload' && ( +
+ {/* Source image — moved here, only in upload mode */} +
+
原图
+
+ source +
+
+ + {/* Mask upload */} +
+
上传蒙版
+ {maskDataUrl && uploadedMaskFile ? ( +
+ mask +
+ ) : ( + { handleMaskUpload({ originFileObj: file } as UploadFile); return false }} className="!rounded !border-dashed"> +
+ + 点击或拖拽上传蒙版 PNG + 尺寸需与原图一致 ({imgNaturalSize.current.w}×{imgNaturalSize.current.h}) +
+
+ )} +
+
+ )} + + )} +
+ + {/* ── Prompt ──────────────────────────────────── */} +
+
编辑提示词
+ setPrompt(e.target.value)} + rows={2} + /> +
+ + {/* ── Submit button ────────────────────────────── */} +
+ +
+ + +
+ )} + + )} +
+ ) +} diff --git a/src/client/pages/module/ImageEdit/useCanvasMask.ts b/src/client/pages/module/ImageEdit/useCanvasMask.ts new file mode 100644 index 0000000..3525a5d --- /dev/null +++ b/src/client/pages/module/ImageEdit/useCanvasMask.ts @@ -0,0 +1,124 @@ +import { useEffect, useRef, useState } from 'react' + +interface UseCanvasMaskOptions { + hasAlpha: boolean + imageStatus: 'loading' | 'ready' | 'error' + sourceDataUrl: string + maskMode: 'brush' | 'upload' + /** Called when the canvas mask changes (drawn or reset) */ + onMaskChange?: (dataUrl: string) => void + imgRef: React.RefObject +} + +export function useCanvasMask({ + hasAlpha, + imageStatus, + sourceDataUrl, + maskMode, + onMaskChange, + imgRef, +}: UseCanvasMaskOptions) { + const canvasRef = useRef(null) + const [brushSize, setBrushSize] = useState(40) + const isDrawing = useRef(false) + const hasDrawn = useRef(false) + + // ── Drawing helpers ──────────────────────────────────────── + + const getCanvasPos = (e: React.MouseEvent) => { + const canvas = canvasRef.current + if (!canvas) return { x: 0, y: 0 } + const rect = canvas.getBoundingClientRect() + return { + x: (e.clientX - rect.left) * (canvas.width / rect.width), + y: (e.clientY - rect.top) * (canvas.height / rect.height), + } + } + + const drawAt = (x: number, y: number) => { + const canvas = canvasRef.current + if (!canvas) return + const ctx = canvas.getContext('2d') + if (!ctx) return + ctx.globalCompositeOperation = 'destination-out' + ctx.beginPath() + ctx.arc(x, y, brushSize, 0, Math.PI * 2) + ctx.fill() + } + + const syncMaskToParent = () => { + if (canvasRef.current && onMaskChange) { + onMaskChange(canvasRef.current.toDataURL('image/png')) + } + } + + const startDraw = (e: React.MouseEvent) => { + if (hasAlpha) return + isDrawing.current = true + hasDrawn.current = true + const pos = getCanvasPos(e) + drawAt(pos.x, pos.y) + } + + const draw = (e: React.MouseEvent) => { + if (!isDrawing.current) return + const pos = getCanvasPos(e) + drawAt(pos.x, pos.y) + } + + const stopDraw = () => { + isDrawing.current = false + syncMaskToParent() + } + + const initCanvas = () => { + const canvas = canvasRef.current + if (!canvas) return + const img = imgRef.current + if (!img) return + + const maxW = 400 + const scale = Math.min(maxW / img.naturalWidth, 1) + const w = Math.round(img.naturalWidth * scale) + const h = Math.round(img.naturalHeight * scale) + + canvas.width = w + canvas.height = h + + const ctx = canvas.getContext('2d') + if (!ctx) return + + ctx.fillStyle = '#000000' + ctx.fillRect(0, 0, w, h) + ctx.globalCompositeOperation = 'source-over' + syncMaskToParent() + } + + useEffect(() => { + if (imageStatus === 'ready' && sourceDataUrl && !hasAlpha && maskMode === 'brush') { + initCanvas() + hasDrawn.current = false + } + }, [imageStatus, sourceDataUrl, hasAlpha, maskMode]) + + const resetCanvas = () => { + hasDrawn.current = false + if (onMaskChange) onMaskChange('') + initCanvas() + } + + return { + canvasRef, + brushSize, + setBrushSize, + resetCanvas, + hasDrawn, + /** Mouse event handlers to spread on the element */ + handlers: { + onMouseDown: startDraw, + onMouseMove: draw, + onMouseUp: stopDraw, + onMouseLeave: stopDraw, + }, + } +} diff --git a/src/client/routes.tsx b/src/client/routes.tsx index 9119d39..c335507 100644 --- a/src/client/routes.tsx +++ b/src/client/routes.tsx @@ -1,6 +1,7 @@ import { Home } from './pages/common/Home' -import { TTS } from './pages/module/GeminiTTS' +import { ImageEdit } from './pages/module/ImageEdit' import { MediaClassifier } from './pages/module/MediaClassifier' +import { TTS } from './pages/module/GeminiTTS' export const appRoutes = [ { @@ -9,6 +10,12 @@ export const appRoutes = [ element: , key: 'home', }, + { + path: '/image-edit', + label: '图片编辑', + element: , + key: 'image-edit', + }, { path: '/tts', label: '语音合成', diff --git a/src/client/utils/image.ts b/src/client/utils/image.ts new file mode 100644 index 0000000..4a877d8 --- /dev/null +++ b/src/client/utils/image.ts @@ -0,0 +1,38 @@ +/** + * Check if an HTMLImageElement has any transparent (alpha < 255) pixels. + */ +export function hasTransparentPixels(img: HTMLImageElement): Promise { + return new Promise((resolve) => { + const canvas = document.createElement('canvas') + canvas.width = img.naturalWidth + canvas.height = img.naturalHeight + const ctx = canvas.getContext('2d') + if (!ctx) { + resolve(false) + return + } + ctx.drawImage(img, 0, 0) + const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data + for (let i = 3; i < data.length; i += 4) { + if (data[i] < 255) { + resolve(true) + return + } + } + resolve(false) + }) +} + +/** + * Fetch an image URL and return its content as a base64 data URL. + */ +export async function fetchImageAsBase64(url: string): Promise { + const resp = await fetch(url) + const blob = await resp.blob() + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(reader.result as string) + reader.onerror = () => reject(new Error('图片读取失败')) + reader.readAsDataURL(blob) + }) +} diff --git a/src/server/api/gpt-image.ts b/src/server/api/gpt-image.ts index fcbf463..e806ed9 100644 --- a/src/server/api/gpt-image.ts +++ b/src/server/api/gpt-image.ts @@ -1,13 +1,19 @@ import { zValidator } from '@hono/zod-validator' import { Hono } from 'hono' import { v4 as uuidv4 } from 'uuid' -import { z } from 'zod' -import { getYunwuApiKey } from '../common/config' import { TaskTemplate, templateManager } from '../common/template-manager' import { TRIAL_TEMPLATE_TITLE } from '../common/template-manager/enum' import { handleImageGeneration } from '../module/gpt-image' import { GPT_IMAGE_OUTPUT_MAX_N } from '../module/gpt-image/enum' +import { GPT_IMAGE_SOURCE_MODEL } from '../module/gpt-image/enum' +import { taskManager } from '../common/task-manager' +import { handleImageEdit } from '../module/gpt-image/edit' +import { GENERATED_IMAGES_API_PATH } from '../common/static/enum' +import { z } from 'zod' +import { getYunwuApiKey } from '../common/config' +import { Logger } from '../module/utils/logger' +const editLogger = new Logger('gpt-image-edit') export interface GPTImageQuotaResponse { message: string data: { @@ -57,6 +63,96 @@ const gptImageApi = new Hono() ) } }) + .post( + '/edit', + zValidator( + 'json', + z.object({ + image: z.string().min(1, 'Image is required'), + mask: z.string().optional(), + prompt: z.string().min(1, 'Prompt is required'), + n: z + .number() + .min(1) + .max(4) + .optional() + .default(1), + quality: z + .enum(['standard', 'low', 'medium', 'high', 'auto']) + .optional() + .default('auto'), + }), + ), + async (c) => { + const { image, mask, prompt, n, quality } = c.req.valid('json') + const apiKey = getYunwuApiKey() + if (!apiKey) { + return c.json( + { success: false as const, error: '[配置] API Key is not configured' }, + 400, + ) + } + + // Create a pseudo-template to track the edit task + const editTemplate: TaskTemplate = { + id: uuidv4(), + title: '图片编辑', + prompt, + images: [], + createdAt: Date.now(), + } + + const task = await taskManager.createTaskFromTemplate({ + template: editTemplate, + source: GPT_IMAGE_SOURCE_MODEL, + }) + if (!task) { + return c.json( + { success: false as const, error: '[服务] Failed to create task' }, + 500, + ) + } + + await taskManager.updateTaskStatus(task.id, 'running') + await taskManager.updateTask(task.id, { taskType: 'edit' }) + const taskId = task.id + const startTime = Date.now() + + // Return immediately — process in background + ;(async () => { + try { + editLogger.info(`Edit generation started — task=${taskId} prompt="${prompt.slice(0, 40)}${prompt.length > 40 ? '...' : ''}"`) + const result = await handleImageEdit({ + apiKey, + baseURL: 'https://api.wlai.vip/v1', + imageBase64: image, + maskBase64: mask, + prompt, + n, + quality, + }) + const duration = Date.now() - startTime + const outputUrls = result.filenames.map( + (f) => `${GENERATED_IMAGES_API_PATH}/${f}`, + ) + editLogger.info(`Edit generation completed — task=${taskId} files=${result.filenames.length} duration=${duration}ms`) + await taskManager.updateTask(taskId, { + status: 'completed', + duration, + outputUrls, + }) + } catch (error: any) { + editLogger.error(`Edit generation failed — task=${taskId} error=${error.message}`) + await taskManager.updateTaskStatus(taskId, 'failed', `[api.wlai.vip] ${error.message}`) + } + })() + + return c.json({ + success: true as const, + taskId, + }) + }, + ) .post( '/generate', zValidator( diff --git a/src/server/common/task-manager/index.ts b/src/server/common/task-manager/index.ts index 8286209..2831118 100644 --- a/src/server/common/task-manager/index.ts +++ b/src/server/common/task-manager/index.ts @@ -18,6 +18,8 @@ export interface Task { outputUrl?: string outputUrls?: string[] createdAt: number + /** 'generate' | 'edit' | etc. */ + taskType?: string size?: GptImageSize quality?: GptImageQuality [key: string]: any @@ -50,7 +52,7 @@ export class TaskManager extends EventEmitter { for (const task of tasks) { if (task.status === 'pending' || task.status === 'running') { task.status = 'failed' - task.error = '连接已丢失' + task.error = '[服务] 连接已丢失' changed = true } } diff --git a/src/server/module/gpt-image/edit.ts b/src/server/module/gpt-image/edit.ts new file mode 100644 index 0000000..51e9567 --- /dev/null +++ b/src/server/module/gpt-image/edit.ts @@ -0,0 +1,114 @@ +import crypto from 'crypto' +import { writeFile } from 'fs/promises' +import OpenAI, { toFile } from 'openai' +import type { ImagesResponse } from 'openai/resources/images' +import path from 'path' +import { GENERATED_IMAGES_DIR } from '../../common/static' +import { Logger } from '../utils/logger' + +const logger = new Logger('gpt-image-edit') + +export interface EditImageOptions { + apiKey: string + baseURL: string + imageBase64: string + maskBase64?: string + prompt: string + model?: string + n?: number + quality?: 'standard' | 'low' | 'medium' | 'high' | 'auto' +} + +export async function handleImageEdit(options: EditImageOptions) { + const { + apiKey, + baseURL, + imageBase64, + maskBase64, + prompt, + model = 'gpt-image-2', + n = 1, + quality = 'auto', + } = options + + logger.info(`Step 1/5 — Creating OpenAI client — baseURL=${baseURL} model=${model}`) + const client = new OpenAI({ apiKey, baseURL }) + + logger.info(`Step 2/5 — Decoding image (${imageBase64.length} chars)`) + const imageBuffer = Buffer.from(imageBase64.replace(/^data:image\/\w+;base64,/, ''), 'base64') + logger.info(` → image buffer: ${imageBuffer.length} bytes`) + const imageFile = await toFile(imageBuffer, 'image.png', { type: 'image/png' }) + + let maskFile: File | undefined + if (maskBase64) { + logger.info(`Step 2b/5 — Decoding mask (${maskBase64.length} chars)`) + const maskBuffer = Buffer.from(maskBase64.replace(/^data:image\/\w+;base64,/, ''), 'base64') + logger.info(` → mask buffer: ${maskBuffer.length} bytes`) + maskFile = await toFile(maskBuffer, 'mask.png', { type: 'image/png' }) + } else { + logger.info(`Step 2b/5 — No mask provided`) + } + + logger.info(`Step 3/5 — Calling client.images.edit() — n=${n}`) + let res: ImagesResponse + try { + res = await client.images.edit({ + model, + image: imageFile, + mask: maskFile, + prompt, + n, + quality, + output_format: 'webp', + size: 'auto', + response_format: 'b64_json', + }) + } catch (apiError: unknown) { + const message = apiError instanceof Error ? apiError.message : String(apiError) + const status = apiError && typeof apiError === 'object' && 'status' in apiError + ? (apiError as { status: number }).status : undefined + logger.error(`Step 3/5 FAILED — error=${message} status=${status}`) + if (apiError instanceof Error && apiError.stack) { + logger.error(` stack: ${apiError.stack.split('\n').slice(0, 4).join(' | ')}`) + } + throw apiError + } + + logger.info(`Step 4/5 — API returned`) + + // Standard SDK returns data as Image[], but yunwu.ai returns data as { b64_json } + const items = Array.isArray(res.data) + ? res.data + : res.data + ? [res.data as unknown as { b64_json?: string; url?: string }] + : [] + + logger.info(` → items=${items.length}`) + + const filenames: string[] = [] + for (let i = 0; i < items.length; i++) { + const item = items[i] + if (item.b64_json) { + const imgBuffer = Buffer.from(item.b64_json, 'base64') + const hash = crypto.createHash('md5').update(imgBuffer).digest('hex') + const filename = `${hash}.webp` + await writeFile(path.join(GENERATED_IMAGES_DIR, filename), imgBuffer) + filenames.push(filename) + logger.info(` → saved image[${i}]: ${filename} (${imgBuffer.length} bytes)`) + } else if (item.url) { + logger.info(` → image[${i}] has url=${item.url} — fetching...`) + const imgResp = await fetch(item.url) + const imgBuffer = Buffer.from(await imgResp.arrayBuffer()) + const urlHash = crypto.createHash('md5').update(imgBuffer).digest('hex') + const filename = `${urlHash}.webp` + await writeFile(path.join(GENERATED_IMAGES_DIR, filename), imgBuffer) + filenames.push(filename) + logger.info(` → saved image[${i}] from url: ${filename} (${imgBuffer.length} bytes)`) + } else { + logger.warn(` → image[${i}] has neither b64_json nor url`) + } + } + + logger.info(`Step 5/5 — Done — ${filenames.length} files saved`) + return { filenames } +}