import { CanvasChromeButton } from '@genarrative/image-canvas-react'; import { Check, ChevronDown, Circle, Cpu, ImageIcon, ImagePlus, Paintbrush, Square, } from 'lucide-react'; import type { ComponentType, SVGProps } from 'react'; import type { PointerEvent as ReactPointerEvent } from 'react'; import { useState } from 'react'; import { PlatformActionButton } from '../common/PlatformActionButton'; import { PlatformFloatingMenu } from '../common/PlatformFloatingMenu'; import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; import type { CanvasLayer, CanvasViewport } from './ImageCanvasEditorTypes'; import { calculateEditorIconSpritesheetPrice, EDITOR_IMAGE_MODEL_OPTIONS, getEditorImageModelDisplayName, resolveExtraImageReferenceLimit, UI_EXTRA_REFERENCE_LIMIT, } from './ImageCanvasGenerationModel'; import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot'; import type { UiAssetExtractionMark, UiAssetExtractionState, UiAssetExtractionTool, } from './ImageCanvasUiAssetExtractionModel'; import { buildUiAssetExtractionBrushPath, resolveUiAssetExtractionGenerationPlan, } from './ImageCanvasUiAssetExtractionModel'; type ImageCanvasUiAssetExtractionOverlayViewProps = { variant?: 'ui-extraction' | 'quick-edit'; showIndexLabels?: boolean; sourceLayer: CanvasLayer | null; viewport: CanvasViewport; state: UiAssetExtractionState | null; onToolChange: (tool: UiAssetExtractionTool | null) => void; onPointerStart: (point: { x: number; y: number }) => void; onPointerMove: (point: { x: number; y: number }) => void; onPointerEnd: () => void; onModelChange?: (model: string) => void; onRequestUpload?: () => void; onRemoveReference?: (referenceId: string) => void; onSubmit?: () => void; hasPendingImageReferenceUploads?: boolean; }; const TOOL_OPTIONS: Array<{ value: UiAssetExtractionTool; label: string; icon: ComponentType>; }> = [ { value: 'rect', label: '矩形框选', icon: Square }, { value: 'ellipse', label: '椭圆框选', icon: Circle }, { value: 'brush', label: '画笔自由框选', icon: Paintbrush }, ]; function resolvePointerImagePoint( event: ReactPointerEvent, layer: CanvasLayer, ) { const rect = event.currentTarget.getBoundingClientRect(); const fallbackWidth = Math.max(1, layer.width); const fallbackHeight = Math.max(1, layer.height); const ratioX = (event.clientX - rect.left) / (rect.width > 0 ? rect.width : fallbackWidth); const ratioY = (event.clientY - rect.top) / (rect.height > 0 ? rect.height : fallbackHeight); return { x: Math.min( Math.max(ratioX * Math.max(1, layer.originalWidth), 0), Math.max(1, layer.originalWidth), ), y: Math.min( Math.max(ratioY * Math.max(1, layer.originalHeight), 0), Math.max(1, layer.originalHeight), ), }; } function resolveMarkLabelPoint(mark: UiAssetExtractionMark) { if (mark.tool === 'brush') { return mark.points[0] ?? { x: 0, y: 0 }; } return { x: Math.min(mark.x, mark.x + mark.width), y: Math.min(mark.y, mark.y + mark.height), }; } function resolveMarkPreviewBounds( mark: UiAssetExtractionMark, sourceLayer: CanvasLayer, ) { const originalWidth = Math.max(1, sourceLayer.originalWidth); const originalHeight = Math.max(1, sourceLayer.originalHeight); const normalizeBounds = (bounds: { x: number; y: number; width: number; height: number; }) => { const left = Math.min(Math.max(bounds.x, 0), originalWidth); const top = Math.min(Math.max(bounds.y, 0), originalHeight); const right = Math.min(Math.max(bounds.x + bounds.width, 0), originalWidth); const bottom = Math.min( Math.max(bounds.y + bounds.height, 0), originalHeight, ); return { x: left, y: top, width: Math.max(1, right - left), height: Math.max(1, bottom - top), }; }; if (mark.tool === 'brush') { const xs = mark.points.map((point) => point.x); const ys = mark.points.map((point) => point.y); const left = Math.min(...xs); const top = Math.min(...ys); const right = Math.max(...xs); const bottom = Math.max(...ys); return normalizeBounds({ x: Number.isFinite(left) ? left : 0, y: Number.isFinite(top) ? top : 0, width: Math.max(1, Number.isFinite(right - left) ? right - left : 1), height: Math.max(1, Number.isFinite(bottom - top) ? bottom - top : 1), }); } return normalizeBounds({ x: Math.min(mark.x, mark.x + mark.width), y: Math.min(mark.y, mark.y + mark.height), width: Math.max(1, Math.abs(mark.width)), height: Math.max(1, Math.abs(mark.height)), }); } function resolveMarkPreviewFallbackSrc(sourceSrc: string) { const normalizedSrc = sourceSrc.trim(); if (!normalizedSrc) { return undefined; } if (/^(?:data|blob):/iu.test(normalizedSrc)) { return normalizedSrc; } if ( /^https?:\/\//iu.test(normalizedSrc) && /[?&](?:x-oss-signature|OSSAccessKeyId|Signature)=/iu.test(normalizedSrc) ) { return normalizedSrc; } return undefined; } function UiAssetExtractionMarkPreview({ mark, index, sourceLayer, }: { mark: UiAssetExtractionMark; index: number; sourceLayer: CanvasLayer; }) { const bounds = resolveMarkPreviewBounds(mark, sourceLayer); const originalWidth = Math.max(1, sourceLayer.originalWidth); const originalHeight = Math.max(1, sourceLayer.originalHeight); const previewRefreshKey = sourceLayer.taskId ?? sourceLayer.resourceId; return (
{index + 1}
); } function renderMarkPreview( mark: UiAssetExtractionMark, index: number, sourceLayer: CanvasLayer, ) { return ( ); } function renderMark( mark: UiAssetExtractionMark, index: number, showIndexLabels: boolean, ) { const markNode = mark.tool === 'brush' ? ( ) : mark.tool === 'ellipse' ? ( ) : ( ); if (!showIndexLabels) { return {markNode}; } const labelPoint = resolveMarkLabelPoint(mark); // 中文注释:序号直接落在红色圈选框左上角,生成模型可通过“1号红色圈选框”稳定对应用户修改说明。 return ( {markNode} {index + 1} ); } export function ImageCanvasUiAssetExtractionOverlayView({ variant = 'ui-extraction', showIndexLabels = false, sourceLayer, viewport, state, onToolChange, onPointerStart, onPointerMove, onPointerEnd, onModelChange = () => {}, onRequestUpload, onRemoveReference, onSubmit, hasPendingImageReferenceUploads = false, }: ImageCanvasUiAssetExtractionOverlayViewProps) { const [isModelMenuOpen, setIsModelMenuOpen] = useState(false); if (!sourceLayer || !state) { return null; } const isQuickEdit = variant === 'quick-edit'; const marks = [...state.marks, ...(state.draftMark ? [state.draftMark] : [])]; const canSubmit = state.marks.length > 0 && state.status !== 'extracting' && !hasPendingImageReferenceUploads; const extractionPlan = resolveUiAssetExtractionGenerationPlan( state.marks.length, ); const extractionPrice = calculateEditorIconSpritesheetPrice( state.model, extractionPlan.imageSize, ); const selectedModelLabel = getEditorImageModelDisplayName(state.model); const screenFrame = { left: viewport.x + sourceLayer.x * viewport.scale, top: viewport.y + sourceLayer.y * viewport.scale, width: sourceLayer.width * viewport.scale, height: sourceLayer.height * viewport.scale, }; return ( <>
event.stopPropagation()} > {TOOL_OPTIONS.map((option) => { const Icon = option.icon; const isActive = state.tool === option.value; return ( } onClick={() => onToolChange(isActive ? null : option.value)} /> ); })}
{ event.preventDefault(); event.stopPropagation(); event.currentTarget.setPointerCapture?.(event.pointerId); onPointerStart(resolvePointerImagePoint(event, sourceLayer)); }} onPointerMove={(event) => { event.preventDefault(); event.stopPropagation(); onPointerMove(resolvePointerImagePoint(event, sourceLayer)); }} onPointerUp={(event) => { event.preventDefault(); event.stopPropagation(); onPointerEnd(); }} onPointerCancel={(event) => { event.preventDefault(); event.stopPropagation(); onPointerEnd(); }} > {marks.map((mark, index) => renderMark( mark, index, showIndexLabels && index < state.marks.length, ), )} {!isQuickEdit ? (
event.stopPropagation()} >
使用框选工具框选你希望从画面中提取的素材
{state.references.map((reference, index) => (
{state.marks.length ? ( state.marks.map((mark, index) => renderMarkPreview(mark, index, sourceLayer), ) ) : ( 框选后显示预览 )}
{`${extractionPlan.aspectRatio}·${extractionPlan.imageSize}`}
} onClick={() => setIsModelMenuOpen((open) => !open)} > {selectedModelLabel} {isModelMenuOpen ? ( event.stopPropagation()} >
{EDITOR_IMAGE_MODEL_OPTIONS.map((option) => { const selected = state.model === option.value; return ( ); })}
) : null}
{state.status === 'extracting' ? '提取中' : `提取 · ${extractionPrice}泥点`}
) : null} {state.errorMessage ? (
{state.errorMessage}
) : null} ); }