From 542f048a67129dfcfacf6ef5ecc1afbb5bc735fb Mon Sep 17 00:00:00 2001 From: jialin Date: Mon, 16 Dec 2024 15:52:33 +0800 Subject: [PATCH] chore: image edit ux --- src/components/auto-image/index.tsx | 61 +-- src/components/auto-image/progress-line.less | 44 ++ src/components/auto-image/single-image.less | 7 + src/components/auto-image/single-image.tsx | 92 +++- src/components/image-editor/index.less | 18 + src/components/image-editor/index.tsx | 433 ++++++++++++++++++ src/global.less | 4 + src/pages/playground/apis/index.ts | 1 + .../playground/components/ground-images.tsx | 59 ++- .../playground/components/image-edit.tsx | 369 ++++++++++----- src/pages/playground/components/thumb-img.tsx | 77 +++- .../playground/components/upload-img.tsx | 69 ++- src/pages/playground/config/params-config.ts | 20 + src/pages/playground/style/ground-left.less | 1 - src/pages/playground/view-code/image.ts | 43 +- src/utils/fetch-chunk-data.ts | 57 ++- src/utils/index.ts | 33 ++ 17 files changed, 1190 insertions(+), 198 deletions(-) create mode 100644 src/components/auto-image/progress-line.less create mode 100644 src/components/image-editor/index.less create mode 100644 src/components/image-editor/index.tsx diff --git a/src/components/auto-image/index.tsx b/src/components/auto-image/index.tsx index a62a27b2..d6650c1e 100644 --- a/src/components/auto-image/index.tsx +++ b/src/components/auto-image/index.tsx @@ -19,10 +19,11 @@ const AutoImage: React.FC< height: number | string; width?: number | string; autoSize?: boolean; + preview?: boolean; onLoad?: () => void; } > = (props) => { - const { height = 100, width: w, autoSize, ...rest } = props; + const { height = 100, width: w, autoSize, preview = true, ...rest } = props; const [width, setWidth] = useState(w || 0); const [isError, setIsError] = useState(false); @@ -88,35 +89,37 @@ const AutoImage: React.FC< onLoad={handleImgLoad} fallback={fallbackImg} crossOrigin="anonymous" - preview={{ - mask: , - toolbarRender: ( - _, - { - transform: { scale }, - actions: { - onFlipY, - onFlipX, - onRotateLeft, - onRotateRight, - onZoomOut, - onZoomIn, - onReset + preview={ + preview ?? { + mask: , + toolbarRender: ( + _, + { + transform: { scale }, + actions: { + onFlipY, + onFlipX, + onRotateLeft, + onRotateRight, + onZoomOut, + onZoomIn, + onReset + } } - } - ) => ( - - - - - - - - - - - ) - }} + ) => ( + + + + + + + + + + + ) + } + } /> ); }; diff --git a/src/components/auto-image/progress-line.less b/src/components/auto-image/progress-line.less new file mode 100644 index 00000000..3e49983d --- /dev/null +++ b/src/components/auto-image/progress-line.less @@ -0,0 +1,44 @@ +.img-wrapper { + position: relative; + display: inline-block; +} + +.img-wrapper .auto-image { + display: block; +} + +.img-wrapper .progress-wrapper { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.progress-square { + width: 100%; + height: 100%; + transform: rotate(0deg); +} + +.progress-square-bg { + fill: none; +} + +.progress-square-fg { + fill: none; + stroke-linecap: square; + stroke-dasharray: 400; + stroke-dashoffset: 400; + transition: stroke-dashoffset 0.3s ease; +} + +.progress-text { + position: absolute; + color: black; + font-size: 20px; + font-weight: bold; +} diff --git a/src/components/auto-image/single-image.less b/src/components/auto-image/single-image.less index c7f2faaa..88a32180 100644 --- a/src/components/auto-image/single-image.less +++ b/src/components/auto-image/single-image.less @@ -8,6 +8,13 @@ border-radius: var(--border-radius-base); overflow: hidden; + .progress-wrapper { + position: absolute; + bottom: 20px; + left: 20px; + right: 20px; + } + .img { display: flex; width: auto; diff --git a/src/components/auto-image/single-image.tsx b/src/components/auto-image/single-image.tsx index cf9e6fcf..cf76be24 100644 --- a/src/components/auto-image/single-image.tsx +++ b/src/components/auto-image/single-image.tsx @@ -1,11 +1,10 @@ import { CloseCircleOutlined } from '@ant-design/icons'; -import { Progress } from 'antd'; +import { Progress, Tooltip } from 'antd'; import classNames from 'classnames'; import ResizeObserver from 'rc-resize-observer'; import React, { useCallback } from 'react'; import AutoImage from './index'; import './single-image.less'; - interface SingleImageProps { loading?: boolean; width?: number; @@ -15,17 +14,23 @@ interface SingleImageProps { maxWidth?: number; dataUrl: string; uid: number; + preview?: boolean; autoSize?: boolean; onDelete: (uid: number) => void; + onClick?: (item: any) => void; autoBgColor?: boolean; editable?: boolean; style?: React.CSSProperties; + progressType?: 'line' | 'circle' | 'dashboard'; + progressColor?: string; + progressWidth?: number; } const SingleImage: React.FC = (props) => { const { editable, onDelete: handleOnDelete, + onClick, autoSize, uid, loading, @@ -36,10 +41,13 @@ const SingleImage: React.FC = (props) => { maxWidth, dataUrl, style, - autoBgColor + autoBgColor, + progressColor = 'var(--ant-color-primary)', + progressWidth = 2, + preview = true, + progressType = 'dashboard' } = props; - const [color, setColor] = React.useState({}); const imgWrapper = React.useRef(null); const [imgSize, setImgSize] = React.useState({ width: width, @@ -50,6 +58,10 @@ const SingleImage: React.FC = (props) => { return loading ? { width: '100%', height: '100%' } : {}; }, [loading, imgSize]); + const handleOnClick = useCallback(() => { + onClick?.(props); + }, [onClick, props]); + const handleResize = useCallback( (size: { width: number; height: number }) => { if (!autoSize) return; @@ -119,18 +131,47 @@ const SingleImage: React.FC = (props) => { overflow: 'hidden' }} > - ( - {progress}% - )} - trailColor="var(--ant-color-fill-secondary)" - /> + {progressType === 'dashboard' ? ( + ( + {progress}% + )} + trailColor="var(--ant-color-fill-secondary)" + /> + ) : ( + + + + + + )} ) : ( = (props) => { }} > + {progress && progress < 100 && ( + + + + + + )} )} diff --git a/src/components/image-editor/index.less b/src/components/image-editor/index.less new file mode 100644 index 00000000..9f97046a --- /dev/null +++ b/src/components/image-editor/index.less @@ -0,0 +1,18 @@ +.editor-wrapper { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + + .tools { + margin-bottom: 10px; + display: flex; + gap: 10px; + } + + .editor-content { + display: flex; + justify-content: center; + align-items: center; + } +} diff --git a/src/components/image-editor/index.tsx b/src/components/image-editor/index.tsx new file mode 100644 index 00000000..ca3699db --- /dev/null +++ b/src/components/image-editor/index.tsx @@ -0,0 +1,433 @@ +import { + DownloadOutlined, + FormatPainterOutlined, + SaveOutlined, + SyncOutlined, + UndoOutlined +} from '@ant-design/icons'; +import { Button, Slider, Tooltip } from 'antd'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import './index.less'; + +type Point = { x: number; y: number }; +type Stroke = { start: Point; end: Point }; + +type CanvasImageEditorProps = { + imageSrc: string; + disabled?: boolean; + onSave: (imageData: string) => void; + uploadButton: React.ReactNode; +}; + +const CanvasImageEditor: React.FC = ({ + imageSrc, + disabled, + onSave, + uploadButton +}) => { + const COLOR = 'rgba(0, 0, 255, 0.3)'; + const canvasRef = useRef(null); + const overlayCanvasRef = useRef(null); + const containerRef = useRef(null); + const [lineWidth, setLineWidth] = useState(30); + const [strokes, setStrokes] = useState([]); + const isDrawing = useRef(false); + const lastPoint = useRef(null); + const imgLoaded = useRef(false); + const currentPoint = useRef(null); + const [cursorVisible, setCursorVisible] = useState(false); + const [cursorPosition, setCursorPosition] = useState(null); + + useEffect(() => { + console.log('Image src:', imageSrc); + if (!containerRef.current || !canvasRef.current) return; + imgLoaded.current = false; + const canvas = canvasRef.current; + const overlayCanvas = overlayCanvasRef.current; + const ctx = canvas!.getContext('2d'); + + const img = new Image(); + img.src = imageSrc; + img.onload = () => { + const container = containerRef.current; + const scale = Math.min( + container!.offsetWidth / img.width, + container!.offsetHeight / img.height, + 1 + ); + canvas!.width = img.width * scale; + canvas!.height = img.height * scale; + overlayCanvas!.width = canvas!.width; + overlayCanvas!.height = canvas!.height; + + ctx!.clearRect(0, 0, canvas.width, canvas.height); + ctx!.drawImage(img, 0, 0, canvas!.width, canvas!.height); + imgLoaded.current = true; + }; + }, [imageSrc, containerRef.current, canvasRef.current]); + + const handleResize = useCallback(() => { + const container = containerRef.current; + const canvas = canvasRef.current; + const overlayCanvas = overlayCanvasRef.current; + + if ( + !container || + !canvas || + !overlayCanvas || + !overlayCanvas.width || + !overlayCanvas.height + ) + return; + console.log( + 'disconnect:', + container, + canvas, + overlayCanvas, + overlayCanvas.width, + overlayCanvas.height + ); + + // Save current overlay content + const imgData = overlayCanvas + .getContext('2d')! + .getImageData(0, 0, overlayCanvas.width, overlayCanvas.height); + + const img = new Image(); + img.src = imageSrc; + + img.onload = () => { + // Recalculate scale + const scale = Math.min( + container.offsetWidth / img.width, + container.offsetHeight / img.height, + 1 + ); + + // Update canvas dimensions + canvas.width = img.width * scale; + canvas.height = img.height * scale; + overlayCanvas.width = canvas.width; + overlayCanvas.height = canvas.height; + + // Redraw background image + const ctx = canvas.getContext('2d'); + ctx!.clearRect(0, 0, canvas.width, canvas.height); + ctx!.drawImage(img, 0, 0, canvas.width, canvas.height); + + // Restore overlay content + overlayCanvas.getContext('2d')!.putImageData(imgData, 0, 0); + }; + }, [imageSrc, containerRef.current, canvasRef.current]); + + const handleMouseEnter = () => { + setCursorVisible(true); + }; + + const mapMousePosition = ( + e: React.MouseEvent, + canvas: HTMLCanvasElement + ) => { + const rect = canvas.getBoundingClientRect(); + const scaleX = canvas.width / rect.width; + const scaleY = canvas.height / rect.height; + + return { + x: (e.clientX - rect.left) * scaleX, + y: (e.clientY - rect.top) * scaleY + }; + }; + + const handleMouseMove = (e: React.MouseEvent) => { + const { offsetX, offsetY } = e.nativeEvent; + setCursorPosition({ x: offsetX, y: offsetY }); + }; + + const handleMouseLeave = () => { + setCursorVisible(false); + setCursorPosition(null); + }; + + useEffect(() => { + const resizeObserver = new ResizeObserver(() => { + handleResize(); + }); + + const container = containerRef.current; + if (container) resizeObserver.observe(container); + + return () => { + resizeObserver.disconnect(); + }; + }, [handleResize, strokes, containerRef.current]); + + const generateMask = useCallback(() => { + const canvas = overlayCanvasRef.current!; + const finalImage = document.createElement('canvas'); + finalImage.width = canvas.width; + finalImage.height = canvas.height; + const finalCtx = finalImage.getContext('2d'); + + // Create the transparent overlay + finalCtx!.fillStyle = 'black'; + finalCtx!.fillRect(0, 0, finalImage.width, finalImage.height); + finalCtx!.globalCompositeOperation = 'destination-out'; + finalCtx!.drawImage(canvas, 0, 0); + + onSave(finalImage.toDataURL('image/png')); + }, [onSave]); + + const downloadMask = useCallback(() => { + const canvas = overlayCanvasRef.current!; + const maskCanvas = document.createElement('canvas'); + maskCanvas.width = canvas.width; + maskCanvas.height = canvas.height; + const maskCtx = maskCanvas.getContext('2d'); + + // set the background to black + maskCtx!.fillStyle = 'black'; + maskCtx!.fillRect(0, 0, maskCanvas.width, maskCanvas.height); + + maskCtx!.globalCompositeOperation = 'destination-out'; + maskCtx!.drawImage(canvas, 0, 0); + + const link = document.createElement('a'); + link.download = 'mask.png'; + link.href = maskCanvas.toDataURL('image/png'); + link.click(); + }, []); + + const draw = (e: React.MouseEvent) => { + if (!isDrawing.current || !lastPoint.current || !currentPoint.current) + return; + + const { offsetX, offsetY } = e.nativeEvent; + currentPoint.current = { x: offsetX, y: offsetY }; + const ctx = overlayCanvasRef.current!.getContext('2d'); + + ctx!.save(); // 保存当前状态 + + // 1. 清除当前路径重叠的部分 + ctx!.globalCompositeOperation = 'destination-out'; + ctx!.lineWidth = lineWidth; + ctx!.lineCap = 'round'; + ctx!.lineJoin = 'round'; + + ctx!.beginPath(); + ctx!.moveTo(lastPoint.current.x, lastPoint.current.y); + ctx!.lineTo(offsetX, offsetY); + ctx!.stroke(); + + // 2. 重新绘制带颜色的路径 + ctx!.globalCompositeOperation = 'source-over'; + ctx!.strokeStyle = COLOR; + ctx!.beginPath(); + ctx!.moveTo(lastPoint.current.x, lastPoint.current.y); + ctx!.lineTo(offsetX, offsetY); + ctx!.stroke(); + + ctx!.restore(); // 恢复状态 + // lastPoint.current = { x: offsetX, y: offsetY }; + }; + + const startDrawing = (e: React.MouseEvent) => { + isDrawing.current = true; + lastPoint.current = null; + currentPoint.current = null; + const { offsetX, offsetY } = e.nativeEvent; + console.log('Start Drawing:', { x: offsetX, y: offsetY }); + lastPoint.current = { x: offsetX, y: offsetY }; + currentPoint.current = { x: offsetX, y: offsetY }; + draw(e); + }; + + const endDrawing = () => { + console.log('End Drawing:', lastPoint.current); + if (!lastPoint.current || !currentPoint.current) return; + + isDrawing.current = false; + const copyLastPoint = { ...lastPoint.current }; + const copyCurrentPoint = { ...currentPoint.current }; + setStrokes((prevStrokes) => [ + ...prevStrokes, + { + start: { x: copyLastPoint!.x, y: copyLastPoint!.y }, + end: { x: copyCurrentPoint!.x, y: copyCurrentPoint!.y } + } + ]); + lastPoint.current = null; + currentPoint.current = null; + + generateMask(); + }; + + const onClear = () => { + const ctx = overlayCanvasRef.current!.getContext('2d'); + ctx!.clearRect( + 0, + 0, + overlayCanvasRef.current!.width, + overlayCanvasRef.current!.height + ); + setStrokes([]); + lastPoint.current = null; + }; + + const undo = () => { + if (strokes.length === 0) return; + + const newStrokes = strokes.slice(0, -1); + setStrokes(newStrokes); + + const ctx = overlayCanvasRef.current!.getContext('2d'); + ctx!.clearRect( + 0, + 0, + overlayCanvasRef.current!.width, + overlayCanvasRef.current!.height + ); + + newStrokes.forEach((stroke) => { + ctx!.globalCompositeOperation = 'source-over'; + ctx!.strokeStyle = COLOR; + ctx!.lineWidth = lineWidth; + ctx!.lineCap = 'round'; + ctx!.lineJoin = 'round'; + + ctx!.beginPath(); + ctx!.moveTo(stroke.start.x, stroke.start.y); + ctx!.lineTo(stroke.end.x, stroke.end.y); + ctx!.stroke(); + }); + }; + + const download = () => { + // download the canvasRef as an image + const canvas = canvasRef.current!; + const link = document.createElement('a'); + link.download = 'image.png'; + link.href = canvas.toDataURL('image/png'); + link.click(); + link.remove(); + }; + + useEffect(() => { + const handleUndoShortcut = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === 'z') { + undo(); + } + }; + + window.addEventListener('keydown', handleUndoShortcut); + return () => { + window.removeEventListener('keydown', handleUndoShortcut); + }; + }, [strokes]); + + return ( +
+
+
+ + Brush Size + setLineWidth(value)} + /> +
+ } + > + + + + + + {uploadButton} + + + +
+
+ + + + + + +
+
+
+ + { + draw(e); + }} + onMouseLeave={(e) => { + endDrawing(); + }} + /> + {cursorVisible && ( +
+ )} +
+
+ ); +}; + +export default React.memo(CanvasImageEditor); diff --git a/src/global.less b/src/global.less index 1efc8b85..7869dd3b 100644 --- a/src/global.less +++ b/src/global.less @@ -636,6 +636,10 @@ body { } } +.ant-upload.ant-upload-drag { + background: none; +} + .ant-message-notice-wrapper { .ant-message-notice-error { .ant-message-notice-content { diff --git a/src/pages/playground/apis/index.ts b/src/pages/playground/apis/index.ts index c447980f..d51ac7de 100644 --- a/src/pages/playground/apis/index.ts +++ b/src/pages/playground/apis/index.ts @@ -3,6 +3,7 @@ import { request } from '@umijs/max'; export const CHAT_API = '/v1-openai/chat/completions'; export const CREAT_IMAGE_API = '/v1-openai/images/generations'; +export const EDIT_IMAGE_API = '/v1-openai/images/edits'; export const EMBEDDING_API = '/v1-openai/embeddings'; diff --git a/src/pages/playground/components/ground-images.tsx b/src/pages/playground/components/ground-images.tsx index 02ad9bf3..a32f89a8 100644 --- a/src/pages/playground/components/ground-images.tsx +++ b/src/pages/playground/components/ground-images.tsx @@ -11,7 +11,7 @@ import { } from '@/utils/fetch-chunk-data'; import { FileImageOutlined, SwapOutlined } from '@ant-design/icons'; import { useIntl, useSearchParams } from '@umijs/max'; -import { Button, Form, Tooltip } from 'antd'; +import { Button, Checkbox, Form, Tooltip } from 'antd'; import classNames from 'classnames'; import _ from 'lodash'; import 'overlayscrollbars/overlayscrollbars.css'; @@ -99,6 +99,10 @@ const GroundImages: React.FC = forwardRef((props, ref) => { const [currentPrompt, setCurrentPrompt] = useState(''); const form = useRef(null); const inputRef = useRef(null); + const previewRef = useRef({ + preview: false, + preview_faster: false + }); const size = Form.useWatch('size', form.current?.form); @@ -153,7 +157,7 @@ const GroundImages: React.FC = forwardRef((props, ref) => { const finalParameters = useMemo(() => { if (parameters.size === 'custom') { return { - ..._.omit(parameters, ['width', 'height']), + ..._.omit(parameters, ['width', 'height', 'preview']), size: parameters.width && parameters.height ? `${parameters.width}x${parameters.height}` @@ -161,7 +165,7 @@ const GroundImages: React.FC = forwardRef((props, ref) => { }; } return { - ..._.omit(parameters, ['width', 'height', 'random_seed']) + ..._.omit(parameters, ['width', 'height', 'random_seed', 'preview']) }; }, [parameters]); @@ -205,6 +209,23 @@ const GroundImages: React.FC = forwardRef((props, ref) => { setCurrentPrompt(current?.content || ''); const imgSize = _.split(finalParameters.size, 'x'); + // preview + let stream_options: Record = { + chunk_size: 16 * 1024, + chunk_results: true + }; + if (parameters.preview === 'preview') { + stream_options = { + preview: true + }; + } + + if (parameters.preview === 'preview_faster') { + stream_options = { + preview_faster: true + }; + } + let newImageList = Array(parameters.n) .fill({}) .map((item, index: number) => { @@ -215,6 +236,7 @@ const GroundImages: React.FC = forwardRef((props, ref) => { height: imgSize[1], width: imgSize[0], loading: true, + progressType: stream_options.chunk_results ? 'dashboard' : 'line', uid: setMessageId() }; }); @@ -228,8 +250,7 @@ const GroundImages: React.FC = forwardRef((props, ref) => { seed: parameters.random_seed ? generateRandomNumber() : parameters.seed, stream: true, stream_options: { - chunk_size: 16 * 1024, - chunk_results: true + ...stream_options }, prompt: current?.content || currentPrompt || '' }; @@ -266,8 +287,10 @@ const GroundImages: React.FC = forwardRef((props, ref) => { } chunk?.data?.forEach((item: any) => { const imgItem = newImageList[item.index]; - if (item.b64_json) { + if (item.b64_json && stream_options.chunk_results) { imgItem.dataUrl += item.b64_json; + } else if (item.b64_json) { + imgItem.dataUrl = `data:image/png;base64,${item.b64_json}`; } const progress = _.round(item.progress, 0); newImageList[item.index] = { @@ -278,7 +301,7 @@ const GroundImages: React.FC = forwardRef((props, ref) => { maxWidth: `${imgSize[0]}px`, uid: imgItem.uid, span: imgItem.span, - loading: progress < 100, + loading: stream_options.chunk_results ? progress < 100 : false, progress: progress }; }); @@ -439,6 +462,27 @@ const GroundImages: React.FC = forwardRef((props, ref) => { return null; }, [size, intl]); + const hanldeOnPreview = (e: any) => { + previewRef.current.preview = e.target.checked; + }; + + const hanldeOnPreviewFaster = (e: any) => { + previewRef.current.preview_faster = e.target.checked; + }; + + const renderPreview = useMemo(() => { + return ( + <> + + Preview + + + Preview Faster + + + ); + }, []); + useEffect(() => { return () => { requestToken.current?.abort?.(); @@ -508,7 +552,6 @@ const GroundImages: React.FC = forwardRef((props, ref) => { autoBgColor={false} editable={false} dataList={imageList} - loading={loading} responseable={true} gutter={[8, 16]} autoSize={true} diff --git a/src/pages/playground/components/image-edit.tsx b/src/pages/playground/components/image-edit.tsx index 33993d03..c7006f6f 100644 --- a/src/pages/playground/components/image-edit.tsx +++ b/src/pages/playground/components/image-edit.tsx @@ -1,16 +1,19 @@ import AlertInfo from '@/components/alert-info'; +import SingleImage from '@/components/auto-image/single-image'; +import IconFont from '@/components/icon-font'; +import CanvasImageEditor from '@/components/image-editor'; import FieldComponent from '@/components/seal-form/field-component'; import SealSelect from '@/components/seal-form/seal-select'; import useOverlayScroller from '@/hooks/use-overlay-scroller'; -import ThumbImg from '@/pages/playground/components/thumb-img'; -import { generateRandomNumber } from '@/utils'; +import UploadImg from '@/pages/playground/components/upload-img'; +import { base64ToFile, generateRandomNumber } from '@/utils'; import { - fetchChunkedData, + fetchChunkedDataPostFormData as fetchChunkedData, readLargeStreamData as readStreamData } from '@/utils/fetch-chunk-data'; -import { FileImageOutlined, SwapOutlined } from '@ant-design/icons'; +import { SwapOutlined } from '@ant-design/icons'; import { useIntl, useSearchParams } from '@umijs/max'; -import { Button, Form, Tooltip } from 'antd'; +import { Button, Divider, Form, Image, Tooltip } from 'antd'; import classNames from 'classnames'; import _ from 'lodash'; import 'overlayscrollbars/overlayscrollbars.css'; @@ -24,7 +27,7 @@ import React, { useRef, useState } from 'react'; -import { CREAT_IMAGE_API } from '../apis'; +import { EDIT_IMAGE_API } from '../apis'; import { promptList } from '../config'; import { ImageAdvancedParamsConfig, @@ -46,7 +49,7 @@ interface MessageProps { ref?: any; } const advancedFieldsDefaultValus = { - seed: null, + seed: 1, sampler: 'euler_a', cfg_scale: 4.5, sample_steps: 10, @@ -98,6 +101,10 @@ const GroundImages: React.FC = forwardRef((props, ref) => { const [currentPrompt, setCurrentPrompt] = useState(''); const form = useRef(null); const inputRef = useRef(null); + const [image, setImage] = useState(''); + const [mask, setMask] = useState(''); + const [showOriginal, setShowOriginal] = useState(false); + const [uploadList, setUploadList] = useState([]); const size = Form.useWatch('size', form.current?.form); @@ -141,10 +148,10 @@ const GroundImages: React.FC = forwardRef((props, ref) => { size.span = 12; } if (parameters.n === 3) { - size.span = 12; + size.span = 8; } if (parameters.n === 4) { - size.span = 12; + size.span = 6; } return size; }, [parameters.n]); @@ -152,7 +159,9 @@ const GroundImages: React.FC = forwardRef((props, ref) => { const finalParameters = useMemo(() => { if (parameters.size === 'custom') { return { - ..._.omit(parameters, ['width', 'height']), + ..._.omit(parameters, ['width', 'height', 'preview']), + image: base64ToFile(image, 'image'), + mask: base64ToFile(mask, 'mask'), size: parameters.width && parameters.height ? `${parameters.width}x${parameters.height}` @@ -160,14 +169,18 @@ const GroundImages: React.FC = forwardRef((props, ref) => { }; } return { - ..._.omit(parameters, ['width', 'height', 'random_seed']) + image: base64ToFile(image, 'image'), + mask: base64ToFile(mask, 'mask'), + ..._.omit(parameters, ['width', 'height', 'random_seed', 'preview']) }; - }, [parameters]); + }, [parameters, image, mask]); const viewCodeContent = useMemo(() => { if (isOpenaiCompatible) { return generateOpenaiImageCode({ - api: '/v1-openai/images/generations', + api: EDIT_IMAGE_API, + edit: true, + isFormdata: true, parameters: { ...finalParameters, prompt: currentPrompt @@ -175,7 +188,9 @@ const GroundImages: React.FC = forwardRef((props, ref) => { }); } return generateImageCode({ - api: '/v1-openai/images/generations', + api: EDIT_IMAGE_API, + isFormdata: true, + edit: true, parameters: { ...finalParameters, prompt: currentPrompt @@ -202,8 +217,26 @@ const GroundImages: React.FC = forwardRef((props, ref) => { setMessageId(); setTokenResult(null); setCurrentPrompt(current?.content || ''); + const imgSize = _.split(finalParameters.size, 'x'); + // preview + let stream_options: Record = { + chunk_size: 16 * 1024, + chunk_results: true + }; + if (parameters.preview === 'preview') { + stream_options = { + preview: true + }; + } + + if (parameters.preview === 'preview_faster') { + stream_options = { + preview_faster: true + }; + } + let newImageList = Array(parameters.n) .fill({}) .map((item, index: number) => { @@ -214,6 +247,7 @@ const GroundImages: React.FC = forwardRef((props, ref) => { height: imgSize[1], width: imgSize[0], loading: true, + progressType: stream_options.chunk_results ? 'dashboard' : 'line', uid: setMessageId() }; }); @@ -227,8 +261,7 @@ const GroundImages: React.FC = forwardRef((props, ref) => { seed: parameters.random_seed ? generateRandomNumber() : parameters.seed, stream: true, stream_options: { - chunk_size: 16 * 1024, - chunk_results: true + ...stream_options }, prompt: current?.content || currentPrompt || '' }; @@ -240,7 +273,7 @@ const GroundImages: React.FC = forwardRef((props, ref) => { const result: any = await fetchChunkedData({ data: params, - url: `${CREAT_IMAGE_API}?t=${Date.now()}`, + url: `http://192.168.50.4:40325/v1/images/edits?t=${Date.now()}`, signal: requestToken.current.signal }); if (result.error) { @@ -265,8 +298,10 @@ const GroundImages: React.FC = forwardRef((props, ref) => { } chunk?.data?.forEach((item: any) => { const imgItem = newImageList[item.index]; - if (item.b64_json) { + if (item.b64_json && stream_options.chunk_results) { imgItem.dataUrl += item.b64_json; + } else if (item.b64_json) { + imgItem.dataUrl = `data:image/png;base64,${item.b64_json}`; } const progress = _.round(item.progress, 0); newImageList[item.index] = { @@ -277,7 +312,7 @@ const GroundImages: React.FC = forwardRef((props, ref) => { maxWidth: `${imgSize[0]}px`, uid: imgItem.uid, span: imgItem.span, - loading: progress < 100, + loading: stream_options.chunk_results ? progress < 100 : false, progress: progress }; }); @@ -295,6 +330,9 @@ const GroundImages: React.FC = forwardRef((props, ref) => { setMessageId(); setImageList([]); setTokenResult(null); + setMask(''); + setImage(''); + setUploadList([]); }; const handleInputChange = (e: any) => { @@ -438,6 +476,62 @@ const GroundImages: React.FC = forwardRef((props, ref) => { return null; }, [size, intl]); + const handleUpdateImageList = useCallback((base64List: any) => { + const img = _.get(base64List, '[0].dataUrl', ''); + setUploadList(base64List); + setImage(img); + }, []); + + const handleOnSave = useCallback((url: string) => { + console.log('url:', url); + setMask(url); + }, []); + + const renderImageEditor = useMemo(() => { + if (image) { + return ( + + + + } + > + ); + } + return ( + +
+ +

Click or drag image to this area to upload

+
+
+ ); + }, [image, loading, handleOnSave, handleUpdateImageList]); + + const handleOnImgClick = useCallback((item: any) => { + console.log('item:', item); + setImage(item.dataUrl); + setShowOriginal(true); + }, []); + useEffect(() => { return () => { requestToken.current?.abort?.(); @@ -495,62 +589,177 @@ const GroundImages: React.FC = forwardRef((props, ref) => { > <>
- - {!imageList.length && ( + {
- - - - - {intl.formatMessage({ id: 'playground.params.empty.tips' })} - + {renderImageEditor}
- )} + }
- {tokenResult && ( -
- +
+ {tokenResult && ( +
+ +
+ )} +
+ {uploadList.length > 0 && ( + handleOnImgClick(uploadList[0])} + src={uploadList[0]?.dataUrl} + style={{ + height: 125, + objectFit: 'cover' + }} + > + )} + {imageList.length > 0 && ( + <> + +
+ {_.map(imageList, (item: any, index: number) => { + return ( +
+ handleOnImgClick(item)} + > +
+ ); + })} +
+ + )} + {/* */} +
+
+
+
+
+
+ + + {intl.formatMessage({ id: 'playground.parameters' })} + + + + +
+ } + setParams={setParams} + paramsConfig={paramsConfig} + initialValues={initialValues} + params={parameters} + selectedModel={selectModel} + modelList={modelList} + extra={[renderCustomSize, ...renderExtra, ...renderAdvanced]} + />
- )} -
+
+
{intl.formatMessage({ id: 'playground.image.prompt' })} } loading={loading} - disabled={!parameters.model} + disabled={!parameters.model || mask === ''} isEmpty={!imageList.length} handleSubmit={handleSendMessage} handleAbortFetch={handleStopConversation} @@ -560,52 +769,6 @@ const GroundImages: React.FC = forwardRef((props, ref) => { />
-
-
- - - {intl.formatMessage({ id: 'playground.parameters' })} - - - - -
- } - setParams={setParams} - paramsConfig={paramsConfig} - initialValues={initialValues} - params={parameters} - selectedModel={selectModel} - modelList={modelList} - extra={[renderCustomSize, ...renderExtra, ...renderAdvanced]} - /> -
- void; + onClick?: (item: any) => void; loading?: boolean; style?: React.CSSProperties; responseable?: boolean; @@ -16,11 +23,18 @@ const ThumbImg: React.FC<{ autoSize?: boolean; autoBgColor?: boolean; }> = ({ + layout = { + rows: 1, + cols: 1 + }, + preview = true, + column = 2, dataList, editable, responseable, gutter, onDelete, + onClick, autoBgColor, autoSize, style @@ -32,6 +46,14 @@ const ThumbImg: React.FC<{ [onDelete] ); + const handleOnClick = useCallback( + (item: any) => { + console.log('item=======', item); + onClick?.(item); + }, + [onClick] + ); + const responseableStyle: Record = useMemo(() => { if (!responseable) return {}; if (dataList.length === 1) { @@ -88,7 +110,7 @@ const ThumbImg: React.FC<{ alignItems: 'flex-start' } }; - }, [dataList, responseable]); + }, [dataList, responseable, column]); return ( <> @@ -100,34 +122,40 @@ const ThumbImg: React.FC<{ gutter={gutter || []} className="flex-center" style={{ - height: dataList.length > 2 ? '50%' : '100%', + height: dataList.length > column ? '50%' : '100%', flex: 'none', width: '100%', justifyContent: dataList.length === 1 ? 'center' : 'flex-start' }} > - {_.map(_.slice(dataList, 0, 2), (item: any, index: number) => { - return ( - - - - ); - })} + {_.map( + _.slice(dataList, 0, column), + (item: any, index: number) => { + return ( + + handleOnClick(item)} + > + + ); + } + )} - {dataList.length > 2 && ( + {dataList.length > column && ( {_.map(_.slice(dataList, 2), (item: any, index: number) => { - console.log('index=======', index); return ( handleOnClick(item)} > ); @@ -169,11 +198,13 @@ const ThumbImg: React.FC<{ return ( handleOnClick(item)} > ); })} diff --git a/src/pages/playground/components/upload-img.tsx b/src/pages/playground/components/upload-img.tsx index 4f0d6528..dc2e241d 100644 --- a/src/pages/playground/components/upload-img.tsx +++ b/src/pages/playground/components/upload-img.tsx @@ -9,6 +9,10 @@ import React, { useCallback, useRef } from 'react'; interface UploadImgProps { size?: 'small' | 'middle' | 'large'; height?: number; + multiple?: boolean; + drag?: boolean; + disabled?: boolean; + children?: React.ReactNode; handleUpdateImgList: ( imgList: { dataUrl: string; uid: number | string }[] ) => void; @@ -16,7 +20,10 @@ interface UploadImgProps { const UploadImg: React.FC = ({ handleUpdateImgList, - height = 100, + multiple = true, + drag = false, + disabled = false, + children, size = 'small' }) => { const intl = useIntl(); @@ -71,19 +78,53 @@ const UploadImg: React.FC = ({ return ( <> - false} - onChange={handleChange} - > - - - - + {drag ? ( + false} + onChange={handleChange} + > + {children ?? ( + + + + )} + + ) : ( + false} + onChange={handleChange} + > + {children ?? ( + + + + )} + + )} ); }; diff --git a/src/pages/playground/config/params-config.ts b/src/pages/playground/config/params-config.ts index 9efc43c0..9789f1b5 100644 --- a/src/pages/playground/config/params-config.ts +++ b/src/pages/playground/config/params-config.ts @@ -330,6 +330,26 @@ export const ImageAdvancedParamsConfig: ParamsSchema[] = [ } ] }, + { + type: 'Select', + name: 'preview', + options: [ + { label: 'Faster', value: 'preview_faster' }, + { label: 'Normal', value: 'preview' } + ], + label: { + text: 'Preview', + isLocalized: false + }, + attrs: { + allowClear: true + }, + rules: [ + { + required: false + } + ] + }, { type: 'InputNumber', name: 'seed', diff --git a/src/pages/playground/style/ground-left.less b/src/pages/playground/style/ground-left.less index dce4bae0..f0a3e245 100644 --- a/src/pages/playground/style/ground-left.less +++ b/src/pages/playground/style/ground-left.less @@ -71,7 +71,6 @@ } .ant-upload-wrapper .ant-upload-drag { - background-color: var(--color-fill-sider); display: flex; flex-direction: column; justify-content: center; diff --git a/src/pages/playground/view-code/image.ts b/src/pages/playground/view-code/image.ts index f1013b32..aba78b3a 100644 --- a/src/pages/playground/view-code/image.ts +++ b/src/pages/playground/view-code/image.ts @@ -1,14 +1,32 @@ +import _ from 'lodash'; import { fomatNodeJsParams, formatCurlArgs, formatPyParams } from './utils'; -export const generateImageCode = ({ api, parameters }: Record) => { +export const generateImageCode = ({ + api, + parameters, + isFormdata = false, + edit = false +}: Record) => { const host = window.location.origin; // ========================= Curl ========================= - const curlCode = ` + let curlCode = ` curl ${host}${api} \\ -H "Content-Type: application/json" \\ -H "Authorization: Bearer $\{YOUR_GPUSTACK_API_KEY}" \\ -${formatCurlArgs(parameters, false)}`.trim(); +${formatCurlArgs(parameters, isFormdata)}`.trim(); + + if (edit) { + curlCode = ` +curl ${host}${api} \\ +-H "Content-Type: multipart/form-data" \\ +-H "Authorization: Bearer $\{YOUR_GPUSTACK_API_KEY}" \\ +-F image="@image.png" \\ +-F mask="@mask.png" \\ +${formatCurlArgs(_.omit(parameters, ['mask', 'image']), isFormdata)}` + .trim() + .replace(/\\$/, ''); + } // ========================= Python ========================= const pythonCode = ` @@ -46,16 +64,29 @@ axios.post(url, data, { headers }).then((response) => { export const generateOpenaiImageCode = ({ api, - parameters + parameters, + isFormdata = false, + edit = false }: Record) => { const host = window.location.origin; // ========================= Curl ========================= - const curlCode = ` + let curlCode = ` +curl ${host}${api} \\ +-H "Content-Type: multipart/form-data" \\ +-H "Authorization: Bearer $\{YOUR_GPUSTACK_API_KEY}" \\ +${formatCurlArgs(parameters, isFormdata)}`.trim(); + if (edit) { + curlCode = ` curl ${host}${api} \\ -H "Content-Type: application/json" \\ -H "Authorization: Bearer $\{YOUR_GPUSTACK_API_KEY}" \\ -${formatCurlArgs(parameters, false)}`.trim(); +-F image="@image.png" \\ +-F mask="@mask.png" \\ +${formatCurlArgs(_.omit(parameters, ['mask', 'image']), isFormdata)}` + .trim() + .replace(/\\$/, ''); + } // ========================= Python ========================= const pythonCode = ` diff --git a/src/utils/fetch-chunk-data.ts b/src/utils/fetch-chunk-data.ts index ef20f7ea..0b3dcacb 100644 --- a/src/utils/fetch-chunk-data.ts +++ b/src/utils/fetch-chunk-data.ts @@ -1,5 +1,4 @@ import qs from 'query-string'; - const extractStreamRegx = /data:\s*({.*?})(?=\n|$)/g; const extractJSON = (dataStr: string) => { @@ -68,6 +67,62 @@ export const fetchChunkedData = async (params: { }; }; +const createFormData = (data: any): FormData => { + const formData = new FormData(); + + const appendToFormData = (key: string, value: any) => { + if (value instanceof File) { + // 处理文件类型 + formData.append(key, value); + } else if (typeof value === 'object' && value !== null) { + // 如果是对象或数组,序列化为 JSON 字符串 + formData.append(key, JSON.stringify(value)); + } else { + // 处理基本数据类型 + formData.append(key, String(value)); + } + }; + + for (const key in data) { + if (Object.prototype.hasOwnProperty.call(data, key)) { + appendToFormData(key, data[key]); + } + } + + return formData; +}; + +export const fetchChunkedDataPostFormData = async (params: { + data?: any; + url: string; + params?: any; + signal?: AbortSignal; + method?: string; + headers?: any; +}) => { + const { url } = params; + const response = await fetch(url, { + method: 'POST', + body: createFormData(params.data), + signal: params.signal + }); + console.log('response====', response); + if (!response.ok) { + return { + error: true, + data: await response.json() + }; + } + const reader = response?.body?.getReader(); + const decoder = new TextDecoder('utf-8', { + fatal: true + }); + return { + reader, + decoder + }; +}; + export const readStreamData = async ( reader: any, decoder: TextDecoder, diff --git a/src/utils/index.ts b/src/utils/index.ts index 20136416..05bee919 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -140,3 +140,36 @@ export const generateRandomNumber = () => { // 16: 0x1000;32:0x100000000 return Math.floor(Math.random() * 0x100000000); }; + +function base64ToBlob(base64: string, contentType = '', sliceSize = 512) { + const byteCharacters = atob(base64.split(',')[1]); // 去掉 Base64 前缀部分 + const byteArrays = []; + + for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) { + const slice = byteCharacters.slice(offset, offset + sliceSize); + + const byteNumbers = new Array(slice.length); + for (let i = 0; i < slice.length; i++) { + byteNumbers[i] = slice.charCodeAt(i); + } + + const byteArray = new Uint8Array(byteNumbers); + byteArrays.push(byteArray); + } + + return new Blob(byteArrays, { type: contentType }); +} + +export const base64ToFile = (base64String: string, fileName: string) => { + if (!base64String) { + return null; + } + console.log('base64String:', base64String); + const match = base64String.match(/data:(.*?);base64,/); + if (!match) { + throw new Error('Invalid base64 string'); + } + const contentType = match[1]; + const blob = base64ToBlob(base64String, contentType); + return new File([blob], fileName || contentType, { type: contentType }); +};