diff --git a/app/components/ImageMask.tsx b/app/components/ImageMask.tsx new file mode 100644 index 0000000..a9dad5e --- /dev/null +++ b/app/components/ImageMask.tsx @@ -0,0 +1,294 @@ +// app/components/ImageMask.tsx + +import React, {useEffect, useRef, useState} from 'react'; +import {Button, Modal, Segmented, Slider, Spin} from 'antd'; +import {GatewayOutlined, HighlightOutlined, RedoOutlined, ZoomInOutlined, ZoomOutOutlined} from '@ant-design/icons'; + +type BrushType = 'free' | 'rectangle'; + +const ImageMaskModal = (props: { + open: boolean; + onClose: () => void; + originalImageUrl: string; + onFinished: (maskBase64: string) => void; +}) => { + const canvasRef = useRef(null); + const maskCanvasRef = useRef(null); + const tempCanvasRef = useRef(null); + const containerRef = useRef(null); + const [isDrawing, setIsDrawing] = useState(false); + const [maskCtx, setMaskCtx] = useState(null); + const [tempCtx, setTempCtx] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [imageLoadError, setImageLoadError] = useState(null); + // const [debugInfo, setDebugInfo] = useState(''); + const [scale, setScale] = useState(1); + const [originalSize, setOriginalSize] = useState({width: 0, height: 0}); + const [brushType, setBrushType] = useState('free'); + const [startPoint, setStartPoint] = useState<{ x: number; y: number } | null>(null); + + useEffect(() => { + if (props.open) { + // setDebugInfo(`Attempting to load image: ${props.originalImageUrl}`); + setIsLoading(true); + setImageLoadError(null); + + const img = new Image(); + img.crossOrigin = "Anonymous"; + + img.onload = () => { + // setDebugInfo(prev => `${prev}\nImage loaded successfully. Size: ${img.width}x${img.height}`); + setOriginalSize({width: img.width, height: img.height}); + if (canvasRef.current && maskCanvasRef.current && tempCanvasRef.current && containerRef.current) { + const canvas = canvasRef.current; + const maskCanvas = maskCanvasRef.current; + const tempCanvas = tempCanvasRef.current; + const container = containerRef.current; + const context = canvas.getContext('2d'); + const maskContext = maskCanvas.getContext('2d'); + const tempContext = tempCanvas.getContext('2d'); + canvas.width = img.width; + canvas.height = img.height; + maskCanvas.width = img.width; + maskCanvas.height = img.height; + tempCanvas.width = img.width; + tempCanvas.height = img.height; + context?.drawImage(img, 0, 0); + // setCtx(context); + setMaskCtx(maskContext); + setTempCtx(tempContext); + + // Calculate initial scale + const scaleX = container.clientWidth / img.width; + const scaleY = container.clientHeight / img.height; + const initialScale = Math.min(scaleX, scaleY, 1); + setScale(initialScale); + } + setIsLoading(false); + }; + + img.onerror = (e) => { + // setDebugInfo(prev => `${prev}\nImage failed to load. Error: ${e}`); + console.error(e); + setImageLoadError("图片加载失败"); + setIsLoading(false); + }; + + if (props.originalImageUrl.startsWith('data:image')) { + img.src = props.originalImageUrl; + } else { + img.src = `${props.originalImageUrl}?t=${new Date().getTime()}`; + } + } + }, [props.open, props.originalImageUrl]); + + const startDrawing = (e: React.MouseEvent) => { + if (!tempCtx) return; + setIsDrawing(true); + const {x, y} = getCoordinates(e); + if (brushType === 'rectangle') { + setStartPoint({x, y}); + } else { + tempCtx.beginPath(); + tempCtx.moveTo(x, y); + } + }; + + const stopDrawing = () => { + if (!tempCtx || !maskCtx || !isDrawing) return; + setIsDrawing(false); + if (brushType === 'rectangle' && startPoint) { + const {x, y} = startPoint; + const width = Math.abs(x - startPoint.x); + const height = Math.abs(y - startPoint.y); + const startX = Math.min(x, startPoint.x); + const startY = Math.min(y, startPoint.y); + tempCtx.fillRect(startX, startY, width, height); + } + maskCtx.drawImage(tempCanvasRef.current!, 0, 0); + tempCtx.clearRect(0, 0, tempCanvasRef.current!.width, tempCanvasRef.current!.height); + setStartPoint(null); + }; + + const draw = (e: React.MouseEvent) => { + if (!isDrawing || !tempCtx || !tempCanvasRef.current) return; + + const {x, y} = getCoordinates(e); + + tempCtx.lineWidth = 10 / scale; + tempCtx.lineCap = 'round'; + tempCtx.strokeStyle = 'white'; + tempCtx.fillStyle = 'white'; + + if (brushType === 'free') { + tempCtx.lineTo(x, y); + tempCtx.stroke(); + } else if (brushType === 'rectangle' && startPoint) { + tempCtx.clearRect(0, 0, tempCanvasRef.current.width, tempCanvasRef.current.height); + const width = x - startPoint.x; + const height = y - startPoint.y; + tempCtx.fillRect(startPoint.x, startPoint.y, width, height); + } + }; + + const handleReset = () => { + if (maskCtx && maskCanvasRef.current) { + maskCtx.clearRect(0, 0, maskCanvasRef.current.width, maskCanvasRef.current.height); + } + if (tempCtx && tempCanvasRef.current) { + tempCtx.clearRect(0, 0, tempCanvasRef.current.width, tempCanvasRef.current.height); + } + }; + + const getCoordinates = (e: React.MouseEvent): { x: number; y: number } => { + const canvas = maskCanvasRef.current!; + const rect = canvas.getBoundingClientRect(); + return { + x: (e.clientX - rect.left) / scale, + y: (e.clientY - rect.top) / scale + }; + }; + + const getMaskBase64 = () => { + if (!maskCanvasRef.current) return ''; + const tempCanvas = document.createElement('canvas'); + tempCanvas.width = maskCanvasRef.current.width; + tempCanvas.height = maskCanvasRef.current.height; + const tempCtx = tempCanvas.getContext('2d'); + if (tempCtx) { + tempCtx.fillStyle = 'black'; + tempCtx.fillRect(0, 0, tempCanvas.width, tempCanvas.height); + tempCtx.globalCompositeOperation = 'destination-out'; + tempCtx.drawImage(maskCanvasRef.current, 0, 0); + tempCtx.globalCompositeOperation = 'source-over'; + } + return tempCanvas.toDataURL('image/png').split(',')[1]; + }; + + const handleFinish = () => { + const maskBase64 = getMaskBase64(); + props.onFinished(maskBase64); + props.onClose(); + }; + + const handleZoom = (newScale: number) => { + setScale(newScale); + }; + + return ( + 取消, + , + ]} + closeIcon={false} + centered={true} + destroyOnClose={true} + width="70%" + style={{maxHeight: '80vh', overflow: 'auto'}} + > +
+ {/*

请在图片上绘制需要重绘的区域

*/} +

Please draw the area to be redrawn on the image

+
+ }, + // {label: '矩形工具', value: 'rectangle', icon: } + {label: 'Free Brush', value: 'free', icon: , disabled: isLoading}, + { + label: 'Rectangle Tool', + value: 'rectangle', + icon: , + disabled: isLoading + }, + ]} + value={brushType} + onChange={(value) => setBrushType(value as BrushType)} + /> + +
+ + + +
+
+ +
+ {imageLoadError ? ( +

{imageLoadError}

+ ) : ( +
+ + + +
+ )} +
+ {/*
*/}
+                    {/*    {debugInfo}*/}
+                    {/*
*/} +
+
+
+ ); +}; + +export default ImageMaskModal; diff --git a/app/pages/Midjourney.tsx b/app/pages/Midjourney.tsx index 68450fa..398c586 100644 --- a/app/pages/Midjourney.tsx +++ b/app/pages/Midjourney.tsx @@ -6,7 +6,7 @@ import { Divider, Empty, FloatButton, - Image, + Image, Input, message, Segmented, Space, @@ -52,6 +52,8 @@ import { import {CodeModal, renderCode, RenderSubmitter} from "@/app/render"; import {handelResponseError, safeJsonStringify} from "@/app/utils"; import {api2Provider, useAppConfig} from "@/app/store"; +import ImageMaskModal from "@/app/components/ImageMask"; +import {ProFormItem} from "@ant-design/pro-form"; const Midjourney_Preset_Description_Option = { styleList: [ @@ -810,69 +812,106 @@ const ModalForm = (props: { const [abortController, setAbortController] = useState(null); const [submitting, setSubmitting] = useState(false); - return ( - - form={props.form} - onFinish={async (values) => { - // Modal 任务返回的不是完整的任务信息,不需要更新任务列表,而是刷新当前任务的状态 + const [showImageMaskModal, setShowImageMaskModal] = useState(false); + const [originalImageUrl, setOriginalImageUrl] = useState(""); - props.updateError(null); - const controller = new AbortController(); - setAbortController(controller); - setSubmitting(true); - try { - const res = await props.api.submitModalTask(values, controller.signal); - const resJson = await res.json() as MidjourneySubmitTaskResponseType - if (res.ok && resJson.code === 1) { - message.success(resJson.description + " Please manually refresh the task."); - // 重置表单,避免重复提交 - props.form.resetFields(); - props.queryTask(values.taskId); - } else { - props.updateError(resJson); + + return ( + <> + + form={props.form} + onFinish={async (values) => { + // Modal 任务返回的不是完整的任务信息,不需要更新任务列表,而是刷新当前任务的状态 + + props.updateError(null); + const controller = new AbortController(); + setAbortController(controller); + setSubmitting(true); + try { + const res = await props.api.submitModalTask(values, controller.signal); + const resJson = await res.json() as MidjourneySubmitTaskResponseType + if (res.ok && resJson.code === 1) { + message.success(resJson.description + " . Please manually refresh the task."); + // 重置表单,避免重复提交 + props.form.resetFields(); + props.queryTask(values.taskId); + } else { + props.updateError(resJson); + } + } catch (e) { + console.error(e); + props.updateError(e); + } finally { + setAbortController(null); + setSubmitting(false); } - } catch (e) { - console.error(e); - props.updateError(e); - } finally { - setAbortController(null); - setSubmitting(false); - } - }} - submitter={{ - render: (submitterProps) => { - return JSON.stringify(props.form.getFieldsValue(), null, 2) || ""} - /> - } - }} - > - + }} + submitter={{ + render: (submitterProps) => { + return JSON.stringify(props.form.getFieldsValue(), null, 2) || ""} + /> + } + }} + > + - + - + + + + + setOriginalImageUrl(e.target.value)} + value={originalImageUrl} + style={{marginBottom: 8}} + autoSize={{minRows: 1, maxRows: 3}} + /> + + + + setShowImageMaskModal(false)} + onFinished={(maskBase64) => { + props.form.setFieldsValue({maskBase64}); + setShowImageMaskModal(false); + }} /> - + ) }