diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index 87eda22a8..d2a82042c 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -90,6 +90,24 @@ import { sanitizeFileName, triggerBrowserDownload, } from "@/components/canvas/nodes/image-node/image-node-utils"; +import { + createLocalEditPromptDraftMap, + createLocalEditReferenceImageId, + createLocalEditRegionId, + getLocalEditRegionsFromMetadata, + getNormalizedRectOverlayStyle, + isHttpImageUrl, + LOCAL_EDIT_COLORS, + LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION, + LOCAL_EDIT_MAX_REGIONS, + LOCAL_EDIT_MAX_POLL_ATTEMPTS, + LOCAL_EDIT_METADATA_KEY, + LOCAL_EDIT_MIN_EDGE_PX, + LOCAL_EDIT_POLL_INTERVAL_MS, + reindexLocalEditRegions, + type LocalEditReferenceImage, + type LocalEditRegion, +} from "@/components/canvas/nodes/image-node/image-node-local-edit"; import { PromptRegenerateDialog, ThreeDGenerationDialog, @@ -248,42 +266,8 @@ type AnglePreviewDragState = { startVerticalAngle: number; }; -interface LocalEditReferenceImage { - id: string; - url: string; - name?: string; - source?: "upload" | "canvas"; - createdAt: number; -} - -interface LocalEditRegion { - id: string; - index: number; - color: string; - prompt: string; - rect: NormalizedCropRect; - referenceImages?: LocalEditReferenceImage[]; - createdAt: number; -} - const BOX_CUTOUT_DEFAULT_PADDING_RATIO = 0.08; const BOX_CUTOUT_MIN_EDGE_PX = 24; -const LOCAL_EDIT_METADATA_KEY = "localEditRegions"; -const LOCAL_EDIT_MAX_REGIONS = 8; -const LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION = 3; -const LOCAL_EDIT_MIN_EDGE_PX = BOX_CUTOUT_MIN_EDGE_PX; -const LOCAL_EDIT_POLL_INTERVAL_MS = 2500; -const LOCAL_EDIT_MAX_POLL_ATTEMPTS = 180; -const LOCAL_EDIT_COLORS = [ - "#ef4444", - "#2563eb", - "#16a34a", - "#f97316", - "#a855f7", - "#06b6d4", - "#eab308", - "#ec4899", -]; const isLocalEditControlTarget = (target: EventTarget | null) => target instanceof HTMLElement && Boolean(target.closest('[data-local-edit-control="true"]')); @@ -446,134 +430,6 @@ const normalizeTextEditItems = ( .filter((item): item is ImageTextEditItem => !!item); }; -const createLocalEditRegionId = () => - `local-edit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; -const createLocalEditReferenceImageId = () => - `local-edit-ref-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - -const normalizeLocalEditRect = (value: unknown): NormalizedCropRect | null => { - if (!value || typeof value !== "object") return null; - const rect = value as Partial; - const x = clampNumber(Number(rect.x), 0, 1); - const y = clampNumber(Number(rect.y), 0, 1); - const width = clampNumber(Number(rect.width), 0, 1 - x); - const height = clampNumber(Number(rect.height), 0, 1 - y); - if ( - !Number.isFinite(x) || - !Number.isFinite(y) || - !Number.isFinite(width) || - !Number.isFinite(height) || - width <= 0 || - height <= 0 - ) { - return null; - } - return { x, y, width, height }; -}; - -const normalizeLocalEditReferenceImages = ( - value: unknown -): LocalEditReferenceImage[] => { - if (!Array.isArray(value)) return []; - const references: LocalEditReferenceImage[] = []; - for (const item of value) { - if (!item || typeof item !== "object") continue; - const record = item as Record; - const url = typeof record.url === "string" ? record.url.trim() : ""; - if (!isHttpImageUrl(url)) continue; - const name = - typeof record.name === "string" && record.name.trim() - ? record.name.trim().slice(0, 80) - : ""; - references.push({ - id: - typeof record.id === "string" && record.id.trim() - ? record.id.trim() - : createLocalEditReferenceImageId(), - url, - ...(name ? { name } : {}), - source: record.source === "canvas" ? "canvas" : "upload", - createdAt: - typeof record.createdAt === "number" && Number.isFinite(record.createdAt) - ? record.createdAt - : Date.now(), - }); - if (references.length >= LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION) { - break; - } - } - return references; -}; - -const reindexLocalEditRegions = ( - regions: LocalEditRegion[] -): LocalEditRegion[] => - regions.map((region, index) => ({ - ...region, - index: index + 1, - color: region.color || LOCAL_EDIT_COLORS[index % LOCAL_EDIT_COLORS.length], - })); - -const normalizeLocalEditRegions = (value: unknown): LocalEditRegion[] => { - if (!Array.isArray(value)) return []; - const regions: LocalEditRegion[] = []; - value.forEach((item, index) => { - if (!item || typeof item !== "object") return; - const record = item as Record; - const rect = normalizeLocalEditRect(record.rect); - if (!rect) return; - regions.push({ - id: - typeof record.id === "string" && record.id.trim() - ? record.id.trim() - : createLocalEditRegionId(), - index: - typeof record.index === "number" && Number.isFinite(record.index) - ? record.index - : index + 1, - color: - typeof record.color === "string" && record.color.trim() - ? record.color.trim() - : LOCAL_EDIT_COLORS[index % LOCAL_EDIT_COLORS.length], - prompt: - typeof record.prompt === "string" ? record.prompt.trim() : "", - rect, - referenceImages: normalizeLocalEditReferenceImages(record.referenceImages), - createdAt: - typeof record.createdAt === "number" && Number.isFinite(record.createdAt) - ? record.createdAt - : Date.now(), - }); - }); - return reindexLocalEditRegions(regions.slice(0, LOCAL_EDIT_MAX_REGIONS)); -}; - -const getLocalEditRegionsFromMetadata = ( - metadata: Record | undefined -) => normalizeLocalEditRegions(metadata?.[LOCAL_EDIT_METADATA_KEY]); - -const createLocalEditPromptDraftMap = ( - regions: LocalEditRegion[], - draftSource?: Record -): Record => - Object.fromEntries( - regions.map((region) => [ - region.id, - draftSource?.[region.id] ?? region.prompt, - ]) - ); - -const getNormalizedRectOverlayStyle = ( - rect: NormalizedCropRect, - metrics: ImageDisplayMetrics -): ImageOverlayRectStyle => ({ - left: metrics.offsetX + rect.x * metrics.renderedWidth, - top: metrics.offsetY + rect.y * metrics.renderedHeight, - width: rect.width * metrics.renderedWidth, - height: rect.height * metrics.renderedHeight, -}); - -const isHttpImageUrl = (url?: string) => !!url && /^https?:\/\//i.test(url); const CHAT_ATTACHMENT_MAX_UPLOAD_BYTES = 30 * 1024 * 1024; const CHAT_ATTACHMENT_MAX_DATA_URL_LENGTH = 42 * 1024 * 1024; const CANVAS_3D_POLL_INTERVAL_MS = 2000; diff --git a/components/canvas/nodes/image-node/image-node-local-edit.ts b/components/canvas/nodes/image-node/image-node-local-edit.ts new file mode 100644 index 000000000..0a913d28a --- /dev/null +++ b/components/canvas/nodes/image-node/image-node-local-edit.ts @@ -0,0 +1,182 @@ +import type { NormalizedCropRect } from "@/services/canvas"; + +export type LocalEditReferenceImage = { + id: string; + url: string; + name?: string; + source?: "upload" | "canvas"; + createdAt: number; +}; + +export type LocalEditRegion = { + id: string; + index: number; + color: string; + prompt: string; + rect: NormalizedCropRect; + referenceImages?: LocalEditReferenceImage[]; + createdAt: number; +}; + +export type LocalEditOverlayMetrics = { + offsetX: number; + offsetY: number; + renderedWidth: number; + renderedHeight: number; +}; + +export type LocalEditOverlayRectStyle = { + left: number; + top: number; + width: number; + height: number; +}; + +export const LOCAL_EDIT_METADATA_KEY = "localEditRegions"; +export const LOCAL_EDIT_MAX_REGIONS = 8; +export const LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION = 3; +export const LOCAL_EDIT_MIN_EDGE_PX = 24; +export const LOCAL_EDIT_POLL_INTERVAL_MS = 2500; +export const LOCAL_EDIT_MAX_POLL_ATTEMPTS = 180; +export const LOCAL_EDIT_COLORS = [ + "#ef4444", + "#2563eb", + "#16a34a", + "#f97316", + "#a855f7", + "#06b6d4", + "#eab308", + "#ec4899", +]; + +const clampLocalEditNumber = (value: number, min: number, max: number) => + Math.min(max, Math.max(min, Number.isFinite(value) ? value : min)); + +export const isHttpImageUrl = (url?: string) => !!url && /^https?:\/\//i.test(url); + +export const createLocalEditRegionId = () => + `local-edit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + +export const createLocalEditReferenceImageId = () => + `local-edit-ref-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + +export const normalizeLocalEditRect = (value: unknown): NormalizedCropRect | null => { + if (!value || typeof value !== "object") return null; + const rect = value as Partial; + const x = clampLocalEditNumber(Number(rect.x), 0, 1); + const y = clampLocalEditNumber(Number(rect.y), 0, 1); + const width = clampLocalEditNumber(Number(rect.width), 0, 1 - x); + const height = clampLocalEditNumber(Number(rect.height), 0, 1 - y); + if ( + !Number.isFinite(x) || + !Number.isFinite(y) || + !Number.isFinite(width) || + !Number.isFinite(height) || + width <= 0 || + height <= 0 + ) { + return null; + } + return { x, y, width, height }; +}; + +export const normalizeLocalEditReferenceImages = ( + value: unknown +): LocalEditReferenceImage[] => { + if (!Array.isArray(value)) return []; + const references: LocalEditReferenceImage[] = []; + for (const item of value) { + if (!item || typeof item !== "object") continue; + const record = item as Record; + const url = typeof record.url === "string" ? record.url.trim() : ""; + if (!isHttpImageUrl(url)) continue; + const name = + typeof record.name === "string" && record.name.trim() + ? record.name.trim().slice(0, 80) + : ""; + references.push({ + id: + typeof record.id === "string" && record.id.trim() + ? record.id.trim() + : createLocalEditReferenceImageId(), + url, + ...(name ? { name } : {}), + source: record.source === "canvas" ? "canvas" : "upload", + createdAt: + typeof record.createdAt === "number" && Number.isFinite(record.createdAt) + ? record.createdAt + : Date.now(), + }); + if (references.length >= LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION) { + break; + } + } + return references; +}; + +export const reindexLocalEditRegions = ( + regions: LocalEditRegion[] +): LocalEditRegion[] => + regions.map((region, index) => ({ + ...region, + index: index + 1, + color: region.color || LOCAL_EDIT_COLORS[index % LOCAL_EDIT_COLORS.length], + })); + +export const normalizeLocalEditRegions = (value: unknown): LocalEditRegion[] => { + if (!Array.isArray(value)) return []; + const regions: LocalEditRegion[] = []; + value.forEach((item, index) => { + if (!item || typeof item !== "object") return; + const record = item as Record; + const rect = normalizeLocalEditRect(record.rect); + if (!rect) return; + regions.push({ + id: + typeof record.id === "string" && record.id.trim() + ? record.id.trim() + : createLocalEditRegionId(), + index: + typeof record.index === "number" && Number.isFinite(record.index) + ? record.index + : index + 1, + color: + typeof record.color === "string" && record.color.trim() + ? record.color.trim() + : LOCAL_EDIT_COLORS[index % LOCAL_EDIT_COLORS.length], + prompt: typeof record.prompt === "string" ? record.prompt.trim() : "", + rect, + referenceImages: normalizeLocalEditReferenceImages(record.referenceImages), + createdAt: + typeof record.createdAt === "number" && Number.isFinite(record.createdAt) + ? record.createdAt + : Date.now(), + }); + }); + return reindexLocalEditRegions(regions.slice(0, LOCAL_EDIT_MAX_REGIONS)); +}; + +export const getLocalEditRegionsFromMetadata = ( + metadata: Record | undefined +) => normalizeLocalEditRegions(metadata?.[LOCAL_EDIT_METADATA_KEY]); + +export const createLocalEditPromptDraftMap = ( + regions: LocalEditRegion[], + draftSource?: Record +): Record => + Object.fromEntries( + regions.map((region) => [ + region.id, + draftSource?.[region.id] ?? region.prompt, + ]) + ); + +export const getNormalizedRectOverlayStyle = ( + rect: NormalizedCropRect, + metrics: LocalEditOverlayMetrics +): LocalEditOverlayRectStyle => ({ + left: metrics.offsetX + rect.x * metrics.renderedWidth, + top: metrics.offsetY + rect.y * metrics.renderedHeight, + width: rect.width * metrics.renderedWidth, + height: rect.height * metrics.renderedHeight, +});