文字编辑支持删除条目并传递到生图
This commit is contained in:
parent
18a3495d2c
commit
927514ad6b
@ -10,7 +10,8 @@ export const maxDuration = 120;
|
|||||||
const textItemSchema = z.object({
|
const textItemSchema = z.object({
|
||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
originalText: z.string().min(1),
|
originalText: z.string().min(1),
|
||||||
editedText: z.string().min(1),
|
editedText: z.string().optional(),
|
||||||
|
action: z.enum(["replace", "delete"]).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const requestSchema = z.object({
|
const requestSchema = z.object({
|
||||||
@ -56,22 +57,39 @@ function parseImageFromMarkdown(markdown: string): { originalUrl: string; thumbn
|
|||||||
throw new Error("未解析到生成图片");
|
throw new Error("未解析到生成图片");
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTextEditPrompt(replacements: Array<{ originalText: string; editedText: string }>) {
|
function buildTextEditPrompt(
|
||||||
const replacementLines = replacements
|
replacements: Array<{ originalText: string; editedText: string }>,
|
||||||
.map((item, index) => `${index + 1}. 原文: ${item.originalText}\n 替换为: ${item.editedText}`)
|
deletions: Array<{ originalText: string }>,
|
||||||
.join("\n");
|
) {
|
||||||
|
const sections: string[] = [
|
||||||
return [
|
|
||||||
"请基于输入图片执行精确文字替换。",
|
"请基于输入图片执行精确文字替换。",
|
||||||
"硬性要求:",
|
"硬性要求:",
|
||||||
"1. 仅替换下方列出的文字内容,不要修改其他主体、配色、材质、光影与构图。",
|
"1. 仅替换下方列出的文字内容,不要修改其他主体、配色、材质、光影与构图。",
|
||||||
"2. 必须保持原图排版、字重、笔画风格与字体形状尽可能一致,仅替换指定文字内容,其他视觉元素不变。",
|
"2. 必须保持原图排版、字重、笔画风格与字体形状尽可能一致,仅替换指定文字内容,其他视觉元素不变。",
|
||||||
"3. 输出图严格保持原图宽高比例,不裁切、不拉伸、不加边框。",
|
"3. 输出图严格保持原图宽高比例,不裁切、不拉伸、不加边框。",
|
||||||
"4. 若目标文案更长,优先微调字距或字号,确保自然贴合原位置。",
|
"4. 若目标文案更长,优先微调字距或字号,确保自然贴合原位置。",
|
||||||
"替换清单:",
|
];
|
||||||
replacementLines,
|
|
||||||
"请返回一张最终完成的图片。",
|
if (replacements.length > 0) {
|
||||||
].join("\n");
|
const replacementLines = replacements
|
||||||
|
.map((item, index) => `${index + 1}. 原文: ${item.originalText}\n 替换为: ${item.editedText}`)
|
||||||
|
.join("\n");
|
||||||
|
sections.push("替换清单:", replacementLines);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deletions.length > 0) {
|
||||||
|
const deletionLines = deletions
|
||||||
|
.map((item, index) => `${index + 1}. 删除文字: ${item.originalText}`)
|
||||||
|
.join("\n");
|
||||||
|
sections.push(
|
||||||
|
"删除清单:",
|
||||||
|
deletionLines,
|
||||||
|
"删除时请清除对应文字并自然修复背景,不保留残影或笔画痕迹。",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
sections.push("请返回一张最终完成的图片。");
|
||||||
|
return sections.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
@ -92,16 +110,41 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ success: false, error: "仅支持 URL 图片,不允许 base64 传输" }, { status: 400 });
|
return NextResponse.json({ success: false, error: "仅支持 URL 图片,不允许 base64 传输" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const replacements = parsed.data.items
|
const normalizedItems = parsed.data.items
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
originalText: item.originalText.trim(),
|
originalText: item.originalText.trim(),
|
||||||
editedText: item.editedText.trim(),
|
editedText: (item.editedText || "").trim(),
|
||||||
}))
|
action: item.action,
|
||||||
.filter((item) => item.originalText && item.editedText && item.originalText !== item.editedText);
|
}));
|
||||||
|
|
||||||
if (replacements.length === 0) {
|
const replacements = normalizedItems
|
||||||
return NextResponse.json({ success: false, error: "未检测到有效的文字改动" }, { status: 400 });
|
.filter(
|
||||||
|
(item) =>
|
||||||
|
item.originalText &&
|
||||||
|
item.editedText &&
|
||||||
|
item.action !== "delete" &&
|
||||||
|
item.originalText !== item.editedText,
|
||||||
|
)
|
||||||
|
.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
originalText: item.originalText,
|
||||||
|
editedText: item.editedText,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const deletions = normalizedItems
|
||||||
|
.filter(
|
||||||
|
(item) =>
|
||||||
|
item.originalText &&
|
||||||
|
(item.action === "delete" || item.editedText.length === 0),
|
||||||
|
)
|
||||||
|
.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
originalText: item.originalText,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (replacements.length === 0 && deletions.length === 0) {
|
||||||
|
return NextResponse.json({ success: false, error: "未检测到有效的文字改动或删除" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const models = await getEnabledAiModelsWithProvider();
|
const models = await getEnabledAiModelsWithProvider();
|
||||||
@ -116,7 +159,7 @@ export async function POST(request: Request) {
|
|||||||
apiKey: selected.provider.apiKey,
|
apiKey: selected.provider.apiKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
const prompt = buildTextEditPrompt(replacements);
|
const prompt = buildTextEditPrompt(replacements, deletions);
|
||||||
|
|
||||||
const result = await provider.chat(selected.model.modelId).doGenerate({
|
const result = await provider.chat(selected.model.modelId).doGenerate({
|
||||||
messages: [
|
messages: [
|
||||||
|
|||||||
@ -167,6 +167,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
const [textEditError, setTextEditError] = useState<string | null>(null);
|
const [textEditError, setTextEditError] = useState<string | null>(null);
|
||||||
const [isDetectingText, setIsDetectingText] = useState(false);
|
const [isDetectingText, setIsDetectingText] = useState(false);
|
||||||
const [isApplyingTextEdit, setIsApplyingTextEdit] = useState(false);
|
const [isApplyingTextEdit, setIsApplyingTextEdit] = useState(false);
|
||||||
|
const [deletedTextItems, setDeletedTextItems] = useState<Array<{ id: string; originalText: string }>>([]);
|
||||||
|
|
||||||
// 文件输入的 ref
|
// 文件输入的 ref
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
@ -201,6 +202,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!textEditPanelOpen) {
|
if (!textEditPanelOpen) {
|
||||||
setTextEditItems(normalizeTextEditItems(data.textEditItems));
|
setTextEditItems(normalizeTextEditItems(data.textEditItems));
|
||||||
|
setDeletedTextItems([]);
|
||||||
setTextEditError(null);
|
setTextEditError(null);
|
||||||
}
|
}
|
||||||
}, [data.textEditItems, textEditPanelOpen]);
|
}, [data.textEditItems, textEditPanelOpen]);
|
||||||
@ -333,6 +335,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
const cached = normalizeTextEditItems(data.textEditItems);
|
const cached = normalizeTextEditItems(data.textEditItems);
|
||||||
if (cached.length > 0) {
|
if (cached.length > 0) {
|
||||||
setTextEditItems(cached);
|
setTextEditItems(cached);
|
||||||
|
setDeletedTextItems([]);
|
||||||
setTextEditError(null);
|
setTextEditError(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -362,6 +365,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
.filter((item): item is ImageTextEditItem => !!item);
|
.filter((item): item is ImageTextEditItem => !!item);
|
||||||
|
|
||||||
setTextEditItems(detectedItems);
|
setTextEditItems(detectedItems);
|
||||||
|
setDeletedTextItems([]);
|
||||||
|
|
||||||
updateNode(node.id, {
|
updateNode(node.id, {
|
||||||
data: {
|
data: {
|
||||||
@ -395,6 +399,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
const cached = normalizeTextEditItems(data.textEditItems);
|
const cached = normalizeTextEditItems(data.textEditItems);
|
||||||
if (cached.length > 0 && data.textEditSource === data.src) {
|
if (cached.length > 0 && data.textEditSource === data.src) {
|
||||||
setTextEditItems(cached);
|
setTextEditItems(cached);
|
||||||
|
setDeletedTextItems([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -407,6 +412,19 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRemoveTextItem = (id: string) => {
|
||||||
|
setTextEditItems((prev) => {
|
||||||
|
const target = prev.find((item) => item.id === id);
|
||||||
|
if (target?.originalText) {
|
||||||
|
setDeletedTextItems((deletedPrev) => {
|
||||||
|
if (deletedPrev.some((item) => item.id === id)) return deletedPrev;
|
||||||
|
return [...deletedPrev, { id, originalText: target.originalText }];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return prev.filter((item) => item.id !== id);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleApplyTextEdit = useCallback(async () => {
|
const handleApplyTextEdit = useCallback(async () => {
|
||||||
if (!data.src) {
|
if (!data.src) {
|
||||||
toast.error("请先上传图片");
|
toast.error("请先上传图片");
|
||||||
@ -419,20 +437,34 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const changedItems: CanvasTextEditPayloadItem[] = textEditItems
|
const changedItems: CanvasTextEditPayloadItem[] = textEditItems
|
||||||
.map((item) => ({
|
.map((item) => {
|
||||||
|
const editedText = item.editedText.trim();
|
||||||
|
const action: "replace" | "delete" = editedText.length === 0 ? "delete" : "replace";
|
||||||
|
return {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
originalText: item.originalText.trim(),
|
originalText: item.originalText.trim(),
|
||||||
editedText: item.editedText.trim(),
|
editedText,
|
||||||
}))
|
action,
|
||||||
|
};
|
||||||
|
})
|
||||||
.filter(
|
.filter(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.originalText.length > 0 &&
|
item.originalText.length > 0 &&
|
||||||
item.editedText.length > 0 &&
|
((item.action === "delete") || (item.editedText && item.editedText.length > 0 && item.originalText !== item.editedText)),
|
||||||
item.originalText !== item.editedText,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (changedItems.length === 0) {
|
const removedItems: CanvasTextEditPayloadItem[] = deletedTextItems
|
||||||
toast.info("请先修改至少一段文字");
|
.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
originalText: item.originalText.trim(),
|
||||||
|
action: "delete" as const,
|
||||||
|
}))
|
||||||
|
.filter((item) => item.originalText.length > 0);
|
||||||
|
|
||||||
|
const requestItems = [...changedItems, ...removedItems];
|
||||||
|
|
||||||
|
if (requestItems.length === 0) {
|
||||||
|
toast.info("请先修改或删除至少一段文字");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -463,7 +495,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
try {
|
try {
|
||||||
const result = await imageGenerationService.editCanvasText({
|
const result = await imageGenerationService.editCanvasText({
|
||||||
imageUrl: data.src,
|
imageUrl: data.src,
|
||||||
items: changedItems,
|
items: requestItems,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.success || !result.data?.originalUrl) {
|
if (!result.success || !result.data?.originalUrl) {
|
||||||
@ -490,6 +522,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
setTextEditPanelOpen(false);
|
setTextEditPanelOpen(false);
|
||||||
|
setDeletedTextItems([]);
|
||||||
toast.success("文字替换已完成");
|
toast.success("文字替换已完成");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "文字替换失败";
|
const message = error instanceof Error ? error.message : "文字替换失败";
|
||||||
@ -507,7 +540,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
setIsApplyingTextEdit(false);
|
setIsApplyingTextEdit(false);
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
}
|
}
|
||||||
}, [addNode, data, isApplyingTextEdit, node.id, node.position.x, node.position.y, node.size.height, node.size.width, textEditItems, updateNode]);
|
}, [addNode, data, deletedTextItems, isApplyingTextEdit, node.id, node.position.x, node.position.y, node.size.height, node.size.width, textEditItems, updateNode]);
|
||||||
|
|
||||||
// 触发文件选择
|
// 触发文件选择
|
||||||
const handleSelectFile = (e: React.MouseEvent) => {
|
const handleSelectFile = (e: React.MouseEvent) => {
|
||||||
@ -1428,10 +1461,21 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
|
|
||||||
{textEditItems.map((item, index) => (
|
{textEditItems.map((item, index) => (
|
||||||
<div key={item.id} className="rounded-lg border border-border/70 p-3 space-y-2">
|
<div key={item.id} className="rounded-lg border border-border/70 p-3 space-y-2">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="text-[11px] text-muted-foreground">
|
<div className="text-[11px] text-muted-foreground">
|
||||||
文本 {index + 1}
|
文本 {index + 1}
|
||||||
{typeof item.confidence === "number" && ` · 置信度 ${(item.confidence * 100).toFixed(0)}%`}
|
{typeof item.confidence === "number" && ` · 置信度 ${(item.confidence * 100).toFixed(0)}%`}
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => handleRemoveTextItem(item.id)}
|
||||||
|
title="删除该条文字"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<div className="text-xs text-muted-foreground line-clamp-2">
|
<div className="text-xs text-muted-foreground line-clamp-2">
|
||||||
原文:{item.originalText}
|
原文:{item.originalText}
|
||||||
</div>
|
</div>
|
||||||
@ -1442,6 +1486,12 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{deletedTextItems.length > 0 && (
|
||||||
|
<div className="rounded-md border border-dashed border-orange-300 bg-orange-50/60 px-3 py-2 text-xs text-orange-700">
|
||||||
|
已标记删除 {deletedTextItems.length} 条文字,应用时会明确告诉生图模型移除这些文字并修复背景。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-2 px-4 py-3 border-t border-border/70">
|
<div className="flex items-center justify-between gap-2 px-4 py-3 border-t border-border/70">
|
||||||
@ -1456,7 +1506,11 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={isDetectingText || isApplyingTextEdit || textEditItems.length === 0}
|
disabled={
|
||||||
|
isDetectingText ||
|
||||||
|
isApplyingTextEdit ||
|
||||||
|
(textEditItems.length === 0 && deletedTextItems.length === 0)
|
||||||
|
}
|
||||||
onClick={handleApplyTextEdit}
|
onClick={handleApplyTextEdit}
|
||||||
>
|
>
|
||||||
{isApplyingTextEdit ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <Check className="h-3.5 w-3.5 mr-1" />}
|
{isApplyingTextEdit ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <Check className="h-3.5 w-3.5 mr-1" />}
|
||||||
|
|||||||
@ -17,7 +17,8 @@ export interface CanvasTextDetectItem {
|
|||||||
export interface CanvasTextEditPayloadItem {
|
export interface CanvasTextEditPayloadItem {
|
||||||
id: string;
|
id: string;
|
||||||
originalText: string;
|
originalText: string;
|
||||||
editedText: string;
|
editedText?: string;
|
||||||
|
action?: 'replace' | 'delete';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 画布服务
|
// 画布服务
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user