diff --git a/app/api/canvas/local-edit/route.ts b/app/api/canvas/local-edit/route.ts new file mode 100644 index 000000000..fa13891ed --- /dev/null +++ b/app/api/canvas/local-edit/route.ts @@ -0,0 +1,430 @@ +import { NextRequest, NextResponse } from "next/server"; +import sharp from "sharp"; + +import { auth } from "@/app/(auth)/auth"; +import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service"; +import { + createCanvasImageTask, + enqueueCanvasImageTask, + getCanvasImageTaskForUser, +} from "@/lib/canvas/image-task-queue"; +import type { CanvasGenerateImageRequest } from "@/lib/canvas/generate-image"; +import { getPointsSystemConfig } from "@/lib/db/queries"; +import { getImageToolCreditCost } from "@/lib/points/config"; +import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge"; +import { uploadFile } from "@/lib/storage/service"; + +export const runtime = "nodejs"; +export const maxDuration = 120; + +type LocalEditRect = { + x?: number; + y?: number; + width?: number; + height?: number; +}; + +type LocalEditRegionInput = { + id?: string; + index?: number; + color?: string; + prompt?: string; + rect?: LocalEditRect; +}; + +type LocalEditRequest = { + sourceImageUrl?: string; + sourceNodeId?: string; + regions?: LocalEditRegionInput[]; + model?: string; + imageSize?: string; + aspectRatio?: string; +}; + +type NormalizedLocalEditRegion = { + id: string; + index: number; + color: string; + colorName: string; + prompt: string; + rect: { + x: number; + y: number; + width: number; + height: number; + }; + pixelRect: { + left: number; + top: number; + width: number; + height: number; + }; +}; + +const MAX_LOCAL_EDIT_REGIONS = 8; +const MIN_REGION_EDGE_PX = 24; +const DEFAULT_REGION_COLORS = [ + "#ff3b30", + "#1677ff", + "#00c853", + "#ff9500", + "#af52de", + "#00bcd4", + "#ff2d55", + "#8d6e63", +]; + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + +function toArrayBuffer(buffer: Buffer) { + return buffer.buffer.slice( + buffer.byteOffset, + buffer.byteOffset + buffer.byteLength + ) as ArrayBuffer; +} + +function escapeXml(value: string) { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function normalizeHexColor(input: unknown, fallback: string) { + const raw = String(input || "").trim(); + const expanded = raw.replace(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/i, "#$1$1$2$2$3$3"); + return /^#[0-9a-f]{6}$/i.test(expanded) ? expanded.toLowerCase() : fallback; +} + +function getColorName(hex: string) { + const normalized = hex.toLowerCase(); + const names: Record = { + "#ff3b30": "红色", + "#1677ff": "蓝色", + "#00c853": "绿色", + "#ff9500": "橙色", + "#af52de": "紫色", + "#00bcd4": "青色", + "#ff2d55": "玫红色", + "#8d6e63": "棕色", + }; + return names[normalized] || `${normalized} 色`; +} + +function normalizeRect(input: LocalEditRect | undefined) { + if (!input) return null; + const x = Number(input.x); + const y = Number(input.y); + const width = Number(input.width); + const height = Number(input.height); + if (![x, y, width, height].every(Number.isFinite)) return null; + const left = clamp(Math.min(x, x + width), 0, 1); + const top = clamp(Math.min(y, y + height), 0, 1); + const right = clamp(Math.max(x, x + width), 0, 1); + const bottom = clamp(Math.max(y, y + height), 0, 1); + const normalizedWidth = clamp(right - left, 0, 1 - left); + const normalizedHeight = clamp(bottom - top, 0, 1 - top); + if (normalizedWidth <= 0 || normalizedHeight <= 0) return null; + return { + x: left, + y: top, + width: normalizedWidth, + height: normalizedHeight, + }; +} + +function rectToPixels( + rect: NonNullable>, + sourceWidth: number, + sourceHeight: number +) { + const left = clamp(Math.round(rect.x * sourceWidth), 0, Math.max(0, sourceWidth - 1)); + const top = clamp(Math.round(rect.y * sourceHeight), 0, Math.max(0, sourceHeight - 1)); + const width = clamp(Math.round(rect.width * sourceWidth), 1, sourceWidth - left); + const height = clamp(Math.round(rect.height * sourceHeight), 1, sourceHeight - top); + return { left, top, width, height }; +} + +function wrapText(text: string, maxChars: number, maxLines = 3) { + const compact = text.replace(/\s+/g, " ").trim(); + if (!compact) return []; + const lines: string[] = []; + let remaining = compact; + while (remaining.length > 0 && lines.length < maxLines) { + if (remaining.length <= maxChars) { + lines.push(remaining); + break; + } + lines.push(remaining.slice(0, maxChars)); + remaining = remaining.slice(maxChars); + } + if (remaining.length > 0 && lines.length >= maxLines) { + lines[lines.length - 1] = `${lines[lines.length - 1].slice(0, Math.max(1, maxChars - 1))}…`; + } + return lines; +} + +function normalizeRegions( + rawRegions: LocalEditRegionInput[] | undefined, + sourceWidth: number, + sourceHeight: number +): NormalizedLocalEditRegion[] { + if (!Array.isArray(rawRegions) || rawRegions.length === 0) { + throw new Error("请先框选至少一个局部编辑区域"); + } + if (rawRegions.length > MAX_LOCAL_EDIT_REGIONS) { + throw new Error(`局部编辑最多支持 ${MAX_LOCAL_EDIT_REGIONS} 个区域`); + } + + return rawRegions.map((region, itemIndex) => { + const rect = normalizeRect(region.rect); + if (!rect) { + throw new Error(`区域 ${itemIndex + 1} 缺少有效框选范围`); + } + const prompt = String(region.prompt || "").trim(); + if (!prompt) { + throw new Error(`区域 ${itemIndex + 1} 请填写局部编辑提示词`); + } + const pixelRect = rectToPixels(rect, sourceWidth, sourceHeight); + if (pixelRect.width < MIN_REGION_EDGE_PX || pixelRect.height < MIN_REGION_EDGE_PX) { + throw new Error(`区域 ${itemIndex + 1} 过小,请重新框选更大的范围`); + } + const color = normalizeHexColor( + region.color, + DEFAULT_REGION_COLORS[itemIndex % DEFAULT_REGION_COLORS.length] + ); + return { + id: String(region.id || `region-${itemIndex + 1}`), + index: itemIndex + 1, + color, + colorName: getColorName(color), + prompt: prompt.slice(0, 300), + rect, + pixelRect, + }; + }); +} + +function buildAnnotationSvg(params: { + width: number; + height: number; + regions: NormalizedLocalEditRegion[]; +}) { + const { width, height, regions } = params; + const minSide = Math.max(1, Math.min(width, height)); + const strokeWidth = Math.max(4, Math.round(minSide * 0.006)); + const fontSize = Math.max(24, Math.round(minSide * 0.026)); + const smallFontSize = Math.max(18, Math.round(fontSize * 0.72)); + const padX = Math.round(fontSize * 0.55); + const padY = Math.round(fontSize * 0.34); + const gap = Math.round(fontSize * 0.34); + const radius = Math.round(fontSize * 0.5); + + const regionSvg = regions + .map((region) => { + const { left, top, width: rectWidth, height: rectHeight } = region.pixelRect; + const labelText = `区域${region.index} ${region.prompt}`; + const maxChars = Math.max(8, Math.min(24, Math.floor((width * 0.42) / Math.max(1, fontSize * 0.58)))); + const lines = wrapText(labelText, maxChars, 3); + const labelWidth = Math.min( + Math.max( + rectWidth, + ...lines.map((line) => Math.ceil(line.length * fontSize * 0.62) + padX * 2) + ), + Math.max(180, width - 16) + ); + const labelHeight = lines.length * Math.round(fontSize * 1.24) + padY * 2; + const preferBelow = top + rectHeight + gap + labelHeight <= height - 8; + const labelX = clamp(left, 8, Math.max(8, width - labelWidth - 8)); + const labelY = preferBelow + ? top + rectHeight + gap + : clamp(top - gap - labelHeight, 8, Math.max(8, height - labelHeight - 8)); + const badgeSize = Math.max(34, Math.round(fontSize * 1.35)); + const badgeCx = clamp(left, badgeSize / 2 + 6, width - badgeSize / 2 - 6); + const badgeCy = clamp(top, badgeSize / 2 + 6, height - badgeSize / 2 - 6); + const textLines = lines + .map( + (line, lineIndex) => + `${escapeXml(line)}` + ) + .join(""); + + return ` + + + + ${region.index} + + ${textLines} + `; + }) + .join(""); + + return Buffer.from(` + + ${regionSvg} + + `); +} + +function buildLocalEditPrompt(regions: NormalizedLocalEditRegion[]) { + const regionLines = regions + .map((region) => `区域 ${region.index}(${region.colorName},编号 ${region.index}):${region.prompt}`) + .join("\n"); + + return [ + "输入图片1是一张带彩色标注框和框下提示词的图片编辑参考图;输入图片2是同一张原始干净图,用于保持画面细节与未编辑区域。", + "彩色框、编号、提示词气泡只是编辑指令,不是最终画面内容。", + "", + "请严格根据每个区域下方的提示词,只在对应彩色框附近完成局部编辑:", + regionLines, + "", + "最终输出必须去除所有彩色框、编号、提示词气泡和标注文字。", + "未被彩色框标注的区域必须尽量保持原图不变。", + "新增内容必须匹配原图的透视、光线、比例、清晰度、色温和真实摄影风格。", + "如果某个区域提示词与原图空间冲突,以自然融入原图为最高优先级。", + ].join("\n"); +} + +export async function POST(request: NextRequest) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ success: false, error: "未授权,请先登录" }, { status: 401 }); + } + + const body = (await request.json()) as LocalEditRequest; + const rawSourceImageUrl = String(body.sourceImageUrl || "").trim(); + if (!rawSourceImageUrl) { + return NextResponse.json({ success: false, error: "缺少 sourceImageUrl 参数" }, { status: 400 }); + } + + const sourceImageUrl = new URL(rawSourceImageUrl, request.url).toString(); + const sourceBuffer = await sharp(await fetchImageBufferFromUrl(sourceImageUrl), { + failOn: "none", + }) + .rotate() + .toBuffer(); + const sourceMetadata = await sharp(sourceBuffer, { failOn: "none" }).metadata(); + const sourceWidth = Number(sourceMetadata.width || 0); + const sourceHeight = Number(sourceMetadata.height || 0); + if (sourceWidth <= 0 || sourceHeight <= 0) { + return NextResponse.json({ success: false, error: "无法识别原图尺寸" }, { status: 400 }); + } + + let regions: NormalizedLocalEditRegion[]; + try { + regions = normalizeRegions(body.regions, sourceWidth, sourceHeight); + } catch (validationError) { + return NextResponse.json( + { + success: false, + error: + validationError instanceof Error + ? validationError.message + : "局部编辑区域参数无效", + }, + { status: 400 } + ); + } + + const pointsConfig = await getPointsSystemConfig(); + const pointsCharge = await prepareFeaturePointsCharge({ + userId: session.user.id, + unitCost: getImageToolCreditCost(pointsConfig, "repaint"), + reason: "image_repaint", + modelId: "canvas-local-edit", + note: "局部编辑消耗积分", + pointsConfig, + }); + if (!pointsCharge.ok) { + return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status }); + } + + const annotationSvg = buildAnnotationSvg({ + height: sourceHeight, + regions, + width: sourceWidth, + }); + const annotatedBuffer = await sharp(sourceBuffer, { failOn: "none" }) + .composite([{ input: annotationSvg, left: 0, top: 0 }]) + .png({ compressionLevel: 6 }) + .toBuffer(); + const annotatedFilename = `canvas-local-edit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.png`; + const annotatedUpload = await uploadFile( + annotatedFilename, + toArrayBuffer(annotatedBuffer) + ); + const prompt = buildLocalEditPrompt(regions); + + const imageRequest: CanvasGenerateImageRequest = { + aspectRatio: + typeof body.aspectRatio === "string" && body.aspectRatio.trim() + ? body.aspectRatio.trim() + : "auto", + billingFeature: "repaint", + debugLabel: "canvas-local-edit", + debugMeta: { + annotatedReferenceUrl: annotatedUpload.url, + regionCount: regions.length, + sourceNodeId: body.sourceNodeId || null, + }, + imageCount: 1, + imageSize: + typeof body.imageSize === "string" && body.imageSize.trim() + ? body.imageSize.trim() + : "2K", + model: + typeof body.model === "string" && body.model.trim() + ? body.model.trim() + : undefined, + prompt, + promptExtend: false, + referenceImages: [annotatedUpload.url, sourceImageUrl], + ownerKey: session.user.id, + }; + + const task = await createCanvasImageTask({ + request: imageRequest as unknown as Record, + userId: session.user.id, + }); + await enqueueCanvasImageTask(task.id); + const currentTask = await getCanvasImageTaskForUser({ + taskId: task.id, + userId: session.user.id, + }); + + if (pointsCharge.chargeable) { + try { + await pointsCharge.consume({ + note: `局部编辑消耗积分(${task.id})`, + }); + } catch (pointsError) { + console.error("[Points] Failed to consume local edit points:", pointsError); + } + } + + return NextResponse.json({ + success: true, + annotatedReferenceUrl: annotatedUpload.url, + prompt, + regions, + taskId: task.id, + status: currentTask?.status || task.status, + queueStatus: currentTask?.queueStatus, + }); + } catch (error) { + console.error("[Canvas Local Edit] Error:", error); + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : "局部编辑提交失败", + }, + { status: 500 } + ); + } +} diff --git a/components/canvas/LiblibVideoCanvasReplica.tsx b/components/canvas/LiblibVideoCanvasReplica.tsx index 998bee967..b3e0fcc10 100644 --- a/components/canvas/LiblibVideoCanvasReplica.tsx +++ b/components/canvas/LiblibVideoCanvasReplica.tsx @@ -298,6 +298,7 @@ type ImageToolbarAction = | "grid" | "hd" | "crop" + | "localEdit" | "annotate" | "rotate" | "download" @@ -433,6 +434,15 @@ type GridCropRect = { y: number; }; +type LocalEditRegion = { + color: string; + createdAt: number; + id: string; + index: number; + prompt: string; + rect: GridCropRect; +}; + type GridSplitSpec = { columns: number; detected: boolean; @@ -600,6 +610,17 @@ const OFFICIAL_ZOOM_DOCK_SCALE = 0.65; const OFFICIAL_DROPDOWN_SCALE = 0.7; const OFFICIAL_NINE_GRID_WIDTH = 930; const OFFICIAL_NINE_GRID_HEIGHT = 520; +const LOCAL_EDIT_MAX_REGIONS = 8; +const LOCAL_EDIT_COLORS = [ + "#ff3b30", + "#1677ff", + "#00c853", + "#ff9500", + "#af52de", + "#00bcd4", + "#ff2d55", + "#8d6e63", +]; const VIDEO_CLIP_FRAME_COUNT = 9; const STORY_BEAT_GRID_FUNCTION_KEY = "story-beat-grid-4"; const MULTI_VIEW_NINE_GRID_FUNCTION_KEY = "multi-view-nine-grid"; @@ -1912,6 +1933,10 @@ function getFirstGeneratedImageUrl( return first?.originalUrl || first?.thumbnailUrl || ""; } +function wait(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + function createEqualGridRects(rows: number, columns: number): GridCropRect[] { return Array.from({ length: rows * columns }).map((_, index) => { const column = index % columns; @@ -1969,6 +1994,49 @@ function getNodeGridSplitState(node: CloneNode | null | undefined) { return normalizeGridSplitState(node?.draftValues?.gridSplit); } +function normalizeLocalEditRegion(value: unknown, index: number): LocalEditRegion | null { + if (!value || typeof value !== "object") return null; + const record = value as Record; + const rawRect = record.rect && typeof record.rect === "object" ? (record.rect as Record) : null; + if (!rawRect) return null; + const width = Math.min(1, Math.max(0.001, Number(rawRect.width || 0))); + const height = Math.min(1, Math.max(0.001, Number(rawRect.height || 0))); + const x = Math.min(1 - width, Math.max(0, Number(rawRect.x || 0))); + const y = Math.min(1 - height, Math.max(0, Number(rawRect.y || 0))); + return { + color: + typeof record.color === "string" && /^#[0-9a-f]{6}$/i.test(record.color) + ? record.color + : LOCAL_EDIT_COLORS[index % LOCAL_EDIT_COLORS.length], + createdAt: Number(record.createdAt || Date.now()), + id: String(record.id || makeId("local-edit-region")), + index: index + 1, + prompt: typeof record.prompt === "string" ? record.prompt : "", + rect: { height, width, x, y }, + }; +} + +function getNodeLocalEditRegions(node: CloneNode | null | undefined) { + const regions = Array.isArray(node?.draftValues?.localEditRegions) + ? node?.draftValues?.localEditRegions + : []; + return regions + .slice(0, LOCAL_EDIT_MAX_REGIONS) + .map((region, index) => normalizeLocalEditRegion(region, index)) + .filter((region): region is LocalEditRegion => Boolean(region)) + .map((region, index) => ({ ...region, index: index + 1 })); +} + +function withReindexedLocalEditRegions(regions: LocalEditRegion[]) { + return regions + .slice(0, LOCAL_EDIT_MAX_REGIONS) + .map((region, index) => ({ + ...region, + index: index + 1, + color: region.color || LOCAL_EDIT_COLORS[index % LOCAL_EDIT_COLORS.length], + })); +} + function clearGridSplitDraftValues(draftValues: Record | undefined) { const nextDraftValues = { ...(draftValues || {}) }; delete nextDraftValues.gridSplit; @@ -2200,6 +2268,25 @@ async function createGridSplitReferenceAsset({ return upload.success ? upload.data?.url || "" : ""; } +async function ensureRemoteCanvasImageUrl(sourceUrl: string, filenamePrefix: string) { + if (/^https?:\/\//i.test(sourceUrl)) return sourceUrl; + if (!sourceUrl || typeof window === "undefined") return ""; + + const response = await fetch(sourceUrl); + if (!response.ok) return ""; + const blob = await response.blob(); + const extension = blob.type.includes("jpeg") || blob.type.includes("jpg") + ? "jpg" + : blob.type.includes("webp") + ? "webp" + : "png"; + const file = new File([blob], `${filenamePrefix}-${Date.now()}.${extension}`, { + type: blob.type || `image/${extension}`, + }); + const upload = await imageGenerationService.uploadImage(file); + return upload.success ? upload.data?.url || "" : ""; +} + async function resolveNodeReferenceImages(node: CloneNode | null | undefined, limit = 12): Promise { const fallbackUrl = getNodeReferenceMediaUrl(node); const split = getNodeGridSplitState(node); @@ -3438,6 +3525,40 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib return; } + if (action === "localEdit") { + const mediaUrl = getNodeReferenceMediaUrl(anchor); + if (!mediaUrl) { + toast.error("当前图片没有可编辑的图片地址"); + return; + } + if (getNodeGridSplitState(anchor)) { + toast.error("请先退出宫格切分,再使用局部编辑"); + return; + } + const willEnableLocalEdit = !Boolean(anchor.draftValues?.localEditMode); + setNodes((current) => + current.map((node) => + node.id === anchor.id + ? { + ...node, + draftValues: { + ...(node.draftValues || {}), + localEditMode: willEnableLocalEdit, + localEditRegions: getNodeLocalEditRegions(node), + }, + } + : node + ) + ); + setSelectedNodeId(anchor.id); + setCanvasCommand(null); + setNodeContextMenu(null); + setConnectionMenu(null); + setActivePanel(null); + toast.success(willEnableLocalEdit ? "已进入局部编辑模式" : "已退出局部编辑模式"); + return; + } + if (action === "orbit") { setSelectedNodeId(anchor.id); setAngleEditorNodeId(anchor.id); @@ -3507,7 +3628,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib const sourceUrl = resolvedReferenceImages[0] || anchorSourceUrl; const extraReferenceImages = resolvedReferenceImages.slice(1); - const labels: Record, string> = { + const labels: Record, string> = { grid: "九宫格", hd: "高清", lighting: "打光", @@ -4008,6 +4129,297 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib [] ); + const setNodeLocalEditMode = useCallback((nodeId: string, enabled: boolean) => { + setNodes((current) => + current.map((node) => + node.id === nodeId + ? { + ...node, + draftValues: { + ...(node.draftValues || {}), + localEditMode: enabled, + }, + } + : node + ) + ); + }, []); + + const updateNodeLocalEditRegions = useCallback((nodeId: string, regions: LocalEditRegion[]) => { + setNodes((current) => + current.map((node) => + node.id === nodeId + ? { + ...node, + draftValues: { + ...(node.draftValues || {}), + localEditRegions: withReindexedLocalEditRegions(regions), + }, + } + : node + ) + ); + }, []); + + const clearNodeLocalEditRegions = useCallback((nodeId: string) => { + setNodes((current) => + current.map((node) => + node.id === nodeId + ? { + ...node, + draftValues: { + ...(node.draftValues || {}), + localEditRegions: [], + }, + } + : node + ) + ); + }, []); + + const pollLocalEditTaskForNode = useCallback(async (nodeId: string, taskId: string) => { + for (let attempt = 0; attempt < 180; attempt += 1) { + await wait(attempt < 6 ? 1500 : 2500); + const response = await imageGenerationService.getImageTask(taskId); + if (!response.success || !response.data?.task) { + if (attempt > 4) { + setNodes((current) => + current.map((node) => + node.id === nodeId + ? { + ...node, + draftValues: { + ...(node.draftValues || {}), + generationError: response.error || "局部编辑任务查询失败", + generationStatus: "failed", + }, + } + : node + ) + ); + toast.error(response.error || "局部编辑任务查询失败"); + return; + } + continue; + } + + const { task } = response.data; + if (task.status === "succeeded") { + const first = task.result?.images?.[0]; + const generatedUrl = first?.originalUrl || first?.thumbnailUrl || ""; + if (!generatedUrl) { + setNodes((current) => + current.map((node) => + node.id === nodeId + ? { + ...node, + draftValues: { + ...(node.draftValues || {}), + generationError: "局部编辑已完成但未返回图片", + generationStatus: "failed", + }, + } + : node + ) + ); + toast.error("局部编辑已完成但未返回图片"); + return; + } + setNodes((current) => + current.map((node) => + node.id === nodeId + ? { + ...node, + subtitle: "生成结果", + draftValues: { + ...(node.draftValues || {}), + generated: true, + generationProgress: 100, + generationStatus: "complete", + mediaUrl: generatedUrl, + modelRef: task.result?.modelRef, + params: { + ...getNodeParams(node), + model: task.result?.model, + taskId, + }, + url: [generatedUrl], + }, + } + : node + ) + ); + setSelectedNodeId(nodeId); + toast.success("局部编辑结果已生成"); + return; + } + + if (task.status === "failed") { + const errorMessage = task.error || "局部编辑生成失败"; + setNodes((current) => + current.map((node) => + node.id === nodeId + ? { + ...node, + subtitle: "生成失败", + draftValues: { + ...(node.draftValues || {}), + generationError: errorMessage, + generationStatus: "failed", + }, + } + : node + ) + ); + toast.error(errorMessage); + return; + } + + const progress = Math.min(96, 8 + attempt * 2); + setNodes((current) => + current.map((node) => + node.id === nodeId && getDraftString(node, "generationStatus") === "pending" + ? { + ...node, + draftValues: { + ...(node.draftValues || {}), + generationProgress: Math.max(getDraftNumber(node, "generationProgress", 2), progress), + }, + } + : node + ) + ); + } + + setNodes((current) => + current.map((node) => + node.id === nodeId + ? { + ...node, + draftValues: { + ...(node.draftValues || {}), + generationError: "局部编辑任务等待超时,请稍后重试", + generationStatus: "failed", + }, + } + : node + ) + ); + toast.error("局部编辑任务等待超时,请稍后重试"); + }, []); + + const submitNodeLocalEdit = useCallback( + async (nodeId: string, regions: LocalEditRegion[]) => { + const anchor = nodes.find((node) => node.id === nodeId); + if (!anchor) return; + const validRegions = withReindexedLocalEditRegions(regions).filter((region) => region.prompt.trim()); + if (validRegions.length === 0) { + toast.error("请至少框选一个区域并填写提示词"); + return; + } + const rawSourceUrl = getNodeReferenceMediaUrl(anchor); + if (!rawSourceUrl) { + toast.error("当前图片没有可编辑的图片地址"); + return; + } + + toast.info("正在提交局部编辑任务..."); + const sourceUrl = await ensureRemoteCanvasImageUrl(rawSourceUrl, `canvas-local-edit-${anchor.id}`).catch(() => ""); + if (!sourceUrl) { + toast.error("当前图片无法上传为可编辑素材"); + return; + } + + const response = await imageGenerationService.localEdit({ + aspectRatio: "auto", + imageSize: "2K", + regions: validRegions.map((region) => ({ + color: region.color, + id: region.id, + index: region.index, + prompt: region.prompt.trim(), + rect: region.rect, + })), + sourceImageUrl: sourceUrl, + sourceNodeId: anchor.id, + }); + if (!response.success || !response.data?.taskId) { + toast.error(response.error || "局部编辑提交失败"); + return; + } + + const resultWidth = anchor.width; + const resultHeight = anchor.height; + const resultNode = createMediaResourceNode({ + action: "IMAGE_RESOURCE", + height: resultHeight, + mediaType: "image", + mediaUrl: "", + prompt: response.data.prompt, + title: "局部编辑", + width: resultWidth, + x: Math.round(anchor.x + anchor.width + OFFICIAL_NODE_GAP), + y: anchor.y, + }); + const pendingNode: CloneNode = { + ...resultNode, + subtitle: "生成中", + draftValues: { + ...(resultNode.draftValues || {}), + action: "IMAGE_RESOURCE", + annotatedReferenceUrl: response.data.annotatedReferenceUrl, + functionKey: "local-edit", + generated: false, + generationProgress: 6, + generationStatus: "pending", + localEditRegions: validRegions, + mediaType: "image", + mediaUrl: "", + nodeType: "image", + params: { + annotatedReferenceUrl: response.data.annotatedReferenceUrl, + mode: "local-edit", + prompt: response.data.prompt, + regions: validRegions, + sourceNodeId: anchor.id, + sourceUrl, + taskId: response.data.taskId, + }, + pendingLabel: "局部编辑中", + prompt: response.data.prompt, + sourceNodeId: anchor.id, + sourceUrl, + toolbarOption: "局部编辑", + url: [], + }, + }; + + setNodes((current) => + current.map((node) => + node.id === anchor.id + ? { + ...node, + draftValues: { + ...(node.draftValues || {}), + localEditMode: false, + localEditRegions: validRegions, + }, + } + : node + ).concat(pendingNode) + ); + setEdges((current) => [...current, { id: makeId("edge"), source: anchor.id, target: pendingNode.id }]); + setSelectedNodeId(pendingNode.id); + setCanvasCommand(null); + setNodeContextMenu(null); + setConnectionMenu(null); + setActivePanel(null); + toast.success("已创建局部编辑生成任务"); + void pollLocalEditTaskForNode(pendingNode.id, response.data.taskId); + }, + [nodes, pollLocalEditTaskForNode] + ); + const createGridSplitImageNodes = useCallback( async (nodeId: string) => { const anchor = nodes.find((node) => node.id === nodeId); @@ -5392,6 +5804,13 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib const isBusy = isLoading || isProjectLoading; const selectedPresetControlNode = isOfficialImageControlNode(selectedNode) ? selectedNode : null; + const selectedGenericImageNode = + selectedNode && + !selectedPresetControlNode && + selectedNode.kind === "media" && + (getDraftString(selectedNode, "mediaType") || selectedNode.inputType) === "image" + ? selectedNode + : null; const selectedPresetMode = getPresetControlMode(selectedPresetControlNode); const selectedTargetModuleKind = selectedNode ? getTargetModuleKind(selectedNode) : null; const selectedTextResourceNode = @@ -5433,6 +5852,15 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib zoom: viewport.zoom, }; }, [selectedVideoMediaNode, viewport.x, viewport.y, viewport.zoom]); + const selectedGenericImageAnchor = useMemo(() => { + if (!selectedGenericImageNode) return null; + return { + bottomY: Math.round(viewport.y + (selectedGenericImageNode.y + selectedGenericImageNode.height) * viewport.zoom), + centerX: Math.round(viewport.x + (selectedGenericImageNode.x + selectedGenericImageNode.width / 2) * viewport.zoom), + topY: Math.round(viewport.y + selectedGenericImageNode.y * viewport.zoom), + zoom: viewport.zoom, + }; + }, [selectedGenericImageNode, viewport.x, viewport.y, viewport.zoom]); return (
cancelGenerationNode(node.id)} onClearGridSplit={() => dismissNodeGridSplit(node.id)} onClearGridSplitSelection={() => clearNodeGridSplitSelection(node.id)} + onClearLocalEditRegions={() => clearNodeLocalEditRegions(node.id)} onModuleAction={(actionLabel) => runTargetModuleAction(node.id, actionLabel)} onOpenConnectionMenu={(event, side) => openConnectionMenu(event, node, side)} onPointerDown={(event) => startNodeDrag(event, node)} + onSetLocalEditMode={(enabled) => setNodeLocalEditMode(node.id, enabled)} + onSubmitLocalEdit={(regions) => submitNodeLocalEdit(node.id, regions)} onToggleGridSplitSelection={(index, behavior) => toggleNodeGridSplitSelection(node.id, index, behavior)} + onUpdateLocalEditRegions={(regions) => updateNodeLocalEditRegions(node.id, regions)} onTextResourceChange={(content) => updateTextResourceContent(node.id, content)} /> ))} @@ -5573,6 +6005,15 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib /> ) : null} + {selectedGenericImageNode && selectedGenericImageAnchor ? ( +
+ runSelectedImageToolAction(selectedGenericImageNode.id, action, option)} + /> +
+ ) : null} + {selectedVideoMediaNode && selectedVideoAnchor ? (
setToolbarMenu((current) => current === "grid" ? null : "grid")} /> setToolbarMenu((current) => current === "hd" ? null : "hd")} /> setToolbarMenu((current) => current === "crop" ? null : "crop")} /> + onToolAction("localEdit")} />
{iconActions.map((item) => (
) : null} + {localEditMode && mediaUrl ? ( + onSetLocalEditMode(false)} + onRegionsChange={onUpdateLocalEditRegions} + onSubmit={onSubmitLocalEdit} + regions={localEditRegions} + /> + ) : null} {gridSplit ? ( imageRatio) { + const height = containerHeight; + const width = height * imageRatio; + return { + height, + left: (containerWidth - width) / 2, + top: 0, + width, + }; + } + const width = containerWidth; + const height = width / imageRatio; + return { + height, + left: 0, + top: (containerHeight - height) / 2, + width, + }; +} + +function LocalEditOverlay({ + imageUrl, + onClear, + onExit, + onRegionsChange, + onSubmit, + regions, +}: { + imageUrl: string; + onClear: () => void; + onExit: () => void; + onRegionsChange: (regions: LocalEditRegion[]) => void; + onSubmit: (regions: LocalEditRegion[]) => void; + regions: LocalEditRegion[]; +}) { + const overlayRef = useRef(null); + const [containerSize, setContainerSize] = useState({ height: 0, width: 0 }); + const [naturalSize, setNaturalSize] = useState({ height: 0, width: 0 }); + const [dragState, setDragState] = useState<{ + currentX: number; + currentY: number; + pointerId: number; + startX: number; + startY: number; + } | null>(null); + const [editingRegionId, setEditingRegionId] = useState(null); + + useEffect(() => { + const element = overlayRef.current; + if (!element) return; + const updateSize = () => { + const rect = element.getBoundingClientRect(); + setContainerSize({ + height: Math.max(0, rect.height), + width: Math.max(0, rect.width), + }); + }; + updateSize(); + const observer = typeof ResizeObserver !== "undefined" ? new ResizeObserver(updateSize) : null; + observer?.observe(element); + return () => observer?.disconnect(); + }, []); + + useEffect(() => { + if (!imageUrl) return; + const image = new Image(); + if (!imageUrl.startsWith("blob:") && !imageUrl.startsWith("data:")) { + image.crossOrigin = "anonymous"; + image.referrerPolicy = "no-referrer"; + } + image.onload = () => { + setNaturalSize({ + height: image.naturalHeight || image.height || 0, + width: image.naturalWidth || image.width || 0, + }); + }; + image.src = imageUrl; + }, [imageUrl]); + + const imageRect = useMemo( + () => + getContainedImageRect({ + containerHeight: containerSize.height, + containerWidth: containerSize.width, + imageHeight: naturalSize.height, + imageWidth: naturalSize.width, + }), + [containerSize.height, containerSize.width, naturalSize.height, naturalSize.width] + ); + + const pointToImage = useCallback( + (event: ReactPointerEvent) => { + const bounds = overlayRef.current?.getBoundingClientRect(); + if (!bounds || !imageRect) return null; + const localX = event.clientX - bounds.left - imageRect.left; + const localY = event.clientY - bounds.top - imageRect.top; + if (localX < 0 || localY < 0 || localX > imageRect.width || localY > imageRect.height) { + return null; + } + return { + x: Math.min(1, Math.max(0, localX / imageRect.width)), + y: Math.min(1, Math.max(0, localY / imageRect.height)), + }; + }, + [imageRect] + ); + + const draftRect = useMemo(() => { + if (!dragState) return null; + const x = Math.min(dragState.startX, dragState.currentX); + const y = Math.min(dragState.startY, dragState.currentY); + const width = Math.abs(dragState.currentX - dragState.startX); + const height = Math.abs(dragState.currentY - dragState.startY); + return { height, width, x, y }; + }, [dragState]); + + const updateRegionPrompt = useCallback( + (regionId: string, prompt: string) => { + onRegionsChange( + regions.map((region) => + region.id === regionId + ? { + ...region, + prompt, + } + : region + ) + ); + }, + [onRegionsChange, regions] + ); + + const removeRegion = useCallback( + (regionId: string) => { + onRegionsChange(withReindexedLocalEditRegions(regions.filter((region) => region.id !== regionId))); + setEditingRegionId((current) => (current === regionId ? null : current)); + }, + [onRegionsChange, regions] + ); + + const validPromptCount = regions.filter((region) => region.prompt.trim()).length; + + return ( +
{ + event.stopPropagation(); + if ((event.target as HTMLElement).closest("[data-local-edit-ui]")) return; + if (regions.length >= LOCAL_EDIT_MAX_REGIONS) { + toast.error(`局部编辑最多支持 ${LOCAL_EDIT_MAX_REGIONS} 个区域`); + return; + } + const point = pointToImage(event); + if (!point) return; + (event.currentTarget as HTMLDivElement).setPointerCapture(event.pointerId); + setDragState({ + currentX: point.x, + currentY: point.y, + pointerId: event.pointerId, + startX: point.x, + startY: point.y, + }); + }} + onPointerMove={(event) => { + if (!dragState || dragState.pointerId !== event.pointerId) return; + event.stopPropagation(); + const point = pointToImage(event); + if (!point) return; + setDragState((current) => + current && current.pointerId === event.pointerId + ? { + ...current, + currentX: point.x, + currentY: point.y, + } + : current + ); + }} + onPointerUp={(event) => { + if (!dragState || dragState.pointerId !== event.pointerId) return; + event.stopPropagation(); + const nextRect = draftRect; + setDragState(null); + if (!nextRect || !imageRect) return; + if (nextRect.width * imageRect.width < 18 || nextRect.height * imageRect.height < 18) { + return; + } + const index = regions.length; + const regionId = makeId("local-edit-region"); + const nextRegions = withReindexedLocalEditRegions([ + ...regions, + { + color: LOCAL_EDIT_COLORS[index % LOCAL_EDIT_COLORS.length], + createdAt: Date.now(), + id: regionId, + index: index + 1, + prompt: "", + rect: nextRect, + }, + ]); + onRegionsChange(nextRegions); + setEditingRegionId(regionId); + }} + ref={overlayRef} + > + {imageRect ? ( +
+
+ {regions.map((region) => { + const labelBelow = region.rect.y + region.rect.height < 0.82; + const isEditing = editingRegionId === region.id || !region.prompt.trim(); + const regionLeft = `${region.rect.x * 100}%`; + const regionTop = `${region.rect.y * 100}%`; + const regionWidth = `${region.rect.width * 100}%`; + const regionHeight = `${region.rect.height * 100}%`; + return ( +
+
+ + {region.index} + +
event.stopPropagation()} + > +
+ 区域 {region.index} + +
+ {isEditing ? ( +