feat: add detail image scale adjust and processing scan

This commit is contained in:
134 2026-06-17 15:35:04 +08:00
parent 9fae6c6a62
commit 25af7e5526
3 changed files with 541 additions and 198 deletions

View File

@ -45,6 +45,8 @@ type LocalEditRequest = {
model?: string;
imageSize?: string;
aspectRatio?: string;
operation?: string;
scalePercent?: number;
};
type NormalizedLocalEditRegion = {
@ -76,6 +78,9 @@ type NormalizedLocalEditRegion = {
const MAX_LOCAL_EDIT_REGIONS = 8;
const MAX_REFERENCE_IMAGES_PER_REGION = 3;
const MIN_REGION_EDGE_PX = 24;
const SCALE_ADJUST_OPERATION = "scale-adjust";
const SCALE_ADJUST_PERCENT_OPTIONS = [-30, -20, -10, 10, 20, 30] as const;
type ScaleAdjustPercent = (typeof SCALE_ADJUST_PERCENT_OPTIONS)[number];
const DEFAULT_REGION_COLORS = [
"#ff3b30",
"#1677ff",
@ -201,6 +206,24 @@ function normalizeRegionReferenceImages(
.slice(0, MAX_REFERENCE_IMAGES_PER_REGION);
}
function resolveLocalEditOperation(input: unknown) {
return String(input || "").trim() === SCALE_ADJUST_OPERATION
? SCALE_ADJUST_OPERATION
: "local-edit";
}
function normalizeScaleAdjustPercent(input: unknown): ScaleAdjustPercent {
const value = Math.round(Number(input));
if ((SCALE_ADJUST_PERCENT_OPTIONS as readonly number[]).includes(value)) {
return value as ScaleAdjustPercent;
}
throw new Error("请选择有效的比例调整幅度");
}
function getScaleAdjustLabel(scalePercent: ScaleAdjustPercent) {
return scalePercent > 0 ? `放大 ${scalePercent}%` : `缩小 ${Math.abs(scalePercent)}%`;
}
function getColorName(hex: string) {
const normalized = hex.toLowerCase();
const names: Record<string, string> = {
@ -272,7 +295,8 @@ function wrapText(text: string, maxChars: number, maxLines = 3) {
function normalizeRegions(
rawRegions: LocalEditRegionInput[] | undefined,
sourceWidth: number,
sourceHeight: number
sourceHeight: number,
options: { requirePrompt?: boolean; fallbackPrompt?: string } = {}
): NormalizedLocalEditRegion[] {
if (!Array.isArray(rawRegions) || rawRegions.length === 0) {
throw new Error("请先框选至少一个局部编辑区域");
@ -287,7 +311,8 @@ function normalizeRegions(
throw new Error(`区域 ${itemIndex + 1} 缺少有效框选范围`);
}
const prompt = String(region.prompt || "").trim();
if (!prompt) {
const resolvedPrompt = prompt || String(options.fallbackPrompt || "").trim();
if (options.requirePrompt !== false && !resolvedPrompt) {
throw new Error(`区域 ${itemIndex + 1} 请填写局部编辑提示词`);
}
const pixelRect = rectToPixels(rect, sourceWidth, sourceHeight);
@ -303,7 +328,7 @@ function normalizeRegions(
index: itemIndex + 1,
color,
colorName: getColorName(color),
prompt: prompt.slice(0, 300),
prompt: resolvedPrompt.slice(0, 300),
referenceImages: normalizeRegionReferenceImages(region, itemIndex),
rect,
pixelRect,
@ -414,6 +439,40 @@ function buildLocalEditPrompt(regions: NormalizedLocalEditRegion[]) {
].join("\n");
}
function buildScaleAdjustPrompt(
regions: NormalizedLocalEditRegion[],
scalePercent: ScaleAdjustPercent
) {
const scaleFactor = Math.round((1 + scalePercent / 100) * 100) / 100;
const actionLabel = getScaleAdjustLabel(scalePercent);
const regionLines = regions
.map((region) => {
const left = Math.round(region.rect.x * 1000) / 10;
const top = Math.round(region.rect.y * 1000) / 10;
const width = Math.round(region.rect.width * 1000) / 10;
const height = Math.round(region.rect.height * 1000) / 10;
return `区域 ${region.index}(原图位置 x=${left}%,y=${top}%,w=${width}%,h=${height}%):识别该框选范围内的完整主体物体,以主体视觉中心为锚点执行整体等比${actionLabel},目标尺寸为原物体的 ${Math.round(scaleFactor * 100)}%。`;
})
.join("\n");
return [
"输入图片1是原始干净图,也是最终输出必须继承的唯一画面基底。",
"系统已根据用户框选区域计算出下方每个区域的原图相对坐标;彩色框只存在于产品界面里,不会作为最终画面内容。",
"最终结果必须保持输入图片1的原始画幅比例、构图边界、镜头透视、光线、色温、材质质感和清晰度。",
"请不要生成任何彩色框、矩形边框、编号圆点、提示词气泡、标注文字、UI 控件或半透明色块。",
"",
`任务:对用户框选的主体物体做“比例调整”,统一执行${actionLabel}。`,
regionLines,
"",
"关键要求:",
"1. 只调整框选主体物体的整体视觉比例,必须等比缩放,不得压扁、拉伸、扭曲、改变外观结构、改变材质、改变颜色、改变文字或图案。",
"2. 缩小时,主体应围绕原位置中心自然变小,露出的背景需要与原图环境、纹理、阴影和透视自然补全。",
"3. 放大时,主体应围绕原位置中心自然变大,边缘与遮挡关系要合理,不能变形,也不能超出画面造成突兀裁切。",
"4. 未被框选的其它画面区域必须保持原图不变,不允许重绘、改色、补光、移动物体或改变背景细节。",
"5. 最终输出必须是一张干净照片,不得出现任何选区框、说明文字、对比线、比例数字或 UI 元素。",
].join("\n");
}
export async function POST(request: NextRequest) {
try {
const session = await auth();
@ -440,9 +499,34 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ success: false, error: "无法识别原图尺寸" }, { status: 400 });
}
const operation = resolveLocalEditOperation(body.operation);
let scalePercent: ScaleAdjustPercent | null = null;
if (operation === SCALE_ADJUST_OPERATION) {
try {
scalePercent = normalizeScaleAdjustPercent(body.scalePercent);
} catch (validationError) {
return NextResponse.json(
{
success: false,
error:
validationError instanceof Error
? validationError.message
: "比例调整参数无效",
},
{ status: 400 }
);
}
}
let regions: NormalizedLocalEditRegion[];
try {
regions = normalizeRegions(body.regions, sourceWidth, sourceHeight);
regions = normalizeRegions(body.regions, sourceWidth, sourceHeight, {
fallbackPrompt:
operation === SCALE_ADJUST_OPERATION && scalePercent !== null
? `比例调整:${getScaleAdjustLabel(scalePercent)}`
: undefined,
requirePrompt: operation !== SCALE_ADJUST_OPERATION,
});
} catch (validationError) {
return NextResponse.json(
{
@ -460,9 +544,10 @@ export async function POST(request: NextRequest) {
const pointsCharge = await prepareFeaturePointsCharge({
userId: session.user.id,
unitCost: getImageToolCreditCost(pointsConfig, "repaint"),
reason: "image_repaint",
modelId: "canvas-local-edit",
note: "局部编辑消耗积分",
reason: operation === SCALE_ADJUST_OPERATION ? "image_scale_adjust" : "image_repaint",
modelId:
operation === SCALE_ADJUST_OPERATION ? "canvas-scale-adjust" : "canvas-local-edit",
note: operation === SCALE_ADJUST_OPERATION ? "比例调整消耗积分" : "局部编辑消耗积分",
pointsConfig,
});
if (!pointsCharge.ok) {
@ -483,7 +568,10 @@ export async function POST(request: NextRequest) {
annotatedFilename,
toArrayBuffer(annotatedBuffer)
);
const prompt = buildLocalEditPrompt(regions);
const prompt =
operation === SCALE_ADJUST_OPERATION && scalePercent !== null
? buildScaleAdjustPrompt(regions, scalePercent)
: buildLocalEditPrompt(regions);
const regionReferenceImageUrls = regions.flatMap((region) =>
region.referenceImages.map((reference) => reference.url)
);
@ -496,13 +584,16 @@ export async function POST(request: NextRequest) {
const imageRequest: CanvasGenerateImageRequest = {
aspectRatio: resolvedAspectRatio,
billingFeature: "repaint",
debugLabel: "canvas-local-edit",
debugLabel:
operation === SCALE_ADJUST_OPERATION ? "canvas-scale-adjust" : "canvas-local-edit",
debugMeta: {
annotatedReferenceUrl: annotatedUpload.url,
annotatedReferenceSentToModel: false,
operation,
resolvedAspectRatio,
regionReferenceImageCount: regionReferenceImageUrls.length,
regionCount: regions.length,
scalePercent,
sourceHeight,
sourceNodeId: body.sourceNodeId || null,
sourceWidth,
@ -544,7 +635,10 @@ export async function POST(request: NextRequest) {
if (pointsCharge.chargeable) {
try {
await pointsCharge.consume({
note: `局部编辑消耗积分(${task.id})`,
note:
operation === SCALE_ADJUST_OPERATION
? `比例调整消耗积分(${task.id})`
: `局部编辑消耗积分(${task.id})`,
});
} catch (pointsError) {
console.error("[Points] Failed to consume local edit points:", pointsError);

View File

@ -26,6 +26,7 @@ import {
ImagePlus,
Loader2,
Maximize2,
Minimize2,
Sparkles,
Upload,
UserRound,
@ -232,10 +233,14 @@ type BatchDownloadDialogState = {
type DetailResultLocalEditTarget = {
cardId: string;
imageUrl: string;
mode?: DetailResultLocalEditMode;
title: string;
resultModeType: "detail_generation" | "detail_replacement";
};
type DetailResultLocalEditMode = "local_edit" | "scale_adjust";
type DetailResultScalePercent = -30 | -20 | -10 | 10 | 20 | 30;
type DetailResultLocalEditRect = {
x: number;
y: number;
@ -245,6 +250,8 @@ type DetailResultLocalEditRect = {
type DetailResultLocalEditSubmitParams = {
imageUrl: string;
mode: DetailResultLocalEditMode;
scalePercent?: DetailResultScalePercent;
title: string;
regions: LocalEditRegion[];
};
@ -255,6 +262,14 @@ const MAX_CHAT_ATTACHMENT_NAME_LENGTH = 100;
const MAX_AGENT_MODE_QUERY_LENGTH = 64;
const MAX_AGENT_MODE_REFERENCE_COUNT = 40;
const DETAIL_LOCAL_EDIT_REFERENCE_MAX_UPLOAD_BYTES = 30 * 1024 * 1024;
const DETAIL_SCALE_ADJUST_OPTIONS: DetailResultScalePercent[] = [
-30,
-20,
-10,
10,
20,
30,
];
const DIRECT_MODEL_UPLOAD_SLOT_COUNT = 4;
const PRODUCT_DETAIL_CHANNEL_OPTIONS: Array<{
value: AgentWorkflowProductDetailChannel;
@ -784,6 +799,14 @@ async function uploadDetailLocalEditReferenceImage(
};
}
function getDetailScaleAdjustLabel(scalePercent: DetailResultScalePercent) {
return scalePercent > 0 ? `放大 ${scalePercent}%` : `缩小 ${Math.abs(scalePercent)}%`;
}
function getDetailScaleAdjustRegionPrompt(scalePercent: DetailResultScalePercent | null) {
return scalePercent ? `比例调整:${getDetailScaleAdjustLabel(scalePercent)}` : "比例调整";
}
function DetailResultLocalEditDialog({
submitting,
target,
@ -799,15 +822,21 @@ function DetailResultLocalEditDialog({
const [draftRect, setDraftRect] = useState<DetailResultLocalEditRect | null>(null);
const [activeRegionId, setActiveRegionId] = useState<string | null>(null);
const [uploadingRegionId, setUploadingRegionId] = useState<string | null>(null);
const [selectedScalePercent, setSelectedScalePercent] =
useState<DetailResultScalePercent | null>(null);
const dragStartRef = useRef<{ x: number; y: number } | null>(null);
const mode: DetailResultLocalEditMode =
target?.mode === "scale_adjust" ? "scale_adjust" : "local_edit";
const isScaleAdjustMode = mode === "scale_adjust";
useEffect(() => {
setRegions([]);
setDraftRect(null);
setActiveRegionId(null);
setUploadingRegionId(null);
setSelectedScalePercent(null);
dragStartRef.current = null;
}, [target?.cardId, target?.imageUrl]);
}, [target?.cardId, target?.imageUrl, target?.mode]);
useEffect(() => {
if (regions.length === 0) {
@ -952,7 +981,9 @@ function DetailResultLocalEditDialog({
const validRegions = reindexLocalEditRegions(
regions.map((region) => ({
...region,
prompt: region.prompt.trim(),
prompt: isScaleAdjustMode
? getDetailScaleAdjustRegionPrompt(selectedScalePercent)
: region.prompt.trim(),
referenceImages: (region.referenceImages || []).slice(
0,
LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION
@ -972,18 +1003,27 @@ function DetailResultLocalEditDialog({
toast.error(`区域 ${invalidRegion.index} 太小,请重新框选`);
return;
}
const emptyPromptRegion = validRegions.find((region) => !region.prompt);
if (emptyPromptRegion) {
setActiveRegionId(emptyPromptRegion.id);
toast.error(`请填写区域 ${emptyPromptRegion.index} 的提示词`);
return;
if (isScaleAdjustMode) {
if (!selectedScalePercent) {
toast.error("请选择放大或缩小比例");
return;
}
} else {
const emptyPromptRegion = validRegions.find((region) => !region.prompt);
if (emptyPromptRegion) {
setActiveRegionId(emptyPromptRegion.id);
toast.error(`请填写区域 ${emptyPromptRegion.index} 的提示词`);
return;
}
}
onSubmit({
imageUrl: target.imageUrl,
mode,
scalePercent: isScaleAdjustMode ? selectedScalePercent || undefined : undefined,
title: target.title,
regions: validRegions,
});
}, [onSubmit, regions, target, uploadingRegionId]);
}, [isScaleAdjustMode, mode, onSubmit, regions, selectedScalePercent, target, uploadingRegionId]);
const completeDraftRegion = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
@ -1008,7 +1048,9 @@ function DetailResultLocalEditDialog({
id: createLocalEditRegionId(),
index: regions.length + 1,
color: LOCAL_EDIT_COLORS[regions.length % LOCAL_EDIT_COLORS.length],
prompt: "",
prompt: isScaleAdjustMode
? getDetailScaleAdjustRegionPrompt(selectedScalePercent)
: "",
rect: nextRect,
referenceImages: [],
createdAt: Date.now(),
@ -1019,7 +1061,7 @@ function DetailResultLocalEditDialog({
});
setActiveRegionId(nextRegion.id);
},
[createRectFromPoints, getPoint, regions.length]
[createRectFromPoints, getPoint, isScaleAdjustMode, regions.length, selectedScalePercent]
);
const completionCopy =
@ -1027,14 +1069,18 @@ function DetailResultLocalEditDialog({
? "处理完成后会更新当前套详情结果图。"
: "处理完成后会加入 AI详情结果区。";
const isBusy = submitting || Boolean(uploadingRegionId);
const dialogTitle = isScaleAdjustMode ? "比例调整详情图" : "局部编辑详情图";
const dialogDescription = isScaleAdjustMode
? "框选要调整比例的物体,选择固定放大或缩小幅度后生成。"
: "在图片上拖拽框选编辑位置,可添加多个区域,并为每个区域上传专属参考图。";
return (
<Dialog open={Boolean(target)} onOpenChange={(open) => (!open ? onClose() : undefined)}>
<DialogContent className="max-w-6xl border-white/10 bg-[#101214] text-white">
<DialogHeader>
<DialogTitle>局部编辑详情图</DialogTitle>
<DialogTitle>{dialogTitle}</DialogTitle>
<DialogDescription className="text-white/50">
在图片上拖拽框选编辑位置,可添加多个区域,并为每个区域上传专属参考图。
{dialogDescription}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_340px]">
@ -1053,7 +1099,9 @@ function DetailResultLocalEditDialog({
onPointerDown={(event) => {
if (event.button !== 0 || isBusy) return;
if (regions.length >= LOCAL_EDIT_MAX_REGIONS) {
toast.info(`局部编辑最多支持 ${LOCAL_EDIT_MAX_REGIONS} 个区域`);
toast.info(
`${isScaleAdjustMode ? "比例调整" : "局部编辑"}最多支持 ${LOCAL_EDIT_MAX_REGIONS} 个区域`
);
return;
}
event.preventDefault();
@ -1131,12 +1179,45 @@ function DetailResultLocalEditDialog({
</div>
<div className="flex min-h-0 flex-col gap-3">
<div className="rounded-2xl border border-white/10 bg-white/[0.04] p-3 text-sm text-white/58">
拖拽图片添加区域。最多 {LOCAL_EDIT_MAX_REGIONS} 个区域,每个区域最多{" "}
{LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION} 张参考图。{completionCopy}
{isScaleAdjustMode
? `拖拽框选物体,建议留出少量周边背景,最多 ${LOCAL_EDIT_MAX_REGIONS} 个区域。${completionCopy}`
: `拖拽图片添加区域。最多 ${LOCAL_EDIT_MAX_REGIONS} 个区域,每个区域最多 ${LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION} 张参考图。${completionCopy}`}
</div>
{isScaleAdjustMode ? (
<div className="rounded-2xl border border-white/10 bg-white/[0.035] p-3">
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-white/38">
Scale
</div>
<div className="grid grid-cols-3 gap-2">
{DETAIL_SCALE_ADJUST_OPTIONS.map((option) => {
const active = selectedScalePercent === option;
const Icon = option > 0 ? Maximize2 : Minimize2;
return (
<button
className={cn(
"inline-flex h-10 items-center justify-center gap-1.5 rounded-full border text-xs font-semibold transition disabled:cursor-not-allowed disabled:opacity-45",
active
? "border-emerald-300/70 bg-emerald-400/18 text-emerald-50"
: "border-white/10 bg-black/20 text-white/68 hover:border-white/24 hover:bg-white/[0.08] hover:text-white"
)}
disabled={isBusy}
key={option}
onClick={() => setSelectedScalePercent(option)}
type="button"
>
<Icon className="h-3.5 w-3.5" />
{getDetailScaleAdjustLabel(option)}
</button>
);
})}
</div>
</div>
) : null}
{regions.length === 0 ? (
<div className="flex min-h-[260px] items-center justify-center rounded-2xl border border-dashed border-white/12 bg-white/[0.02] px-5 text-center text-sm text-white/38">
在左侧图片上拖拽框选后,这里会显示区域提示词和参考图。
{isScaleAdjustMode
? "在左侧图片上拖拽框选物体后,选择一个比例即可生成。"
: "在左侧图片上拖拽框选后,这里会显示区域提示词和参考图。"}
</div>
) : (
<div className="max-h-[52vh] space-y-3 overflow-y-auto pr-1">
@ -1168,10 +1249,18 @@ function DetailResultLocalEditDialog({
<span className="truncate text-sm font-semibold text-white">
区域 {region.index}
</span>
<span className="text-xs text-white/36">
参考图 {references.length}/
{LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION}
</span>
{isScaleAdjustMode ? (
<span className="text-xs text-white/36">
{selectedScalePercent
? getDetailScaleAdjustLabel(selectedScalePercent)
: "待选比例"}
</span>
) : (
<span className="text-xs text-white/36">
参考图 {references.length}/
{LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION}
</span>
)}
</div>
<button
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-white/45 hover:bg-white/10 hover:text-white"
@ -1187,85 +1276,93 @@ function DetailResultLocalEditDialog({
<X className="h-4 w-4" />
</button>
</div>
<Textarea
className="min-h-[92px] resize-none border-white/10 bg-black/30 text-sm text-white placeholder:text-white/30"
disabled={submitting}
maxLength={600}
onChange={(event) => updateRegionPrompt(region.id, event.target.value)}
placeholder={`区域 ${region.index} 要怎么改`}
value={region.prompt}
/>
<input
accept="image/jpeg,image/png,image/webp,image/heic,image/heif,.jpg,.jpeg,.png,.webp,.heic,.heif"
className="hidden"
id={`detail-local-edit-reference-upload-${region.id}`}
multiple
onChange={(event) => {
void handleReferenceUpload(region.id, event.currentTarget.files);
event.currentTarget.value = "";
}}
type="file"
/>
<div className="mt-3 flex items-center gap-2">
<button
className="inline-flex h-8 items-center gap-1.5 rounded-full border border-white/10 bg-white/[0.05] px-3 text-xs font-medium text-white/72 hover:bg-white/[0.1] hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
disabled={!canUpload}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
document
.getElementById(
`detail-local-edit-reference-upload-${region.id}`
)
?.click();
}}
type="button"
>
{uploadingRegionId === region.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<ImagePlus className="h-3.5 w-3.5" />
)}
参考图
</button>
{references.length === 0 ? (
<span className="text-xs text-white/32">可选</span>
) : null}
</div>
{references.length ? (
<div className="mt-3 grid grid-cols-3 gap-2">
{references.map((reference) => (
<div
className="group relative aspect-square overflow-hidden rounded-xl border border-white/10 bg-white/[0.04]"
key={reference.id}
title={reference.name || "区域参考图"}
>
<img
alt={reference.name || "区域参考图"}
className="h-full w-full object-cover"
draggable={false}
src={buildImageProxyUrl(reference.url, {
width: 240,
quality: 82,
})}
/>
<button
className="absolute inset-0 hidden items-center justify-center bg-black/55 text-white group-hover:flex"
disabled={submitting}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
removeReferenceImage(region.id, reference.id);
}}
title="移除参考图"
type="button"
>
<X className="h-4 w-4" />
</button>
</div>
))}
{isScaleAdjustMode ? (
<div className="rounded-xl border border-white/10 bg-black/24 px-3 py-2 text-xs leading-5 text-white/52">
将框选物体按所选比例整体缩放,保持外观结构不变。
</div>
) : null}
) : (
<>
<Textarea
className="min-h-[92px] resize-none border-white/10 bg-black/30 text-sm text-white placeholder:text-white/30"
disabled={submitting}
maxLength={600}
onChange={(event) => updateRegionPrompt(region.id, event.target.value)}
placeholder={`区域 ${region.index} 要怎么改`}
value={region.prompt}
/>
<input
accept="image/jpeg,image/png,image/webp,image/heic,image/heif,.jpg,.jpeg,.png,.webp,.heic,.heif"
className="hidden"
id={`detail-local-edit-reference-upload-${region.id}`}
multiple
onChange={(event) => {
void handleReferenceUpload(region.id, event.currentTarget.files);
event.currentTarget.value = "";
}}
type="file"
/>
<div className="mt-3 flex items-center gap-2">
<button
className="inline-flex h-8 items-center gap-1.5 rounded-full border border-white/10 bg-white/[0.05] px-3 text-xs font-medium text-white/72 hover:bg-white/[0.1] hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
disabled={!canUpload}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
document
.getElementById(
`detail-local-edit-reference-upload-${region.id}`
)
?.click();
}}
type="button"
>
{uploadingRegionId === region.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<ImagePlus className="h-3.5 w-3.5" />
)}
参考图
</button>
{references.length === 0 ? (
<span className="text-xs text-white/32">可选</span>
) : null}
</div>
{references.length ? (
<div className="mt-3 grid grid-cols-3 gap-2">
{references.map((reference) => (
<div
className="group relative aspect-square overflow-hidden rounded-xl border border-white/10 bg-white/[0.04]"
key={reference.id}
title={reference.name || "区域参考图"}
>
<img
alt={reference.name || "区域参考图"}
className="h-full w-full object-cover"
draggable={false}
src={buildImageProxyUrl(reference.url, {
width: 240,
quality: 82,
})}
/>
<button
className="absolute inset-0 hidden items-center justify-center bg-black/55 text-white group-hover:flex"
disabled={submitting}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
removeReferenceImage(region.id, reference.id);
}}
title="移除参考图"
type="button"
>
<X className="h-4 w-4" />
</button>
</div>
))}
</div>
) : null}
</>
)}
</div>
);
})}
@ -1281,15 +1378,20 @@ function DetailResultLocalEditDialog({
>
取消
</Button>
<Button
className="bg-white text-black hover:bg-white/90"
disabled={submitting || Boolean(uploadingRegionId) || regions.length === 0}
onClick={handleSubmit}
type="button"
>
{submitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
生成局部编辑
</Button>
<Button
className="bg-white text-black hover:bg-white/90"
disabled={
submitting ||
Boolean(uploadingRegionId) ||
regions.length === 0 ||
(isScaleAdjustMode && !selectedScalePercent)
}
onClick={handleSubmit}
type="button"
>
{submitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{isScaleAdjustMode ? "生成比例调整" : "生成局部编辑"}
</Button>
</div>
</div>
</div>
@ -1816,6 +1918,42 @@ function saveStoredAgentPendingGenerations(items: LocalGenerationPendingState[])
}
}
function DetailResultProcessingScanOverlay({ label }: { label: string }) {
return (
<div className="absolute inset-0 z-30 overflow-hidden rounded-[inherit] bg-zinc-950/38 backdrop-blur-[1.5px]">
<motion.div
aria-hidden
className="absolute inset-0 opacity-45"
animate={{
backgroundPosition: ["0px 0px", "0px 28px"],
}}
transition={{ duration: 1.8, repeat: Infinity, ease: "linear" }}
style={{
backgroundImage:
"linear-gradient(rgba(255,255,255,0.08) 1px, transparent 1px)",
backgroundSize: "100% 28px",
}}
/>
<motion.div
aria-hidden
className="absolute inset-x-[-18%] h-24 bg-[linear-gradient(180deg,transparent_0%,rgba(34,211,238,0.10)_17%,rgba(16,185,129,0.64)_43%,rgba(255,255,255,0.92)_50%,rgba(16,185,129,0.58)_58%,rgba(34,211,238,0.10)_83%,transparent_100%)] shadow-[0_0_42px_rgba(16,185,129,0.46)]"
animate={{ y: ["-45%", "115%"], opacity: [0.58, 1, 0.7] }}
transition={{ duration: 1.85, repeat: Infinity, repeatType: "mirror", ease: "easeInOut" }}
/>
<motion.div
aria-hidden
className="absolute inset-0 bg-[radial-gradient(circle_at_50%_50%,rgba(16,185,129,0.20),transparent_58%)]"
animate={{ opacity: [0.18, 0.44, 0.18] }}
transition={{ duration: 2.2, repeat: Infinity, ease: "easeInOut" }}
/>
<div className="absolute left-3 top-3 inline-flex items-center gap-2 rounded-full border border-emerald-100/65 bg-zinc-950/86 px-3 py-1.5 text-[11px] font-semibold text-emerald-50 shadow-[0_14px_36px_rgba(0,0,0,0.48),0_0_22px_rgba(16,185,129,0.24)] backdrop-blur-md">
<Loader2 className="h-3.5 w-3.5 animate-spin text-emerald-200" />
<span>{label}</span>
</div>
</div>
);
}
function AnimatedGenerationPlaceholderCard(params: {
title: string;
description: string;
@ -2014,6 +2152,8 @@ export function AgentGenerationWorkspaceDarkV2({
useState<DetailResultLocalEditTarget | null>(null);
const [detailLocalEditSubmittingId, setDetailLocalEditSubmittingId] =
useState<string | null>(null);
const [detailLocalEditSubmittingMode, setDetailLocalEditSubmittingMode] =
useState<DetailResultLocalEditMode | null>(null);
const [detailEnhancingCardIds, setDetailEnhancingCardIds] = useState<Set<string>>(
() => new Set()
);
@ -5076,19 +5216,25 @@ export function AgentGenerationWorkspaceDarkV2({
const cardId = String(target?.cardId || "").trim();
const imageUrl = String(params.imageUrl || "").trim();
const resultModeType = target?.resultModeType || "detail_generation";
const editMode: DetailResultLocalEditMode =
params.mode || target?.mode || "local_edit";
const isScaleAdjust = editMode === "scale_adjust";
if (!target || !cardId || !/^https?:\/\//i.test(imageUrl)) {
toast.error("详情图地址无效,无法局部编辑");
toast.error(`详情图地址无效,无法${isScaleAdjust ? "比例调整" : "局部编辑"}`);
return;
}
if (detailLocalEditSubmittingId) return;
setDetailLocalEditSubmittingId(cardId);
toast.info("正在提交局部编辑...");
setDetailLocalEditSubmittingMode(editMode);
toast.info(isScaleAdjust ? "正在提交比例调整..." : "正在提交局部编辑...");
try {
const submitResult = await imageGenerationService.localEdit({
aspectRatio: "auto",
imageSize: "2K",
operation: isScaleAdjust ? "scale-adjust" : undefined,
scalePercent: isScaleAdjust ? params.scalePercent : undefined,
sourceImageUrl: imageUrl,
sourceNodeId: `agent-detail-${cardId}`,
regions: params.regions.map((region) => ({
@ -5107,7 +5253,7 @@ export function AgentGenerationWorkspaceDarkV2({
});
if (!submitResult.success || !submitResult.data?.taskId) {
throw new Error(submitResult.error || "局部编辑提交失败");
throw new Error(submitResult.error || `${isScaleAdjust ? "比例调整" : "局部编辑"}提交失败`);
}
const deadline = Date.now() + AGENT_IMAGE_TASK_TIMEOUT_MS;
@ -5121,7 +5267,7 @@ export function AgentGenerationWorkspaceDarkV2({
const status = String(task?.status || "").toLowerCase();
if (!taskResult.success || !task) {
lastError = taskResult.error || "查询局部编辑任务失败";
lastError = taskResult.error || `查询${isScaleAdjust ? "比例调整" : "局部编辑"}任务失败`;
continue;
}
@ -5130,14 +5276,14 @@ export function AgentGenerationWorkspaceDarkV2({
const nextUrl =
generatedImage?.originalUrl || generatedImage?.thumbnailUrl || "";
if (!/^https?:\/\//i.test(nextUrl)) {
throw new Error("局部编辑完成但结果地址无效");
throw new Error(`${isScaleAdjust ? "比例调整" : "局部编辑"}完成但结果地址无效`);
}
if (resultModeType === "detail_replacement") {
handleUpdateReplacementResultRecord({
modeType: "detail_replacement",
cardId,
url: nextUrl,
text: "已完成局部编辑处理。",
text: isScaleAdjust ? "已完成比例调整处理。" : "已完成局部编辑处理。",
status: "completed",
});
setActiveResultModeType("detail_replacement");
@ -5153,23 +5299,33 @@ export function AgentGenerationWorkspaceDarkV2({
setDetailLocalEditTarget(null);
toast.success(
resultModeType === "detail_replacement"
? "局部编辑完成,已更新当前套详情结果图"
: "局部编辑完成,已加入 AI详情结果区"
? `${isScaleAdjust ? "比例调整" : "局部编辑"}完成,已更新当前套详情结果图`
: `${isScaleAdjust ? "比例调整" : "局部编辑"}完成,已加入 AI详情结果区`
);
return;
}
if (["failed", "cancelled", "expired", "banned"].includes(status)) {
throw new Error(task.error || "局部编辑生成失败");
throw new Error(task.error || `${isScaleAdjust ? "比例调整" : "局部编辑"}生成失败`);
}
}
throw new Error(lastError || "局部编辑任务等待超时,请稍后重试");
throw new Error(
lastError || `${isScaleAdjust ? "比例调整" : "局部编辑"}任务等待超时,请稍后重试`
);
} catch (error) {
console.error("[AgentGenerationWorkspaceDarkV2] 详情图局部编辑失败:", error);
toast.error(error instanceof Error ? error.message : "局部编辑失败,请稍后重试");
console.error(
`[AgentGenerationWorkspaceDarkV2] 详情图${isScaleAdjust ? "比例调整" : "局部编辑"}失败:`,
error
);
toast.error(
error instanceof Error
? error.message
: `${isScaleAdjust ? "比例调整" : "局部编辑"}失败,请稍后重试`
);
} finally {
setDetailLocalEditSubmittingId(null);
setDetailLocalEditSubmittingMode(null);
}
},
[
@ -7504,12 +7660,12 @@ export function AgentGenerationWorkspaceDarkV2({
{isDetailReplacementPreset && !isDetailAiGenerationMethod ? (
<AgentDetailReferenceStitchDialog
open={detailReferenceStitchDialogOpen}
onOpenChange={setDetailReferenceStitchDialogOpen}
cards={detailReplacementReferenceCards}
sourceLabel={`${detailReferenceSourceLabel}参考图`}
onPrepared={handlePreparedDetailReferenceCards}
onApply={handleApplyStitchedDetailReferenceCards}
/>
onOpenChange={setDetailReferenceStitchDialogOpen}
cards={detailReplacementReferenceCards}
sourceLabel={`${detailReferenceSourceLabel}参考图`}
onPrepared={handlePreparedDetailReferenceCards}
onApply={handleApplyStitchedDetailReferenceCards}
/>
) : null}
<motion.section
@ -7906,15 +8062,32 @@ export function AgentGenerationWorkspaceDarkV2({
<div className="grid grid-cols-1 gap-3">
{detailResultCards.length > 0 ? (
detailResultCards.map((card) => (
(() => {
const isActive = activeDetailReferenceCardId === card.id;
const statusMeta = getReplacementCardStatusMeta(card.status);
const isPending = card.status === "generating" || card.status === "queued";
const hasResult = Boolean(card.resultUrl) && !isPending;
const isWatermarkRemoving = detailWatermarkRemoveCardIds.has(card.id);
const isDetailLocalEditing = detailLocalEditSubmittingId === card.id;
const isDetailEnhancing = detailEnhancingCardIds.has(card.id);
return (
(() => {
const isActive = activeDetailReferenceCardId === card.id;
const statusMeta = getReplacementCardStatusMeta(card.status);
const isPending = card.status === "generating" || card.status === "queued";
const hasResult = Boolean(card.resultUrl);
const isWatermarkRemoving = detailWatermarkRemoveCardIds.has(card.id);
const isDetailLocalEditBusy = detailLocalEditSubmittingId === card.id;
const isDetailLocalEditing =
isDetailLocalEditBusy && detailLocalEditSubmittingMode !== "scale_adjust";
const isDetailScaleAdjusting =
isDetailLocalEditBusy && detailLocalEditSubmittingMode === "scale_adjust";
const isDetailEnhancing = detailEnhancingCardIds.has(card.id);
const processingLabel = isDetailScaleAdjusting
? "比例调整中"
: isDetailLocalEditing
? "局部编辑中"
: isDetailEnhancing
? "高清处理中"
: isWatermarkRemoving
? "去水印中"
: card.resultUrl && card.status === "generating"
? "重新生图中"
: card.resultUrl && card.status === "queued"
? "排队处理中"
: "";
return (
<div
key={`${card.id}-result`}
style={AGENT_DEFERRED_LIST_CARD_STYLE}
@ -7971,30 +8144,55 @@ export function AgentGenerationWorkspaceDarkV2({
下载
</button>
<div className="absolute bottom-3 left-3 flex max-w-[calc(100%-6.5rem)] flex-wrap gap-2">
<button
type="button"
disabled={isDetailLocalEditing}
onClick={() => {
setDetailLocalEditTarget({
cardId: card.id,
imageUrl: card.resultUrl!,
title: `${detailReferenceSourceLabel}结果图 ${card.index + 1}`,
resultModeType: "detail_replacement",
<button
type="button"
disabled={isDetailLocalEditBusy}
onClick={() => {
setDetailLocalEditTarget({
cardId: card.id,
imageUrl: card.resultUrl!,
mode: "local_edit",
title: `${detailReferenceSourceLabel}结果图 ${card.index + 1}`,
resultModeType: "detail_replacement",
});
}}
className={cn(
IMAGE_OVERLAY_ACTION_CLASS,
"inline-flex items-center gap-1.5 disabled:cursor-not-allowed disabled:opacity-45"
)}
>
{isDetailLocalEditing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Wand2 className="h-3.5 w-3.5" />
)}
<span>{isDetailLocalEditing ? "编辑中" : "局部编辑"}</span>
</button>
<button
)}
>
{isDetailLocalEditBusy && !isDetailScaleAdjusting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Wand2 className="h-3.5 w-3.5" />
)}
<span>{isDetailLocalEditing ? "编辑中" : "局部编辑"}</span>
</button>
<button
type="button"
disabled={isDetailLocalEditBusy}
onClick={() => {
setDetailLocalEditTarget({
cardId: card.id,
imageUrl: card.resultUrl!,
mode: "scale_adjust",
title: `${detailReferenceSourceLabel}结果图 ${card.index + 1}`,
resultModeType: "detail_replacement",
});
}}
className={cn(
IMAGE_OVERLAY_ACTION_CLASS,
"inline-flex items-center gap-1.5 disabled:cursor-not-allowed disabled:opacity-45"
)}
>
{isDetailScaleAdjusting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Maximize2 className="h-3.5 w-3.5" />
)}
<span>{isDetailScaleAdjusting ? "调整中" : "比例调整"}</span>
</button>
<button
type="button"
disabled={isDetailEnhancing}
onClick={() =>
@ -8059,17 +8257,20 @@ export function AgentGenerationWorkspaceDarkV2({
IMAGE_OVERLAY_ACTION_CLASS,
"inline-flex items-center gap-1.5 disabled:cursor-not-allowed disabled:opacity-45"
)}
>
{isWatermarkRemoving ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Droplet className="h-3.5 w-3.5" />
)}
<span>{isWatermarkRemoving ? "去水印中" : "去水印"}</span>
</button>
>
{isWatermarkRemoving ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Droplet className="h-3.5 w-3.5" />
)}
<span>{isWatermarkRemoving ? "去水印中" : "去水印"}</span>
</button>
</div>
{processingLabel ? (
<DetailResultProcessingScanOverlay label={processingLabel} />
) : null}
</div>
</div>
) : (
) : (
<div
className="aspect-[5/3]"
onClick={() => setActiveDetailReferenceCardId(card.id)}
@ -8647,14 +8848,27 @@ export function AgentGenerationWorkspaceDarkV2({
>
<div className="mx-auto w-full max-w-[1200px]">
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{gridResultCards.map((card) => {
const isActive = card.url && displayedResultUrl === card.url;
const isSceneResultCard = activeResultModeType === "scene_generation";
const isDetailGenerationCard =
activeResultModeType === "detail_generation";
const isDetailEnhancing = detailEnhancingCardIds.has(card.id);
const isDetailLocalEditing =
detailLocalEditSubmittingId === card.id;
{gridResultCards.map((card) => {
const isActive = card.url && displayedResultUrl === card.url;
const isSceneResultCard = activeResultModeType === "scene_generation";
const isDetailGenerationCard =
activeResultModeType === "detail_generation";
const isDetailEnhancing = detailEnhancingCardIds.has(card.id);
const isDetailLocalEditBusy =
detailLocalEditSubmittingId === card.id;
const isDetailLocalEditing =
isDetailLocalEditBusy && detailLocalEditSubmittingMode !== "scale_adjust";
const isDetailScaleAdjusting =
isDetailLocalEditBusy && detailLocalEditSubmittingMode === "scale_adjust";
const processingLabel = isDetailGenerationCard
? isDetailScaleAdjusting
? "比例调整中"
: isDetailLocalEditing
? "局部编辑中"
: isDetailEnhancing
? "高清处理中"
: ""
: "";
return (
<div
@ -8779,12 +8993,13 @@ export function AgentGenerationWorkspaceDarkV2({
<div className="absolute bottom-3 left-3 flex max-w-[calc(100%-6.5rem)] flex-wrap gap-2">
<button
type="button"
disabled={isDetailLocalEditing}
disabled={isDetailLocalEditBusy}
onClick={(event) => {
event.stopPropagation();
setDetailLocalEditTarget({
cardId: card.id,
imageUrl: card.url!,
mode: "local_edit",
title: `AI详情 ${card.index + 1}`,
resultModeType: "detail_generation",
});
@ -8794,12 +9009,37 @@ export function AgentGenerationWorkspaceDarkV2({
"inline-flex items-center gap-1.5 disabled:cursor-not-allowed disabled:opacity-45"
)}
>
{isDetailLocalEditing ? (
{isDetailLocalEditBusy && !isDetailScaleAdjusting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Wand2 className="h-3.5 w-3.5" />
)}
<span>局部编辑</span>
<span>{isDetailLocalEditing ? "编辑中" : "局部编辑"}</span>
</button>
<button
type="button"
disabled={isDetailLocalEditBusy}
onClick={(event) => {
event.stopPropagation();
setDetailLocalEditTarget({
cardId: card.id,
imageUrl: card.url!,
mode: "scale_adjust",
title: `AI详情 ${card.index + 1}`,
resultModeType: "detail_generation",
});
}}
className={cn(
IMAGE_OVERLAY_ACTION_CLASS,
"inline-flex items-center gap-1.5 disabled:cursor-not-allowed disabled:opacity-45"
)}
>
{isDetailScaleAdjusting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Maximize2 className="h-3.5 w-3.5" />
)}
<span>{isDetailScaleAdjusting ? "调整中" : "比例调整"}</span>
</button>
<button
type="button"
@ -8834,13 +9074,16 @@ export function AgentGenerationWorkspaceDarkV2({
}}
className={IMAGE_OVERLAY_ACTION_CLASS}
>
送入画布改字
</button>
</div>
</>
) : null}
</>
) : (
送入画布改字
</button>
</div>
</>
) : null}
{processingLabel ? (
<DetailResultProcessingScanOverlay label={processingLabel} />
) : null}
</>
) : (
<AnimatedGenerationPlaceholderCard
badge={card.status === "generating" ? "生成中" : card.status === "queued" ? "排队中" : "待生成"}
badgeClassName={
@ -9193,13 +9436,17 @@ export function AgentGenerationWorkspaceDarkV2({
<DetailResultLocalEditDialog
submitting={Boolean(detailLocalEditSubmittingId)}
target={detailLocalEditTarget}
onClose={() => {
if (detailLocalEditSubmittingId) {
toast.info("局部编辑正在处理中,请稍候");
return;
}
setDetailLocalEditTarget(null);
target={detailLocalEditTarget}
onClose={() => {
if (detailLocalEditSubmittingId) {
toast.info(
detailLocalEditSubmittingMode === "scale_adjust"
? "比例调整正在处理中,请稍候"
: "局部编辑正在处理中,请稍候"
);
return;
}
setDetailLocalEditTarget(null);
}}
onSubmit={handleSubmitDetailLocalEdit}
/>

View File

@ -339,6 +339,8 @@ export const imageGenerationService = {
model?: string;
imageSize?: string;
aspectRatio?: string;
operation?: "scale-adjust";
scalePercent?: -30 | -20 | -10 | 10 | 20 | 30;
}): Promise<
ApiResponse<{
annotatedReferenceUrl: string;