781 lines
27 KiB
TypeScript
781 lines
27 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useMemo, useRef, useState, 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 { Label } from "@/components/ui/label";
|
||
import { cn } from "@/lib/utils";
|
||
import {
|
||
Eraser,
|
||
Loader2,
|
||
Paintbrush,
|
||
RefreshCw,
|
||
Send,
|
||
Sparkles,
|
||
Trash2,
|
||
Wifi,
|
||
WifiOff,
|
||
} 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 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.url === "string" && image.url.trim()) {
|
||
return 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 "";
|
||
}
|
||
|
||
async function canvasToJpegDataUri(
|
||
canvas: HTMLCanvasElement,
|
||
maxInputResolution: number,
|
||
jpegQuality: number
|
||
) {
|
||
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 = "#d8d8db";
|
||
exportCtx.fillRect(0, 0, exportWidth, exportHeight);
|
||
exportCtx.drawImage(canvas, 0, 0, exportWidth, exportHeight);
|
||
|
||
const blob = await new Promise<Blob | null>((resolve) => {
|
||
exportCanvas.toBlob(resolve, "image/jpeg", jpegQuality);
|
||
});
|
||
if (!blob) {
|
||
throw new Error("导出图片失败");
|
||
}
|
||
|
||
return await new Promise<string>((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<HTMLCanvasElement | null>(null);
|
||
const canvasWrapRef = useRef<HTMLDivElement | null>(null);
|
||
const connectionRef = useRef<any>(null);
|
||
const pointerDownRef = useRef(false);
|
||
const lastPointRef = useRef<{ x: number; y: number } | null>(null);
|
||
const pendingFlushTimerRef = useRef<number | null>(null);
|
||
const flushInFlightRef = useRef(false);
|
||
const dirtyRef = useRef(false);
|
||
const seqRef = useRef(0);
|
||
const requestSeqMapRef = useRef<Map<string, number>>(new Map());
|
||
const requestStartMapRef = useRef<Map<string, number>>(new Map());
|
||
const lastRenderedSeqRef = useRef(0);
|
||
const metricsQueueRef = useRef<any[]>([]);
|
||
const sessionIdRef = useRef(`rt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
||
const hasLoadedInitialImageRef = useRef(false);
|
||
const [config, setConfig] = useState<RealtimeConfig>(defaultRealtimeConfig);
|
||
const [status, setStatus] = useState<RealtimeStatus>("idle");
|
||
const [statusMessage, setStatusMessage] = useState("等待连接");
|
||
const [prompt, setPrompt] = useState(FALLBACK_PROMPT);
|
||
const [previewUrl, setPreviewUrl] = useState("");
|
||
const [isApplying, setIsApplying] = useState(false);
|
||
const [brushMode, setBrushMode] = useState<"brush" | "eraser">("brush");
|
||
const [brushSize, setBrushSize] = useState(6);
|
||
const [adaptiveThrottleMs, setAdaptiveThrottleMs] = useState<number>(
|
||
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 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 = "#d8d8db";
|
||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||
ctx.restore();
|
||
}, []);
|
||
|
||
const drawInitialImage = useCallback(async () => {
|
||
if (!open || !initialImageUrl || hasLoadedInitialImageRef.current) return;
|
||
const canvas = canvasRef.current;
|
||
if (!canvas) return;
|
||
const ctx = canvas.getContext("2d");
|
||
if (!ctx) return;
|
||
|
||
const image = new Image();
|
||
image.crossOrigin = "anonymous";
|
||
await new Promise<void>((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;
|
||
}, [initialImageUrl, open]);
|
||
|
||
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
|
||
);
|
||
|
||
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) => {
|
||
dirtyRef.current = true;
|
||
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 initializeCanvas = useCallback(() => {
|
||
const canvas = canvasRef.current;
|
||
const wrap = canvasWrapRef.current;
|
||
if (!canvas || !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`;
|
||
const ctx = canvas.getContext("2d");
|
||
if (!ctx) return;
|
||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||
ctx.scale(dpr, dpr);
|
||
resetCanvas();
|
||
hasLoadedInitialImageRef.current = false;
|
||
}, [resetCanvas]);
|
||
|
||
const getCanvasPoint = useCallback((event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||
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 = "#18181b";
|
||
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, brushSize]
|
||
);
|
||
|
||
const handlePointerDown = useCallback(
|
||
(event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||
const point = getCanvasPoint(event);
|
||
if (!point) return;
|
||
pointerDownRef.current = true;
|
||
lastPointRef.current = point;
|
||
event.currentTarget.setPointerCapture(event.pointerId);
|
||
},
|
||
[getCanvasPoint]
|
||
);
|
||
|
||
const handlePointerMove = useCallback(
|
||
(event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||
if (!pointerDownRef.current) return;
|
||
const point = getCanvasPoint(event);
|
||
if (!point || !lastPointRef.current) return;
|
||
drawStroke(lastPointRef.current, point);
|
||
lastPointRef.current = point;
|
||
scheduleFlush();
|
||
},
|
||
[drawStroke, getCanvasPoint, scheduleFlush]
|
||
);
|
||
|
||
const handlePointerUp = useCallback(
|
||
(event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||
pointerDownRef.current = false;
|
||
lastPointRef.current = null;
|
||
try {
|
||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||
} catch {
|
||
// ignore
|
||
}
|
||
scheduleFlush(true);
|
||
},
|
||
[scheduleFlush]
|
||
);
|
||
|
||
const uploadPreviewToCanvas = useCallback(async () => {
|
||
if (!previewUrl) {
|
||
toast.error("请先完成一帧实时预览");
|
||
return;
|
||
}
|
||
|
||
setIsApplying(true);
|
||
try {
|
||
const blob = await fetch(previewUrl).then((res) => res.blob());
|
||
const formData = new FormData();
|
||
const filename = `realtime-canvas-${Date.now()}.jpg`;
|
||
formData.append("file", new File([blob], filename, { type: blob.type || "image/jpeg" }));
|
||
|
||
const response = await fetch("/api/files/upload", {
|
||
method: "POST",
|
||
body: formData,
|
||
});
|
||
const result = await response.json().catch(() => ({}));
|
||
|
||
if (!response.ok || !result?.url) {
|
||
throw new Error(result?.error || "上传到画布失败");
|
||
}
|
||
|
||
onApplyResult({
|
||
imageUrl: String(result.url),
|
||
prompt: prompt.trim() || FALLBACK_PROMPT,
|
||
});
|
||
|
||
toast.success("已发送到画布");
|
||
onOpenChange(false);
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "发送到画布失败");
|
||
} finally {
|
||
setIsApplying(false);
|
||
}
|
||
}, [onApplyResult, onOpenChange, previewUrl, prompt]);
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
initializeCanvas();
|
||
void drawInitialImage().then(() => {
|
||
scheduleFlush(true);
|
||
});
|
||
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 {
|
||
const configResponse = await fetch("/api/realtime/config", { cache: "no-store" });
|
||
const configJson = (await configResponse.json().catch(() => ({}))) as Partial<RealtimeConfig>;
|
||
if (!configResponse.ok) {
|
||
throw new Error((configJson as any)?.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 latestImage = Array.isArray(result?.images)
|
||
? result.images[result.images.length - 1]
|
||
: 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);
|
||
} 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();
|
||
dirtyRef.current = true;
|
||
scheduleFlush(true);
|
||
}, [resetCanvas, scheduleFlush]);
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
<DialogContent className="h-[90vh] w-[96vw] max-w-[1540px] overflow-hidden rounded-[24px] border border-zinc-200/80 bg-[#f2f3f5] p-0 shadow-2xl">
|
||
<DialogHeader className="border-b border-zinc-200/80 bg-white/70 px-6 py-4 backdrop-blur">
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div>
|
||
<DialogTitle className="text-[24px] font-semibold tracking-tight text-zinc-900">
|
||
实时画布
|
||
</DialogTitle>
|
||
<DialogDescription className="mt-1 text-[13px] text-zinc-500">
|
||
左侧绘制草图,右侧实时渲染,整体风格与无限画布保持一致
|
||
</DialogDescription>
|
||
<div className="mt-2 text-[12px] text-zinc-500">{statusMessage}</div>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<span
|
||
className={cn(
|
||
"inline-flex h-9 items-center gap-1.5 rounded-full px-3 text-[12px] font-medium",
|
||
statusBadge.className
|
||
)}
|
||
>
|
||
{status === "error" ? <WifiOff className="h-3.5 w-3.5" /> : <Wifi className="h-3.5 w-3.5" />}
|
||
{statusBadge.label}
|
||
</span>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={forceReconnect}
|
||
className="h-9 rounded-xl border-zinc-300 bg-white px-3"
|
||
>
|
||
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
|
||
重连
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</DialogHeader>
|
||
|
||
<div className="flex min-h-0 flex-1 flex-col px-5 pb-5 pt-4">
|
||
<div className="grid min-h-0 flex-1 grid-cols-2 gap-4">
|
||
<div className="flex min-h-0 flex-col overflow-hidden rounded-2xl border border-zinc-200 bg-white/95 shadow-sm">
|
||
<div className="flex h-12 items-center justify-between border-b border-zinc-200 px-4">
|
||
<span className="text-[15px] font-medium text-zinc-800">草图画布</span>
|
||
<div className="flex items-center gap-1.5">
|
||
<Button
|
||
type="button"
|
||
size="icon"
|
||
variant={brushMode === "brush" ? "default" : "outline"}
|
||
className="h-8 w-8 rounded-lg"
|
||
onClick={() => setBrushMode("brush")}
|
||
>
|
||
<Paintbrush className="h-4 w-4" />
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
size="icon"
|
||
variant={brushMode === "eraser" ? "default" : "outline"}
|
||
className="h-8 w-8 rounded-lg"
|
||
onClick={() => setBrushMode("eraser")}
|
||
>
|
||
<Eraser className="h-4 w-4" />
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
size="icon"
|
||
variant="outline"
|
||
className="h-8 w-8 rounded-lg"
|
||
onClick={clearSketch}
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<div ref={canvasWrapRef} className="min-h-0 flex-1 bg-[#d0d1d4] p-3">
|
||
<canvas
|
||
ref={canvasRef}
|
||
className="h-full w-full touch-none rounded-xl border border-zinc-300/80 bg-[#d0d1d4]"
|
||
onPointerDown={handlePointerDown}
|
||
onPointerMove={handlePointerMove}
|
||
onPointerUp={handlePointerUp}
|
||
onPointerCancel={handlePointerUp}
|
||
/>
|
||
</div>
|
||
<div className="flex h-12 items-center gap-3 border-t border-zinc-200 px-4">
|
||
<Label htmlFor="rt-brush-size" className="text-[12px] font-medium text-zinc-500">
|
||
画笔粗细
|
||
</Label>
|
||
<input
|
||
id="rt-brush-size"
|
||
type="range"
|
||
min={2}
|
||
max={30}
|
||
step={1}
|
||
value={brushSize}
|
||
onChange={(event) => setBrushSize(Number(event.target.value))}
|
||
className="h-2 w-40 accent-zinc-800"
|
||
/>
|
||
<span className="text-[12px] text-zinc-500">{brushSize}px</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex min-h-0 flex-col overflow-hidden rounded-2xl border border-zinc-200 bg-white/95 shadow-sm">
|
||
<div className="flex h-12 items-center justify-between border-b border-zinc-200 px-4">
|
||
<span className="text-[15px] font-medium text-zinc-800">实时预览</span>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-8 rounded-lg border-zinc-300 bg-white px-3 text-[12px]"
|
||
onClick={() => scheduleFlush(true)}
|
||
>
|
||
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
|
||
刷新
|
||
</Button>
|
||
</div>
|
||
<div className="relative min-h-0 flex-1 overflow-hidden bg-[#d0d1d4] p-3">
|
||
<div className="h-full w-full overflow-hidden rounded-xl border border-zinc-300/80 bg-[#d0d1d4]">
|
||
{previewUrl ? (
|
||
<img src={previewUrl} alt="实时预览" className="h-full w-full object-contain" />
|
||
) : (
|
||
<div className="flex h-full items-center justify-center text-[13px] text-zinc-500">
|
||
{status === "connecting" ? (
|
||
<span className="inline-flex items-center rounded-full bg-white/70 px-3 py-1.5">
|
||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||
正在等待首帧...
|
||
</span>
|
||
) : (
|
||
<span>开始绘制后将显示实时预览</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-4 rounded-2xl border border-zinc-200 bg-white/95 p-3 shadow-sm">
|
||
<div className="flex items-center gap-2">
|
||
<Input
|
||
value={prompt}
|
||
onChange={(event) => setPrompt(event.target.value)}
|
||
onBlur={() => scheduleFlush(true)}
|
||
placeholder="输入提示词,例如:将草图渲染为高端工业设计风格..."
|
||
className="h-11 flex-1 rounded-xl border-zinc-300 bg-white text-[15px]"
|
||
/>
|
||
<Button
|
||
type="button"
|
||
className="h-11 rounded-xl px-5"
|
||
disabled={!previewUrl || isApplying}
|
||
onClick={uploadPreviewToCanvas}
|
||
>
|
||
{isApplying ? (
|
||
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
||
) : (
|
||
<Send className="mr-1.5 h-4 w-4" />
|
||
)}
|
||
发送到画布
|
||
</Button>
|
||
</div>
|
||
<div className="mt-2 text-[12px] text-zinc-500">
|
||
绘制会自动触发实时渲染,当前节流间隔 {adaptiveThrottleMs}ms。
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|