"use client"; import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type PointerEvent as ReactPointerEvent, } from "react"; import * as fal from "@fal-ai/serverless-client"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Slider } from "@/components/ui/slider"; import { cn } from "@/lib/utils"; import { Eraser, Loader2, Paintbrush, Square, Circle as CircleIcon, Slash, Upload, RefreshCw, Sparkles, Trash2, Download, Undo2, Redo2, Send, } from "lucide-react"; import { toast } from "sonner"; type RealtimeConfig = { transport: { throttleInterval: number; maxBuffering: number; }; image: { maxInputResolution: number; jpegQuality: number; }; model: { id: string; defaults: { steps: number; scheduleMu: number; feedback: number; imageSize: "square" | "square_hd"; }; }; }; type RealtimeStatus = "idle" | "connecting" | "live" | "error"; type RealtimeCanvasDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; initialImageUrl?: string | null; onApplyResult: (payload: { imageUrl: string; prompt: string }) => void; }; const FALLBACK_PROMPT = "将草图渲染为高质感产品设计图,保持结构和主轮廓一致。"; const CANVAS_BACKGROUND = "#ffffff"; const BRUSH_COLOR_PRESETS = [ "#18181b", "#ef4444", "#f59e0b", "#22c55e", "#3b82f6", "#a855f7", ] as const; const CANVAS_BACKGROUND_PRESETS = ["#ffffff", "#f4f4f5", "#fdf2f8", "#ecfeff", "#f0fdf4"] as const; const defaultRealtimeConfig: RealtimeConfig = { transport: { throttleInterval: 100, maxBuffering: 2, }, image: { maxInputResolution: 1024, jpegQuality: 0.55, }, model: { id: "fal-ai/flux-2/klein/realtime", defaults: { steps: 3, scheduleMu: 2.3, feedback: 0.95, imageSize: "square", }, }, }; function buildImageDataUrl(image: any) { if (!image) return ""; if (typeof image === "string" && image.trim()) { return image; } if (typeof image.url === "string" && image.url.trim()) { return image.url; } if (typeof image.image_url === "string" && image.image_url.trim()) { return image.image_url; } if (typeof image.content === "string" && image.content.trim()) { if (image.content.startsWith("data:")) { return image.content; } const contentType = image.content_type || "image/jpeg"; return `data:${contentType};base64,${image.content}`; } return ""; } function isShapeMode(mode: "brush" | "eraser" | "rect" | "circle" | "line") { return mode === "rect" || mode === "circle" || mode === "line"; } async function canvasToJpegDataUri( canvas: HTMLCanvasElement, maxInputResolution: number, jpegQuality: number, backgroundColor: string ) { const sourceWidth = canvas.width; const sourceHeight = canvas.height; const longestSide = Math.max(sourceWidth, sourceHeight); const scale = longestSide > maxInputResolution ? maxInputResolution / longestSide : 1; const exportWidth = Math.max(1, Math.round(sourceWidth * scale)); const exportHeight = Math.max(1, Math.round(sourceHeight * 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); exportCtx.drawImage(canvas, 0, 0, exportWidth, exportHeight); const blob = await new Promise((resolve) => { exportCanvas.toBlob(resolve, "image/jpeg", jpegQuality); }); if (!blob) { throw new Error("导出图片失败"); } return await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result || "")); reader.onerror = () => reject(new Error("读取导出图片失败")); reader.readAsDataURL(blob); }); } export function RealtimeCanvasDialog({ open, onOpenChange, initialImageUrl, onApplyResult, }: RealtimeCanvasDialogProps) { const canvasRef = useRef(null); const previewCanvasRef = useRef(null); const canvasWrapRef = useRef(null); const connectionRef = useRef(null); const pointerDownRef = useRef(false); 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 seqRef = useRef(0); const requestSeqMapRef = useRef>(new Map()); const requestStartMapRef = useRef>(new Map()); const lastRenderedSeqRef = useRef(0); const metricsQueueRef = useRef([]); const sessionIdRef = useRef(`rt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`); const hasLoadedInitialImageRef = useRef(false); const canvasBackgroundRef = useRef(CANVAS_BACKGROUND); // History Management const historyRef = useRef([]); const historyIndexRef = useRef(-1); const cursorRef = useRef(null); const uploadInputRef = useRef(null); const [canUndo, setCanUndo] = useState(false); const [canRedo, setCanRedo] = useState(false); const [config, setConfig] = useState(defaultRealtimeConfig); const [status, setStatus] = useState("idle"); const [statusMessage, setStatusMessage] = useState("等待连接"); const [prompt, setPrompt] = useState(FALLBACK_PROMPT); const [previewUrl, setPreviewUrl] = useState(""); const [brushMode, setBrushMode] = useState<"brush" | "eraser" | "rect" | "circle" | "line">("brush"); const [brushColor, setBrushColor] = useState(BRUSH_COLOR_PRESETS[0]); const [canvasBackground, setCanvasBackground] = useState(canvasBackgroundRef.current); const [brushSize, setBrushSize] = useState(6); const [adaptiveThrottleMs, setAdaptiveThrottleMs] = useState( defaultRealtimeConfig.transport.throttleInterval ); const [refreshToken, setRefreshToken] = useState(0); const statusBadge = useMemo(() => { if (status === "live") return { label: "实时中", className: "bg-emerald-50 text-emerald-700" }; if (status === "connecting") { return { label: "连接中", className: "bg-blue-50 text-blue-700" }; } if (status === "error") return { label: "异常", className: "bg-rose-50 text-rose-700" }; return { label: "待启动", className: "bg-zinc-100 text-zinc-600" }; }, [status]); 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]); const saveHistory = useCallback(() => { 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); historyRef.current.push(imageData); 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 flushFrame = useCallback( async (force = false) => { if (!open) return; if (!connectionRef.current) return; if (flushInFlightRef.current) return; if (!dirtyRef.current && !force) return; const canvas = canvasRef.current; if (!canvas) return; flushInFlightRef.current = true; const seq = seqRef.current + 1; seqRef.current = seq; const requestId = `${sessionIdRef.current}-${seq}`; requestSeqMapRef.current.set(requestId, seq); requestStartMapRef.current.set(requestId, Date.now()); try { const imageDataUri = await canvasToJpegDataUri( canvas, config.image.maxInputResolution, config.image.jpegQuality, canvasBackgroundRef.current ); dirtyRef.current = false; setStatus("connecting"); setStatusMessage("正在请求实时生成..."); connectionRef.current.send({ request_id: requestId, image_url: imageDataUri, prompt: prompt.trim() || FALLBACK_PROMPT, image_size: config.model.defaults.imageSize, num_inference_steps: config.model.defaults.steps, schedule_mu: config.model.defaults.scheduleMu, output_feedback_strength: config.model.defaults.feedback, }); pushMetric({ name: "frame_send", ts: Date.now(), seq, }); } catch (error) { console.error("[RealtimeCanvas] flush frame failed:", error); setStatus("error"); setStatusMessage(error instanceof Error ? error.message : "发送失败"); } finally { flushInFlightRef.current = false; } }, [config, open, prompt, pushMetric] ); const scheduleFlush = useCallback( (force = false, markDirty = true) => { if (markDirty) { dirtyRef.current = true; } if (!dirtyRef.current) return; if (force) { if (pendingFlushTimerRef.current) { window.clearTimeout(pendingFlushTimerRef.current); pendingFlushTimerRef.current = null; } void flushFrame(true); return; } if (pendingFlushTimerRef.current) return; pendingFlushTimerRef.current = window.setTimeout(() => { pendingFlushTimerRef.current = null; void flushFrame(); }, adaptiveThrottleMs); }, [adaptiveThrottleMs, flushFrame] ); 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 undo = useCallback(() => { if (historyIndexRef.current <= 0) return; historyIndexRef.current--; const imageData = historyRef.current[historyIndexRef.current]; const canvas = canvasRef.current; const ctx = canvas?.getContext("2d"); if (canvas && ctx && imageData) { ctx.putImageData(imageData, 0, 0); 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 imageData = historyRef.current[historyIndexRef.current]; const canvas = canvasRef.current; const ctx = canvas?.getContext("2d"); if (canvas && ctx && imageData) { ctx.putImageData(imageData, 0, 0); setCanUndo(historyIndexRef.current > 0); setCanRedo(historyIndexRef.current < historyRef.current.length - 1); scheduleFlush(true); } }, [scheduleFlush]); const resetCanvas = useCallback(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext("2d"); if (!ctx) return; ctx.save(); ctx.globalCompositeOperation = "source-over"; ctx.fillStyle = canvasBackgroundRef.current; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.restore(); // Clear history and save initial state historyRef.current = []; historyIndexRef.current = -1; saveHistory(); }, [saveHistory]); const drawInitialImage = useCallback(async () => { if (!open || !initialImageUrl || hasLoadedInitialImageRef.current) return false; const canvas = canvasRef.current; if (!canvas) return false; const ctx = canvas.getContext("2d"); if (!ctx) return false; const image = new Image(); image.crossOrigin = "anonymous"; await new Promise((resolve, reject) => { image.onload = () => resolve(); image.onerror = () => reject(new Error("加载初始图片失败")); image.src = initialImageUrl; }); const scale = Math.min(canvas.width / image.width, canvas.height / image.height); const drawWidth = image.width * scale; const drawHeight = image.height * scale; const offsetX = (canvas.width - drawWidth) / 2; const offsetY = (canvas.height - drawHeight) / 2; ctx.drawImage(image, offsetX, offsetY, drawWidth, drawHeight); hasLoadedInitialImageRef.current = true; dirtyRef.current = true; saveHistory(); // Save initial image state return true; }, [initialImageUrl, open, saveHistory]); const initializeCanvas = useCallback(() => { const canvas = canvasRef.current; const previewCanvas = previewCanvasRef.current; const wrap = canvasWrapRef.current; if (!canvas || !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`; 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 previewCtx = previewCanvas.getContext("2d"); if (!ctx) return; if (!previewCtx) return; ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.scale(dpr, dpr); previewCtx.setTransform(1, 0, 0, 1, 0, 0); previewCtx.scale(dpr, dpr); 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 (isShapeMode(brushMode)) { shapeStartPointRef.current = point; clearPreviewCanvas(); } else { drawStroke(point, point); scheduleFlush(); } event.currentTarget.setPointerCapture(event.pointerId); }, [brushMode, clearPreviewCanvas, drawStroke, getCanvasPoint, scheduleFlush] ); const handlePointerLeave = useCallback(() => { const wasDrawing = pointerDownRef.current; if (cursorRef.current) { cursorRef.current.style.opacity = '0'; } pointerDownRef.current = false; lastPointRef.current = null; shapeStartPointRef.current = null; clearPreviewCanvas(); if (wasDrawing) { scheduleFlush(true, false); } }, [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 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 (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); } lastPointRef.current = point; scheduleFlush(); }, [drawShape, drawStroke, getCanvasPoint, scheduleFlush, brushMode, brushSize] ); const handlePointerUp = useCallback( (event: ReactPointerEvent) => { event.preventDefault(); pointerDownRef.current = false; const endPoint = getCanvasPoint(event); const shapeStart = shapeStartPointRef.current; if (isShapeMode(brushMode) && endPoint && shapeStart) { drawShape(shapeStart, endPoint); clearPreviewCanvas(); } lastPointRef.current = null; shapeStartPointRef.current = null; try { event.currentTarget.releasePointerCapture(event.pointerId); } catch { // ignore } saveHistory(); // Save state after stroke scheduleFlush(true, false); }, [brushMode, clearPreviewCanvas, drawShape, getCanvasPoint, saveHistory, scheduleFlush] ); useEffect(() => { clearPreviewCanvas(); }, [brushMode, clearPreviewCanvas]); // Shortcuts useEffect(() => { if (!open) return; const handleKeyDown = (e: KeyboardEvent) => { // 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)); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [open, undo, redo]); useEffect(() => { if (!open) return; initializeCanvas(); void drawInitialImage().then((hasInitialImage) => { if (hasInitialImage) { scheduleFlush(true, false); } }); const onResize = () => initializeCanvas(); window.addEventListener("resize", onResize); return () => { window.removeEventListener("resize", onResize); }; }, [drawInitialImage, initializeCanvas, open, scheduleFlush]); 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, scheduleMu: configJson.model?.defaults?.scheduleMu || defaultRealtimeConfig.model.defaults.scheduleMu, feedback: configJson.model?.defaults?.feedback || defaultRealtimeConfig.model.defaults.feedback, imageSize: configJson.model?.defaults?.imageSize || defaultRealtimeConfig.model.defaults.imageSize, }, }, }; setConfig(resolvedConfig); setAdaptiveThrottleMs(resolvedConfig.transport.throttleInterval); fal.config({ proxyUrl: "/api/fal/proxy", }); const connection = fal.realtime.connect(resolvedConfig.model.id, { connectionKey: `canvas-realtime-${sessionIdRef.current}-${refreshToken}`, throttleInterval: resolvedConfig.transport.throttleInterval, maxBuffering: resolvedConfig.transport.maxBuffering, onResult: (result: any) => { const requestId = String(result?.request_id || ""); const seqFromRequest = requestSeqMapRef.current.get(requestId) || 0; const seq = Math.max(seqFromRequest, Number(result?.seq || 0)); if (seq > 0 && seq < lastRenderedSeqRef.current) { return; } const imageGroups = [result?.images, result?.output?.images, result?.data?.images]; const firstImageGroup = imageGroups.find((group) => Array.isArray(group)) as any[] | undefined; const latestImage = (firstImageGroup && firstImageGroup[firstImageGroup.length - 1]) || result?.image || result?.output?.image || result?.data?.image || null; const nextPreviewUrl = buildImageDataUrl(latestImage); if (!nextPreviewUrl) return; lastRenderedSeqRef.current = Math.max(lastRenderedSeqRef.current, seq); setPreviewUrl(nextPreviewUrl); setStatus("live"); setStatusMessage("实时渲染中"); if (requestId) { const startedAt = requestStartMapRef.current.get(requestId); if (startedAt) { pushMetric({ name: "frame_result", ts: Date.now(), seq, latencyMs: Date.now() - startedAt, }); } requestStartMapRef.current.delete(requestId); requestSeqMapRef.current.delete(requestId); } }, onError: (error: any) => { const errorMessage = error?.body?.message || error?.message || "实时连接异常,请稍后重试"; setStatus("error"); setStatusMessage(String(errorMessage)); if (Number(error?.status) === 429) { setAdaptiveThrottleMs((prev) => Math.min(240, prev + 40)); } pushMetric({ name: "frame_error", ts: Date.now(), message: String(errorMessage), }); }, }); if (cancelled) { connection.close(); return; } connectionRef.current = connection; setStatus("connecting"); setStatusMessage("连接成功,等待首帧..."); scheduleFlush(true, false); } catch (error) { console.error("[RealtimeCanvas] init failed:", error); setStatus("error"); setStatusMessage(error instanceof Error ? error.message : "实时初始化失败"); } }; void start(); const metricTimer = window.setInterval(() => { void flushMetrics(); }, 5000); return () => { cancelled = true; if (pendingFlushTimerRef.current) { window.clearTimeout(pendingFlushTimerRef.current); pendingFlushTimerRef.current = null; } window.clearInterval(metricTimer); void flushMetrics(); connectionRef.current?.close?.(); connectionRef.current = null; setStatus("idle"); setStatusMessage("等待连接"); pointerDownRef.current = false; lastPointRef.current = null; }; }, [flushMetrics, open, pushMetric, refreshToken, scheduleFlush]); const forceReconnect = useCallback(() => { connectionRef.current?.close?.(); connectionRef.current = null; setStatus("connecting"); setStatusMessage("正在重连..."); setRefreshToken((prev) => prev + 1); }, []); const clearSketch = useCallback(() => { resetCanvas(); clearPreviewCanvas(); dirtyRef.current = true; scheduleFlush(true, false); }, [clearPreviewCanvas, resetCanvas, scheduleFlush]); const handleSketchUpload = useCallback( async (event: ChangeEvent) => { const file = event.target.files?.[0]; if (!file) return; if (!file.type.startsWith("image/")) { toast.error("请上传图片文件"); event.currentTarget.value = ""; return; } try { const objectUrl = URL.createObjectURL(file); const image = new Image(); await new Promise((resolve, reject) => { image.onload = () => resolve(); image.onerror = () => reject(new Error("图片加载失败")); image.src = objectUrl; }); const canvas = canvasRef.current; const ctx = canvas?.getContext("2d"); if (!canvas || !ctx) { URL.revokeObjectURL(objectUrl); throw new Error("画布未初始化"); } const rect = canvas.getBoundingClientRect(); const canvasWidth = Math.max(1, rect.width); const canvasHeight = Math.max(1, rect.height); const scale = Math.min(canvasWidth / image.width, canvasHeight / image.height); const drawWidth = image.width * scale; const drawHeight = image.height * scale; const offsetX = (canvasWidth - drawWidth) / 2; const offsetY = (canvasHeight - drawHeight) / 2; ctx.drawImage(image, offsetX, offsetY, drawWidth, drawHeight); URL.revokeObjectURL(objectUrl); saveHistory(); scheduleFlush(true); toast.success("图片已导入绘画区"); } catch (error) { toast.error(error instanceof Error ? error.message : "导入图片失败"); } finally { event.currentTarget.value = ""; } }, [saveHistory, scheduleFlush] ); const handleCanvasBackgroundChange = useCallback( (nextColor: string) => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext("2d"); if (!ctx) return; const snapshot = document.createElement("canvas"); snapshot.width = canvas.width; snapshot.height = canvas.height; const snapshotCtx = snapshot.getContext("2d"); if (!snapshotCtx) return; snapshotCtx.drawImage(canvas, 0, 0); ctx.save(); ctx.globalCompositeOperation = "source-over"; ctx.fillStyle = nextColor; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.drawImage(snapshot, 0, 0); ctx.restore(); canvasBackgroundRef.current = nextColor; setCanvasBackground(nextColor); saveHistory(); scheduleFlush(true); }, [saveHistory, scheduleFlush] ); const handleDownloadCanvas = useCallback(async () => { const canvas = canvasRef.current; if (!canvas) return; try { const dataUrl = await canvasToJpegDataUri(canvas, 2048, 0.92, canvasBackgroundRef.current); const anchor = document.createElement("a"); anchor.href = dataUrl; anchor.download = `realtime-canvas-${Date.now()}.jpg`; anchor.click(); toast.success("已保存当前画布"); } catch (error) { toast.error(error instanceof Error ? error.message : "保存失败"); } }, []); const handleApplyToCanvas = useCallback(() => { if (!previewUrl) { toast.error("请先生成预览结果"); return; } onApplyResult({ imageUrl: previewUrl, prompt: prompt.trim() || FALLBACK_PROMPT, }); onOpenChange(false); }, [onApplyResult, onOpenChange, previewUrl, prompt]); return ( 实时画布 左侧绘制草图,右侧实时预览。 {/* Top Header - Minimal */}
Realtime Canvas
{statusBadge.label}
{/* Main Canvas Area - Split View */}
{/* Left: Input Canvas */}
{/* Custom Brush Cursor */}
INPUT
{/* Right: Output Preview */}
{previewUrl ? ( 实时预览 ) : (
{status === "connecting" ? ( <> Connecting... ) : ( <> Start drawing to generate )}
)}
OUTPUT
{/* Floating Toolbar - Top (Lovart-like) */}
{BRUSH_COLOR_PRESETS.map((color) => (
setBrushSize(val)} />
{CANVAS_BACKGROUND_PRESETS.map((color) => (
{/* Floating Toolbar - Bottom (Prompt) */}
setPrompt(e.target.value)} onBlur={() => scheduleFlush(true, false)} placeholder="Describe what you want to create..." className="flex-1 h-10 border-0 bg-transparent focus-visible:ring-0 px-2 text-base shadow-none" />
{statusMessage} • Latency: {adaptiveThrottleMs}ms
); }