feat: add detail image scale adjust and processing scan
This commit is contained in:
parent
9fae6c6a62
commit
25af7e5526
@ -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);
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user