diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx
index 55e2f39c1..0989ae7b6 100644
--- a/app/(admin)/settings/admin-settings-client.tsx
+++ b/app/(admin)/settings/admin-settings-client.tsx
@@ -55,6 +55,7 @@ import {
Video,
Loader2,
Check,
+ Paintbrush,
} from "lucide-react";
import {
Dialog,
@@ -197,6 +198,7 @@ type SiteSettings = {
tutorialEnabled: boolean;
rankingEnabled: boolean;
canvasEnabled: boolean;
+ realtimeCanvasEnabled: boolean;
aiDetailGenerationEnabled: boolean;
canvasDefaultModelId: string | null;
canvasAutoModeDefaultModelId: string | null;
@@ -519,6 +521,7 @@ export function AdminSettingsClient() {
tutorialEnabled: false,
rankingEnabled: true,
canvasEnabled: true,
+ realtimeCanvasEnabled: true,
aiDetailGenerationEnabled: false,
canvasDefaultModelId: null,
canvasAutoModeDefaultModelId: null,
@@ -587,6 +590,7 @@ export function AdminSettingsClient() {
tutorialEnabled: siteSettingsData.tutorialEnabled ?? false,
rankingEnabled: siteSettingsData.rankingEnabled ?? true,
canvasEnabled: siteSettingsData.canvasEnabled ?? true,
+ realtimeCanvasEnabled: (siteSettingsData as any).realtimeCanvasEnabled ?? true,
aiDetailGenerationEnabled: siteSettingsData.aiDetailGenerationEnabled ?? false,
canvasDefaultModelId: siteSettingsData.canvasDefaultModelId || null,
canvasAutoModeDefaultModelId: siteSettingsData.canvasAutoModeDefaultModelId || null,
@@ -695,6 +699,20 @@ export function AdminSettingsClient() {
timeout: 120,
});
+ // 实时画布API配置状态
+ const [realtimeCanvasConfig, setRealtimeCanvasConfig] = useState({
+ enabled: true,
+ falApiKey: "",
+ throttleInterval: 100,
+ maxBuffering: 2,
+ maxInputResolution: 1024,
+ jpegQuality: 0.55,
+ steps: 3,
+ scheduleMu: 2.3,
+ feedback: 0.95,
+ imageSize: "square" as "square" | "square_hd",
+ });
+
// 详情替换配置状态
const [sceneFusionConfig, setSceneFusionConfig] = useState({
enabled: true,
@@ -821,6 +839,42 @@ export function AdminSettingsClient() {
}
}, [angleEditConfigData]);
+ // 加载实时画布API配置
+ const { data: realtimeCanvasConfigData } = useSWR(
+ "/api/admin/settings?key=realtime_canvas_config",
+ fetcher
+ );
+ useEffect(() => {
+ if (realtimeCanvasConfigData?.setting?.value) {
+ const config = realtimeCanvasConfigData.setting.value as {
+ enabled?: boolean;
+ falApiKey?: string;
+ throttleInterval?: number;
+ maxBuffering?: number;
+ maxInputResolution?: number;
+ jpegQuality?: number;
+ defaults?: {
+ steps?: number;
+ scheduleMu?: number;
+ feedback?: number;
+ imageSize?: "square" | "square_hd";
+ };
+ };
+ setRealtimeCanvasConfig({
+ enabled: config.enabled ?? true,
+ falApiKey: config.falApiKey || "",
+ throttleInterval: Number(config.throttleInterval || 100),
+ maxBuffering: Number(config.maxBuffering || 2),
+ maxInputResolution: Number(config.maxInputResolution || 1024),
+ jpegQuality: Number(config.jpegQuality || 0.55),
+ steps: Number(config.defaults?.steps || 3),
+ scheduleMu: Number(config.defaults?.scheduleMu || 2.3),
+ feedback: Number(config.defaults?.feedback || 0.95),
+ imageSize: config.defaults?.imageSize === "square_hd" ? "square_hd" : "square",
+ });
+ }
+ }, [realtimeCanvasConfigData]);
+
// 加载详情替换配置
const { data: sceneFusionConfigData } = useSWR("/api/admin/settings?key=scene_fusion_config", fetcher);
useEffect(() => {
@@ -1635,6 +1689,46 @@ export function AdminSettingsClient() {
}
};
+ // --- 实时画布API配置逻辑 ---
+ const handleSaveRealtimeCanvasConfig = async () => {
+ try {
+ if (realtimeCanvasConfig.enabled && !realtimeCanvasConfig.falApiKey.trim()) {
+ toast.error("启用实时画布功能需要配置 fal.ai API Key");
+ return;
+ }
+
+ const response = await fetch("/api/admin/settings", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ key: "realtime_canvas_config",
+ value: {
+ enabled: realtimeCanvasConfig.enabled,
+ falApiKey: realtimeCanvasConfig.falApiKey.trim(),
+ throttleInterval: Math.min(260, Math.max(60, Number(realtimeCanvasConfig.throttleInterval) || 100)),
+ maxBuffering: Math.min(6, Math.max(1, Number(realtimeCanvasConfig.maxBuffering) || 2)),
+ maxInputResolution: Math.min(
+ 1536,
+ Math.max(512, Number(realtimeCanvasConfig.maxInputResolution) || 1024)
+ ),
+ jpegQuality: Math.min(0.82, Math.max(0.35, Number(realtimeCanvasConfig.jpegQuality) || 0.55)),
+ defaults: {
+ steps: Math.min(6, Math.max(1, Number(realtimeCanvasConfig.steps) || 3)),
+ scheduleMu: Math.min(3.5, Math.max(1.5, Number(realtimeCanvasConfig.scheduleMu) || 2.3)),
+ feedback: Math.min(1, Math.max(0.7, Number(realtimeCanvasConfig.feedback) || 0.95)),
+ imageSize: realtimeCanvasConfig.imageSize,
+ },
+ },
+ }),
+ });
+
+ if (!response.ok) throw new Error("保存失败");
+ toast.success("实时画布配置保存成功");
+ } catch (_error) {
+ toast.error("保存实时画布配置失败");
+ }
+ };
+
// --- 详情替换配置逻辑 ---
const handleSaveSceneFusionConfig = async () => {
try {
@@ -4554,6 +4648,22 @@ export function AdminSettingsClient() {
{siteSettings.canvasEnabled && (
<>
+
+
+
+
+ 开启后,无限画布左侧工具栏显示“实时画布”按钮;关闭后前端隐藏。
+
+
+
+ setSiteSettings({ ...siteSettings, realtimeCanvasEnabled: checked })
+ }
+ />
+
+
@@ -7092,6 +7202,192 @@ export function AdminSettingsClient() {
+ {/* 实时画布API配置 */}
+
+
+
+
+ 实时画布配置
+
+
+ 配置实时画布(左画右预览)功能使用的 fal realtime 参数与 API Key。该 Key 存储在数据库,前端不暴露。
+
+
+
+
+
+
+
+ 关闭后前端“实时画布”入口仍可显示,但会提示服务未启用。
+
+
+
+ setRealtimeCanvasConfig({ ...realtimeCanvasConfig, enabled: checked })
+ }
+ />
+
+
+
+
+
+ setRealtimeCanvasConfig({ ...realtimeCanvasConfig, falApiKey: e.target.value })
+ }
+ placeholder="fal_xxxxxxxxxxxxx"
+ disabled={!realtimeCanvasConfig.enabled}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+ setRealtimeCanvasConfig({
+ ...realtimeCanvasConfig,
+ scheduleMu: Number.parseFloat(e.target.value) || 2.3,
+ })
+ }
+ />
+
+
+
+
+
+
+
+
+
+
+
{/* 详情替换配置 */}
diff --git a/app/api/fal/proxy/route.ts b/app/api/fal/proxy/route.ts
new file mode 100644
index 000000000..d2151ad94
--- /dev/null
+++ b/app/api/fal/proxy/route.ts
@@ -0,0 +1,116 @@
+import { auth } from "@/app/(auth)/auth";
+import { getSystemSetting } from "@/lib/db/queries";
+
+const ALLOWED_FAL_HOSTS = new Set(["rest.alpha.fal.ai", "rest.fal.ai"]);
+
+type FalRealtimeConfig = {
+ enabled?: boolean;
+ falApiKey?: string;
+};
+
+async function getRealtimeCanvasConfig() {
+ const realtimeSetting = await getSystemSetting({ key: "realtime_canvas_config" }).catch(
+ () => null
+ );
+ return (realtimeSetting?.value || {}) as FalRealtimeConfig;
+}
+
+async function handleProxy(request: Request) {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return Response.json({ code: "UNAUTHORIZED", message: "请先登录" }, { status: 401 });
+ }
+
+ const targetUrl = request.headers.get("x-fal-target-url");
+ if (!targetUrl) {
+ return Response.json(
+ { code: "INVALID_TARGET", message: "缺少 x-fal-target-url 请求头" },
+ { status: 400 }
+ );
+ }
+
+ let parsedUrl: URL;
+ try {
+ parsedUrl = new URL(targetUrl);
+ } catch {
+ return Response.json(
+ { code: "INVALID_TARGET", message: "x-fal-target-url 不是合法 URL" },
+ { status: 400 }
+ );
+ }
+
+ if (!ALLOWED_FAL_HOSTS.has(parsedUrl.host)) {
+ return Response.json(
+ { code: "TARGET_NOT_ALLOWED", message: "不允许代理该目标地址" },
+ { status: 403 }
+ );
+ }
+
+ const realtimeConfig = await getRealtimeCanvasConfig();
+ if (realtimeConfig.enabled === false) {
+ return Response.json(
+ {
+ code: "FEATURE_DISABLED",
+ message: "实时画布服务未启用(请在本地AI设置中开启)",
+ },
+ { status: 403 }
+ );
+ }
+
+ const falApiKey = realtimeConfig.falApiKey?.trim() || "";
+ if (!falApiKey) {
+ return Response.json(
+ {
+ code: "FAL_KEY_MISSING",
+ message: "未配置 FAL API Key(请在本地AI设置的实时画布配置中填写)",
+ },
+ { status: 500 }
+ );
+ }
+
+ let body: ArrayBuffer | undefined;
+ if (request.method !== "GET" && request.method !== "HEAD") {
+ body = await request.arrayBuffer();
+ }
+
+ const headers = new Headers();
+ headers.set("Authorization", `Key ${falApiKey}`);
+ headers.set("Accept", request.headers.get("accept") || "application/json");
+ const contentType = request.headers.get("content-type");
+ if (contentType) {
+ headers.set("Content-Type", contentType);
+ }
+
+ const upstreamResponse = await fetch(parsedUrl.toString(), {
+ method: request.method,
+ headers,
+ body,
+ cache: "no-store",
+ });
+
+ return new Response(upstreamResponse.body, {
+ status: upstreamResponse.status,
+ statusText: upstreamResponse.statusText,
+ headers: upstreamResponse.headers,
+ });
+}
+
+export async function GET(request: Request) {
+ return handleProxy(request);
+}
+
+export async function POST(request: Request) {
+ return handleProxy(request);
+}
+
+export async function PUT(request: Request) {
+ return handleProxy(request);
+}
+
+export async function PATCH(request: Request) {
+ return handleProxy(request);
+}
+
+export async function DELETE(request: Request) {
+ return handleProxy(request);
+}
diff --git a/app/api/realtime/config/route.ts b/app/api/realtime/config/route.ts
new file mode 100644
index 000000000..c61127051
--- /dev/null
+++ b/app/api/realtime/config/route.ts
@@ -0,0 +1,133 @@
+import { auth } from "@/app/(auth)/auth";
+import { getSystemSetting } from "@/lib/db/queries";
+
+type RealtimeCanvasConfig = {
+ enabled?: boolean;
+ falApiKey?: string;
+ throttleInterval?: number;
+ maxBuffering?: number;
+ maxInputResolution?: number;
+ jpegQuality?: number;
+ defaults?: {
+ steps?: number;
+ scheduleMu?: number;
+ feedback?: number;
+ imageSize?: "square" | "square_hd";
+ };
+};
+
+const defaultConfig = {
+ session: {
+ idleTimeoutMs: 120000,
+ },
+ 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" as const,
+ },
+ limits: {
+ steps: { min: 1, max: 6 },
+ feedback: { min: 0.7, max: 1.0 },
+ },
+ },
+ features: {
+ history: true,
+ abCompare: false,
+ },
+};
+
+function clamp(value: number, min: number, max: number) {
+ return Math.min(max, Math.max(min, value));
+}
+
+export async function GET() {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return Response.json({ code: "UNAUTHORIZED", message: "请先登录" }, { status: 401 });
+ }
+
+ const setting = await getSystemSetting({ key: "realtime_canvas_config" }).catch(() => null);
+ const config = (setting?.value || {}) as RealtimeCanvasConfig;
+
+ if (config.enabled === false) {
+ return Response.json(
+ { code: "FEATURE_DISABLED", message: "实时画布功能未开启" },
+ { status: 403 }
+ );
+ }
+
+ if (!String(config.falApiKey || "").trim()) {
+ return Response.json(
+ {
+ code: "FAL_KEY_MISSING",
+ message: "未配置实时画布 API Key(请在本地AI设置中配置)",
+ },
+ { status: 500 }
+ );
+ }
+
+ const throttleInterval = clamp(
+ Number(config.throttleInterval || defaultConfig.transport.throttleInterval),
+ 60,
+ 260
+ );
+ const maxBuffering = clamp(
+ Number(config.maxBuffering || defaultConfig.transport.maxBuffering),
+ 1,
+ 6
+ );
+ const maxInputResolution = clamp(
+ Number(config.maxInputResolution || defaultConfig.image.maxInputResolution),
+ 512,
+ 1536
+ );
+ const jpegQuality = clamp(
+ Number(config.jpegQuality || defaultConfig.image.jpegQuality),
+ 0.35,
+ 0.82
+ );
+ const steps = clamp(Number(config.defaults?.steps || defaultConfig.model.defaults.steps), 1, 6);
+ const scheduleMu = clamp(
+ Number(config.defaults?.scheduleMu || defaultConfig.model.defaults.scheduleMu),
+ 1.5,
+ 3.5
+ );
+ const feedback = clamp(
+ Number(config.defaults?.feedback || defaultConfig.model.defaults.feedback),
+ 0.7,
+ 1.0
+ );
+ const imageSize = config.defaults?.imageSize === "square_hd" ? "square_hd" : "square";
+
+ return Response.json({
+ ...defaultConfig,
+ transport: {
+ throttleInterval,
+ maxBuffering,
+ },
+ image: {
+ maxInputResolution,
+ jpegQuality,
+ },
+ model: {
+ ...defaultConfig.model,
+ defaults: {
+ steps,
+ scheduleMu,
+ feedback,
+ imageSize,
+ },
+ },
+ });
+}
diff --git a/app/api/realtime/metrics/route.ts b/app/api/realtime/metrics/route.ts
new file mode 100644
index 000000000..3f779ebf4
--- /dev/null
+++ b/app/api/realtime/metrics/route.ts
@@ -0,0 +1,54 @@
+import { auth } from "@/app/(auth)/auth";
+
+type MetricEvent = {
+ name: string;
+ ts: number;
+ seq?: number;
+ latencyMs?: number;
+ queueDepth?: number;
+ networkType?: string;
+ deviceClass?: string;
+};
+
+type MetricsPayload = {
+ sessionId: string;
+ events: MetricEvent[];
+};
+
+export async function POST(request: Request) {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return Response.json({ code: "UNAUTHORIZED", message: "请先登录" }, { status: 401 });
+ }
+
+ let payload: MetricsPayload;
+ try {
+ payload = (await request.json()) as MetricsPayload;
+ } catch {
+ return Response.json({ code: "BAD_REQUEST", message: "无效 JSON" }, { status: 400 });
+ }
+
+ if (!payload?.sessionId || !Array.isArray(payload.events)) {
+ return Response.json({ code: "BAD_REQUEST", message: "缺少 sessionId 或 events" }, { status: 400 });
+ }
+
+ if (payload.events.length > 200) {
+ return Response.json(
+ { code: "PAYLOAD_TOO_LARGE", message: "events 超过限制(200)" },
+ { status: 413 }
+ );
+ }
+
+ if (payload.events.length > 0) {
+ const latest = payload.events[payload.events.length - 1];
+ console.log("[RealtimeMetrics]", {
+ userId: session.user.id,
+ sessionId: payload.sessionId,
+ count: payload.events.length,
+ latest,
+ });
+ }
+
+ return Response.json({ ok: true });
+}
+
diff --git a/app/api/site-settings/route.ts b/app/api/site-settings/route.ts
index 7cb3f7c11..800259b87 100644
--- a/app/api/site-settings/route.ts
+++ b/app/api/site-settings/route.ts
@@ -283,6 +283,7 @@ export async function GET() {
tutorialEnabled: false,
rankingEnabled: true,
canvasEnabled: true,
+ realtimeCanvasEnabled: true,
canvasDefaultModelId: null,
canvasAutoModeDefaultModelId: null,
canvasDetailDefaultModelId: null,
@@ -364,6 +365,7 @@ const updateSchema = z.object({
tutorialEnabled: z.boolean().optional(),
rankingEnabled: z.boolean().optional(),
canvasEnabled: z.boolean().optional(),
+ realtimeCanvasEnabled: z.boolean().optional(),
aiDetailGenerationEnabled: z.boolean().optional(),
canvasDefaultModelId: z.string().nullable().optional(),
canvasAutoModeDefaultModelId: z.string().nullable().optional(),
@@ -754,6 +756,7 @@ export async function PUT(request: NextRequest) {
tutorialEnabled: siteSettingsPayload.tutorialEnabled ?? false,
rankingEnabled: siteSettingsPayload.rankingEnabled ?? true,
canvasEnabled: siteSettingsPayload.canvasEnabled ?? true,
+ realtimeCanvasEnabled: siteSettingsPayload.realtimeCanvasEnabled ?? true,
canvasDefaultModelId: siteSettingsPayload.canvasDefaultModelId,
canvasAutoModeDefaultModelId: siteSettingsPayload.canvasAutoModeDefaultModelId,
canvasDetailDefaultModelId: siteSettingsPayload.canvasDetailDefaultModelId,
diff --git a/app/canvas/[id]/page.tsx b/app/canvas/[id]/page.tsx
index 549cffead..aff4c905a 100644
--- a/app/canvas/[id]/page.tsx
+++ b/app/canvas/[id]/page.tsx
@@ -23,6 +23,7 @@ import {
Wand2,
Grid3X3,
Settings,
+ PenTool,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -58,6 +59,7 @@ import { CanvasWorkspace } from "@/components/canvas";
import { CanvasChatPanel } from "@/components/canvas/CanvasChatPanel";
import { CanvasFileListPanel } from "@/components/canvas/CanvasFileListPanel";
import { ObjectPickerOverlay } from "@/components/canvas/ObjectPickerOverlay";
+import { RealtimeCanvasDialog } from "@/components/canvas/RealtimeCanvasDialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn, fetcher } from "@/lib/utils";
import { MessageSquare, FolderOpen, Grid2X2 } from "lucide-react";
@@ -121,11 +123,13 @@ export default function CanvasEditorPage({ params }: PageProps) {
const [isPanelOpen, setIsPanelOpen] = useState(true);
// 面板模式: 'chat' 聊天面板, 'fileList' 文件列表
const [panelMode, setPanelMode] = useState<'chat' | 'fileList'>('chat');
+ const [isRealtimeCanvasOpen, setIsRealtimeCanvasOpen] = useState(false);
const { data: siteSettingsData } = useSWR<{
siteName?: string;
siteLogo?: string | null;
siteLogoDark?: string | null;
+ realtimeCanvasEnabled?: boolean;
}>("/api/site-settings", fetcher);
// 生成配置
@@ -353,6 +357,38 @@ export default function CanvasEditorPage({ params }: PageProps) {
});
};
+ const handleApplyRealtimeResult = useCallback(
+ ({ imageUrl, prompt: imagePrompt }: { imageUrl: string; prompt: string }) => {
+ const viewportX = viewport.x || 0;
+ const viewportY = viewport.y || 0;
+ const viewportZoom = viewport.zoom || 1;
+ const stageWidth = typeof window !== "undefined" ? window.innerWidth : 1280;
+ const stageHeight = typeof window !== "undefined" ? window.innerHeight : 720;
+ const targetWidth = 360;
+ const targetHeight = 360;
+
+ const x = (stageWidth * 0.45 - viewportX) / viewportZoom - targetWidth / 2;
+ const y = (stageHeight * 0.45 - viewportY) / viewportZoom - targetHeight / 2;
+
+ addNode({
+ type: "image",
+ position: { x, y },
+ size: { width: targetWidth, height: targetHeight },
+ data: {
+ src: imageUrl,
+ status: "completed",
+ title: "实时画布结果",
+ prompt: imagePrompt,
+ model: "fal-ai/flux-2/klein/realtime",
+ },
+ connections: [],
+ });
+
+ toast.success("实时画布结果已添加");
+ },
+ [addNode, viewport.x, viewport.y, viewport.zoom]
+ );
+
// 处理图片操作(通过事件分发到选中图片节点执行)
const handleAction = (action: string) => {
if (!selectedImageNode) {
@@ -717,6 +753,22 @@ export default function CanvasEditorPage({ params }: PageProps) {
添加图片
+ {(siteSettingsData?.realtimeCanvasEnabled ?? true) ? (
+
+
+
+
+ 实时画布
+
+ ) : null}
+
@@ -756,6 +808,13 @@ export default function CanvasEditorPage({ params }: PageProps) {
{/* 6. 物品拾取模式覆盖层 */}
+
+
);
diff --git a/components/canvas/RealtimeCanvasDialog.tsx b/components/canvas/RealtimeCanvasDialog.tsx
new file mode 100644
index 000000000..cf45c9433
--- /dev/null
+++ b/components/canvas/RealtimeCanvasDialog.tsx
@@ -0,0 +1,754 @@
+"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
((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 canvasWrapRef = useRef(null);
+ const connectionRef = useRef(null);
+ const pointerDownRef = useRef(false);
+ const lastPointRef = 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