feat: add reference images to local edit regions
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-05-20 11:01:36 +08:00
parent e415de06d8
commit 4c86e01956
3 changed files with 474 additions and 158 deletions

View File

@ -30,6 +30,12 @@ type LocalEditRegionInput = {
color?: string; color?: string;
prompt?: string; prompt?: string;
rect?: LocalEditRect; rect?: LocalEditRect;
referenceImages?: Array<{
id?: string;
url?: string;
name?: string;
source?: string;
}>;
}; };
type LocalEditRequest = { type LocalEditRequest = {
@ -47,6 +53,12 @@ type NormalizedLocalEditRegion = {
color: string; color: string;
colorName: string; colorName: string;
prompt: string; prompt: string;
referenceImages: Array<{
id: string;
url: string;
name?: string;
source: "upload" | "canvas";
}>;
rect: { rect: {
x: number; x: number;
y: number; y: number;
@ -62,6 +74,7 @@ type NormalizedLocalEditRegion = {
}; };
const MAX_LOCAL_EDIT_REGIONS = 8; const MAX_LOCAL_EDIT_REGIONS = 8;
const MAX_REFERENCE_IMAGES_PER_REGION = 3;
const MIN_REGION_EDGE_PX = 24; const MIN_REGION_EDGE_PX = 24;
const DEFAULT_REGION_COLORS = [ const DEFAULT_REGION_COLORS = [
"#ff3b30", "#ff3b30",
@ -100,6 +113,41 @@ function normalizeHexColor(input: unknown, fallback: string) {
return /^#[0-9a-f]{6}$/i.test(expanded) ? expanded.toLowerCase() : fallback; return /^#[0-9a-f]{6}$/i.test(expanded) ? expanded.toLowerCase() : fallback;
} }
function normalizeHttpImageUrl(input: unknown) {
const raw = String(input || "").trim();
if (!/^https?:\/\//i.test(raw)) return "";
return raw;
}
function normalizeRegionReferenceImages(
region: LocalEditRegionInput,
itemIndex: number
): NormalizedLocalEditRegion["referenceImages"] {
if (!Array.isArray(region.referenceImages)) return [];
return region.referenceImages
.map((reference, referenceIndex) => {
const url = normalizeHttpImageUrl(reference?.url);
if (!url) return null;
const name =
typeof reference?.name === "string" && reference.name.trim()
? reference.name.trim().slice(0, 80)
: "";
return {
id:
typeof reference?.id === "string" && reference.id.trim()
? reference.id.trim()
: `region-${itemIndex + 1}-reference-${referenceIndex + 1}`,
url,
...(name ? { name } : {}),
source: reference?.source === "canvas" ? "canvas" as const : "upload" as const,
};
})
.filter((item): item is NormalizedLocalEditRegion["referenceImages"][number] =>
Boolean(item)
)
.slice(0, MAX_REFERENCE_IMAGES_PER_REGION);
}
function getColorName(hex: string) { function getColorName(hex: string) {
const normalized = hex.toLowerCase(); const normalized = hex.toLowerCase();
const names: Record<string, string> = { const names: Record<string, string> = {
@ -203,6 +251,7 @@ function normalizeRegions(
color, color,
colorName: getColorName(color), colorName: getColorName(color),
prompt: prompt.slice(0, 300), prompt: prompt.slice(0, 300),
referenceImages: normalizeRegionReferenceImages(region, itemIndex),
rect, rect,
pixelRect, pixelRect,
}; };
@ -278,18 +327,32 @@ function buildLocalEditPrompt(regions: NormalizedLocalEditRegion[]) {
const top = Math.round(region.rect.y * 1000) / 10; const top = Math.round(region.rect.y * 1000) / 10;
const width = Math.round(region.rect.width * 1000) / 10; const width = Math.round(region.rect.width * 1000) / 10;
const height = Math.round(region.rect.height * 1000) / 10; const height = Math.round(region.rect.height * 1000) / 10;
return `区域 ${region.index}(${region.colorName},编号 ${region.index},原图位置 x=${left}%,y=${top}%,w=${width}%,h=${height}%):${region.prompt}`; const referenceNote =
region.referenceImages.length > 0
? `;该区域有 ${region.referenceImages.length} 张专属参考图,依次为后续输入图片中的“区域 ${region.index} 参考图”,只可用于该区域`
: "";
return `区域 ${region.index}(${region.colorName},编号 ${region.index},原图位置 x=${left}%,y=${top}%,w=${width}%,h=${height}%${referenceNote}):${region.prompt}`;
}) })
.join("\n"); .join("\n");
const referenceLines = regions
.flatMap((region) =>
region.referenceImages.map(
(reference, referenceIndex) =>
`区域 ${region.index} 参考图 ${referenceIndex + 1}:${reference.name || "未命名参考图"},只能影响区域 ${region.index}`
)
)
.join("\n");
return [ return [
"输入图片1是原始干净图,也是最终输出必须继承的唯一画面基底。", "输入图片1是原始干净图,也是最终输出必须继承的唯一画面基底。",
"输入图片2是同一张图的彩色标注说明图,只用于理解编辑区域位置和区域提示词,不是画面参考。", "输入图片2是同一张图的彩色标注说明图,只用于理解编辑区域位置和区域提示词,不是画面参考。",
"输入图片3及之后如存在,是各区域专属参考图;专属参考图只服务于对应区域,不得改变其它区域或整张图的主体身份。",
"请不要复刻、保留、描摹或生成输入图片2里的任何彩色框、编号圆点、提示词气泡、边框线、半透明色块或标注文字。", "请不要复刻、保留、描摹或生成输入图片2里的任何彩色框、编号圆点、提示词气泡、边框线、半透明色块或标注文字。",
"如果输入图片2的标注元素与输入图片1冲突,必须完全忽略标注元素,只保留输入图片1的真实画面内容。", "如果输入图片2的标注元素与输入图片1冲突,必须完全忽略标注元素,只保留输入图片1的真实画面内容。",
"", "",
"请严格根据每个区域下方的提示词,只在对应彩色框附近完成局部编辑:", "请严格根据每个区域下方的提示词,只在对应彩色框附近完成局部编辑:",
regionLines, regionLines,
referenceLines ? `\n区域专属参考图说明:\n${referenceLines}` : "",
"", "",
"最终输出必须是干净照片:不得出现任何彩色框、蓝框、绿框、红框、矩形边框、编号、提示词气泡、标注文字或 UI 元素。", "最终输出必须是干净照片:不得出现任何彩色框、蓝框、绿框、红框、矩形边框、编号、提示词气泡、标注文字或 UI 元素。",
"未被彩色框标注的区域必须尽量保持原图不变。", "未被彩色框标注的区域必须尽量保持原图不变。",
@ -368,6 +431,9 @@ export async function POST(request: NextRequest) {
toArrayBuffer(annotatedBuffer) toArrayBuffer(annotatedBuffer)
); );
const prompt = buildLocalEditPrompt(regions); const prompt = buildLocalEditPrompt(regions);
const regionReferenceImageUrls = regions.flatMap((region) =>
region.referenceImages.map((reference) => reference.url)
);
const imageRequest: CanvasGenerateImageRequest = { const imageRequest: CanvasGenerateImageRequest = {
aspectRatio: aspectRatio:
@ -378,6 +444,7 @@ export async function POST(request: NextRequest) {
debugLabel: "canvas-local-edit", debugLabel: "canvas-local-edit",
debugMeta: { debugMeta: {
annotatedReferenceUrl: annotatedUpload.url, annotatedReferenceUrl: annotatedUpload.url,
regionReferenceImageCount: regionReferenceImageUrls.length,
regionCount: regions.length, regionCount: regions.length,
sourceNodeId: body.sourceNodeId || null, sourceNodeId: body.sourceNodeId || null,
}, },
@ -394,7 +461,11 @@ export async function POST(request: NextRequest) {
"彩色框、蓝色边框、绿色边框、红色边框、矩形框、编号圆点、提示词气泡、标注文字、UI 控件、半透明色块、选区边框", "彩色框、蓝色边框、绿色边框、红色边框、矩形框、编号圆点、提示词气泡、标注文字、UI 控件、半透明色块、选区边框",
prompt, prompt,
promptExtend: false, promptExtend: false,
referenceImages: [sourceImageUrl, annotatedUpload.url], referenceImages: [
sourceImageUrl,
annotatedUpload.url,
...regionReferenceImageUrls,
],
ownerKey: session.user.id, ownerKey: session.user.id,
}; };

View File

@ -248,12 +248,21 @@ interface BoxCutoutPoint {
y: number; y: number;
} }
interface LocalEditReferenceImage {
id: string;
url: string;
name?: string;
source?: "upload" | "canvas";
createdAt: number;
}
interface LocalEditRegion { interface LocalEditRegion {
id: string; id: string;
index: number; index: number;
color: string; color: string;
prompt: string; prompt: string;
rect: NormalizedCropRect; rect: NormalizedCropRect;
referenceImages?: LocalEditReferenceImage[];
createdAt: number; createdAt: number;
} }
@ -261,6 +270,7 @@ const BOX_CUTOUT_DEFAULT_PADDING_RATIO = 0.08;
const BOX_CUTOUT_MIN_EDGE_PX = 24; const BOX_CUTOUT_MIN_EDGE_PX = 24;
const LOCAL_EDIT_METADATA_KEY = "localEditRegions"; const LOCAL_EDIT_METADATA_KEY = "localEditRegions";
const LOCAL_EDIT_MAX_REGIONS = 8; 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_MIN_EDGE_PX = BOX_CUTOUT_MIN_EDGE_PX;
const LOCAL_EDIT_POLL_INTERVAL_MS = 2500; const LOCAL_EDIT_POLL_INTERVAL_MS = 2500;
const LOCAL_EDIT_MAX_POLL_ATTEMPTS = 180; const LOCAL_EDIT_MAX_POLL_ATTEMPTS = 180;
@ -875,6 +885,8 @@ const normalizeTextEditItems = (
const createLocalEditRegionId = () => const createLocalEditRegionId = () =>
`local-edit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; `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 => { const normalizeLocalEditRect = (value: unknown): NormalizedCropRect | null => {
if (!value || typeof value !== "object") return null; if (!value || typeof value !== "object") return null;
@ -896,6 +908,40 @@ const normalizeLocalEditRect = (value: unknown): NormalizedCropRect | null => {
return { x, y, width, height }; 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<string, unknown>;
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 = ( const reindexLocalEditRegions = (
regions: LocalEditRegion[] regions: LocalEditRegion[]
): LocalEditRegion[] => ): LocalEditRegion[] =>
@ -907,38 +953,36 @@ const reindexLocalEditRegions = (
const normalizeLocalEditRegions = (value: unknown): LocalEditRegion[] => { const normalizeLocalEditRegions = (value: unknown): LocalEditRegion[] => {
if (!Array.isArray(value)) return []; if (!Array.isArray(value)) return [];
return reindexLocalEditRegions( const regions: LocalEditRegion[] = [];
value value.forEach((item, index) => {
.map((item, index) => { if (!item || typeof item !== "object") return;
if (!item || typeof item !== "object") return null; const record = item as Record<string, unknown>;
const record = item as Record<string, unknown>; const rect = normalizeLocalEditRect(record.rect);
const rect = normalizeLocalEditRect(record.rect); if (!rect) return;
if (!rect) return null; regions.push({
return { id:
id: typeof record.id === "string" && record.id.trim()
typeof record.id === "string" && record.id.trim() ? record.id.trim()
? record.id.trim() : createLocalEditRegionId(),
: createLocalEditRegionId(), index:
index: typeof record.index === "number" && Number.isFinite(record.index)
typeof record.index === "number" && Number.isFinite(record.index) ? record.index
? record.index : index + 1,
: index + 1, color:
color: typeof record.color === "string" && record.color.trim()
typeof record.color === "string" && record.color.trim() ? record.color.trim()
? record.color.trim() : LOCAL_EDIT_COLORS[index % LOCAL_EDIT_COLORS.length],
: LOCAL_EDIT_COLORS[index % LOCAL_EDIT_COLORS.length], prompt:
prompt: typeof record.prompt === "string" ? record.prompt.trim() : "",
typeof record.prompt === "string" ? record.prompt.trim() : "", rect,
rect, referenceImages: normalizeLocalEditReferenceImages(record.referenceImages),
createdAt: createdAt:
typeof record.createdAt === "number" && Number.isFinite(record.createdAt) typeof record.createdAt === "number" && Number.isFinite(record.createdAt)
? record.createdAt ? record.createdAt
: Date.now(), : Date.now(),
} satisfies LocalEditRegion; });
}) });
.filter((item): item is LocalEditRegion => !!item) return reindexLocalEditRegions(regions.slice(0, LOCAL_EDIT_MAX_REGIONS));
.slice(0, LOCAL_EDIT_MAX_REGIONS)
);
}; };
const getLocalEditRegionsFromMetadata = ( const getLocalEditRegionsFromMetadata = (
@ -1838,6 +1882,10 @@ export function ImageNode({
const [editingLocalEditRegionId, setEditingLocalEditRegionId] = const [editingLocalEditRegionId, setEditingLocalEditRegionId] =
useState<string | null>(null); useState<string | null>(null);
const [isSubmittingLocalEdit, setIsSubmittingLocalEdit] = useState(false); const [isSubmittingLocalEdit, setIsSubmittingLocalEdit] = useState(false);
const [
uploadingLocalEditReferenceRegionId,
setUploadingLocalEditReferenceRegionId,
] = useState<string | null>(null);
const anglePreviewDragRef = useRef<AnglePreviewDragState | null>(null); const anglePreviewDragRef = useRef<AnglePreviewDragState | null>(null);
const activeImageAdjustments = imageAdjustPanelOpen const activeImageAdjustments = imageAdjustPanelOpen
? imageAdjustmentsDraft ? imageAdjustmentsDraft
@ -2384,9 +2432,13 @@ export function ImageNode({
const width = Number(style.width || 0); const width = Number(style.width || 0);
const height = Number(style.height || 0); const height = Number(style.height || 0);
const imageBottom = boxCutoutMetrics.offsetY + boxCutoutMetrics.renderedHeight; const imageBottom = boxCutoutMetrics.offsetY + boxCutoutMetrics.renderedHeight;
const placeAbove = top + height + 58 > imageBottom; const referenceCount = region.referenceImages?.length || 0;
const promptTop = placeAbove ? top - 46 : top + height + 8; const promptStackHeight = referenceCount > 0 ? 82 : 42;
const promptWidth = Math.min(Math.max(width, 176), 300); const placeAbove = top + height + promptStackHeight + 14 > imageBottom;
const promptTop = placeAbove
? top - promptStackHeight - 8
: top + height + 8;
const promptWidth = Math.min(Math.max(width, 236), 360);
return { return {
region, region,
@ -2467,6 +2519,7 @@ export function ImageNode({
setLocalEditDragEnd(null); setLocalEditDragEnd(null);
setLocalEditRegionInteraction(null); setLocalEditRegionInteraction(null);
setEditingLocalEditRegionId(null); setEditingLocalEditRegionId(null);
setUploadingLocalEditReferenceRegionId(null);
}, []); }, []);
const focusLocalEditPromptInput = useCallback((regionId: string) => { const focusLocalEditPromptInput = useCallback((regionId: string) => {
@ -3402,6 +3455,7 @@ export function ImageNode({
LOCAL_EDIT_COLORS[localEditRegions.length % LOCAL_EDIT_COLORS.length], LOCAL_EDIT_COLORS[localEditRegions.length % LOCAL_EDIT_COLORS.length],
prompt: "", prompt: "",
rect: nextRect, rect: nextRect,
referenceImages: [],
createdAt: Date.now(), createdAt: Date.now(),
}; };
commitLocalEditRegions([...localEditRegions, nextRegion]); commitLocalEditRegions([...localEditRegions, nextRegion]);
@ -3618,6 +3672,95 @@ export function ImageNode({
[commitLocalEditRegions, editingLocalEditRegionId] [commitLocalEditRegions, editingLocalEditRegionId]
); );
const handleLocalEditReferenceUpload = useCallback(
async (regionId: string, fileList: FileList | null) => {
const files = Array.from(fileList || []).filter((file) =>
file.type.startsWith("image/")
);
if (files.length === 0) return;
const currentRegion = localEditRegionsRef.current.find(
(region) => region.id === regionId
);
if (!currentRegion) return;
const currentReferences = currentRegion.referenceImages || [];
const remainingSlots =
LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION - currentReferences.length;
if (remainingSlots <= 0) {
toast.info(
`每个局部编辑区域最多添加 ${LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION} 张参考图`
);
return;
}
const selectedFiles = files.slice(0, remainingSlots);
if (files.length > selectedFiles.length) {
toast.info(
`已自动保留前 ${LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION} 张参考图`
);
}
setUploadingLocalEditReferenceRegionId(regionId);
try {
const uploadedReferences = await Promise.all(
selectedFiles.map(async (file) => {
const uploaded = await uploadChatAttachmentBlob(file, file.name);
return {
id: createLocalEditReferenceImageId(),
url: uploaded.url,
name: file.name,
source: "upload" as const,
createdAt: Date.now(),
} satisfies LocalEditReferenceImage;
})
);
commitLocalEditRegions(
localEditRegionsRef.current.map((region) =>
region.id === regionId
? {
...region,
referenceImages: [
...(region.referenceImages || []),
...uploadedReferences,
].slice(0, LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION),
}
: region
)
);
toast.success(
uploadedReferences.length > 1
? `已添加 ${uploadedReferences.length} 张区域参考图`
: "已添加区域参考图"
);
} catch (error) {
toast.error(error instanceof Error ? error.message : "参考图上传失败");
} finally {
setUploadingLocalEditReferenceRegionId(null);
}
},
[commitLocalEditRegions]
);
const removeLocalEditReferenceImage = useCallback(
(regionId: string, referenceId: string) => {
commitLocalEditRegions(
localEditRegionsRef.current.map((region) =>
region.id === regionId
? {
...region,
referenceImages: (region.referenceImages || []).filter(
(reference) => reference.id !== referenceId
),
}
: region
)
);
},
[commitLocalEditRegions]
);
const clearLocalEditRegions = useCallback(() => { const clearLocalEditRegions = useCallback(() => {
localEditPromptDraftRefs.current = {}; localEditPromptDraftRefs.current = {};
commitLocalEditRegions([]); commitLocalEditRegions([]);
@ -3696,6 +3839,12 @@ export function ImageNode({
color: region.color, color: region.color,
prompt: region.prompt, prompt: region.prompt,
rect: region.rect, rect: region.rect,
referenceImages: (region.referenceImages || []).map((reference) => ({
id: reference.id,
url: reference.url,
name: reference.name,
source: reference.source,
})),
})), })),
}); });
@ -8366,7 +8515,7 @@ export function ImageNode({
localEditPromptPortalOverlays.map(({ region, isEditing, style }) => localEditPromptPortalOverlays.map(({ region, isEditing, style }) =>
createPortal( createPortal(
<div <div
className="nodrag nopan nowheel fixed z-[10000] flex items-center gap-1.5" className="nodrag nopan nowheel fixed z-[10000] flex flex-col items-stretch gap-1.5"
data-canvas-no-drag="true" data-canvas-no-drag="true"
data-local-edit-control="true" data-local-edit-control="true"
key={region.id} key={region.id}
@ -8378,110 +8527,190 @@ export function ImageNode({
onPointerDownCapture={(event) => event.stopPropagation()} onPointerDownCapture={(event) => event.stopPropagation()}
style={style} style={style}
> >
{isEditing ? ( <div className="flex items-center gap-1.5">
<input {isEditing ? (
autoFocus={editingLocalEditRegionId === region.id} <input
className="nodrag nopan nowheel h-9 min-w-0 flex-1 rounded-full border border-slate-300 bg-white/95 px-3 text-[12px] font-medium text-slate-950 caret-slate-950 shadow-[0_10px_24px_rgba(15,23,42,0.14)] outline-none ring-2 ring-transparent transition placeholder:text-slate-400 focus:border-slate-950 focus:ring-slate-950/15 dark:border-white/20 dark:bg-slate-950/88 dark:text-white dark:caret-white dark:placeholder:text-white/42 dark:focus:border-white/40 dark:focus:ring-white/18" autoFocus={editingLocalEditRegionId === region.id}
data-canvas-no-drag="true" className="nodrag nopan nowheel h-9 min-w-0 flex-1 rounded-full border border-slate-300 bg-white/95 px-3 text-[12px] font-medium text-slate-950 caret-slate-950 shadow-[0_10px_24px_rgba(15,23,42,0.14)] outline-none ring-2 ring-transparent transition placeholder:text-slate-400 focus:border-slate-950 focus:ring-slate-950/15 dark:border-white/20 dark:bg-slate-950/88 dark:text-white dark:caret-white dark:placeholder:text-white/42 dark:focus:border-white/40 dark:focus:ring-white/18"
data-local-edit-control="true" data-canvas-no-drag="true"
defaultValue={ data-local-edit-control="true"
localEditPromptDraftRefs.current[region.id] ?? region.prompt defaultValue={
} localEditPromptDraftRefs.current[region.id] ?? region.prompt
onBlur={(event) => {
const nextPrompt = event.currentTarget.value;
localEditPromptDraftRefs.current[region.id] = nextPrompt;
persistLocalEditRegionPrompt(region.id, nextPrompt);
if (isLocalEditControlTarget(event.relatedTarget)) {
return;
} }
window.setTimeout(() => { onBlur={(event) => {
if (isLocalEditControlTarget(document.activeElement)) { const nextPrompt = event.currentTarget.value;
localEditPromptDraftRefs.current[region.id] = nextPrompt;
persistLocalEditRegionPrompt(region.id, nextPrompt);
if (isLocalEditControlTarget(event.relatedTarget)) {
return; return;
} }
setEditingLocalEditRegionId((current) => { window.setTimeout(() => {
if (current === region.id) { if (isLocalEditControlTarget(document.activeElement)) {
focusLocalEditPromptInput(region.id); return;
} }
return current; setEditingLocalEditRegionId((current) => {
}); if (current === region.id) {
}, 80); focusLocalEditPromptInput(region.id);
}} }
onClick={(event) => { return current;
event.nativeEvent.stopImmediatePropagation?.(); });
event.stopPropagation(); }, 80);
event.currentTarget.focus({ preventScroll: true }); }}
}} onClick={(event) => {
onFocus={(event) => { event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation(); event.stopPropagation();
setEditingLocalEditRegionId((current) => event.currentTarget.focus({ preventScroll: true });
current === region.id ? current : region.id }}
); onFocus={(event) => {
}} event.stopPropagation();
onInput={(event) => { setEditingLocalEditRegionId((current) =>
localEditPromptDraftRefs.current[region.id] = current === region.id ? current : region.id
event.currentTarget.value; );
}} }}
onInput={(event) => {
localEditPromptDraftRefs.current[region.id] =
event.currentTarget.value;
}}
onChange={(event) => {
localEditPromptDraftRefs.current[region.id] =
event.currentTarget.value;
}}
onKeyDown={(event) => {
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
if (event.key === "Enter") {
event.preventDefault();
persistLocalEditRegionPrompt(
region.id,
event.currentTarget.value
);
setEditingLocalEditRegionId(null);
}
if (event.key === "Escape") {
event.preventDefault();
persistLocalEditRegionPrompt(
region.id,
event.currentTarget.value
);
setEditingLocalEditRegionId(null);
}
}}
onKeyUp={(event) => {
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
}}
onMouseDown={(event) => {
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
}}
onMouseDownCapture={(event) => {
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
event.currentTarget.focus({ preventScroll: true });
}}
onPointerDown={(event) => {
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
}}
onPointerDownCapture={(event) => {
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
event.currentTarget.focus({ preventScroll: true });
}}
placeholder={`区域 ${region.index} 提示词`}
ref={(input) => {
localEditPromptInputRefs.current[region.id] = input;
}}
/>
) : (
<button
className="nodrag nopan nowheel h-9 min-w-0 flex-1 truncate rounded-full border border-slate-200 bg-white/95 px-3 text-left text-[12px] font-semibold text-slate-900 shadow-[0_10px_24px_rgba(15,23,42,0.14)] hover:bg-slate-50 dark:border-white/18 dark:bg-slate-950/82 dark:text-white dark:shadow-[0_10px_24px_rgba(0,0,0,0.24)] dark:hover:bg-slate-900"
data-canvas-no-drag="true"
data-local-edit-control="true"
onClick={(event) => {
event.preventDefault();
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
setEditingLocalEditRegionId(region.id);
}}
onMouseDown={(event) => {
event.preventDefault();
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
}}
onPointerDown={(event) => {
event.preventDefault();
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
setEditingLocalEditRegionId(region.id);
}}
title={region.prompt}
type="button"
>
{region.index}. {region.prompt}
</button>
)}
<input
accept="image/jpeg,image/png,image/webp"
className="hidden"
data-canvas-no-drag="true"
data-local-edit-control="true"
id={`local-edit-reference-upload-${region.id}`}
multiple
onChange={(event) => { onChange={(event) => {
localEditPromptDraftRefs.current[region.id] = void handleLocalEditReferenceUpload(
event.currentTarget.value; region.id,
}} event.currentTarget.files
onKeyDown={(event) => { );
event.nativeEvent.stopImmediatePropagation?.(); event.currentTarget.value = "";
event.stopPropagation();
if (event.key === "Enter") {
event.preventDefault();
persistLocalEditRegionPrompt(
region.id,
event.currentTarget.value
);
setEditingLocalEditRegionId(null);
}
if (event.key === "Escape") {
event.preventDefault();
persistLocalEditRegionPrompt(
region.id,
event.currentTarget.value
);
setEditingLocalEditRegionId(null);
}
}}
onKeyUp={(event) => {
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
}}
onMouseDown={(event) => {
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
}}
onMouseDownCapture={(event) => {
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
event.currentTarget.focus({ preventScroll: true });
}}
onPointerDown={(event) => {
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
}}
onPointerDownCapture={(event) => {
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
event.currentTarget.focus({ preventScroll: true });
}}
placeholder={`区域 ${region.index} 提示词`}
ref={(input) => {
localEditPromptInputRefs.current[region.id] = input;
}} }}
type="file"
/> />
) : (
<button <button
className="nodrag nopan nowheel h-9 min-w-0 flex-1 truncate rounded-full border border-slate-200 bg-white/95 px-3 text-left text-[12px] font-semibold text-slate-900 shadow-[0_10px_24px_rgba(15,23,42,0.14)] hover:bg-slate-50 dark:border-white/18 dark:bg-slate-950/82 dark:text-white dark:shadow-[0_10px_24px_rgba(0,0,0,0.24)] dark:hover:bg-slate-900" className="nodrag nopan nowheel flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-slate-200 bg-white/95 text-slate-600 shadow-[0_10px_24px_rgba(15,23,42,0.12)] hover:bg-slate-950 hover:text-white disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/18 dark:bg-slate-950/82 dark:text-white/72 dark:shadow-[0_10px_24px_rgba(0,0,0,0.24)] dark:hover:bg-white dark:hover:text-slate-950"
data-canvas-no-drag="true" data-canvas-no-drag="true"
data-local-edit-control="true" data-local-edit-control="true"
onClick={(event) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();
event.nativeEvent.stopImmediatePropagation?.(); event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation(); event.stopPropagation();
setEditingLocalEditRegionId(region.id); document
.getElementById(`local-edit-reference-upload-${region.id}`)
?.click();
}}
disabled={
uploadingLocalEditReferenceRegionId === region.id ||
(region.referenceImages?.length || 0) >=
LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION
}
onMouseDown={(event) => {
event.preventDefault();
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
}}
onPointerDown={(event) => {
event.preventDefault();
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
}}
title="添加区域参考图"
type="button"
>
{uploadingLocalEditReferenceRegionId === region.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<ImagePlus className="h-3.5 w-3.5" />
)}
</button>
<button
className="nodrag nopan nowheel flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-slate-200 bg-white/95 text-slate-500 shadow-[0_10px_24px_rgba(15,23,42,0.12)] hover:bg-red-500 hover:text-white dark:border-white/18 dark:bg-slate-950/82 dark:text-white/72 dark:shadow-[0_10px_24px_rgba(0,0,0,0.24)]"
data-canvas-no-drag="true"
data-local-edit-control="true"
onClick={(event) => {
event.preventDefault();
event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation();
deleteLocalEditRegion(region.id);
}} }}
onMouseDown={(event) => { onMouseDown={(event) => {
event.preventDefault(); event.preventDefault();
@ -8492,40 +8721,50 @@ export function ImageNode({
event.preventDefault(); event.preventDefault();
event.nativeEvent.stopImmediatePropagation?.(); event.nativeEvent.stopImmediatePropagation?.();
event.stopPropagation(); event.stopPropagation();
setEditingLocalEditRegionId(region.id); deleteLocalEditRegion(region.id);
}} }}
title={region.prompt} title="删除区域"
type="button" type="button"
> >
{region.index}. {region.prompt} <X className="h-3.5 w-3.5" />
</button> </button>
)} </div>
<button {region.referenceImages?.length ? (
className="nodrag nopan nowheel flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-slate-200 bg-white/95 text-slate-500 shadow-[0_10px_24px_rgba(15,23,42,0.12)] hover:bg-red-500 hover:text-white dark:border-white/18 dark:bg-slate-950/82 dark:text-white/72 dark:shadow-[0_10px_24px_rgba(0,0,0,0.24)]" <div className="flex max-w-full items-center gap-1 overflow-hidden rounded-2xl border border-slate-200 bg-white/92 px-1.5 py-1 shadow-[0_8px_20px_rgba(15,23,42,0.1)] dark:border-white/12 dark:bg-slate-950/80">
data-canvas-no-drag="true" <span className="shrink-0 px-1 text-[10px] font-semibold text-slate-500 dark:text-white/52">
data-local-edit-control="true" 参考
onClick={(event) => { </span>
event.preventDefault(); {region.referenceImages.map((reference) => (
event.nativeEvent.stopImmediatePropagation?.(); <div
event.stopPropagation(); className="group relative h-8 w-8 shrink-0 overflow-hidden rounded-lg border border-slate-200 bg-slate-100 dark:border-white/12 dark:bg-slate-800"
deleteLocalEditRegion(region.id); key={reference.id}
}} title={reference.name || "区域参考图"}
onMouseDown={(event) => { >
event.preventDefault(); <img
event.nativeEvent.stopImmediatePropagation?.(); alt={reference.name || "区域参考图"}
event.stopPropagation(); className="h-full w-full object-cover"
}} draggable={false}
onPointerDown={(event) => { src={reference.url}
event.preventDefault(); />
event.nativeEvent.stopImmediatePropagation?.(); <button
event.stopPropagation(); className="absolute inset-0 hidden items-center justify-center bg-black/52 text-white group-hover:flex"
deleteLocalEditRegion(region.id); data-canvas-no-drag="true"
}} data-local-edit-control="true"
title="删除区域" onClick={(event) => {
type="button" event.preventDefault();
> event.nativeEvent.stopImmediatePropagation?.();
<X className="h-3.5 w-3.5" /> event.stopPropagation();
</button> removeLocalEditReferenceImage(region.id, reference.id);
}}
title="移除参考图"
type="button"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
) : null}
</div>, </div>,
document.body document.body
) )

View File

@ -307,6 +307,12 @@ export const imageGenerationService = {
color: string; color: string;
prompt: string; prompt: string;
rect: NormalizedCropRect; rect: NormalizedCropRect;
referenceImages?: Array<{
id?: string;
url: string;
name?: string;
source?: "upload" | "canvas";
}>;
}>; }>;
model?: string; model?: string;
imageSize?: string; imageSize?: string;