"use client"; import { ArrowLeft, Circle as CircleIcon, Download, Eraser, FolderOpen, Layers3, Loader2, MousePointer2, Paintbrush, Plus, Redo2, Save, Send, Slash, Sparkles, Square, Undo2, Upload, Wand2, X, } from "lucide-react"; import { type ChangeEvent, type PointerEvent as ReactPointerEvent, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { toast } from "sonner"; import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon"; import { EnhanceProWorkspace } from "@/components/canvas/EnhanceProWorkspace"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { Slider } from "@/components/ui/slider"; import { Textarea } from "@/components/ui/textarea"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import { buildImageProxyUrl } from "@/lib/image-delivery"; import { defaultCanvasToolbarConfig, getCanvasToolbarItem, getDefaultCanvasToolbarItem, normalizeCanvasToolbarConfig, type CanvasToolbarConfig, } from "@/lib/canvas-toolbar-config"; import { cn } from "@/lib/utils"; import { imageGenerationService } from "@/services/canvas"; type RealtimeConfig = { transport: { throttleInterval: number; maxBuffering: number; }; image: { maxInputResolution: number; jpegQuality: number; }; model: { id: string; defaults: { steps: number; cfgScale: number; strength: number; imageSize: "square" | "square_hd"; }; }; behavior?: { strictSketch?: boolean; }; }; type RealtimeStatus = "idle" | "connecting" | "live" | "error"; type RealtimeWorkspaceMode = "realtime" | "enhance-pro"; type RealtimeCanvasModeIconConfig = { realtimeIconUrl: string; enhanceIconUrl: string; realtimeLabel: string; enhanceLabel: string; }; const DEFAULT_REALTIME_CANVAS_MODE_LABEL = "实时画布模式"; const DEFAULT_ENHANCE_CANVAS_MODE_LABEL = "图像增强模式"; type RealtimeBrushMode = | "select" | "brush" | "eraser" | "rect" | "circle" | "line"; type LayerResizeHandle = "nw" | "ne" | "sw" | "se"; type ImageLayer = { id: string; name: string; src: string; x: number; y: number; width: number; height: number; baseWidth: number; baseHeight: number; }; type HistorySnapshot = { imageData: ImageData; imageLayers: ImageLayer[]; canvasBackground: string; }; type SavedRealtimeProject = { id: string; name: string; updatedAt: string; prompt: string; brushColor: string; brushSize: number; canvasBackground: string; imageLayers: ImageLayer[]; drawingLayerDataUrl: string; coverImageUrl?: string; previewUrl?: string; }; type LayerSplitBounds = { x: number; y: number; width: number; height: number; originalWidth: number; originalHeight: number; coverage: number; }; type LayerSplitItem = { originalUrl: string; title?: string; layerIndex: number; totalLayers: number; bounds?: LayerSplitBounds | null; }; type RealtimeCanvasDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; initialImageUrl?: string | null; initialMode?: RealtimeWorkspaceMode; onApplyResult: (payload: { imageUrl: string; sourceImageUrl?: string; prompt: string; title?: string; model?: string; width?: number; height?: number; }) => void; standalone?: boolean; }; const FALLBACK_PROMPT = "严格按照输入草图生成:保持主体轮廓、位置、比例和数量一致,只做材质与光影细化;禁止改变构图,禁止新增无关背景纹理。"; const CANVAS_BACKGROUND = "#ffffff"; const DEFAULT_BRUSH_COLOR = "#18181b"; const DRAW_LAYER_ID = "__draw_layer__"; const REALTIME_PROJECTS_STORAGE_KEY = "ai-chatbot:realtime-canvas-projects"; function cloneImageLayers(layers: ImageLayer[]) { return layers.map((layer) => ({ ...layer })); } function stripFileExtension(filename: string) { const trimmed = String(filename || "").trim(); return trimmed.replace(/\.[^.]+$/, "") || trimmed || "图层"; } function isFullFrameSplitLayer(bounds?: LayerSplitBounds | null) { if (!bounds) return false; return ( bounds.coverage >= 0.92 && bounds.width >= bounds.originalWidth - 2 && bounds.height >= bounds.originalHeight - 2 ); } function orderLayerSplitItems(layers: T[]) { const ordered = [...layers].sort( (left, right) => (left.layerIndex || 0) - (right.layerIndex || 0) ); const backgroundLayers = ordered.filter((layer) => isFullFrameSplitLayer(layer.bounds) ); const subjectLayers = ordered.filter( (layer) => !isFullFrameSplitLayer(layer.bounds) ); return [...backgroundLayers, ...subjectLayers]; } function getResizeCursor(handle: LayerResizeHandle) { return handle === "nw" || handle === "se" ? "nwse-resize" : "nesw-resize"; } function buildSuggestedProjectName(prompt: string) { const trimmed = prompt.trim(); if (trimmed) { return trimmed.slice(0, 24); } return `实时项目 ${new Date().toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", })}`; } function readRealtimeProjectsFromStorage() { if (typeof window === "undefined") return [] as SavedRealtimeProject[]; try { const raw = window.localStorage.getItem(REALTIME_PROJECTS_STORAGE_KEY); if (!raw) return []; const parsed = JSON.parse(raw); return Array.isArray(parsed) ? (parsed as SavedRealtimeProject[]) : []; } catch { return []; } } function writeRealtimeProjectsToStorage(projects: SavedRealtimeProject[]) { if (typeof window === "undefined") return; window.localStorage.setItem( REALTIME_PROJECTS_STORAGE_KEY, JSON.stringify(projects) ); } const defaultRealtimeConfig: RealtimeConfig = { transport: { throttleInterval: 100, maxBuffering: 2, }, image: { maxInputResolution: 1024, jpegQuality: 0.55, }, model: { id: "flux-2-klein-4b", defaults: { steps: 3, cfgScale: 7, strength: 0.32, imageSize: "square", }, }, behavior: { strictSketch: true, }, }; function isShapeMode(mode: RealtimeBrushMode) { return mode === "rect" || mode === "circle" || mode === "line"; } function createSessionSeed() { return Math.floor(Math.random() * 2_147_483_647); } function imageDataHasVisiblePixels(imageData: ImageData) { const pixels = imageData.data; for (let index = 3; index < pixels.length; index += 4) { if (pixels[index] > 0) return true; } return false; } async function canvasToJpegDataUri( canvas: HTMLCanvasElement, maxInputResolution: number, jpegQuality: number, backgroundColor: string, options?: { autoCrop?: boolean; } ): Promise<{ dataUri: string; width: number; height: number }> { type CropBox = { x: number; y: number; width: number; height: number; foregroundRatio: number; }; const parseColor = (rawColor: string) => { const tempCanvas = document.createElement("canvas"); tempCanvas.width = 1; tempCanvas.height = 1; const tempCtx = tempCanvas.getContext("2d"); if (!tempCtx) { return { r: 255, g: 255, b: 255 }; } tempCtx.fillStyle = rawColor; const normalized = String(tempCtx.fillStyle || "") .trim() .toLowerCase(); if (normalized.startsWith("#")) { const hex = normalized.slice(1); if (hex.length === 3) { return { r: Number.parseInt(hex[0] + hex[0], 16), g: Number.parseInt(hex[1] + hex[1], 16), b: Number.parseInt(hex[2] + hex[2], 16), }; } if (hex.length >= 6) { return { r: Number.parseInt(hex.slice(0, 2), 16), g: Number.parseInt(hex.slice(2, 4), 16), b: Number.parseInt(hex.slice(4, 6), 16), }; } } const rgbMatch = normalized.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/); if (rgbMatch) { return { r: Number(rgbMatch[1]), g: Number(rgbMatch[2]), b: Number(rgbMatch[3]), }; } return { r: 255, g: 255, b: 255 }; }; const findForegroundBox = (): CropBox | null => { const sourceWidth = canvas.width; const sourceHeight = canvas.height; const longestSide = Math.max(sourceWidth, sourceHeight); const analysisScale = longestSide > 320 ? 320 / longestSide : 1; const analysisWidth = Math.max(48, Math.round(sourceWidth * analysisScale)); const analysisHeight = Math.max( 48, Math.round(sourceHeight * analysisScale) ); const analysisCanvas = document.createElement("canvas"); analysisCanvas.width = analysisWidth; analysisCanvas.height = analysisHeight; const analysisCtx = analysisCanvas.getContext("2d", { willReadFrequently: true, }); if (!analysisCtx) return null; analysisCtx.fillStyle = backgroundColor; analysisCtx.fillRect(0, 0, analysisWidth, analysisHeight); analysisCtx.drawImage(canvas, 0, 0, analysisWidth, analysisHeight); const imageData = analysisCtx.getImageData( 0, 0, analysisWidth, analysisHeight ); const data = imageData.data; const totalPixels = analysisWidth * analysisHeight; const mask = new Uint8Array(totalPixels); const bg = parseColor(backgroundColor); const tolerance = 18; let minX = analysisWidth; let minY = analysisHeight; let maxX = -1; let maxY = -1; let foregroundPixels = 0; for (let y = 0; y < analysisHeight; y += 1) { for (let x = 0; x < analysisWidth; x += 1) { const pixelIndex = y * analysisWidth + x; const offset = pixelIndex * 4; const alpha = data[offset + 3]; if (alpha < 16) continue; const dr = Math.abs(data[offset] - bg.r); const dg = Math.abs(data[offset + 1] - bg.g); const db = Math.abs(data[offset + 2] - bg.b); const isForeground = dr > tolerance || dg > tolerance || db > tolerance; if (!isForeground) continue; mask[pixelIndex] = 1; foregroundPixels += 1; if (x < minX) minX = x; if (x > maxX) maxX = x; if (y < minY) minY = y; if (y > maxY) maxY = y; } } if (foregroundPixels <= 0 || maxX < minX || maxY < minY) { return null; } const visited = new Uint8Array(totalPixels); const queue = new Uint32Array(totalPixels); const minComponentArea = Math.max(9, Math.floor(foregroundPixels * 0.0015)); let bestArea = 0; let bestMinX = minX; let bestMinY = minY; let bestMaxX = maxX; let bestMaxY = maxY; for (let idx = 0; idx < totalPixels; idx += 1) { if (!mask[idx] || visited[idx]) continue; let head = 0; let tail = 0; queue[tail] = idx; tail += 1; visited[idx] = 1; let area = 0; let componentMinX = analysisWidth; let componentMinY = analysisHeight; let componentMaxX = -1; let componentMaxY = -1; while (head < tail) { const current = queue[head]; head += 1; const x = current % analysisWidth; const y = Math.floor(current / analysisWidth); area += 1; if (x < componentMinX) componentMinX = x; if (x > componentMaxX) componentMaxX = x; if (y < componentMinY) componentMinY = y; if (y > componentMaxY) componentMaxY = y; const startY = Math.max(0, y - 1); const endY = Math.min(analysisHeight - 1, y + 1); const startX = Math.max(0, x - 1); const endX = Math.min(analysisWidth - 1, x + 1); for (let ny = startY; ny <= endY; ny += 1) { for (let nx = startX; nx <= endX; nx += 1) { const neighbor = ny * analysisWidth + nx; if (!mask[neighbor] || visited[neighbor]) continue; visited[neighbor] = 1; queue[tail] = neighbor; tail += 1; } } } if (area >= minComponentArea && area > bestArea) { bestArea = area; bestMinX = componentMinX; bestMinY = componentMinY; bestMaxX = componentMaxX; bestMaxY = componentMaxY; } } if (bestArea > 0) { minX = bestMinX; minY = bestMinY; maxX = bestMaxX; maxY = bestMaxY; } else { bestArea = foregroundPixels; } const rowCounts = new Uint32Array(analysisHeight); const colCounts = new Uint32Array(analysisWidth); for (let y = minY; y <= maxY; y += 1) { for (let x = minX; x <= maxX; x += 1) { const idx = y * analysisWidth + x; if (!mask[idx]) continue; rowCounts[y] += 1; colCounts[x] += 1; } } const trimPixels = Math.max(0, Math.floor(bestArea * 0.008)); if (trimPixels > 0) { let cumulative = 0; for (let x = minX; x <= maxX; x += 1) { cumulative += colCounts[x]; if (cumulative > trimPixels) { minX = x; break; } } cumulative = 0; for (let x = maxX; x >= minX; x -= 1) { cumulative += colCounts[x]; if (cumulative > trimPixels) { maxX = x; break; } } cumulative = 0; for (let y = minY; y <= maxY; y += 1) { cumulative += rowCounts[y]; if (cumulative > trimPixels) { minY = y; break; } } cumulative = 0; for (let y = maxY; y >= minY; y -= 1) { cumulative += rowCounts[y]; if (cumulative > trimPixels) { maxY = y; break; } } } const scaleX = sourceWidth / analysisWidth; const scaleY = sourceHeight / analysisHeight; const boxX = Math.max(0, Math.floor(minX * scaleX)); const boxY = Math.max(0, Math.floor(minY * scaleY)); const boxRight = Math.min(sourceWidth, Math.ceil((maxX + 1) * scaleX)); const boxBottom = Math.min(sourceHeight, Math.ceil((maxY + 1) * scaleY)); const boxWidth = Math.max(1, boxRight - boxX); const boxHeight = Math.max(1, boxBottom - boxY); const foregroundRatio = Math.min(1, bestArea / totalPixels); const paddingRatio = foregroundRatio < 0.08 ? 0.34 : foregroundRatio < 0.2 ? 0.28 : 0.22; const padding = Math.max( 16, Math.floor(Math.max(boxWidth, boxHeight) * paddingRatio) ); const x = Math.max(0, boxX - padding); const y = Math.max(0, boxY - padding); const right = Math.min(sourceWidth, boxRight + padding); const bottom = Math.min(sourceHeight, boxBottom + padding); return { x, y, width: Math.max(1, right - x), height: Math.max(1, bottom - y), foregroundRatio, }; }; const sourceWidth = canvas.width; const sourceHeight = canvas.height; const cropBox = options?.autoCrop ? findForegroundBox() : null; const drawSourceX = cropBox?.x || 0; const drawSourceY = cropBox?.y || 0; const drawSourceWidth = cropBox?.width || sourceWidth; const drawSourceHeight = cropBox?.height || sourceHeight; const autoCropEnabled = Boolean(options?.autoCrop && cropBox); const longestSide = Math.max(drawSourceWidth, drawSourceHeight); const scale = longestSide > maxInputResolution ? maxInputResolution / longestSide : 1; const exportWidth = autoCropEnabled ? Math.max(512, maxInputResolution) : Math.max(1, Math.round(drawSourceWidth * scale)); const exportHeight = autoCropEnabled ? Math.max(512, maxInputResolution) : Math.max(1, Math.round(drawSourceHeight * scale)); const exportCanvas = document.createElement("canvas"); exportCanvas.width = exportWidth; exportCanvas.height = exportHeight; const exportCtx = exportCanvas.getContext("2d"); if (!exportCtx) { throw new Error("无法初始化导出画布"); } exportCtx.fillStyle = backgroundColor; exportCtx.fillRect(0, 0, exportWidth, exportHeight); if (autoCropEnabled) { const fitRatio = (cropBox?.foregroundRatio || 0) < 0.12 ? 0.9 : 0.94; const fitScale = Math.min( (exportWidth * fitRatio) / drawSourceWidth, (exportHeight * fitRatio) / drawSourceHeight ); const targetWidth = Math.max(1, Math.round(drawSourceWidth * fitScale)); const targetHeight = Math.max(1, Math.round(drawSourceHeight * fitScale)); const targetX = Math.round((exportWidth - targetWidth) / 2); const targetY = Math.round((exportHeight - targetHeight) / 2); exportCtx.imageSmoothingEnabled = true; exportCtx.imageSmoothingQuality = "high"; exportCtx.drawImage( canvas, drawSourceX, drawSourceY, drawSourceWidth, drawSourceHeight, targetX, targetY, targetWidth, targetHeight ); if ((cropBox?.foregroundRatio || 0) < 0.28) { exportCtx.save(); exportCtx.globalAlpha = 0.22; exportCtx.drawImage( canvas, drawSourceX, drawSourceY, drawSourceWidth, drawSourceHeight, targetX, targetY, targetWidth, targetHeight ); exportCtx.restore(); } } else { exportCtx.drawImage( canvas, drawSourceX, drawSourceY, drawSourceWidth, drawSourceHeight, 0, 0, exportWidth, exportHeight ); } const blob = await new Promise((resolve) => { exportCanvas.toBlob(resolve, "image/jpeg", jpegQuality); }); if (!blob) { throw new Error("导出图片失败"); } const dataUri = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result || "")); reader.onerror = () => reject(new Error("读取导出图片失败")); reader.readAsDataURL(blob); }); return { dataUri, width: exportWidth, height: exportHeight, }; } type FlushMode = "preview" | "refine"; type NormalizedRegion = { x: number; y: number; width: number; height: number; }; function clamp01(value: number) { return Math.min(1, Math.max(0, value)); } function mergeRegion( current: NormalizedRegion | null, next: NormalizedRegion ): NormalizedRegion { if (!current) return next; const x1 = Math.min(current.x, next.x); const y1 = Math.min(current.y, next.y); const x2 = Math.max(current.x + current.width, next.x + next.width); const y2 = Math.max(current.y + current.height, next.y + next.height); return { x: clamp01(x1), y: clamp01(y1), width: clamp01(x2 - x1), height: clamp01(y2 - y1), }; } function toNormalizedRegion( canvas: HTMLCanvasElement, rect: { minX: number; minY: number; maxX: number; maxY: number; } ): NormalizedRegion | null { const bounds = canvas.getBoundingClientRect(); const width = Math.max(1, bounds.width); const height = Math.max(1, bounds.height); const x = clamp01(rect.minX / width); const y = clamp01(rect.minY / height); const right = clamp01(rect.maxX / width); const bottom = clamp01(rect.maxY / height); const regionWidth = right - x; const regionHeight = bottom - y; if (regionWidth <= 0 || regionHeight <= 0) return null; return { x, y, width: regionWidth, height: regionHeight }; } async function createMaskDataUri( width: number, height: number, region: NormalizedRegion ) { const canvas = document.createElement("canvas"); canvas.width = Math.max(1, width); canvas.height = Math.max(1, height); const ctx = canvas.getContext("2d"); if (!ctx) { throw new Error("无法初始化遮罩画布"); } const basePad = Math.max(12, Math.floor(Math.max(width, height) * 0.02)); let x = Math.floor(region.x * width) - basePad; let y = Math.floor(region.y * height) - basePad; let regionWidth = Math.ceil(region.width * width) + basePad * 2; let regionHeight = Math.ceil(region.height * height) + basePad * 2; const minPatch = Math.max(40, Math.floor(Math.max(width, height) * 0.1)); if (regionWidth < minPatch) { const delta = Math.ceil((minPatch - regionWidth) / 2); x -= delta; regionWidth += delta * 2; } if (regionHeight < minPatch) { const delta = Math.ceil((minPatch - regionHeight) / 2); y -= delta; regionHeight += delta * 2; } x = Math.max(0, x); y = Math.max(0, y); const right = Math.min(width, x + regionWidth); const bottom = Math.min(height, y + regionHeight); ctx.fillStyle = "#000000"; ctx.fillRect(0, 0, width, height); ctx.fillStyle = "#ffffff"; ctx.fillRect(x, y, Math.max(1, right - x), Math.max(1, bottom - y)); return canvas.toDataURL("image/png"); } export function RealtimeCanvasDialog({ open, onOpenChange, initialImageUrl, initialMode = "realtime", onApplyResult, standalone = false, }: RealtimeCanvasDialogProps) { const canvasRef = useRef(null); const imageLayerCanvasRef = useRef(null); const previewCanvasRef = useRef(null); const canvasWrapRef = useRef(null); const connectionRef = useRef(null); const pointerDownRef = useRef(false); const draggingLayerIdRef = useRef(null); const resizingLayerIdRef = useRef(null); const layerDragOriginRef = useRef<{ pointerX: number; pointerY: number; layerX: number; layerY: number; } | null>(null); const layerResizeOriginRef = useRef<{ pointerX: number; pointerY: number; layerX: number; layerY: number; layerWidth: number; layerHeight: number; handle: LayerResizeHandle; } | null>(null); const lastPointRef = useRef<{ x: number; y: number } | null>(null); const shapeStartPointRef = useRef<{ x: number; y: number } | null>(null); const pendingFlushTimerRef = useRef(null); const flushInFlightRef = useRef(false); const dirtyRef = useRef(false); const dirtyRegionRef = useRef(null); const draftVersionRef = useRef(0); const pendingFlushModeRef = useRef(null); const pendingFlushForceRef = useRef(false); const latencyEmaRef = useRef(null); const baseThrottleMsRef = useRef( defaultRealtimeConfig.transport.throttleInterval ); const pumpFlushRef = useRef<(() => void) | null>(null); const warningMessageRef = useRef(""); const seqRef = useRef(0); const requestStartMapRef = useRef>(new Map()); const lastRenderedSeqRef = useRef(0); const layerRenderSeqRef = useRef(0); const metricsQueueRef = useRef([]); const sessionIdRef = useRef( `rt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` ); const seedRef = useRef(createSessionSeed()); const hasLoadedInitialImageRef = useRef(false); const canvasBackgroundRef = useRef(CANVAS_BACKGROUND); const imageLayersRef = useRef([]); const colorInputRef = useRef(null); const layerObjectUrlsRef = useRef>(new Set()); const sidebarDraggingLayerIdRef = useRef(null); const drawingHasContentRef = useRef(false); const drawingLayerSnapshotRef = useRef(""); const dragDepthRef = useRef(0); // History Management const historyRef = useRef([]); const historyIndexRef = useRef(-1); const cursorRef = useRef(null); const uploadInputRef = useRef(null); const splitUploadInputRef = useRef(null); const [canUndo, setCanUndo] = useState(false); const [canRedo, setCanRedo] = useState(false); const [config, setConfig] = useState(defaultRealtimeConfig); const [status, setStatus] = useState("idle"); const [, setStatusMessage] = useState("等待连接"); const [prompt, setPrompt] = useState(""); const [previewUrl, setPreviewUrl] = useState(""); const [workspaceMode, setWorkspaceMode] = useState(initialMode); const [enhanceUploadedSourceImageUrl, setEnhanceUploadedSourceImageUrl] = useState(""); const [brushMode, setBrushMode] = useState("select"); const [brushColor, setBrushColor] = useState(DEFAULT_BRUSH_COLOR); const [canvasBackground, setCanvasBackground] = useState( canvasBackgroundRef.current ); const [imageLayers, setImageLayers] = useState([]); const [isLayerSplitImporting, setIsLayerSplitImporting] = useState(false); const [selectedLayerId, setSelectedLayerId] = useState(DRAW_LAYER_ID); const [brushSize, setBrushSize] = useState(6); const [savedProjects, setSavedProjects] = useState( [] ); const [projectPanelOpen, setProjectPanelOpen] = useState(false); const [saveProjectDialogOpen, setSaveProjectDialogOpen] = useState(false); const [pendingProjectName, setPendingProjectName] = useState(""); const [brushPopoverOpen, setBrushPopoverOpen] = useState(false); const [eraserPopoverOpen, setEraserPopoverOpen] = useState(false); const [isDragImportActive, setIsDragImportActive] = useState(false); const [adaptiveThrottleMs, setAdaptiveThrottleMs] = useState( defaultRealtimeConfig.transport.throttleInterval ); const [refreshToken, setRefreshToken] = useState(0); const [isLayerSplitEnabled, setIsLayerSplitEnabled] = useState(false); const [isEnhanceProEnabled, setIsEnhanceProEnabled] = useState(false); const [hasResolvedCanvasSettings, setHasResolvedCanvasSettings] = useState(false); const [modeIconConfig, setModeIconConfig] = useState({ realtimeIconUrl: "", enhanceIconUrl: "", realtimeLabel: DEFAULT_REALTIME_CANVAS_MODE_LABEL, enhanceLabel: DEFAULT_ENHANCE_CANVAS_MODE_LABEL, }); const [canvasToolbarConfig, setCanvasToolbarConfig] = useState(defaultCanvasToolbarConfig); const statusBadge = useMemo(() => { if (status === "live") return { label: "实时中", className: "border border-[#e5e5e5] bg-white text-[#111111] dark:border-white/12 dark:bg-white/10 dark:text-white", }; if (status === "connecting") { return { label: "连接中", className: "border border-[#e5e5e5] bg-white text-[#111111] dark:border-white/12 dark:bg-white/10 dark:text-white", }; } if (status === "error") return { label: "异常", className: "border border-[#e5e5e5] bg-[#fafafa] text-[#111111] dark:border-red-400/25 dark:bg-red-500/10 dark:text-red-100", }; return { label: "待启动", className: "border border-[#e5e5e5] bg-white text-[#666666] dark:border-white/12 dark:bg-white/8 dark:text-white/58", }; }, [status]); const selectedImageLayer = useMemo( () => imageLayers.find((layer) => layer.id === selectedLayerId) || null, [imageLayers, selectedLayerId] ); const enhanceHistoryScopeKey = useMemo(() => { return ( initialImageUrl || enhanceUploadedSourceImageUrl || imageLayers[0]?.src || previewUrl || "realtime-canvas-enhance" ); }, [ enhanceUploadedSourceImageUrl, imageLayers, initialImageUrl, previewUrl, ]); const enhanceSourceImageUrl = useMemo(() => { return ( selectedImageLayer?.src || imageLayers[0]?.src || enhanceUploadedSourceImageUrl || previewUrl || initialImageUrl || "" ); }, [ enhanceUploadedSourceImageUrl, imageLayers, initialImageUrl, previewUrl, selectedImageLayer?.src, ]); const previewDisplayUrl = useMemo(() => { if (!previewUrl) return ""; return /^https?:\/\//i.test(previewUrl) ? buildImageProxyUrl(previewUrl, { delivery: "proxy" }) : previewUrl; }, [previewUrl]); const resolveRealtimeModeItem = useCallback( ( key: "realtimeModeRealtime" | "realtimeModeEnhance", fallback: { label: string; iconUrl: string; enabled?: boolean } ) => { const item = getCanvasToolbarItem(canvasToolbarConfig, key); const defaultItem = getDefaultCanvasToolbarItem(key); return { label: item.label === defaultItem.label ? fallback.label : item.label, iconUrl: item.iconUrl || fallback.iconUrl, enabled: item.enabled ?? fallback.enabled ?? true, }; }, [canvasToolbarConfig] ); const realtimeModeRealtimeItem = resolveRealtimeModeItem( "realtimeModeRealtime", { label: modeIconConfig.realtimeLabel, iconUrl: modeIconConfig.realtimeIconUrl, } ); const realtimeModeEnhanceItem = resolveRealtimeModeItem("realtimeModeEnhance", { label: modeIconConfig.enhanceLabel, iconUrl: modeIconConfig.enhanceIconUrl, }); const realtimeReturnCanvasItem = getCanvasToolbarItem( canvasToolbarConfig, "realtimeReturnCanvas" ); const realtimeLayerPanelItem = getCanvasToolbarItem( canvasToolbarConfig, "realtimeLayerPanel" ); const realtimeLayerSplitImportItem = getCanvasToolbarItem( canvasToolbarConfig, "realtimeLayerSplitImport" ); const realtimeAddImageLayerItem = getCanvasToolbarItem( canvasToolbarConfig, "realtimeAddImageLayer" ); const realtimeDownloadPreviewItem = getCanvasToolbarItem( canvasToolbarConfig, "realtimeDownloadPreview" ); const realtimeSelectToolItem = getCanvasToolbarItem( canvasToolbarConfig, "realtimeSelectTool" ); const realtimeBrushToolItem = getCanvasToolbarItem( canvasToolbarConfig, "realtimeBrushTool" ); const realtimeEraserToolItem = getCanvasToolbarItem( canvasToolbarConfig, "realtimeEraserTool" ); const realtimeShapeToolItem = getCanvasToolbarItem( canvasToolbarConfig, "realtimeShapeTool" ); const realtimeSaveProjectItem = getCanvasToolbarItem( canvasToolbarConfig, "realtimeSaveProject" ); const realtimeProjectListItem = getCanvasToolbarItem( canvasToolbarConfig, "realtimeProjectList" ); const realtimeNewBlankItem = getCanvasToolbarItem( canvasToolbarConfig, "realtimeNewBlank" ); const realtimeGenerateItem = getCanvasToolbarItem( canvasToolbarConfig, "realtimeGenerate" ); const realtimeSendToCanvasItem = getCanvasToolbarItem( canvasToolbarConfig, "realtimeSendToCanvas" ); const realtimeUndoItem = getCanvasToolbarItem(canvasToolbarConfig, "undo"); const realtimeRedoItem = getCanvasToolbarItem(canvasToolbarConfig, "redo"); useEffect(() => { if (!open) return; setWorkspaceMode(initialMode); setEnhanceUploadedSourceImageUrl(""); }, [initialMode, open]); useEffect(() => { let cancelled = false; setHasResolvedCanvasSettings(false); setIsEnhanceProEnabled(false); fetch(`/api/site-settings?scope=canvas&t=${Date.now()}`) .then((res) => (res.ok ? res.json() : null)) .then((data) => { if (!data || cancelled) return; setIsLayerSplitEnabled(data.layerSplitEnabled === true); setIsEnhanceProEnabled(data.enhanceProEnabled === true); setCanvasToolbarConfig(normalizeCanvasToolbarConfig(data.canvasToolbarConfig)); const iconConfig = data.realtimeCanvasModeIconConfig && typeof data.realtimeCanvasModeIconConfig === "object" ? data.realtimeCanvasModeIconConfig : {}; const normalizeModeLabel = (value: unknown, fallback: string) => { const label = String(value || "").trim().slice(0, 32); return label || fallback; }; setModeIconConfig({ realtimeIconUrl: typeof iconConfig.realtimeIconUrl === "string" ? iconConfig.realtimeIconUrl.trim() : "", enhanceIconUrl: typeof iconConfig.enhanceIconUrl === "string" ? iconConfig.enhanceIconUrl.trim() : "", realtimeLabel: normalizeModeLabel( iconConfig.realtimeLabel, DEFAULT_REALTIME_CANVAS_MODE_LABEL ), enhanceLabel: normalizeModeLabel( iconConfig.enhanceLabel, DEFAULT_ENHANCE_CANVAS_MODE_LABEL ), }); }) .catch((error) => { console.warn("[RealtimeCanvas] Failed to load site settings:", error); }) .finally(() => { if (!cancelled) { setHasResolvedCanvasSettings(true); } }); return () => { cancelled = true; }; }, [open]); useEffect(() => { if (!open || !hasResolvedCanvasSettings) { return; } if (workspaceMode === "enhance-pro" && !isEnhanceProEnabled) { setWorkspaceMode("realtime"); } }, [hasResolvedCanvasSettings, isEnhanceProEnabled, open, workspaceMode]); const pushMetric = useCallback((event: any) => { metricsQueueRef.current.push(event); if (metricsQueueRef.current.length > 120) { metricsQueueRef.current = metricsQueueRef.current.slice(-80); } }, []); const flushMetrics = useCallback(async () => { if (!open || metricsQueueRef.current.length === 0) return; const events = metricsQueueRef.current.splice( 0, metricsQueueRef.current.length ); try { await fetch("/api/realtime/metrics", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sessionId: sessionIdRef.current, events, }), }); } catch { metricsQueueRef.current.unshift(...events.slice(-20)); } }, [open]); useEffect(() => { imageLayersRef.current = imageLayers; }, [imageLayers]); const revokeAllLayerObjectUrls = useCallback(() => { for (const url of layerObjectUrlsRef.current) { URL.revokeObjectURL(url); } layerObjectUrlsRef.current.clear(); }, []); const clearDrawingCanvas = useCallback(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext("2d"); if (!ctx) return; ctx.clearRect(0, 0, canvas.width, canvas.height); drawingHasContentRef.current = false; }, []); const loadRenderableImage = useCallback((src: string) => { return new Promise((resolve, reject) => { const image = new Image(); const resolvedSrc = /^https?:\/\//i.test(src) ? buildImageProxyUrl(src, { delivery: "proxy" }) : src; image.crossOrigin = "anonymous"; image.onload = () => resolve(image); image.onerror = () => reject(new Error("图层图片加载失败")); image.src = resolvedSrc; }); }, []); const drawImageLayersToContext = useCallback( async ( ctx: CanvasRenderingContext2D, width: number, height: number, options?: { renderSeq?: number } ) => { ctx.clearRect(0, 0, width, height); ctx.save(); ctx.globalCompositeOperation = "source-over"; ctx.fillStyle = canvasBackgroundRef.current; ctx.fillRect(0, 0, width, height); for (const layer of imageLayersRef.current) { try { const image = await loadRenderableImage(layer.src); if ( options?.renderSeq && options.renderSeq !== layerRenderSeqRef.current ) { ctx.restore(); return false; } ctx.drawImage(image, layer.x, layer.y, layer.width, layer.height); } catch (error) { console.error("[RealtimeCanvas] layer render failed:", error); } } ctx.restore(); return true; }, [loadRenderableImage] ); const renderImageLayers = useCallback(async () => { const layerCanvas = imageLayerCanvasRef.current; if (!layerCanvas) return; const ctx = layerCanvas.getContext("2d"); if (!ctx) return; const renderSeq = layerRenderSeqRef.current + 1; layerRenderSeqRef.current = renderSeq; const cssWidth = Math.max( 1, Number.parseFloat(layerCanvas.style.width) || layerCanvas.width ); const cssHeight = Math.max( 1, Number.parseFloat(layerCanvas.style.height) || layerCanvas.height ); await drawImageLayersToContext(ctx, cssWidth, cssHeight, { renderSeq }); }, [drawImageLayersToContext]); const buildCompositeCanvas = useCallback(async () => { const drawingCanvas = canvasRef.current; if (!drawingCanvas) return null; const cssWidth = Math.max( 1, Number.parseFloat(drawingCanvas.style.width) || drawingCanvas.width ); const cssHeight = Math.max( 1, Number.parseFloat(drawingCanvas.style.height) || drawingCanvas.height ); const dpr = Math.max(1, drawingCanvas.width / cssWidth); const compositeCanvas = document.createElement("canvas"); compositeCanvas.width = drawingCanvas.width; compositeCanvas.height = drawingCanvas.height; const compositeCtx = compositeCanvas.getContext("2d"); if (!compositeCtx) return null; compositeCtx.setTransform(1, 0, 0, 1, 0, 0); compositeCtx.scale(dpr, dpr); const didRenderLayers = await drawImageLayersToContext( compositeCtx, cssWidth, cssHeight ); if (!didRenderLayers) { return null; } compositeCtx.drawImage(drawingCanvas, 0, 0, cssWidth, cssHeight); return compositeCanvas; }, [drawImageLayersToContext]); const createPlacedLayer = useCallback( async (params: { src: string; name: string; placement?: { x: number; y: number; width: number; height: number; }; }) => { const image = await loadRenderableImage(params.src); const canvas = canvasRef.current; if (!canvas) { throw new Error("画布未初始化"); } if (params.placement) { return { id: `layer-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, name: params.name, src: params.src, x: params.placement.x, y: params.placement.y, width: params.placement.width, height: params.placement.height, baseWidth: params.placement.width, baseHeight: params.placement.height, } satisfies ImageLayer; } const rect = canvas.getBoundingClientRect(); const canvasWidth = Math.max(1, rect.width); const canvasHeight = Math.max(1, rect.height); const maxWidth = canvasWidth * 0.82; const maxHeight = canvasHeight * 0.82; const scale = Math.min( maxWidth / image.width, maxHeight / image.height, 1 ); const drawWidth = Math.max(24, image.width * scale); const drawHeight = Math.max(24, image.height * scale); const offsetX = (canvasWidth - drawWidth) / 2; const offsetY = (canvasHeight - drawHeight) / 2; return { id: `layer-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, name: params.name, src: params.src, x: offsetX, y: offsetY, width: drawWidth, height: drawHeight, baseWidth: drawWidth, baseHeight: drawHeight, } satisfies ImageLayer; }, [loadRenderableImage] ); const findLayerAtPoint = useCallback((point: { x: number; y: number }) => { for ( let index = imageLayersRef.current.length - 1; index >= 0; index -= 1 ) { const layer = imageLayersRef.current[index]; const withinX = point.x >= layer.x && point.x <= layer.x + layer.width; const withinY = point.y >= layer.y && point.y <= layer.y + layer.height; if (withinX && withinY) { return layer; } } return null; }, []); const getResizeHandleAtPoint = useCallback( ( point: { x: number; y: number }, layer: ImageLayer | null ): LayerResizeHandle | null => { if (!layer) return null; const handleSize = 14; const half = handleSize / 2; const handles: Array<{ key: LayerResizeHandle; x: number; y: number }> = [ { key: "nw", x: layer.x, y: layer.y }, { key: "ne", x: layer.x + layer.width, y: layer.y }, { key: "sw", x: layer.x, y: layer.y + layer.height }, { key: "se", x: layer.x + layer.width, y: layer.y + layer.height }, ]; for (const handle of handles) { const withinX = point.x >= handle.x - half && point.x <= handle.x + half; const withinY = point.y >= handle.y - half && point.y <= handle.y + half; if (withinX && withinY) { return handle.key; } } return null; }, [] ); const updateLayerPosition = useCallback( (layerId: string, x: number, y: number) => { const nextLayers = imageLayersRef.current.map((layer) => layer.id === layerId ? { ...layer, x, y } : layer ); imageLayersRef.current = nextLayers; setImageLayers(nextLayers); return nextLayers; }, [] ); const resizeLayerFromHandle = useCallback( ( layerId: string, point: { x: number; y: number }, origin: NonNullable ) => { const canvas = canvasRef.current; if (!canvas) return; const rect = canvas.getBoundingClientRect(); const canvasWidth = Math.max(1, rect.width); const canvasHeight = Math.max(1, rect.height); const aspectRatio = Math.max( 0.01, origin.layerWidth / Math.max(1, origin.layerHeight) ); const minSize = 24; let anchorX = origin.layerX; let anchorY = origin.layerY; let maxWidth = canvasWidth; let maxHeight = canvasHeight; if (origin.handle === "nw") { anchorX = origin.layerX + origin.layerWidth; anchorY = origin.layerY + origin.layerHeight; maxWidth = anchorX; maxHeight = anchorY; } else if (origin.handle === "ne") { anchorX = origin.layerX; anchorY = origin.layerY + origin.layerHeight; maxWidth = canvasWidth - anchorX; maxHeight = anchorY; } else if (origin.handle === "sw") { anchorX = origin.layerX + origin.layerWidth; anchorY = origin.layerY; maxWidth = anchorX; maxHeight = canvasHeight - anchorY; } else { anchorX = origin.layerX; anchorY = origin.layerY; maxWidth = canvasWidth - anchorX; maxHeight = canvasHeight - anchorY; } let targetWidth = Math.max(minSize, Math.abs(point.x - anchorX)); let targetHeight = targetWidth / aspectRatio; if (targetHeight < minSize) { targetHeight = minSize; targetWidth = targetHeight * aspectRatio; } if (targetHeight > Math.abs(point.y - anchorY)) { targetHeight = Math.max(minSize, Math.abs(point.y - anchorY)); targetWidth = targetHeight * aspectRatio; } const fitRatio = Math.min( 1, maxWidth / targetWidth, maxHeight / targetHeight ); targetWidth = Math.max(minSize, targetWidth * fitRatio); targetHeight = Math.max(minSize, targetHeight * fitRatio); const nextX = origin.handle === "ne" || origin.handle === "se" ? anchorX : anchorX - targetWidth; const nextY = origin.handle === "sw" || origin.handle === "se" ? anchorY : anchorY - targetHeight; const nextLayers = imageLayersRef.current.map((layer) => layer.id === layerId ? { ...layer, x: Math.max(0, nextX), y: Math.max(0, nextY), width: Math.min(canvasWidth, targetWidth), height: Math.min(canvasHeight, targetHeight), } : layer ); imageLayersRef.current = nextLayers; setImageLayers(nextLayers); return nextLayers; }, [] ); const exportDrawingLayerDataUrl = useCallback(() => { const canvas = canvasRef.current; if (!canvas) return ""; return canvas.toDataURL("image/png"); }, []); const drawingCanvasHasContent = useCallback((accurate = false) => { if (!accurate) { return drawingHasContentRef.current; } const canvas = canvasRef.current; const ctx = canvas?.getContext("2d", { willReadFrequently: true }); if (!canvas || !ctx) return false; try { const hasContent = imageDataHasVisiblePixels( ctx.getImageData(0, 0, canvas.width, canvas.height) ); drawingHasContentRef.current = hasContent; return hasContent; } catch { return drawingHasContentRef.current; } }, []); const hasRenderableInput = useCallback( (accurate = false) => imageLayersRef.current.length > 0 || drawingCanvasHasContent(accurate), [drawingCanvasHasContent] ); const fileToDataUrl = useCallback((file: File) => { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result || "")); reader.onerror = () => reject(new Error("读取图片失败")); reader.readAsDataURL(file); }); }, []); const saveHistory = useCallback( (layersOverride?: ImageLayer[], backgroundOverride?: string) => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext("2d", { willReadFrequently: true }); if (!ctx) return; // If we are not at the end of history, truncate future history if (historyIndexRef.current < historyRef.current.length - 1) { historyRef.current = historyRef.current.slice( 0, historyIndexRef.current + 1 ); } try { const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); drawingHasContentRef.current = imageDataHasVisiblePixels(imageData); historyRef.current.push({ imageData, imageLayers: cloneImageLayers( layersOverride || imageLayersRef.current ), canvasBackground: backgroundOverride || canvasBackgroundRef.current, }); historyIndexRef.current = historyRef.current.length - 1; } catch (error) { console.warn("[RealtimeCanvas] saveHistory skipped:", error); return; } // Limit history size if (historyRef.current.length > 50) { historyRef.current.shift(); historyIndexRef.current--; } setCanUndo(historyIndexRef.current > 0); setCanRedo(false); }, [] ); const markCanvasDirty = useCallback((region?: NormalizedRegion | null) => { dirtyRef.current = true; draftVersionRef.current += 1; if (!region) { dirtyRegionRef.current = { x: 0, y: 0, width: 1, height: 1 }; return; } dirtyRegionRef.current = mergeRegion(dirtyRegionRef.current, region); }, []); const markCanvasDirtyRect = useCallback( (rect: { minX: number; minY: number; maxX: number; maxY: number }) => { const canvas = canvasRef.current; if (!canvas) return; const region = toNormalizedRegion(canvas, rect); if (!region) return; markCanvasDirty(region); }, [markCanvasDirty] ); const markCanvasDirtyStroke = useCallback( ( from: { x: number; y: number }, to: { x: number; y: number }, lineWidth: number ) => { const pad = Math.max(6, Math.ceil(lineWidth * 1.2)); markCanvasDirtyRect({ minX: Math.min(from.x, to.x) - pad, minY: Math.min(from.y, to.y) - pad, maxX: Math.max(from.x, to.x) + pad, maxY: Math.max(from.y, to.y) + pad, }); }, [markCanvasDirtyRect] ); useEffect(() => { if (!open) return; void renderImageLayers(); }, [canvasBackground, imageLayers, open, renderImageLayers]); useEffect(() => { return () => { revokeAllLayerObjectUrls(); }; }, [revokeAllLayerObjectUrls]); const flushFrame = useCallback( async (mode: FlushMode, force = false) => { if (!open) return; if (!connectionRef.current) return; if (flushInFlightRef.current) return; if (!dirtyRef.current && !force) return; if (!hasRenderableInput(true)) { dirtyRef.current = false; dirtyRegionRef.current = null; setStatus("idle"); setStatusMessage("画布为空,先上传图片或绘制内容"); return; } const compositeCanvas = await buildCompositeCanvas(); if (!compositeCanvas) return; flushInFlightRef.current = true; const seq = seqRef.current + 1; seqRef.current = seq; const requestId = `${sessionIdRef.current}-${seq}`; requestStartMapRef.current.set(requestId, Date.now()); const draftVersionAtSend = draftVersionRef.current; const dirtyRegionAtSend = dirtyRegionRef.current ? { ...dirtyRegionRef.current } : null; try { const exportedImage = await canvasToJpegDataUri( compositeCanvas, config.image.maxInputResolution, config.image.jpegQuality, canvasBackgroundRef.current, { autoCrop: false } ); const imageDataUri = exportedImage.dataUri; const shouldUseMask = Boolean(dirtyRegionAtSend) && mode !== "refine" && (dirtyRegionAtSend?.width || 0) * (dirtyRegionAtSend?.height || 0) < 0.92; const maskImageUrl = shouldUseMask && dirtyRegionAtSend ? await createMaskDataUri( exportedImage.width, exportedImage.height, dirtyRegionAtSend ) : undefined; const activeConnection = connectionRef.current; if (!activeConnection) { requestStartMapRef.current.delete(requestId); setStatus("connecting"); setStatusMessage("连接重建中,正在重试..."); return; } setStatus("connecting"); setStatusMessage("正在请求实时生成..."); const response = await fetch("/api/realtime/generate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ requestId, imageUrl: imageDataUri, prompt: prompt.trim() || FALLBACK_PROMPT, seed: seedRef.current, strictSketch: config.behavior?.strictSketch !== false, imageSize: config.model.defaults.imageSize, steps: config.model.defaults.steps, cfgScale: config.model.defaults.cfgScale, strength: config.model.defaults.strength, qualityProfile: mode, maskImageUrl, roi: dirtyRegionAtSend || undefined, }), }); const payload = (await response.json().catch(() => ({}))) as { imageUrl?: string; imageDataURI?: string; cost?: number; transport?: string; warning?: string; message?: string; error?: string; }; if (!response.ok) { throw new Error(payload.message || payload.error || "实时生成失败"); } const nextPreviewUrl = String( payload.imageDataURI || payload.imageUrl || "" ); if (!nextPreviewUrl) { throw new Error("实时生成未返回图像"); } const startedAt = requestStartMapRef.current.get(requestId) || Date.now(); const latencyMs = Date.now() - startedAt; requestStartMapRef.current.delete(requestId); if (seq > 0 && seq < lastRenderedSeqRef.current) { return; } lastRenderedSeqRef.current = Math.max(lastRenderedSeqRef.current, seq); setPreviewUrl(nextPreviewUrl); setStatus("live"); setStatusMessage( payload.transport === "ws" ? "实时渲染中(WS)" : "实时渲染中(HTTP)" ); const upstreamWarning = String(payload.warning || "").trim(); if (upstreamWarning && upstreamWarning !== warningMessageRef.current) { warningMessageRef.current = upstreamWarning; toast.warning(upstreamWarning); } if (draftVersionAtSend === draftVersionRef.current) { dirtyRef.current = false; dirtyRegionRef.current = null; } const previousLatency = latencyEmaRef.current; const nextLatency = previousLatency === null ? latencyMs : previousLatency * 0.72 + latencyMs * 0.28; latencyEmaRef.current = nextLatency; const computedThrottle = Math.round( Math.max(baseThrottleMsRef.current, Math.min(520, nextLatency * 0.45)) ); setAdaptiveThrottleMs((prev) => Math.abs(prev - computedThrottle) >= 8 ? computedThrottle : prev ); pushMetric({ name: "frame_send", ts: Date.now(), seq, }); pushMetric({ name: "frame_result", ts: Date.now(), seq, latencyMs, queueDepth: Number(payload.cost || 0), qualityProfile: mode, hasMask: Boolean(maskImageUrl), transport: payload.transport || "unknown", }); } catch (error) { requestStartMapRef.current.delete(requestId); console.error("[RealtimeCanvas] flush frame failed:", error); setStatus("error"); setStatusMessage(error instanceof Error ? error.message : "发送失败"); } finally { flushInFlightRef.current = false; if (pendingFlushModeRef.current) { if (pendingFlushTimerRef.current) { window.clearTimeout(pendingFlushTimerRef.current); pendingFlushTimerRef.current = null; } const shouldDelay = pendingFlushModeRef.current === "preview"; if (shouldDelay) { pendingFlushTimerRef.current = window.setTimeout(() => { pendingFlushTimerRef.current = null; pumpFlushRef.current?.(); }, adaptiveThrottleMs); } else { pumpFlushRef.current?.(); } } } }, [ adaptiveThrottleMs, buildCompositeCanvas, config, hasRenderableInput, open, prompt, pushMetric, ] ); const pumpFlush = useCallback(() => { if (flushInFlightRef.current) return; const mode = pendingFlushModeRef.current; const force = pendingFlushForceRef.current; if (!mode) return; pendingFlushModeRef.current = null; pendingFlushForceRef.current = false; void flushFrame(mode, force); }, [flushFrame]); const scheduleFlush = useCallback( (force = false, markDirty = true, mode?: FlushMode) => { if (markDirty) { markCanvasDirty(null); } const nextMode: FlushMode = mode || (force ? "refine" : "preview"); if (pendingFlushModeRef.current === "refine" || nextMode === "refine") { pendingFlushModeRef.current = "refine"; } else { pendingFlushModeRef.current = "preview"; } pendingFlushForceRef.current = pendingFlushForceRef.current || force; if (!dirtyRef.current && !pendingFlushForceRef.current) { pendingFlushModeRef.current = null; pendingFlushForceRef.current = false; return; } if (force || pendingFlushModeRef.current === "refine") { if (pendingFlushTimerRef.current) { window.clearTimeout(pendingFlushTimerRef.current); pendingFlushTimerRef.current = null; } pumpFlush(); return; } if (pendingFlushTimerRef.current) return; pendingFlushTimerRef.current = window.setTimeout(() => { pendingFlushTimerRef.current = null; pumpFlush(); }, adaptiveThrottleMs); }, [adaptiveThrottleMs, markCanvasDirty, pumpFlush] ); const cancelPendingRealtimeFlush = useCallback((clearDirty = false) => { if (pendingFlushTimerRef.current) { window.clearTimeout(pendingFlushTimerRef.current); pendingFlushTimerRef.current = null; } pendingFlushModeRef.current = null; pendingFlushForceRef.current = false; dirtyRegionRef.current = null; if (clearDirty) { dirtyRef.current = false; } }, []); const reorderLayersFromSidebar = useCallback( (draggedLayerId: string, targetLayerId: string) => { if (!draggedLayerId || !targetLayerId || draggedLayerId === targetLayerId) return; const displayLayers = [...imageLayersRef.current].reverse(); const sourceIndex = displayLayers.findIndex( (layer) => layer.id === draggedLayerId ); const targetIndex = displayLayers.findIndex( (layer) => layer.id === targetLayerId ); if ( sourceIndex === -1 || targetIndex === -1 || sourceIndex === targetIndex ) return; const nextDisplayLayers = [...displayLayers]; const [movedLayer] = nextDisplayLayers.splice(sourceIndex, 1); nextDisplayLayers.splice(targetIndex, 0, movedLayer); const nextLayers = nextDisplayLayers.reverse(); imageLayersRef.current = nextLayers; setImageLayers(nextLayers); setSelectedLayerId(draggedLayerId); saveHistory(nextLayers); markCanvasDirty(null); scheduleFlush(true, false, "refine"); }, [markCanvasDirty, saveHistory, scheduleFlush] ); const appendLayerBatch = useCallback( (nextLayerBatch: ImageLayer[]) => { if (nextLayerBatch.length === 0) return 0; const nextLayers = [...imageLayersRef.current, ...nextLayerBatch]; imageLayersRef.current = nextLayers; setImageLayers(nextLayers); const newestLayer = nextLayerBatch[nextLayerBatch.length - 1]; if (newestLayer) { setSelectedLayerId(newestLayer.id); } saveHistory(nextLayers); markCanvasDirty(null); scheduleFlush(true); return nextLayerBatch.length; }, [markCanvasDirty, saveHistory, scheduleFlush] ); const importFilesAsLayers = useCallback( async (files: File[]) => { if (files.length === 0) return 0; const invalidFile = files.find((file) => !file.type.startsWith("image/")); if (invalidFile) { throw new Error("请上传图片文件"); } const nextLayerBatch: ImageLayer[] = []; for (const file of files) { const dataUrl = await fileToDataUrl(file); const layer = await createPlacedLayer({ src: dataUrl, name: file.name || "粘贴图片", }); nextLayerBatch.push(layer); } return appendLayerBatch(nextLayerBatch); }, [appendLayerBatch, createPlacedLayer, fileToDataUrl] ); const importFilesAsSplitLayers = useCallback( async (files: File[]) => { if (!isLayerSplitEnabled) { throw new Error("图层拆分功能未开启"); } if (files.length === 0) return 0; const invalidFile = files.find((file) => !file.type.startsWith("image/")); if (invalidFile) { throw new Error("请上传图片文件"); } const nextLayerBatch: ImageLayer[] = []; for (const file of files) { const dataUrl = await fileToDataUrl(file); const referenceLayer = await createPlacedLayer({ src: dataUrl, name: file.name || "图层拆分参考图", }); const splitResult = await imageGenerationService.splitImageLayers(dataUrl); const splitLayers = splitResult.data?.layers || []; if (!splitResult.success || splitLayers.length === 0) { throw new Error( splitResult.error || `${file.name || "图片"} 图层拆分失败` ); } const orderedLayers = orderLayerSplitItems(splitLayers); const baseName = stripFileExtension(file.name || "图层拆分"); for (const layer of orderedLayers) { const placedLayer = await createPlacedLayer({ src: layer.originalUrl, name: `${baseName}-${layer.title || `图层${layer.layerIndex}`}`.slice( 0, 48 ), placement: { x: referenceLayer.x, y: referenceLayer.y, width: referenceLayer.width, height: referenceLayer.height, }, }); nextLayerBatch.push(placedLayer); } } return appendLayerBatch(nextLayerBatch); }, [appendLayerBatch, createPlacedLayer, fileToDataUrl, isLayerSplitEnabled] ); const triggerGenerate = useCallback(() => { if (!hasRenderableInput(true)) { setStatus("idle"); setStatusMessage("画布为空,先上传图片或绘制内容"); toast.error("请先上传图片或绘制内容"); return; } scheduleFlush(true); }, [hasRenderableInput, scheduleFlush]); const refreshSavedProjects = useCallback(() => { const projects = readRealtimeProjectsFromStorage().sort( (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() ); setSavedProjects(projects); return projects; }, []); const loadDrawingLayerFromDataUrl = useCallback( async (dataUrl: string) => { if (!dataUrl) { clearDrawingCanvas(); drawingHasContentRef.current = false; return; } const canvas = canvasRef.current; const ctx = canvas?.getContext("2d"); if (!canvas || !ctx) { return; } clearDrawingCanvas(); const image = await loadRenderableImage(dataUrl); const cssWidth = Math.max( 1, Number.parseFloat(canvas.style.width) || canvas.width ); const dpr = Math.max(1, canvas.width / cssWidth); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.drawImage(image, 0, 0, canvas.width, canvas.height); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.scale(dpr, dpr); drawingHasContentRef.current = true; }, [clearDrawingCanvas, loadRenderableImage] ); const captureDrawingLayerSnapshot = useCallback(() => { if (!canvasRef.current) { return; } drawingLayerSnapshotRef.current = drawingCanvasHasContent(true) ? exportDrawingLayerDataUrl() : ""; }, [drawingCanvasHasContent, exportDrawingLayerDataUrl]); const syncRealtimeCanvasAfterRemount = useCallback(async () => { const canvas = canvasRef.current; const imageLayerCanvas = imageLayerCanvasRef.current; const previewCanvas = previewCanvasRef.current; const wrap = canvasWrapRef.current; if (!canvas || !imageLayerCanvas || !previewCanvas || !wrap) { return false; } const rect = wrap.getBoundingClientRect(); const cssWidth = Math.max(1, Math.floor(rect.width)); const cssHeight = Math.max(1, Math.floor(rect.height)); if (cssWidth <= 1 || cssHeight <= 1) { return false; } const dpr = Math.max(1, window.devicePixelRatio || 1); const pixelWidth = Math.max(1, Math.floor(cssWidth * dpr)); const pixelHeight = Math.max(1, Math.floor(cssHeight * dpr)); const configureCanvas = (targetCanvas: HTMLCanvasElement) => { targetCanvas.width = pixelWidth; targetCanvas.height = pixelHeight; targetCanvas.style.width = `${cssWidth}px`; targetCanvas.style.height = `${cssHeight}px`; const ctx = targetCanvas.getContext("2d"); if (!ctx) { return; } ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.scale(dpr, dpr); ctx.clearRect(0, 0, cssWidth, cssHeight); }; const drawingSnapshot = drawingLayerSnapshotRef.current; configureCanvas(imageLayerCanvas); configureCanvas(canvas); configureCanvas(previewCanvas); if (drawingSnapshot) { await loadDrawingLayerFromDataUrl(drawingSnapshot); } else { drawingHasContentRef.current = false; } await renderImageLayers(); return true; }, [loadDrawingLayerFromDataUrl, renderImageLayers]); const openSavedProject = useCallback( async (projectId: string) => { const project = readRealtimeProjectsFromStorage().find( (item) => item.id === projectId ); if (!project) { toast.error("未找到项目"); return; } canvasBackgroundRef.current = project.canvasBackground || CANVAS_BACKGROUND; setCanvasBackground(project.canvasBackground || CANVAS_BACKGROUND); const nextLayers = cloneImageLayers(project.imageLayers || []); imageLayersRef.current = nextLayers; setImageLayers(nextLayers); setSelectedLayerId(nextLayers[0]?.id || DRAW_LAYER_ID); setBrushColor(project.brushColor || DEFAULT_BRUSH_COLOR); setBrushSize(project.brushSize || 6); setPrompt( project.prompt && project.prompt !== FALLBACK_PROMPT ? project.prompt : "" ); setPreviewUrl(project.previewUrl || ""); await loadDrawingLayerFromDataUrl(project.drawingLayerDataUrl || ""); saveHistory(nextLayers, project.canvasBackground || CANVAS_BACKGROUND); setProjectPanelOpen(false); markCanvasDirty(null); scheduleFlush(true); toast.success("项目已打开"); }, [loadDrawingLayerFromDataUrl, markCanvasDirty, saveHistory, scheduleFlush] ); const deleteSavedProject = useCallback((projectId: string) => { const nextProjects = readRealtimeProjectsFromStorage().filter( (item) => item.id !== projectId ); writeRealtimeProjectsToStorage(nextProjects); setSavedProjects(nextProjects); toast.success("项目已删除"); }, []); const saveCurrentProject = useCallback(() => { if (!hasRenderableInput(true)) { toast.error("空白画布无需保存"); return; } setPendingProjectName(buildSuggestedProjectName(prompt)); setSaveProjectDialogOpen(true); }, [hasRenderableInput, prompt]); const confirmSaveCurrentProject = useCallback(async () => { const normalizedProjectName = pendingProjectName.trim(); if (!normalizedProjectName) { toast.error("请输入项目名称"); return; } const existingProjects = readRealtimeProjectsFromStorage(); const projectId = `rtc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const compositeCanvas = await buildCompositeCanvas(); const coverImageUrl = imageLayersRef.current[0]?.src || previewUrl || compositeCanvas?.toDataURL("image/jpeg", 0.72) || ""; const project: SavedRealtimeProject = { id: projectId, name: normalizedProjectName, updatedAt: new Date().toISOString(), prompt: prompt.trim() || FALLBACK_PROMPT, brushColor, brushSize, canvasBackground: canvasBackgroundRef.current, imageLayers: cloneImageLayers(imageLayersRef.current), drawingLayerDataUrl: exportDrawingLayerDataUrl(), coverImageUrl, previewUrl: previewUrl || compositeCanvas?.toDataURL("image/jpeg", 0.72) || "", }; const nextProjects = [project, ...existingProjects].slice(0, 30); writeRealtimeProjectsToStorage(nextProjects); setSavedProjects(nextProjects); setSaveProjectDialogOpen(false); setProjectPanelOpen(true); toast.success("项目已保存"); }, [ brushColor, brushSize, buildCompositeCanvas, exportDrawingLayerDataUrl, pendingProjectName, previewUrl, prompt, ]); const scheduleFlushRef = useRef(scheduleFlush); const flushMetricsRef = useRef(flushMetrics); useEffect(() => { scheduleFlushRef.current = scheduleFlush; }, [scheduleFlush]); useEffect(() => { pumpFlushRef.current = pumpFlush; }, [pumpFlush]); useEffect(() => { flushMetricsRef.current = flushMetrics; }, [flushMetrics]); useEffect(() => { if (!open) return; refreshSavedProjects(); }, [open, refreshSavedProjects]); useEffect(() => { if (!open) return; const handlePaste = async (event: ClipboardEvent) => { const imageFiles = Array.from(event.clipboardData?.files || []).filter( (file) => file.type.startsWith("image/") ); if (imageFiles.length === 0) return; event.preventDefault(); try { const importedCount = await importFilesAsLayers(imageFiles); if (importedCount > 0) { toast.success(`已粘贴 ${importedCount} 张图片`); } } catch (error) { toast.error(error instanceof Error ? error.message : "粘贴图片失败"); } }; window.addEventListener("paste", handlePaste); return () => window.removeEventListener("paste", handlePaste); }, [importFilesAsLayers, open]); const clearPreviewCanvas = useCallback(() => { const previewCanvas = previewCanvasRef.current; if (!previewCanvas) return; const previewCtx = previewCanvas.getContext("2d"); if (!previewCtx) return; previewCtx.clearRect(0, 0, previewCanvas.width, previewCanvas.height); }, []); const resetRealtimeInteractionState = useCallback(() => { pointerDownRef.current = false; draggingLayerIdRef.current = null; resizingLayerIdRef.current = null; layerDragOriginRef.current = null; layerResizeOriginRef.current = null; lastPointRef.current = null; shapeStartPointRef.current = null; sidebarDraggingLayerIdRef.current = null; dragDepthRef.current = 0; setIsDragImportActive(false); clearPreviewCanvas(); if (cursorRef.current) { cursorRef.current.style.opacity = "0"; } }, [clearPreviewCanvas]); const undo = useCallback(() => { if (historyIndexRef.current <= 0) return; historyIndexRef.current--; const snapshot = historyRef.current[historyIndexRef.current]; const canvas = canvasRef.current; const ctx = canvas?.getContext("2d"); if (canvas && ctx && snapshot) { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.putImageData(snapshot.imageData, 0, 0); drawingHasContentRef.current = imageDataHasVisiblePixels( snapshot.imageData ); canvasBackgroundRef.current = snapshot.canvasBackground; setCanvasBackground(snapshot.canvasBackground); setImageLayers(cloneImageLayers(snapshot.imageLayers)); setSelectedLayerId((current) => current !== DRAW_LAYER_ID && snapshot.imageLayers.some((layer) => layer.id === current) ? current : DRAW_LAYER_ID ); setCanUndo(historyIndexRef.current > 0); setCanRedo(historyIndexRef.current < historyRef.current.length - 1); scheduleFlush(true); } }, [scheduleFlush]); const redo = useCallback(() => { if (historyIndexRef.current >= historyRef.current.length - 1) return; historyIndexRef.current++; const snapshot = historyRef.current[historyIndexRef.current]; const canvas = canvasRef.current; const ctx = canvas?.getContext("2d"); if (canvas && ctx && snapshot) { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.putImageData(snapshot.imageData, 0, 0); drawingHasContentRef.current = imageDataHasVisiblePixels( snapshot.imageData ); canvasBackgroundRef.current = snapshot.canvasBackground; setCanvasBackground(snapshot.canvasBackground); setImageLayers(cloneImageLayers(snapshot.imageLayers)); setSelectedLayerId((current) => current !== DRAW_LAYER_ID && snapshot.imageLayers.some((layer) => layer.id === current) ? current : DRAW_LAYER_ID ); setCanUndo(historyIndexRef.current > 0); setCanRedo(historyIndexRef.current < historyRef.current.length - 1); scheduleFlush(true); } }, [scheduleFlush]); const resetCanvas = useCallback(() => { clearDrawingCanvas(); revokeAllLayerObjectUrls(); setImageLayers([]); imageLayersRef.current = []; setSelectedLayerId(DRAW_LAYER_ID); void renderImageLayers(); // Clear history and save initial state historyRef.current = []; historyIndexRef.current = -1; saveHistory(); }, [ clearDrawingCanvas, renderImageLayers, revokeAllLayerObjectUrls, saveHistory, ]); const drawInitialImage = useCallback(async () => { if (!open || !initialImageUrl || hasLoadedInitialImageRef.current) return false; const nextLayer = await createPlacedLayer({ src: initialImageUrl, name: "参考图", }); const nextLayers = [nextLayer]; setImageLayers(nextLayers); imageLayersRef.current = nextLayers; setSelectedLayerId(nextLayer.id); hasLoadedInitialImageRef.current = true; markCanvasDirty(null); saveHistory(nextLayers); // Save initial image state return true; }, [createPlacedLayer, initialImageUrl, markCanvasDirty, open, saveHistory]); const initializeCanvas = useCallback(() => { const canvas = canvasRef.current; const imageLayerCanvas = imageLayerCanvasRef.current; const previewCanvas = previewCanvasRef.current; const wrap = canvasWrapRef.current; if (!canvas || !imageLayerCanvas || !previewCanvas || !wrap) return; const rect = wrap.getBoundingClientRect(); const dpr = Math.max(1, window.devicePixelRatio || 1); canvas.width = Math.max(640, Math.floor(rect.width * dpr)); canvas.height = Math.max(480, Math.floor(rect.height * dpr)); canvas.style.width = `${Math.floor(rect.width)}px`; canvas.style.height = `${Math.floor(rect.height)}px`; imageLayerCanvas.width = canvas.width; imageLayerCanvas.height = canvas.height; imageLayerCanvas.style.width = canvas.style.width; imageLayerCanvas.style.height = canvas.style.height; previewCanvas.width = canvas.width; previewCanvas.height = canvas.height; previewCanvas.style.width = canvas.style.width; previewCanvas.style.height = canvas.style.height; const ctx = canvas.getContext("2d"); const imageLayerCtx = imageLayerCanvas.getContext("2d"); const previewCtx = previewCanvas.getContext("2d"); if (!ctx) return; if (!imageLayerCtx) return; if (!previewCtx) return; ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.scale(dpr, dpr); imageLayerCtx.setTransform(1, 0, 0, 1, 0, 0); imageLayerCtx.scale(dpr, dpr); previewCtx.setTransform(1, 0, 0, 1, 0, 0); previewCtx.scale(dpr, dpr); ctx.clearRect(0, 0, canvas.width, canvas.height); imageLayerCtx.clearRect( 0, 0, imageLayerCanvas.width, imageLayerCanvas.height ); previewCtx.clearRect(0, 0, previewCanvas.width, previewCanvas.height); resetCanvas(); hasLoadedInitialImageRef.current = false; }, [resetCanvas]); const getCanvasPoint = useCallback( (event: ReactPointerEvent) => { const canvas = canvasRef.current; if (!canvas) return null; const rect = canvas.getBoundingClientRect(); return { x: event.clientX - rect.left, y: event.clientY - rect.top, }; }, [] ); const drawStroke = useCallback( (from: { x: number; y: number }, to: { x: number; y: number }) => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext("2d"); if (!ctx) return; ctx.save(); if (brushMode === "eraser") { ctx.globalCompositeOperation = "destination-out"; ctx.strokeStyle = "rgba(0,0,0,1)"; ctx.lineWidth = brushSize * 1.45; } else { ctx.globalCompositeOperation = "source-over"; ctx.strokeStyle = brushColor; ctx.lineWidth = brushSize; } ctx.lineCap = "round"; ctx.lineJoin = "round"; ctx.beginPath(); ctx.moveTo(from.x, from.y); ctx.lineTo(to.x, to.y); ctx.stroke(); ctx.restore(); }, [brushMode, brushColor, brushSize] ); const drawShape = useCallback( ( from: { x: number; y: number }, to: { x: number; y: number }, targetCtx?: CanvasRenderingContext2D | null ) => { const defaultCtx = canvasRef.current?.getContext("2d"); const ctx = targetCtx || defaultCtx; if (!ctx) return; const minX = Math.min(from.x, to.x); const minY = Math.min(from.y, to.y); const width = Math.abs(to.x - from.x); const height = Math.abs(to.y - from.y); ctx.save(); ctx.globalCompositeOperation = "source-over"; ctx.strokeStyle = brushColor; ctx.lineWidth = Math.max(1, brushSize * 0.9); ctx.lineCap = "round"; ctx.lineJoin = "round"; if (brushMode === "rect") { ctx.strokeRect(minX, minY, width, height); } else if (brushMode === "circle") { const centerX = minX + width / 2; const centerY = minY + height / 2; const radiusX = width / 2; const radiusY = height / 2; ctx.beginPath(); ctx.ellipse(centerX, centerY, radiusX, radiusY, 0, 0, Math.PI * 2); ctx.stroke(); } else if (brushMode === "line") { ctx.beginPath(); ctx.moveTo(from.x, from.y); ctx.lineTo(to.x, to.y); ctx.stroke(); } ctx.restore(); }, [brushMode, brushColor, brushSize] ); const handlePointerDown = useCallback( (event: ReactPointerEvent) => { event.preventDefault(); const point = getCanvasPoint(event); if (!point) return; pointerDownRef.current = true; lastPointRef.current = point; if (brushMode === "select" && selectedImageLayer) { const resizeHandle = getResizeHandleAtPoint(point, selectedImageLayer); if (resizeHandle) { resizingLayerIdRef.current = selectedImageLayer.id; layerResizeOriginRef.current = { pointerX: point.x, pointerY: point.y, layerX: selectedImageLayer.x, layerY: selectedImageLayer.y, layerWidth: selectedImageLayer.width, layerHeight: selectedImageLayer.height, handle: resizeHandle, }; event.currentTarget.setPointerCapture(event.pointerId); return; } const hitSelectedLayer = point.x >= selectedImageLayer.x && point.x <= selectedImageLayer.x + selectedImageLayer.width && point.y >= selectedImageLayer.y && point.y <= selectedImageLayer.y + selectedImageLayer.height; if (hitSelectedLayer) { draggingLayerIdRef.current = selectedImageLayer.id; layerDragOriginRef.current = { pointerX: point.x, pointerY: point.y, layerX: selectedImageLayer.x, layerY: selectedImageLayer.y, }; event.currentTarget.setPointerCapture(event.pointerId); return; } } const hitLayer = brushMode === "select" ? findLayerAtPoint(point) : null; if (hitLayer) { setSelectedLayerId(hitLayer.id); draggingLayerIdRef.current = hitLayer.id; layerDragOriginRef.current = { pointerX: point.x, pointerY: point.y, layerX: hitLayer.x, layerY: hitLayer.y, }; event.currentTarget.setPointerCapture(event.pointerId); return; } if (brushMode === "select") { setSelectedLayerId(DRAW_LAYER_ID); lastPointRef.current = null; shapeStartPointRef.current = null; return; } if (isShapeMode(brushMode)) { shapeStartPointRef.current = point; clearPreviewCanvas(); } else { drawStroke(point, point); drawingHasContentRef.current = true; const lineWidth = brushMode === "eraser" ? brushSize * 1.45 : brushSize; markCanvasDirtyStroke(point, point, lineWidth); scheduleFlush(false, false, "preview"); } event.currentTarget.setPointerCapture(event.pointerId); }, [ brushMode, brushSize, clearPreviewCanvas, drawStroke, findLayerAtPoint, getCanvasPoint, getResizeHandleAtPoint, markCanvasDirtyStroke, selectedImageLayer, scheduleFlush, ] ); const handlePointerLeave = useCallback(() => { const wasDrawing = pointerDownRef.current; if (cursorRef.current) { cursorRef.current.style.opacity = "0"; } if (draggingLayerIdRef.current || resizingLayerIdRef.current) { return; } pointerDownRef.current = false; draggingLayerIdRef.current = null; resizingLayerIdRef.current = null; layerDragOriginRef.current = null; layerResizeOriginRef.current = null; lastPointRef.current = null; shapeStartPointRef.current = null; clearPreviewCanvas(); if (wasDrawing) { scheduleFlush(true, false, "refine"); } }, [clearPreviewCanvas, scheduleFlush]); const handlePointerEnter = useCallback(() => { if (cursorRef.current) { cursorRef.current.style.opacity = brushMode === "brush" || brushMode === "eraser" ? "1" : "0"; } }, [brushMode]); const handlePointerMove = useCallback( (event: ReactPointerEvent) => { event.preventDefault(); // Update cursor const point = getCanvasPoint(event); if (point && cursorRef.current) { const resizeHandle = brushMode === "select" ? getResizeHandleAtPoint(point, selectedImageLayer) : null; event.currentTarget.style.cursor = resizeHandle ? getResizeCursor(resizeHandle) : brushMode === "select" && selectedImageLayer ? "move" : brushMode === "brush" || brushMode === "eraser" ? "none" : brushMode === "select" ? "default" : "crosshair"; const showCursor = brushMode === "brush" || brushMode === "eraser"; if (showCursor) { cursorRef.current.style.opacity = "1"; cursorRef.current.style.transform = `translate(${point.x - brushSize / 2}px, ${point.y - brushSize / 2}px)`; cursorRef.current.style.width = `${brushSize}px`; cursorRef.current.style.height = `${brushSize}px`; } else { cursorRef.current.style.opacity = "0"; } } if (!pointerDownRef.current) return; if (!point || !lastPointRef.current) return; if (resizingLayerIdRef.current && layerResizeOriginRef.current) { resizeLayerFromHandle( resizingLayerIdRef.current, point, layerResizeOriginRef.current ); markCanvasDirty(null); scheduleFlush(false, false, "preview"); return; } if (draggingLayerIdRef.current && layerDragOriginRef.current) { const activeLayer = imageLayersRef.current.find( (layer) => layer.id === draggingLayerIdRef.current ); const canvas = canvasRef.current; if (!activeLayer || !canvas) return; const rect = canvas.getBoundingClientRect(); const nextX = layerDragOriginRef.current.layerX + (point.x - layerDragOriginRef.current.pointerX); const nextY = layerDragOriginRef.current.layerY + (point.y - layerDragOriginRef.current.pointerY); const maxX = Math.max(0, rect.width - activeLayer.width); const maxY = Math.max(0, rect.height - activeLayer.height); updateLayerPosition( activeLayer.id, Math.min(maxX, Math.max(0, nextX)), Math.min(maxY, Math.max(0, nextY)) ); markCanvasDirty(null); scheduleFlush(false, false, "preview"); return; } if (isShapeMode(brushMode) && shapeStartPointRef.current) { const previewCanvas = previewCanvasRef.current; const previewCtx = previewCanvas?.getContext("2d"); if (previewCanvas && previewCtx) { previewCtx.clearRect(0, 0, previewCanvas.width, previewCanvas.height); drawShape(shapeStartPointRef.current, point, previewCtx); } } else { drawStroke(lastPointRef.current, point); drawingHasContentRef.current = true; const lineWidth = brushMode === "eraser" ? brushSize * 1.45 : brushSize; markCanvasDirtyStroke(lastPointRef.current, point, lineWidth); } lastPointRef.current = point; scheduleFlush(false, false, "preview"); }, [ drawShape, drawStroke, getCanvasPoint, scheduleFlush, brushMode, brushSize, getResizeHandleAtPoint, markCanvasDirty, markCanvasDirtyStroke, resizeLayerFromHandle, selectedImageLayer, updateLayerPosition, ] ); const handlePointerUp = useCallback( (event: ReactPointerEvent) => { event.preventDefault(); pointerDownRef.current = false; const wasResizingLayer = resizingLayerIdRef.current; const wasDraggingLayer = draggingLayerIdRef.current; const endPoint = getCanvasPoint(event); const shapeStart = shapeStartPointRef.current; if (wasResizingLayer) { resizingLayerIdRef.current = null; layerResizeOriginRef.current = null; lastPointRef.current = null; shapeStartPointRef.current = null; try { event.currentTarget.releasePointerCapture(event.pointerId); } catch { // ignore } saveHistory(imageLayersRef.current); scheduleFlush(true, false, "refine"); return; } if (wasDraggingLayer) { draggingLayerIdRef.current = null; layerDragOriginRef.current = null; lastPointRef.current = null; shapeStartPointRef.current = null; try { event.currentTarget.releasePointerCapture(event.pointerId); } catch { // ignore } saveHistory(imageLayersRef.current); scheduleFlush(true, false, "refine"); return; } if (isShapeMode(brushMode) && endPoint && shapeStart) { drawShape(shapeStart, endPoint); drawingHasContentRef.current = true; const lineWidth = Math.max(1, brushSize * 0.9); const pad = Math.max(6, Math.ceil(lineWidth * 1.2)); markCanvasDirtyRect({ minX: Math.min(shapeStart.x, endPoint.x) - pad, minY: Math.min(shapeStart.y, endPoint.y) - pad, maxX: Math.max(shapeStart.x, endPoint.x) + pad, maxY: Math.max(shapeStart.y, endPoint.y) + pad, }); clearPreviewCanvas(); } lastPointRef.current = null; shapeStartPointRef.current = null; layerDragOriginRef.current = null; layerResizeOriginRef.current = null; try { event.currentTarget.releasePointerCapture(event.pointerId); } catch { // ignore } saveHistory(); // Save state after stroke scheduleFlush(true, false, "refine"); }, [ brushMode, brushSize, clearPreviewCanvas, drawShape, getCanvasPoint, markCanvasDirtyRect, saveHistory, scheduleFlush, ] ); useEffect(() => { clearPreviewCanvas(); }, [brushMode, clearPreviewCanvas]); const deleteSelectedImageLayer = useCallback(() => { if (selectedLayerId === DRAW_LAYER_ID) return; const nextLayers = imageLayersRef.current.filter( (layer) => layer.id !== selectedLayerId ); if (nextLayers.length === imageLayersRef.current.length) return; imageLayersRef.current = nextLayers; setImageLayers(nextLayers); setSelectedLayerId(DRAW_LAYER_ID); saveHistory(nextLayers); markCanvasDirty(null); scheduleFlush(true, false, "refine"); }, [markCanvasDirty, saveHistory, scheduleFlush, selectedLayerId]); // Shortcuts useEffect(() => { if (!open) return; const handleKeyDown = (e: KeyboardEvent) => { const activeElement = document.activeElement as HTMLElement | null; const isTypingTarget = activeElement instanceof HTMLInputElement || activeElement instanceof HTMLTextAreaElement || activeElement instanceof HTMLSelectElement || activeElement?.isContentEditable; // Undo/Redo if ((e.metaKey || e.ctrlKey) && e.key === "z") { e.preventDefault(); if (e.shiftKey) { redo(); } else { undo(); } } // Brush Size if (e.key === "[") { setBrushSize((prev) => Math.max(2, prev - 2)); } if (e.key === "]") { setBrushSize((prev) => Math.min(50, prev + 2)); } if ( !isTypingTarget && (e.key === "Delete" || e.key === "Backspace") && selectedLayerId !== DRAW_LAYER_ID ) { e.preventDefault(); deleteSelectedImageLayer(); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [deleteSelectedImageLayer, open, redo, selectedLayerId, undo]); useEffect(() => { if (!open) return; seedRef.current = createSessionSeed(); sessionIdRef.current = `rt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; seqRef.current = 0; lastRenderedSeqRef.current = 0; requestStartMapRef.current.clear(); draftVersionRef.current = 0; dirtyRef.current = false; dirtyRegionRef.current = null; pendingFlushModeRef.current = null; pendingFlushForceRef.current = false; latencyEmaRef.current = null; warningMessageRef.current = ""; metricsQueueRef.current = []; }, [open]); useEffect(() => { if (!open) return; initializeCanvas(); void drawInitialImage().then((hasInitialImage) => { if (hasInitialImage) { scheduleFlushRef.current(true, false); } }); const onResize = () => initializeCanvas(); window.addEventListener("resize", onResize); return () => { window.removeEventListener("resize", onResize); }; }, [drawInitialImage, initializeCanvas, open]); useEffect(() => { if (!open) { return; } if (workspaceMode !== "realtime") { cancelPendingRealtimeFlush(true); resetRealtimeInteractionState(); return; } let frameId = 0; let followUpFrameId = 0; frameId = window.requestAnimationFrame(() => { followUpFrameId = window.requestAnimationFrame(() => { void syncRealtimeCanvasAfterRemount(); }); }); return () => { window.cancelAnimationFrame(frameId); window.cancelAnimationFrame(followUpFrameId); }; }, [ cancelPendingRealtimeFlush, open, resetRealtimeInteractionState, syncRealtimeCanvasAfterRemount, workspaceMode, ]); useEffect(() => { if (!open) return; let cancelled = false; setStatus("connecting"); setStatusMessage("正在初始化实时连接..."); const start = async () => { try { let configResponse: Response | null = null; let configJson: Partial & { message?: string; retryAfter?: number; } = {}; for (let attempt = 0; attempt < 3; attempt += 1) { configResponse = await fetch("/api/realtime/config", { cache: "no-store", }); configJson = (await configResponse .json() .catch(() => ({}))) as Partial & { message?: string; retryAfter?: number; }; if (configResponse.ok) break; const retryAfterSeconds = Number(configJson.retryAfter || 0); const shouldRetry429 = configResponse.status === 429 && attempt < 2; if (!shouldRetry429) { throw new Error(configJson.message || "无法加载实时配置"); } const retryDelay = retryAfterSeconds > 0 ? retryAfterSeconds * 1000 : 700 * (attempt + 1); await new Promise((resolve) => window.setTimeout(resolve, retryDelay) ); } if (!configResponse?.ok) { throw new Error(configJson.message || "无法加载实时配置"); } if (cancelled) return; const resolvedConfig: RealtimeConfig = { transport: { throttleInterval: configJson.transport?.throttleInterval || defaultRealtimeConfig.transport.throttleInterval, maxBuffering: configJson.transport?.maxBuffering || defaultRealtimeConfig.transport.maxBuffering, }, image: { maxInputResolution: configJson.image?.maxInputResolution || defaultRealtimeConfig.image.maxInputResolution, jpegQuality: configJson.image?.jpegQuality || defaultRealtimeConfig.image.jpegQuality, }, model: { id: configJson.model?.id || defaultRealtimeConfig.model.id, defaults: { steps: configJson.model?.defaults?.steps || defaultRealtimeConfig.model.defaults.steps, cfgScale: configJson.model?.defaults?.cfgScale || defaultRealtimeConfig.model.defaults.cfgScale, strength: configJson.model?.defaults?.strength || defaultRealtimeConfig.model.defaults.strength, imageSize: configJson.model?.defaults?.imageSize || defaultRealtimeConfig.model.defaults.imageSize, }, }, behavior: { strictSketch: configJson.behavior?.strictSketch !== false, }, }; setConfig(resolvedConfig); baseThrottleMsRef.current = resolvedConfig.transport.throttleInterval; setAdaptiveThrottleMs(resolvedConfig.transport.throttleInterval); if (cancelled) return; connectionRef.current = { provider: "bfl", modelId: resolvedConfig.model.id, reconnectToken: refreshToken, }; setStatus("live"); setStatusMessage("实时服务已就绪"); } catch (error) { if (cancelled) return; console.error("[RealtimeCanvas] init failed:", error); setStatus("error"); setStatusMessage( error instanceof Error ? error.message : "实时初始化失败" ); } }; void start(); const metricTimer = window.setInterval(() => { void flushMetricsRef.current(); }, 5000); return () => { cancelled = true; if (pendingFlushTimerRef.current) { window.clearTimeout(pendingFlushTimerRef.current); pendingFlushTimerRef.current = null; } window.clearInterval(metricTimer); void flushMetricsRef.current(); connectionRef.current?.close?.(); connectionRef.current = null; setStatus("idle"); setStatusMessage("等待连接"); pointerDownRef.current = false; lastPointRef.current = null; pendingFlushModeRef.current = null; pendingFlushForceRef.current = false; }; }, [open, pushMetric, refreshToken]); const forceReconnect = useCallback(() => { connectionRef.current = null; setStatus("connecting"); setStatusMessage("正在重连..."); setRefreshToken((prev) => prev + 1); }, []); const clearSketch = useCallback(() => { resetCanvas(); clearPreviewCanvas(); scheduleFlush(true, true, "refine"); }, [clearPreviewCanvas, resetCanvas, scheduleFlush]); const handleSketchUpload = useCallback( async (event: ChangeEvent) => { const files = Array.from(event.target.files || []); if (files.length === 0) return; try { if (workspaceMode === "enhance-pro") { const file = files[0]; if (!file?.type.startsWith("image/")) { throw new Error("请上传图片文件"); } const dataUrl = await fileToDataUrl(file); setEnhanceUploadedSourceImageUrl(dataUrl); toast.success("已选择用于图像增强 Pro 的图片"); return; } const importedCount = await importFilesAsLayers(files); toast.success( importedCount === 1 ? "图片已导入图层" : `已导入 ${importedCount} 张图片到图层` ); } catch (error) { toast.error(error instanceof Error ? error.message : "导入图片失败"); } finally { event.currentTarget.value = ""; } }, [fileToDataUrl, importFilesAsLayers, workspaceMode] ); const handleLayerSplitUpload = useCallback( async (event: ChangeEvent) => { const files = Array.from(event.target.files || []); if (files.length === 0) return; setIsLayerSplitImporting(true); try { const importedCount = await importFilesAsSplitLayers(files); toast.success( importedCount === 1 ? "图层拆分导入完成" : `图层拆分完成,已导入 ${importedCount} 个图层` ); } catch (error) { toast.error( error instanceof Error ? error.message : "图层拆分导入失败" ); } finally { setIsLayerSplitImporting(false); event.currentTarget.value = ""; } }, [importFilesAsSplitLayers] ); const handleDragEnter = useCallback( (event: React.DragEvent) => { if (!Array.from(event.dataTransfer?.types || []).includes("Files")) return; event.preventDefault(); dragDepthRef.current += 1; setIsDragImportActive(true); }, [] ); const handleDragOver = useCallback( (event: React.DragEvent) => { if (!Array.from(event.dataTransfer?.types || []).includes("Files")) return; event.preventDefault(); event.dataTransfer.dropEffect = "copy"; setIsDragImportActive(true); }, [] ); const handleDragLeave = useCallback( (event: React.DragEvent) => { if (!Array.from(event.dataTransfer?.types || []).includes("Files")) return; event.preventDefault(); dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); if (dragDepthRef.current === 0) { setIsDragImportActive(false); } }, [] ); const handleDropUpload = useCallback( async (event: React.DragEvent) => { if (!Array.from(event.dataTransfer?.types || []).includes("Files")) return; event.preventDefault(); dragDepthRef.current = 0; setIsDragImportActive(false); const files = Array.from(event.dataTransfer.files || []); if (files.length === 0) return; try { const importedCount = await importFilesAsLayers(files); if (importedCount > 0) { toast.success(`已拖入 ${importedCount} 张图片`); } } catch (error) { toast.error(error instanceof Error ? error.message : "拖拽上传失败"); } }, [importFilesAsLayers] ); const handleApplyToCanvas = useCallback(() => { if (!previewUrl) { toast.error("请先生成预览结果"); return; } onApplyResult({ imageUrl: previewUrl, prompt: prompt.trim() || FALLBACK_PROMPT, title: "实时画布结果", model: "bfl/flux-2", }); onOpenChange(false); }, [onApplyResult, onOpenChange, previewUrl, prompt]); const handleApplyEnhanceResult = useCallback( (payload: { imageUrl: string; sourceImageUrl?: string; prompt: string; title?: string; model?: string; width?: number; height?: number; }) => { onApplyResult(payload); onOpenChange(false); }, [onApplyResult, onOpenChange] ); const handleDownloadPreview = useCallback(() => { if (!previewUrl) { toast.error("请先生成预览结果"); return; } const anchor = document.createElement("a"); anchor.href = /^https?:\/\//i.test(previewUrl) ? buildImageProxyUrl(previewUrl) : previewUrl; anchor.download = `realtime-preview-${Date.now()}.png`; anchor.rel = "noopener"; anchor.click(); }, [previewUrl]); const handleCloseRealtime = useCallback(() => { onOpenChange(false); }, [onOpenChange]); const closeBrushSettingPopovers = useCallback(() => { setBrushPopoverOpen(false); setEraserPopoverOpen(false); }, []); const handleWorkspaceModeChange = useCallback( (nextMode: RealtimeWorkspaceMode) => { if (nextMode === workspaceMode) { return; } if (nextMode === "enhance-pro" && !isEnhanceProEnabled) { toast.error("图像增强 Pro 当前未开启,请先在系统设置中启用"); return; } if (workspaceMode === "realtime") { captureDrawingLayerSnapshot(); } closeBrushSettingPopovers(); cancelPendingRealtimeFlush(true); resetRealtimeInteractionState(); setWorkspaceMode(nextMode); }, [ cancelPendingRealtimeFlush, captureDrawingLayerSnapshot, closeBrushSettingPopovers, isEnhanceProEnabled, resetRealtimeInteractionState, workspaceMode, ] ); const activateSelectMode = useCallback(() => { setSelectedLayerId(DRAW_LAYER_ID); setBrushMode("select"); closeBrushSettingPopovers(); }, [closeBrushSettingPopovers]); const activateBrushMode = useCallback(() => { setSelectedLayerId(DRAW_LAYER_ID); setBrushMode("brush"); setEraserPopoverOpen(false); setBrushPopoverOpen(true); }, []); const activateEraserMode = useCallback(() => { setSelectedLayerId(DRAW_LAYER_ID); setBrushMode("eraser"); setBrushPopoverOpen(false); setEraserPopoverOpen(true); }, []); const activateShapeMode = useCallback( (mode: Extract) => { setSelectedLayerId(DRAW_LAYER_ID); setBrushMode(mode); closeBrushSettingPopovers(); }, [closeBrushSettingPopovers] ); const startBlankProject = useCallback(() => { setPrompt(""); setPreviewUrl(""); setProjectPanelOpen(false); closeBrushSettingPopovers(); setSelectedLayerId(DRAW_LAYER_ID); drawingHasContentRef.current = false; hasLoadedInitialImageRef.current = true; resetCanvas(); clearPreviewCanvas(); dirtyRef.current = false; dirtyRegionRef.current = null; setStatus("idle"); setStatusMessage("新建空白项目"); }, [clearPreviewCanvas, closeBrushSettingPopovers, resetCanvas]); const floatingSurfaceClass = "border border-[#e5e5e5] bg-white/96 text-[#111111] shadow-[0_0_0_1px_rgba(0,0,0,0.04),0_20px_42px_rgba(0,0,0,0.08)] backdrop-blur-xl dark:border-white/12 dark:bg-[#111317]/92 dark:text-white dark:shadow-[0_0_0_1px_rgba(255,255,255,0.04),0_24px_56px_rgba(0,0,0,0.45)]"; const controlSurfaceClass = "border border-[#e5e5e5] bg-white text-[#111111] shadow-[inset_0_1px_0_rgba(255,255,255,0.95),0_1px_2px_rgba(0,0,0,0.04)] dark:border-white/12 dark:bg-white/[0.07] dark:text-white dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.08),0_1px_2px_rgba(0,0,0,0.35)]"; const softActionButtonClass = "border border-[#111111] bg-[#111111] text-white shadow-[0_12px_24px_rgba(0,0,0,0.12)] transition hover:bg-black hover:text-white dark:border-white dark:bg-white dark:text-[#111111] dark:shadow-[0_14px_30px_rgba(0,0,0,0.42)] dark:hover:bg-white/90 dark:hover:text-[#111111]"; const primaryActionButtonClass = "border border-[#111111] bg-[#111111] text-white shadow-[0_16px_30px_rgba(0,0,0,0.18)] transition hover:bg-black hover:border-black dark:border-white dark:bg-white dark:text-[#111111] dark:hover:border-white/90 dark:hover:bg-white/90"; const compactToolButtonClass = "h-9 w-9 rounded-[16px] border border-transparent text-[#666666] transition hover:border-[#e5e5e5] hover:bg-[#fafafa] hover:text-[#111111] dark:text-white/58 dark:hover:border-white/12 dark:hover:bg-white/10 dark:hover:text-white"; const compactToolButtonActiveClass = "border-[#111111] bg-[#111111] text-white shadow-[0_10px_20px_rgba(0,0,0,0.12)] hover:bg-black hover:text-white dark:border-white dark:bg-white dark:text-[#111111] dark:hover:bg-white/90 dark:hover:text-[#111111]"; const currentModeLabel = brushMode === "select" ? "鼠标" : brushMode === "brush" ? "画笔" : brushMode === "eraser" ? "橡皮" : brushMode === "rect" ? "矩形" : brushMode === "circle" ? "圆形" : "直线"; const currentModeDescription = brushMode === "select" ? "选择图层并拖动位置" : brushMode === "brush" ? "在画布上自由绘制" : brushMode === "eraser" ? "擦除已有草图笔触" : brushMode === "rect" ? "绘制矩形轮廓" : brushMode === "circle" ? "绘制圆形轮廓" : "绘制直线轮廓"; const content = ( <> {standalone ? (

实时画布

左侧绘制草图,右侧实时预览。

) : ( 实时画布 左侧绘制草图,右侧实时预览。 )}
{isEnhanceProEnabled ? ( ) : null}
{saveProjectDialogOpen ? (
保存项目
请输入项目名称后保存当前画布
setPendingProjectName(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); void confirmSaveCurrentProject(); } }} placeholder="请输入项目名称" value={pendingProjectName} />
) : null} {projectPanelOpen ? (
项目列表
本地保存,便于下次继续编辑
{savedProjects.length === 0 ? (
暂无保存的项目
) : ( savedProjects.map((project) => (
{project.coverImageUrl || project.previewUrl ? ( {project.name} ) : (
无预览
)}
{project.name}
)) )}
) : null} {workspaceMode === "enhance-pro" ? ( uploadInputRef.current?.click()} sourceImageUrl={enhanceSourceImageUrl} /> ) : ( <> {/* Main Canvas Area - Split View */}
{/* Left: Input Canvas */}
{isDragImportActive ? (
松开鼠标,上传图片到图层
) : null}
{selectedImageLayer && brushMode === "select" ? (
) : null} {selectedImageLayer && brushMode === "select" ? ( [ { key: "nw", left: selectedImageLayer.x, top: selectedImageLayer.y, }, { key: "ne", left: selectedImageLayer.x + selectedImageLayer.width, top: selectedImageLayer.y, }, { key: "sw", left: selectedImageLayer.x, top: selectedImageLayer.y + selectedImageLayer.height, }, { key: "se", left: selectedImageLayer.x + selectedImageLayer.width, top: selectedImageLayer.y + selectedImageLayer.height, }, ] as const ).map((handle) => (
)) : null} {/* Custom Brush Cursor */}
{imageLayers.length === 0 ? (
暂无图层
) : ( [...imageLayers].reverse().map((layer) => ( )) )}
{isLayerSplitEnabled ? ( ) : null}
{/* Right: Output Preview */}
{previewUrl ? ( 实时预览 ) : (
{status === "connecting" ? ( <> Connecting... ) : ( <> )}
)} {previewUrl ? (
) : null}
{realtimeSelectToolItem.label}
选择图层并拖动位置
颜色
setBrushColor(event.target.value) } ref={colorInputRef} title="自定义颜色" type="color" value={brushColor} />
粗细 {brushSize}
setBrushSize(val)} rangeClassName="bg-[#111111]" step={1} thumbClassName="h-5 w-5 border border-[#d4d4d4] bg-white shadow-sm" trackClassName="h-2.5 bg-[#ededed] shadow-inner" value={[brushSize]} />
{realtimeBrushToolItem.label}
点击后直接调整颜色和粗细
橡皮粗细 {brushSize}
setBrushSize(val)} rangeClassName="bg-[#111111]" step={1} thumbClassName="h-5 w-5 border border-[#d4d4d4] bg-white shadow-sm" trackClassName="h-2.5 bg-[#ededed] shadow-inner" value={[brushSize]} />
{realtimeEraserToolItem.label}
点击后直接调整橡皮粗细
形状工具
{realtimeShapeToolItem.label}
矩形、圆形与直线
{realtimeSaveProjectItem.label}
保存当前画布状态
{realtimeProjectListItem.label}
打开已保存项目
{realtimeNewBlankItem.label}
清空并开始新项目
{realtimeUndoItem.label}
回退上一步编辑
{realtimeRedoItem.label}
恢复已撤销操作