1944 lines
65 KiB
TypeScript
1944 lines
65 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
useCallback,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
type ChangeEvent,
|
||
type PointerEvent as ReactPointerEvent,
|
||
} from "react";
|
||
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,
|
||
X,
|
||
} 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;
|
||
cfgScale: number;
|
||
strength: number;
|
||
imageSize: "square" | "square_hd";
|
||
};
|
||
};
|
||
behavior?: {
|
||
strictSketch?: boolean;
|
||
};
|
||
};
|
||
|
||
type RealtimeStatus = "idle" | "connecting" | "live" | "error";
|
||
|
||
type RealtimeCanvasDialogProps = {
|
||
open: boolean;
|
||
onOpenChange: (open: boolean) => void;
|
||
initialImageUrl?: string | null;
|
||
onApplyResult: (payload: { imageUrl: string; prompt: string }) => void;
|
||
standalone?: boolean;
|
||
};
|
||
|
||
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: "runware:101@1",
|
||
defaults: {
|
||
steps: 8,
|
||
cfgScale: 7,
|
||
strength: 0.32,
|
||
imageSize: "square",
|
||
},
|
||
},
|
||
behavior: {
|
||
strictSketch: true,
|
||
},
|
||
};
|
||
|
||
function isShapeMode(mode: "brush" | "eraser" | "rect" | "circle" | "line") {
|
||
return mode === "rect" || mode === "circle" || mode === "line";
|
||
}
|
||
|
||
function createSessionSeed() {
|
||
return Math.floor(Math.random() * 2147483647);
|
||
}
|
||
|
||
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<Blob | null>((resolve) => {
|
||
exportCanvas.toBlob(resolve, "image/jpeg", jpegQuality);
|
||
});
|
||
if (!blob) {
|
||
throw new Error("导出图片失败");
|
||
}
|
||
|
||
const dataUri = await new Promise<string>((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,
|
||
onApplyResult,
|
||
standalone = false,
|
||
}: RealtimeCanvasDialogProps) {
|
||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||
const previewCanvasRef = 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 shapeStartPointRef = useRef<{ x: number; y: number } | null>(null);
|
||
const pendingFlushTimerRef = useRef<number | null>(null);
|
||
const flushInFlightRef = useRef(false);
|
||
const dirtyRef = useRef(false);
|
||
const dirtyRegionRef = useRef<NormalizedRegion | null>(null);
|
||
const draftVersionRef = useRef(0);
|
||
const pendingFlushModeRef = useRef<FlushMode | null>(null);
|
||
const pendingFlushForceRef = useRef(false);
|
||
const latencyEmaRef = useRef<number | null>(null);
|
||
const baseThrottleMsRef = useRef<number>(defaultRealtimeConfig.transport.throttleInterval);
|
||
const pumpFlushRef = useRef<(() => void) | null>(null);
|
||
const warningMessageRef = useRef("");
|
||
const seqRef = useRef(0);
|
||
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 seedRef = useRef<number>(createSessionSeed());
|
||
const hasLoadedInitialImageRef = useRef(false);
|
||
const canvasBackgroundRef = useRef(CANVAS_BACKGROUND);
|
||
|
||
// History Management
|
||
const historyRef = useRef<ImageData[]>([]);
|
||
const historyIndexRef = useRef(-1);
|
||
const cursorRef = useRef<HTMLDivElement>(null);
|
||
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
||
const [canUndo, setCanUndo] = useState(false);
|
||
const [canRedo, setCanRedo] = useState(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 [brushMode, setBrushMode] = useState<"brush" | "eraser" | "rect" | "circle" | "line">("brush");
|
||
const [brushColor, setBrushColor] = useState<string>(BRUSH_COLOR_PRESETS[0]);
|
||
const [canvasBackground, setCanvasBackground] = useState<string>(canvasBackgroundRef.current);
|
||
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 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 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]
|
||
);
|
||
|
||
const flushFrame = useCallback(
|
||
async (mode: FlushMode, 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}`;
|
||
requestStartMapRef.current.set(requestId, Date.now());
|
||
const draftVersionAtSend = draftVersionRef.current;
|
||
const dirtyRegionAtSend = dirtyRegionRef.current
|
||
? { ...dirtyRegionRef.current }
|
||
: null;
|
||
|
||
try {
|
||
const exportedImage = await canvasToJpegDataUri(
|
||
canvas,
|
||
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, config, 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 scheduleFlushRef = useRef(scheduleFlush);
|
||
const flushMetricsRef = useRef(flushMetrics);
|
||
|
||
useEffect(() => {
|
||
scheduleFlushRef.current = scheduleFlush;
|
||
}, [scheduleFlush]);
|
||
|
||
useEffect(() => {
|
||
pumpFlushRef.current = pumpFlush;
|
||
}, [pumpFlush]);
|
||
|
||
useEffect(() => {
|
||
flushMetricsRef.current = flushMetrics;
|
||
}, [flushMetrics]);
|
||
|
||
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<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;
|
||
markCanvasDirty(null);
|
||
saveHistory(); // Save initial image state
|
||
return true;
|
||
}, [initialImageUrl, markCanvasDirty, 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<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 = 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<HTMLCanvasElement>) => {
|
||
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);
|
||
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,
|
||
getCanvasPoint,
|
||
markCanvasDirtyStroke,
|
||
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, "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<HTMLCanvasElement>) => {
|
||
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);
|
||
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,
|
||
markCanvasDirtyStroke,
|
||
]
|
||
);
|
||
|
||
const handlePointerUp = useCallback(
|
||
(event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||
event.preventDefault();
|
||
pointerDownRef.current = false;
|
||
const endPoint = getCanvasPoint(event);
|
||
const shapeStart = shapeStartPointRef.current;
|
||
if (isShapeMode(brushMode) && endPoint && shapeStart) {
|
||
drawShape(shapeStart, endPoint);
|
||
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;
|
||
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]);
|
||
|
||
// 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;
|
||
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;
|
||
|
||
let cancelled = false;
|
||
setStatus("connecting");
|
||
setStatusMessage("正在初始化实时连接...");
|
||
|
||
const start = async () => {
|
||
try {
|
||
let configResponse: Response | null = null;
|
||
let configJson: Partial<RealtimeConfig> & { 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<RealtimeConfig> & {
|
||
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: "runware",
|
||
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<HTMLInputElement>) => {
|
||
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<void>((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 exportedImage = await canvasToJpegDataUri(
|
||
canvas,
|
||
2048,
|
||
0.92,
|
||
canvasBackgroundRef.current
|
||
);
|
||
const anchor = document.createElement("a");
|
||
anchor.href = exportedImage.dataUri;
|
||
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]);
|
||
|
||
const content = (
|
||
<>
|
||
{standalone ? (
|
||
<div className="sr-only">
|
||
<h2>实时画布</h2>
|
||
<p>左侧绘制草图,右侧实时预览。</p>
|
||
</div>
|
||
) : (
|
||
<DialogHeader className="sr-only">
|
||
<DialogTitle>实时画布</DialogTitle>
|
||
<DialogDescription>左侧绘制草图,右侧实时预览。</DialogDescription>
|
||
</DialogHeader>
|
||
)}
|
||
{standalone ? (
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon"
|
||
className="absolute right-4 top-4 z-[60] h-9 w-9 rounded-full bg-background/85 shadow-sm"
|
||
onClick={() => onOpenChange(false)}
|
||
title="返回画布"
|
||
>
|
||
<X className="h-5 w-5" />
|
||
</Button>
|
||
) : null}
|
||
{/* Top Header - Minimal */}
|
||
<div className="absolute left-0 right-0 top-0 z-50 flex h-14 items-center justify-start px-6 bg-gradient-to-b from-black/20 to-transparent pointer-events-none">
|
||
<div className="pointer-events-auto flex items-center gap-3 rounded-full bg-background/95 backdrop-blur-sm px-4 py-1.5 border border-border/50 shadow-sm">
|
||
<span className="font-semibold text-sm">Realtime Canvas</span>
|
||
<div className="h-4 w-[1px] bg-border" />
|
||
<span className={cn("flex items-center gap-1.5 text-xs font-medium", statusBadge.className, "bg-transparent p-0")}>
|
||
<div className={cn("h-2 w-2 rounded-full", status === "live" ? "bg-emerald-500 animate-pulse" : status === "error" ? "bg-rose-500" : "bg-zinc-400")} />
|
||
{statusBadge.label}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Main Canvas Area - Split View */}
|
||
<div className="relative flex h-full w-full bg-[#f4f4f5] dark:bg-[#09090b]">
|
||
{/* Left: Input Canvas */}
|
||
<div className="relative flex-1 flex flex-col items-center justify-center overflow-hidden border-r border-border/10">
|
||
<div ref={canvasWrapRef} className="relative h-full w-full flex items-center justify-center bg-white dark:bg-[#18181b]">
|
||
<div className="absolute inset-0 pattern-grid-lg opacity-[0.03] pointer-events-none" />
|
||
|
||
<div className="relative">
|
||
<canvas
|
||
ref={canvasRef}
|
||
className={cn(
|
||
"touch-none shadow-sm block",
|
||
brushMode === "brush" || brushMode === "eraser" ? "cursor-none" : "cursor-crosshair"
|
||
)}
|
||
onPointerDown={handlePointerDown}
|
||
onPointerMove={handlePointerMove}
|
||
onPointerUp={handlePointerUp}
|
||
onPointerCancel={handlePointerUp}
|
||
onPointerEnter={handlePointerEnter}
|
||
onPointerLeave={handlePointerLeave}
|
||
/>
|
||
<canvas
|
||
ref={previewCanvasRef}
|
||
className="pointer-events-none absolute inset-0 touch-none"
|
||
/>
|
||
{/* Custom Brush Cursor */}
|
||
<div
|
||
ref={cursorRef}
|
||
className="pointer-events-none absolute top-0 left-0 rounded-full border border-zinc-500/50 bg-zinc-500/10 backdrop-invert transition-opacity duration-75 z-10"
|
||
style={{
|
||
width: brushSize,
|
||
height: brushSize,
|
||
opacity: 0,
|
||
willChange: 'transform, width, height'
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="absolute top-4 left-4 pointer-events-none z-10">
|
||
<span className="text-xs font-medium text-zinc-400 bg-black/5 px-2 py-1 rounded-md backdrop-blur-sm">INPUT</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Right: Output Preview */}
|
||
<div className="relative flex-1 flex flex-col items-center justify-center overflow-hidden bg-zinc-50 dark:bg-[#0c0c0e]">
|
||
<div className="relative h-full w-full flex items-center justify-center">
|
||
{previewUrl ? (
|
||
<img src={previewUrl} alt="实时预览" className="max-h-full max-w-full object-contain shadow-sm" />
|
||
) : (
|
||
<div className="flex flex-col items-center justify-center text-zinc-400 gap-3">
|
||
{status === "connecting" ? (
|
||
<>
|
||
<Loader2 className="h-8 w-8 animate-spin opacity-50" />
|
||
<span className="text-sm">Connecting...</span>
|
||
</>
|
||
) : (
|
||
<>
|
||
<Sparkles className="h-8 w-8 opacity-20" />
|
||
<span className="text-sm">Start drawing to generate</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="absolute top-4 right-4 pointer-events-none">
|
||
<span className="text-xs font-medium text-zinc-400 bg-black/5 px-2 py-1 rounded-md backdrop-blur-sm">OUTPUT</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Floating Toolbar - Top (Lovart-like) */}
|
||
<div className="absolute left-1/2 top-[72px] z-20 -translate-x-1/2 pointer-events-none">
|
||
<div className="pointer-events-auto flex items-center gap-1 rounded-[30px] border border-black/10 bg-white/90 p-2 shadow-xl backdrop-blur-xl">
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
className={cn(
|
||
"h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100",
|
||
brushMode === "brush" && "bg-black text-white hover:bg-black"
|
||
)}
|
||
onClick={() => setBrushMode("brush")}
|
||
title="画笔"
|
||
>
|
||
<Paintbrush className="h-5 w-5" />
|
||
</Button>
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
className={cn(
|
||
"h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100",
|
||
brushMode === "eraser" && "bg-black text-white hover:bg-black"
|
||
)}
|
||
onClick={() => setBrushMode("eraser")}
|
||
title="橡皮"
|
||
>
|
||
<Eraser className="h-5 w-5" />
|
||
</Button>
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
className={cn(
|
||
"h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100",
|
||
brushMode === "rect" && "bg-black text-white hover:bg-black"
|
||
)}
|
||
onClick={() => setBrushMode("rect")}
|
||
title="矩形"
|
||
>
|
||
<Square className="h-5 w-5" />
|
||
</Button>
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
className={cn(
|
||
"h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100",
|
||
brushMode === "circle" && "bg-black text-white hover:bg-black"
|
||
)}
|
||
onClick={() => setBrushMode("circle")}
|
||
title="圆形"
|
||
>
|
||
<CircleIcon className="h-5 w-5" />
|
||
</Button>
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
className={cn(
|
||
"h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100",
|
||
brushMode === "line" && "bg-black text-white hover:bg-black"
|
||
)}
|
||
onClick={() => setBrushMode("line")}
|
||
title="直线"
|
||
>
|
||
<Slash className="h-5 w-5" />
|
||
</Button>
|
||
|
||
<div className="mx-1 h-7 w-px bg-black/10" />
|
||
|
||
<div className="flex items-center gap-1 px-1">
|
||
{BRUSH_COLOR_PRESETS.map((color) => (
|
||
<button
|
||
key={color}
|
||
type="button"
|
||
className={cn(
|
||
"h-5 w-5 rounded-full border border-black/15 transition-all",
|
||
brushColor === color && "scale-110 ring-2 ring-black/25"
|
||
)}
|
||
style={{ backgroundColor: color }}
|
||
onClick={() => setBrushColor(color)}
|
||
title={color}
|
||
/>
|
||
))}
|
||
<input
|
||
type="color"
|
||
value={brushColor}
|
||
onChange={(event) => setBrushColor(event.target.value)}
|
||
className="h-5 w-5 cursor-pointer rounded-full border border-black/15 bg-transparent p-0"
|
||
title="自定义颜色"
|
||
/>
|
||
</div>
|
||
|
||
<div className="mx-1 h-7 w-px bg-black/10" />
|
||
|
||
<div className="w-[88px] px-1">
|
||
<Slider
|
||
value={[brushSize]}
|
||
min={2}
|
||
max={50}
|
||
step={1}
|
||
onValueChange={([val]) => setBrushSize(val)}
|
||
/>
|
||
</div>
|
||
|
||
<div className="mx-1 h-7 w-px bg-black/10" />
|
||
|
||
<div className="flex items-center gap-1">
|
||
{CANVAS_BACKGROUND_PRESETS.map((color) => (
|
||
<button
|
||
key={color}
|
||
type="button"
|
||
className={cn(
|
||
"h-4 w-4 rounded border border-black/15 transition-all",
|
||
canvasBackground === color && "scale-110 ring-2 ring-black/20"
|
||
)}
|
||
style={{ backgroundColor: color }}
|
||
onClick={() => handleCanvasBackgroundChange(color)}
|
||
title="画布底色"
|
||
/>
|
||
))}
|
||
</div>
|
||
|
||
<div className="mx-1 h-7 w-px bg-black/10" />
|
||
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
className="h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100"
|
||
onClick={() => uploadInputRef.current?.click()}
|
||
title="上传图片"
|
||
>
|
||
<Upload className="h-5 w-5" />
|
||
</Button>
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
className="h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100"
|
||
onClick={clearSketch}
|
||
title="清空"
|
||
>
|
||
<Trash2 className="h-5 w-5" />
|
||
</Button>
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
className="h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100"
|
||
onClick={undo}
|
||
disabled={!canUndo}
|
||
title="撤销"
|
||
>
|
||
<Undo2 className="h-5 w-5" />
|
||
</Button>
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
className="h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100"
|
||
onClick={redo}
|
||
disabled={!canRedo}
|
||
title="重做"
|
||
>
|
||
<Redo2 className="h-5 w-5" />
|
||
</Button>
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
className="h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100"
|
||
onClick={() => scheduleFlush(true)}
|
||
disabled={status === "connecting"}
|
||
title="生成"
|
||
>
|
||
{status === "connecting" ? (
|
||
<Loader2 className="h-5 w-5 animate-spin" />
|
||
) : (
|
||
<Sparkles className="h-5 w-5" />
|
||
)}
|
||
</Button>
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
className="h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100"
|
||
onClick={handleDownloadCanvas}
|
||
title="保存"
|
||
>
|
||
<Download className="h-5 w-5" />
|
||
</Button>
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
className="h-10 w-10 rounded-full text-zinc-600 hover:bg-zinc-100"
|
||
onClick={forceReconnect}
|
||
title="重连"
|
||
>
|
||
<RefreshCw className="h-5 w-5" />
|
||
</Button>
|
||
</div>
|
||
<input
|
||
ref={uploadInputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
className="hidden"
|
||
onChange={handleSketchUpload}
|
||
/>
|
||
</div>
|
||
|
||
{/* Floating Toolbar - Bottom (Prompt) */}
|
||
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 w-full max-w-2xl px-4 z-20 pointer-events-none">
|
||
<div className="pointer-events-auto flex flex-col gap-2 p-1.5 bg-background/80 backdrop-blur-xl border border-border/50 shadow-2xl rounded-[24px]">
|
||
<div className="flex items-center gap-2 pl-2 pr-1.5 py-1">
|
||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||
<Sparkles className="h-4 w-4" />
|
||
</div>
|
||
<Input
|
||
value={prompt}
|
||
onChange={(e) => 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"
|
||
/>
|
||
<Button
|
||
size="sm"
|
||
className="h-9 rounded-xl px-4 font-medium"
|
||
onClick={() => scheduleFlush(true)}
|
||
disabled={status === 'connecting'}
|
||
>
|
||
{status === 'connecting' ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Generate'}
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
className="h-9 rounded-xl px-4 font-medium"
|
||
onClick={handleApplyToCanvas}
|
||
disabled={!previewUrl}
|
||
>
|
||
<Send className="h-4 w-4 mr-1.5" />
|
||
发送到画布
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<div className="text-center mt-2">
|
||
<span className="text-[10px] text-white/50 bg-black/40 px-3 py-1 rounded-full backdrop-blur-sm">
|
||
{statusMessage} • Latency: {adaptiveThrottleMs}ms
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
|
||
if (standalone) {
|
||
return (
|
||
<div className="h-dvh w-full overflow-hidden bg-background">
|
||
<div className="relative h-full w-full overflow-hidden bg-background">{content}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
<DialogContent className="h-[95vh] w-[98vw] max-w-[1800px] overflow-hidden rounded-[20px] border-0 bg-background p-0 shadow-2xl sm:rounded-[20px] md:rounded-[24px]">
|
||
{content}
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|