feat: support target detail uploads and improve scale adjust
This commit is contained in:
parent
25af7e5526
commit
af63ebba9e
@ -451,13 +451,13 @@ function buildScaleAdjustPrompt(
|
||||
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)}%。`;
|
||||
return `区域 ${region.index}(原图位置 x=${left}%,y=${top}%,w=${width}%,h=${height}%):该矩形只是“寻找目标主体”的范围,不是要缩放或贴回的矩形补丁。请识别此范围内最主要的商品/物体主体,以主体自身轮廓和视觉中心为准,执行整体等比${actionLabel},目标视觉尺寸为原主体的 ${Math.round(scaleFactor * 100)}%。如果框中只覆盖到主体的一部分,请根据连接关系推断该主体的完整可见结构,只调整该主体,不调整矩形框内背景。`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
return [
|
||||
"输入图片1是原始干净图,也是最终输出必须继承的唯一画面基底。",
|
||||
"系统已根据用户框选区域计算出下方每个区域的原图相对坐标;彩色框只存在于产品界面里,不会作为最终画面内容。",
|
||||
"系统已根据用户框选区域计算出下方每个区域的原图相对坐标;框选区域只是定位目标主体的搜索范围,不是最终画面的裁切块、贴片或蒙版边界。",
|
||||
"最终结果必须保持输入图片1的原始画幅比例、构图边界、镜头透视、光线、色温、材质质感和清晰度。",
|
||||
"请不要生成任何彩色框、矩形边框、编号圆点、提示词气泡、标注文字、UI 控件或半透明色块。",
|
||||
"",
|
||||
@ -465,11 +465,13 @@ function buildScaleAdjustPrompt(
|
||||
regionLines,
|
||||
"",
|
||||
"关键要求:",
|
||||
"1. 只调整框选主体物体的整体视觉比例,必须等比缩放,不得压扁、拉伸、扭曲、改变外观结构、改变材质、改变颜色、改变文字或图案。",
|
||||
"2. 缩小时,主体应围绕原位置中心自然变小,露出的背景需要与原图环境、纹理、阴影和透视自然补全。",
|
||||
"3. 放大时,主体应围绕原位置中心自然变大,边缘与遮挡关系要合理,不能变形,也不能超出画面造成突兀裁切。",
|
||||
"4. 未被框选的其它画面区域必须保持原图不变,不允许重绘、改色、补光、移动物体或改变背景细节。",
|
||||
"5. 最终输出必须是一张干净照片,不得出现任何选区框、说明文字、对比线、比例数字或 UI 元素。",
|
||||
"1. 只调整目标主体物体本身的整体视觉比例,必须等比缩放,不得压扁、拉伸、扭曲、改变外观结构、改变材质、改变颜色、改变文字或图案。",
|
||||
"2. 严禁把整个矩形框选区域当成图片块整体缩放、平移、复制、裁切或贴回;最终画面不得出现矩形拼接边缘、色差硬边、补丁感、裁切线或局部贴片痕迹。",
|
||||
"3. 缩小时,目标主体应围绕原位置视觉中心自然变小;主体缩小后露出的区域必须根据周围真实背景、阴影、遮挡、纹理和透视进行无缝补全。",
|
||||
"4. 放大时,目标主体应围绕原位置视觉中心自然变大;边缘与遮挡关系要合理,不能变形,也不能产生突兀裁切。",
|
||||
"5. 框选区域内非目标主体的背景、墙面、桌面、植物、文字和其它物体应保持原场景逻辑,不要跟着主体一起缩放。",
|
||||
"6. 未受比例调整影响的其它画面区域必须尽量保持原图不变,不允许整体重绘、改色、补光、移动物体或改变背景细节。",
|
||||
"7. 最终输出必须是一张完整、干净、无缝的照片,不得出现任何选区框、说明文字、对比线、比例数字或 UI 元素。",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
@ -572,6 +574,8 @@ export async function POST(request: NextRequest) {
|
||||
operation === SCALE_ADJUST_OPERATION && scalePercent !== null
|
||||
? buildScaleAdjustPrompt(regions, scalePercent)
|
||||
: buildLocalEditPrompt(regions);
|
||||
const isScaleAdjustOperation =
|
||||
operation === SCALE_ADJUST_OPERATION && scalePercent !== null;
|
||||
const regionReferenceImageUrls = regions.flatMap((region) =>
|
||||
region.referenceImages.map((reference) => reference.url)
|
||||
);
|
||||
@ -594,6 +598,7 @@ export async function POST(request: NextRequest) {
|
||||
regionReferenceImageCount: regionReferenceImageUrls.length,
|
||||
regionCount: regions.length,
|
||||
scalePercent,
|
||||
scaleAdjustUsesFullFrameOutput: isScaleAdjustOperation,
|
||||
sourceHeight,
|
||||
sourceNodeId: body.sourceNodeId || null,
|
||||
sourceWidth,
|
||||
@ -608,17 +613,21 @@ export async function POST(request: NextRequest) {
|
||||
? body.model.trim()
|
||||
: undefined,
|
||||
negativePrompt:
|
||||
"彩色框、蓝色边框、绿色边框、红色边框、橙色边框、矩形框、编号圆点、提示词气泡、标注文字、UI 控件、半透明色块、选区边框、白边、画幅压扁、画面拉伸",
|
||||
isScaleAdjustOperation
|
||||
? "彩色框、蓝色边框、绿色边框、红色边框、橙色边框、矩形框、编号圆点、提示词气泡、标注文字、UI 控件、半透明色块、选区边框、白边、画幅压扁、画面拉伸、矩形贴片、硬边、拼接线、补丁感、局部裁切、色差边缘、背景断裂、主体未缩放、整块框选区域缩放"
|
||||
: "彩色框、蓝色边框、绿色边框、红色边框、橙色边框、矩形框、编号圆点、提示词气泡、标注文字、UI 控件、半透明色块、选区边框、白边、画幅压扁、画面拉伸",
|
||||
prompt,
|
||||
promptExtend: false,
|
||||
referenceImages: [
|
||||
sourceImageUrl,
|
||||
...regionReferenceImageUrls,
|
||||
],
|
||||
localEditComposite: {
|
||||
sourceImageUrl,
|
||||
regions: regions.map((region) => region.rect),
|
||||
},
|
||||
localEditComposite: isScaleAdjustOperation
|
||||
? undefined
|
||||
: {
|
||||
sourceImageUrl,
|
||||
regions: regions.map((region) => region.rect),
|
||||
},
|
||||
ownerKey: session.user.id,
|
||||
};
|
||||
|
||||
|
||||
@ -6,10 +6,11 @@ import {
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type SyntheticEvent,
|
||||
} from "react";
|
||||
import { Loader2, Plus, RotateCcw, Scissors, Trash2 } from "lucide-react";
|
||||
import { Loader2, Plus, RotateCcw, Scissors, Trash2, Upload } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@ -22,6 +23,10 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||
import { createClientLogger } from "@/lib/observability/client-logger";
|
||||
import {
|
||||
createUploadedDetailReferenceCard,
|
||||
uploadDetailReferenceImageFile,
|
||||
} from "@/components/agents/detail-reference-upload";
|
||||
import type { AgentDetailReplacementReferenceItem } from "@/types/agent";
|
||||
|
||||
const MIN_SLICE_HEIGHT = 120;
|
||||
@ -211,6 +216,10 @@ export function AgentDetailReferenceStitchDialog({
|
||||
const [warningText, setWarningText] = useState<string | null>(null);
|
||||
const [previewImageLoadFailed, setPreviewImageLoadFailed] = useState(false);
|
||||
const [passthroughSegmentIndexes, setPassthroughSegmentIndexes] = useState<number[]>([]);
|
||||
const [segmentReplacementCards, setSegmentReplacementCards] = useState<
|
||||
Record<number, AgentDetailReplacementReferenceItem>
|
||||
>({});
|
||||
const [uploadingSegmentIndex, setUploadingSegmentIndex] = useState<number | null>(null);
|
||||
const hasManualCutLineChangesRef = useRef(false);
|
||||
const onPreparedRef = useRef(onPrepared);
|
||||
|
||||
@ -265,6 +274,10 @@ export function AgentDetailReferenceStitchDialog({
|
||||
() => new Set(passthroughSegmentIndexes),
|
||||
[passthroughSegmentIndexes]
|
||||
);
|
||||
const segmentReplacementCount = useMemo(
|
||||
() => Object.keys(segmentReplacementCards).length,
|
||||
[segmentReplacementCards]
|
||||
);
|
||||
const mergedPreviewDisplayUrl = useMemo(
|
||||
() =>
|
||||
snapshot
|
||||
@ -285,6 +298,8 @@ export function AgentDetailReferenceStitchDialog({
|
||||
setWarningText(null);
|
||||
setPreviewImageLoadFailed(false);
|
||||
setPassthroughSegmentIndexes([]);
|
||||
setSegmentReplacementCards({});
|
||||
setUploadingSegmentIndex(null);
|
||||
hasManualCutLineChangesRef.current = false;
|
||||
return;
|
||||
}
|
||||
@ -311,6 +326,8 @@ export function AgentDetailReferenceStitchDialog({
|
||||
setPreviewImageLoadFailed(true);
|
||||
setIsLoading(false);
|
||||
setIsPreparing(true);
|
||||
setSegmentReplacementCards({});
|
||||
setUploadingSegmentIndex(null);
|
||||
setLoadingProgressText(`正在后台校准 ${setupCards.length} 张参考图尺寸`);
|
||||
|
||||
const setup = async () => {
|
||||
@ -431,6 +448,15 @@ export function AgentDetailReferenceStitchDialog({
|
||||
setPassthroughSegmentIndexes((current) =>
|
||||
current.filter((index) => validIndexes.has(index))
|
||||
);
|
||||
setSegmentReplacementCards((current) => {
|
||||
const nextEntries = Object.entries(current).filter(([index]) =>
|
||||
validIndexes.has(Number(index))
|
||||
);
|
||||
if (nextEntries.length === Object.keys(current).length) {
|
||||
return current;
|
||||
}
|
||||
return Object.fromEntries(nextEntries);
|
||||
});
|
||||
}, [sliceSegments]);
|
||||
|
||||
useEffect(() => {
|
||||
@ -658,6 +684,39 @@ export function AgentDetailReferenceStitchDialog({
|
||||
[]
|
||||
);
|
||||
|
||||
const handleReplaceSegmentImage = useCallback(
|
||||
async (segmentIndex: number, event: ChangeEvent<HTMLInputElement>) => {
|
||||
const input = event.currentTarget;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setUploadingSegmentIndex(segmentIndex);
|
||||
try {
|
||||
const upload = await uploadDetailReferenceImageFile(file);
|
||||
const replacementCard = createUploadedDetailReferenceCard({
|
||||
upload,
|
||||
index: segmentIndex,
|
||||
labelPrefix: "更换切片",
|
||||
});
|
||||
setSegmentReplacementCards((current) => ({
|
||||
...current,
|
||||
[segmentIndex]: {
|
||||
...replacementCard,
|
||||
sortOrder: segmentIndex,
|
||||
passthrough: false,
|
||||
},
|
||||
}));
|
||||
toast.success(`已更换切片 ${segmentIndex + 1}`);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "切片图片更换失败");
|
||||
} finally {
|
||||
setUploadingSegmentIndex(null);
|
||||
input.value = "";
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleApply = useCallback(async () => {
|
||||
if (!snapshot) {
|
||||
toast.error("当前还没有可用的拼合图");
|
||||
@ -668,6 +727,10 @@ export function AgentDetailReferenceStitchDialog({
|
||||
toast.error("当前没有可切割的参考图");
|
||||
return;
|
||||
}
|
||||
if (uploadingSegmentIndex !== null) {
|
||||
toast.error("请等待切片图片上传完成");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsApplying(true);
|
||||
setApplyProgressText(`正在服务端切割 ${sliceSegments.length} 张`);
|
||||
@ -691,11 +754,14 @@ export function AgentDetailReferenceStitchDialog({
|
||||
throw new Error(result.error || "切割参考图失败");
|
||||
}
|
||||
|
||||
const orderedCards = result.data.cards.map((card, index) => ({
|
||||
...card,
|
||||
sortOrder: index,
|
||||
passthrough: passthroughSegmentIndexSet.has(index),
|
||||
}));
|
||||
const orderedCards = result.data.cards.map((card, index) => {
|
||||
const replacementCard = segmentReplacementCards[index];
|
||||
return {
|
||||
...(replacementCard || card),
|
||||
sortOrder: index,
|
||||
passthrough: passthroughSegmentIndexSet.has(index),
|
||||
};
|
||||
});
|
||||
if (orderedCards.length !== sliceSegments.length) {
|
||||
throw new Error("部分切片处理失败,请稍后重试");
|
||||
}
|
||||
@ -728,8 +794,10 @@ export function AgentDetailReferenceStitchDialog({
|
||||
onOpenChange,
|
||||
passthroughSegmentIndexes,
|
||||
passthroughSegmentIndexSet,
|
||||
segmentReplacementCards,
|
||||
sliceSegments.length,
|
||||
snapshot,
|
||||
uploadingSegmentIndex,
|
||||
]);
|
||||
|
||||
return (
|
||||
@ -770,6 +838,11 @@ export function AgentDetailReferenceStitchDialog({
|
||||
直出 {passthroughSegmentIndexes.length} 张
|
||||
</div>
|
||||
) : null}
|
||||
{segmentReplacementCount > 0 ? (
|
||||
<div className="rounded-full border border-emerald-400/20 bg-emerald-500/[0.08] px-3 py-1 text-xs text-emerald-100/85">
|
||||
已更换 {segmentReplacementCount} 张
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
@ -856,28 +929,28 @@ export function AgentDetailReferenceStitchDialog({
|
||||
<div
|
||||
key={`${segment.card.id || "segment"}-${index}-${segment.top}`}
|
||||
className="relative overflow-hidden bg-white"
|
||||
style={{
|
||||
aspectRatio: `${snapshot.width} / ${Math.max(segment.height, 1)}`,
|
||||
contentVisibility: index > 4 ? "auto" : undefined,
|
||||
containIntrinsicSize:
|
||||
index > 4 ? `${Math.max(segment.height, 1)}px` : undefined,
|
||||
}}
|
||||
>
|
||||
style={{
|
||||
aspectRatio: `${snapshot.width} / ${Math.max(segment.height, 1)}`,
|
||||
contentVisibility: index > 4 ? "auto" : undefined,
|
||||
containIntrinsicSize:
|
||||
index > 4 ? `${Math.max(segment.height, 1)}px` : undefined,
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 flex items-center justify-center px-4 text-center text-xs text-neutral-400">
|
||||
参考图 {index + 1} 加载失败
|
||||
</div>
|
||||
{segmentImageUrl ? (
|
||||
<img
|
||||
src={buildStitchSegmentDisplayImageUrl(segmentImageUrl, snapshot.width)}
|
||||
alt={`拼合分段预览 ${index + 1}`}
|
||||
className="relative z-10 block h-full w-full select-none object-fill"
|
||||
draggable={false}
|
||||
decoding="async"
|
||||
loading={index > 2 ? "lazy" : "eager"}
|
||||
fetchPriority={index < 2 ? "high" : "low"}
|
||||
onError={handleSegmentPreviewImageError(originalImageUrl)}
|
||||
/>
|
||||
) : null}
|
||||
{segmentImageUrl ? (
|
||||
<img
|
||||
src={buildStitchSegmentDisplayImageUrl(segmentImageUrl, snapshot.width)}
|
||||
alt={`拼合分段预览 ${index + 1}`}
|
||||
className="relative z-10 block h-full w-full select-none object-fill"
|
||||
draggable={false}
|
||||
decoding="async"
|
||||
loading={index > 2 ? "lazy" : "eager"}
|
||||
fetchPriority={index < 2 ? "high" : "low"}
|
||||
onError={handleSegmentPreviewImageError(originalImageUrl)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@ -976,51 +1049,121 @@ export function AgentDetailReferenceStitchDialog({
|
||||
|
||||
<div className="agent-dark-scrollbar flex-1 space-y-3 overflow-y-auto px-5 py-4">
|
||||
{sliceSegments.length > 0 ? (
|
||||
sliceSegments.map((segment) => (
|
||||
<div
|
||||
key={`slice-segment-${segment.index}`}
|
||||
className="rounded-[20px] border border-white/10 bg-white/[0.03] p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-medium text-white">
|
||||
切片 {segment.index + 1}
|
||||
sliceSegments.map((segment) => {
|
||||
const replacementCard = segmentReplacementCards[segment.index];
|
||||
const replacementUrl = String(
|
||||
replacementCard?.previewUrl || replacementCard?.imageUrl || ""
|
||||
).trim();
|
||||
const isUploadingThisSegment = uploadingSegmentIndex === segment.index;
|
||||
return (
|
||||
<div
|
||||
key={`slice-segment-${segment.index}`}
|
||||
className={cn(
|
||||
"rounded-[20px] border bg-white/[0.03] p-3",
|
||||
replacementCard
|
||||
? "border-emerald-400/25"
|
||||
: "border-white/10"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-medium text-white">
|
||||
切片 {segment.index + 1}
|
||||
</div>
|
||||
<div className="rounded-full border border-white/10 bg-black/30 px-2.5 py-1 text-[11px] text-white/52">
|
||||
高度 {segment.height}px
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-full border border-white/10 bg-black/30 px-2.5 py-1 text-[11px] text-white/52">
|
||||
高度 {segment.height}px
|
||||
<div className="mt-2 text-xs leading-6 text-white/42">
|
||||
从 {Math.round(segment.top)}px 到 {Math.round(segment.top + segment.height)}px
|
||||
</div>
|
||||
{replacementUrl ? (
|
||||
<div className="mt-3 overflow-hidden rounded-[16px] border border-emerald-400/20 bg-white">
|
||||
<img
|
||||
src={buildStitchSegmentDisplayImageUrl(replacementUrl, 360)}
|
||||
alt={`已更换切片 ${segment.index + 1}`}
|
||||
className="block aspect-[4/3] w-full object-contain"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={handleSegmentPreviewImageError(replacementCard?.imageUrl || replacementUrl)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="min-w-[120px] flex-1 text-[11px] text-white/45">
|
||||
{replacementCard
|
||||
? "已使用手动更换图片,应用后会替代当前切片。"
|
||||
: passthroughSegmentIndexSet.has(segment.index)
|
||||
? "当前切片不会参与生图,应用后会直接进入结果区。"
|
||||
: "当前切片会继续参与详情替换生成。"}
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<label
|
||||
className={cn(
|
||||
"inline-flex cursor-pointer items-center rounded-full border px-3 py-1 text-[11px] transition-colors",
|
||||
isUploadingThisSegment
|
||||
? "border-emerald-400/35 bg-emerald-500/[0.12] text-emerald-100"
|
||||
: "border-white/10 bg-white/[0.03] text-white/68 hover:border-white/20 hover:text-white",
|
||||
(isApplying || uploadingSegmentIndex !== null) &&
|
||||
!isUploadingThisSegment &&
|
||||
"pointer-events-none opacity-50"
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*,.heic,.heif"
|
||||
className="hidden"
|
||||
disabled={isApplying || uploadingSegmentIndex !== null}
|
||||
onChange={(event) => {
|
||||
void handleReplaceSegmentImage(segment.index, event);
|
||||
}}
|
||||
/>
|
||||
{isUploadingThisSegment ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Upload className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
{replacementCard ? "重新更换" : "更换图片"}
|
||||
</label>
|
||||
{replacementCard ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setSegmentReplacementCards((current) => {
|
||||
const next = { ...current };
|
||||
delete next[segment.index];
|
||||
return next;
|
||||
})
|
||||
}
|
||||
className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-[11px] text-white/62 transition-colors hover:border-white/20 hover:text-white"
|
||||
>
|
||||
恢复切片
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setPassthroughSegmentIndexes((current) =>
|
||||
current.includes(segment.index)
|
||||
? current.filter((item) => item !== segment.index)
|
||||
: [...current, segment.index].sort((left, right) => left - right)
|
||||
)
|
||||
}
|
||||
className={cn(
|
||||
"rounded-full border px-3 py-1 text-[11px] transition-colors",
|
||||
passthroughSegmentIndexSet.has(segment.index)
|
||||
? "border-cyan-400/35 bg-cyan-500/[0.12] text-cyan-100"
|
||||
: "border-white/10 bg-white/[0.03] text-white/68 hover:border-white/20 hover:text-white"
|
||||
)}
|
||||
>
|
||||
{passthroughSegmentIndexSet.has(segment.index)
|
||||
? "取消不处理"
|
||||
: "标记不处理"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-6 text-white/42">
|
||||
从 {Math.round(segment.top)}px 到 {Math.round(segment.top + segment.height)}px
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between gap-2">
|
||||
<div className="text-[11px] text-white/45">
|
||||
{passthroughSegmentIndexSet.has(segment.index)
|
||||
? "当前切片不会参与生图,应用后会直接进入结果区。"
|
||||
: "当前切片会继续参与详情替换生成。"}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setPassthroughSegmentIndexes((current) =>
|
||||
current.includes(segment.index)
|
||||
? current.filter((item) => item !== segment.index)
|
||||
: [...current, segment.index].sort((left, right) => left - right)
|
||||
)
|
||||
}
|
||||
className={cn(
|
||||
"rounded-full border px-3 py-1 text-[11px] transition-colors",
|
||||
passthroughSegmentIndexSet.has(segment.index)
|
||||
? "border-cyan-400/35 bg-cyan-500/[0.12] text-cyan-100"
|
||||
: "border-white/10 bg-white/[0.03] text-white/68 hover:border-white/20 hover:text-white"
|
||||
)}
|
||||
>
|
||||
{passthroughSegmentIndexSet.has(segment.index)
|
||||
? "取消不处理"
|
||||
: "标记不处理"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="flex min-h-[220px] items-center justify-center rounded-[20px] border border-dashed border-white/12 bg-black/20 px-5 text-center text-sm text-white/42">
|
||||
当前还没有可切割的长图。
|
||||
@ -1029,12 +1172,19 @@ export function AgentDetailReferenceStitchDialog({
|
||||
</div>
|
||||
|
||||
<div className="border-t border-white/10 px-5 py-4">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleApply}
|
||||
disabled={isLoading || isPreparing || isApplying || !snapshot || sliceSegments.length === 0}
|
||||
className="h-11 w-full rounded-[18px] bg-emerald-500 text-black hover:bg-emerald-400 disabled:opacity-50"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleApply}
|
||||
disabled={
|
||||
isLoading ||
|
||||
isPreparing ||
|
||||
isApplying ||
|
||||
uploadingSegmentIndex !== null ||
|
||||
!snapshot ||
|
||||
sliceSegments.length === 0
|
||||
}
|
||||
className="h-11 w-full rounded-[18px] bg-emerald-500 text-black hover:bg-emerald-400 disabled:opacity-50"
|
||||
>
|
||||
{isApplying ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
|
||||
@ -65,6 +65,12 @@ import { AgentDetailReferenceStitchDialog } from "@/components/agents/AgentDetai
|
||||
import { AgentImageTextEditDialog } from "@/components/agents/AgentImageTextEditDialog";
|
||||
import { AgentPsdTemplateEditorDialog } from "@/components/agents/AgentPsdTemplateEditorDialog";
|
||||
import { AgentPreviewImage } from "@/components/agents/AgentPreviewImage";
|
||||
import {
|
||||
applyUploadedDetailReferenceToCard,
|
||||
createUploadedDetailReferenceCard,
|
||||
uploadDetailReferenceImageFile,
|
||||
type UploadedDetailReferenceImage,
|
||||
} from "@/components/agents/detail-reference-upload";
|
||||
import { useAgentStylePerformanceMonitor } from "@/components/agents/use-agent-style-performance-monitor";
|
||||
import { ImageAnnotator } from "@/components/image-annotator";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
@ -1174,6 +1180,11 @@ function DetailResultLocalEditDialog({
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{submitting ? (
|
||||
<DetailResultProcessingScanOverlay
|
||||
label={isScaleAdjustMode ? "比例调整处理中" : "局部编辑处理中"}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@ -1920,31 +1931,36 @@ 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]">
|
||||
<div className="absolute inset-0 z-30 overflow-hidden rounded-[inherit] bg-zinc-950/34">
|
||||
<motion.div
|
||||
aria-hidden
|
||||
className="absolute inset-0 opacity-45"
|
||||
className="absolute inset-0 opacity-50"
|
||||
animate={{
|
||||
backgroundPosition: ["0px 0px", "0px 28px"],
|
||||
backgroundPosition: ["0px 0px", "0px 20px"],
|
||||
}}
|
||||
transition={{ duration: 1.8, repeat: Infinity, ease: "linear" }}
|
||||
transition={{ duration: 1.45, repeat: Infinity, ease: "linear" }}
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(rgba(255,255,255,0.08) 1px, transparent 1px)",
|
||||
backgroundSize: "100% 28px",
|
||||
"linear-gradient(rgba(255,255,255,0.11) 0px, rgba(255,255,255,0.11) 1px, transparent 1px, transparent 20px)",
|
||||
backgroundSize: "100% 20px",
|
||||
}}
|
||||
/>
|
||||
<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" }}
|
||||
/>
|
||||
className="absolute inset-x-[-8%] h-[26%] min-h-[110px]"
|
||||
animate={{ top: ["-30%", "104%"], opacity: [0.38, 0.95, 0.48] }}
|
||||
transition={{ duration: 3.2, repeat: Infinity, ease: "linear" }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,transparent_0%,rgba(34,211,238,0.06)_18%,rgba(52,211,153,0.22)_34%,rgba(255,255,255,0.92)_50%,rgba(52,211,153,0.26)_58%,rgba(34,211,238,0.08)_74%,transparent_100%)]" />
|
||||
<div className="absolute inset-x-[3%] top-1/2 h-px -translate-y-1/2 bg-white/95 shadow-[0_0_18px_rgba(255,255,255,0.92),0_0_34px_rgba(52,211,153,0.55)]" />
|
||||
<div className="absolute inset-x-[6%] top-[calc(50%-10px)] h-px bg-emerald-200/48" />
|
||||
<div className="absolute inset-x-[6%] top-[calc(50%+10px)] h-px bg-cyan-200/38" />
|
||||
</motion.div>
|
||||
<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" }}
|
||||
className="absolute inset-0 bg-[radial-gradient(circle_at_50%_50%,rgba(16,185,129,0.12),transparent_62%)]"
|
||||
animate={{ opacity: [0.16, 0.28, 0.16] }}
|
||||
transition={{ duration: 2.6, 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" />
|
||||
@ -1954,6 +1970,23 @@ function DetailResultProcessingScanOverlay({ label }: { label: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ResultProcessedHintBubble({ label }: { label: string }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -8, scale: 0.96 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -10, scale: 0.96 }}
|
||||
transition={{ duration: 0.22, ease: "easeOut" }}
|
||||
className="pointer-events-none absolute left-1/2 top-3 z-40 -translate-x-1/2"
|
||||
>
|
||||
<div className="inline-flex items-center gap-1.5 rounded-full border border-emerald-200/70 bg-zinc-950/88 px-3 py-1.5 text-[11px] font-semibold text-emerald-50 shadow-[0_14px_34px_rgba(0,0,0,0.42),0_0_20px_rgba(16,185,129,0.22)] backdrop-blur-md">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-300" />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function AnimatedGenerationPlaceholderCard(params: {
|
||||
title: string;
|
||||
description: string;
|
||||
@ -2176,6 +2209,8 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
const [detailProductIdQueryInput, setDetailProductIdQueryInput] = useState("");
|
||||
const [detailReferenceStitchDialogOpen, setDetailReferenceStitchDialogOpen] = useState(false);
|
||||
const [activeDetailReferenceCardId, setActiveDetailReferenceCardId] = useState<string | null>(null);
|
||||
const [detailReferenceUploadTarget, setDetailReferenceUploadTarget] =
|
||||
useState<null | { type: "append" } | { type: "replace"; cardId: string }>(null);
|
||||
const [skuReplacementSearchState, setSkuReplacementSearchState] = useState<{
|
||||
isLoading: boolean;
|
||||
lastQuery: string | null;
|
||||
@ -2194,6 +2229,9 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
const [detailGenerationRunResults, setDetailGenerationRunResults] = useState<
|
||||
DirectDetailGenerationImage[]
|
||||
>([]);
|
||||
const [processedResultHintByUrl, setProcessedResultHintByUrl] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const [replacementResultState, setReplacementResultState] = useState<ReplacementResultState>(
|
||||
() => createEmptyReplacementResultState()
|
||||
);
|
||||
@ -2206,6 +2244,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
const [replacementRerunPrompt, setReplacementRerunPrompt] = useState("");
|
||||
const [detailWatermarkRemoveCardIds, setDetailWatermarkRemoveCardIds] =
|
||||
useState<Set<string>>(() => new Set());
|
||||
const processedResultHintTimerRef = useRef<Record<string, number>>({});
|
||||
const closePsdTemplateDialog = useCallback(() => {
|
||||
setIsPsdTemplateDialogOpen(false);
|
||||
setPsdTemplateDialogSourceUrl(null);
|
||||
@ -3139,6 +3178,15 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
);
|
||||
}, [historyMessages, setMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
Object.values(processedResultHintTimerRef.current).forEach((timerId) => {
|
||||
window.clearTimeout(timerId);
|
||||
});
|
||||
processedResultHintTimerRef.current = {};
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isWorkflowModelLocked) {
|
||||
setSelectedModelId((current) => (current === "auto" ? current : "auto"));
|
||||
@ -5121,11 +5169,11 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
},
|
||||
[mergedResultCollections.scene_generation, onResultPreviewUrlsChange, uploadBase64ResultToStorage]
|
||||
);
|
||||
const appendResultUrlToCollection = useCallback(
|
||||
(modeType: AgentGenerationResultModeType, imageUrl: string) => {
|
||||
const normalizedUrl = String(imageUrl || "").trim();
|
||||
if (!normalizedUrl || !onResultPreviewUrlsChange) {
|
||||
return false;
|
||||
const appendResultUrlToCollection = useCallback(
|
||||
(modeType: AgentGenerationResultModeType, imageUrl: string) => {
|
||||
const normalizedUrl = String(imageUrl || "").trim();
|
||||
if (!normalizedUrl || !onResultPreviewUrlsChange) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nextUrls = Array.from(
|
||||
@ -5139,13 +5187,38 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
setSelectedResultUrl(normalizedUrl);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[activeResultModeType, mergedResultCollections, onResultPreviewUrlsChange]
|
||||
);
|
||||
const handleEnhanceDetailGenerationResult = useCallback(
|
||||
async (params: {
|
||||
cardId: string;
|
||||
imageUrl: string;
|
||||
},
|
||||
[activeResultModeType, mergedResultCollections, onResultPreviewUrlsChange]
|
||||
);
|
||||
const showProcessedResultHint = useCallback((imageUrl: string, label: string) => {
|
||||
const normalizedUrl = String(imageUrl || "").trim();
|
||||
const normalizedLabel = String(label || "").trim();
|
||||
if (!/^https?:\/\//i.test(normalizedUrl) || !normalizedLabel) return;
|
||||
|
||||
const existingTimer = processedResultHintTimerRef.current[normalizedUrl];
|
||||
if (existingTimer) {
|
||||
window.clearTimeout(existingTimer);
|
||||
}
|
||||
|
||||
setProcessedResultHintByUrl((current) => ({
|
||||
...current,
|
||||
[normalizedUrl]: normalizedLabel,
|
||||
}));
|
||||
|
||||
processedResultHintTimerRef.current[normalizedUrl] = window.setTimeout(() => {
|
||||
setProcessedResultHintByUrl((current) => {
|
||||
if (!(normalizedUrl in current)) return current;
|
||||
const next = { ...current };
|
||||
delete next[normalizedUrl];
|
||||
return next;
|
||||
});
|
||||
delete processedResultHintTimerRef.current[normalizedUrl];
|
||||
}, 2800);
|
||||
}, []);
|
||||
const handleEnhanceDetailGenerationResult = useCallback(
|
||||
async (params: {
|
||||
cardId: string;
|
||||
imageUrl: string;
|
||||
resultModeType?: "detail_generation" | "detail_replacement";
|
||||
}) => {
|
||||
const cardId = String(params.cardId || "").trim();
|
||||
@ -5296,6 +5369,10 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
setActiveResultModeType("detail_generation");
|
||||
setSelectedResultUrl(nextUrl);
|
||||
}
|
||||
showProcessedResultHint(
|
||||
nextUrl,
|
||||
isScaleAdjust ? "比例调整完成" : "局部编辑完成"
|
||||
);
|
||||
setDetailLocalEditTarget(null);
|
||||
toast.success(
|
||||
resultModeType === "detail_replacement"
|
||||
@ -5333,6 +5410,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
detailLocalEditSubmittingId,
|
||||
detailLocalEditTarget,
|
||||
handleUpdateReplacementResultRecord,
|
||||
showProcessedResultHint,
|
||||
]
|
||||
);
|
||||
const handlePsdDerivedImageCreated = useCallback(
|
||||
@ -5341,13 +5419,13 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
},
|
||||
[appendResultUrlToCollection, psdTemplateDialogResultModeType]
|
||||
);
|
||||
const handlePsdRenderedResultCreated = useCallback(
|
||||
(imageUrl: string) => {
|
||||
appendResultUrlToCollection(psdTemplateDialogResultModeType, imageUrl);
|
||||
},
|
||||
[appendResultUrlToCollection, psdTemplateDialogResultModeType]
|
||||
);
|
||||
const handleDeleteResultImage = useCallback(
|
||||
const handlePsdRenderedResultCreated = useCallback(
|
||||
(imageUrl: string) => {
|
||||
appendResultUrlToCollection(psdTemplateDialogResultModeType, imageUrl);
|
||||
},
|
||||
[appendResultUrlToCollection, psdTemplateDialogResultModeType]
|
||||
);
|
||||
const handleDeleteResultImage = useCallback(
|
||||
(modeType: AgentGenerationResultModeType, imageUrl: string) => {
|
||||
const normalizedUrl = String(imageUrl || "").trim();
|
||||
if (!normalizedUrl || !onDeleteResultImage) {
|
||||
@ -5363,6 +5441,17 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
if (sceneResizeEditorTargetUrl === normalizedUrl) {
|
||||
setSceneResizeEditorTargetUrl(null);
|
||||
}
|
||||
const existingTimer = processedResultHintTimerRef.current[normalizedUrl];
|
||||
if (existingTimer) {
|
||||
window.clearTimeout(existingTimer);
|
||||
delete processedResultHintTimerRef.current[normalizedUrl];
|
||||
}
|
||||
setProcessedResultHintByUrl((current) => {
|
||||
if (!(normalizedUrl in current)) return current;
|
||||
const next = { ...current };
|
||||
delete next[normalizedUrl];
|
||||
return next;
|
||||
});
|
||||
|
||||
onDeleteResultImage(modeType, normalizedUrl);
|
||||
toast.success("结果图已删除");
|
||||
@ -6258,6 +6347,101 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
references: [],
|
||||
}));
|
||||
}, [updateDetailReplacementState]);
|
||||
const clearDetailReplacementResultRecords = useCallback((cardIds: string[]) => {
|
||||
const idSet = new Set(cardIds.map((item) => String(item || "").trim()).filter(Boolean));
|
||||
if (idSet.size === 0) return;
|
||||
|
||||
setReplacementResultState((current) => {
|
||||
const nextDetail = { ...current.detail_replacement };
|
||||
let changed = false;
|
||||
idSet.forEach((cardId) => {
|
||||
if (cardId in nextDetail) {
|
||||
delete nextDetail[cardId];
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
return changed
|
||||
? {
|
||||
...current,
|
||||
detail_replacement: nextDetail,
|
||||
}
|
||||
: current;
|
||||
});
|
||||
}, []);
|
||||
const handleAppendDetailReferenceFiles = useCallback(
|
||||
async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const input = event.currentTarget;
|
||||
const files = Array.from(input.files || []);
|
||||
if (files.length === 0) return;
|
||||
|
||||
setDetailReferenceUploadTarget({ type: "append" });
|
||||
try {
|
||||
const uploads: UploadedDetailReferenceImage[] = [];
|
||||
for (const file of files) {
|
||||
uploads.push(await uploadDetailReferenceImageFile(file));
|
||||
}
|
||||
|
||||
updateDetailReplacementState((current) => {
|
||||
const startIndex = current.references.length;
|
||||
const uploadedCards = uploads.map((upload, uploadIndex) =>
|
||||
createUploadedDetailReferenceCard({
|
||||
upload,
|
||||
index: startIndex + uploadIndex,
|
||||
labelPrefix: "上传目标详情",
|
||||
})
|
||||
);
|
||||
return {
|
||||
...current,
|
||||
references: [...current.references, ...uploadedCards].map((card, index) => ({
|
||||
...card,
|
||||
sortOrder: index,
|
||||
})),
|
||||
};
|
||||
});
|
||||
toast.success(`已上传 ${uploads.length} 张目标详情`);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "目标详情上传失败");
|
||||
} finally {
|
||||
setDetailReferenceUploadTarget(null);
|
||||
input.value = "";
|
||||
}
|
||||
},
|
||||
[updateDetailReplacementState]
|
||||
);
|
||||
const handleReplaceDetailReferenceCardFile = useCallback(
|
||||
async (cardId: string, event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const input = event.currentTarget;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setDetailReferenceUploadTarget({ type: "replace", cardId });
|
||||
try {
|
||||
const upload = await uploadDetailReferenceImageFile(file);
|
||||
let didReplace = false;
|
||||
updateDetailReplacementState((current) => ({
|
||||
...current,
|
||||
references: current.references.map((card, index) => {
|
||||
if (card.id !== cardId) return card;
|
||||
didReplace = true;
|
||||
return {
|
||||
...applyUploadedDetailReferenceToCard(card, upload),
|
||||
sortOrder: card.sortOrder ?? index,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
if (didReplace) {
|
||||
clearDetailReplacementResultRecords([cardId]);
|
||||
toast.success("已更换当前目标详情图");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "目标详情更换失败");
|
||||
} finally {
|
||||
setDetailReferenceUploadTarget(null);
|
||||
input.value = "";
|
||||
}
|
||||
},
|
||||
[clearDetailReplacementResultRecords, updateDetailReplacementState]
|
||||
);
|
||||
const handlePreparedDetailReferenceCards = useCallback(
|
||||
(preparedCards: AgentModeWorkspaceState["detailReplacement"]["references"]) => {
|
||||
const preparedById = new Map(preparedCards.map((card) => [card.id, card]));
|
||||
@ -7425,11 +7609,34 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
目标 / {detailReferenceSourceLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Input
|
||||
value={detailProductIdQueryInput}
|
||||
onChange={(event) => setDetailProductIdQueryInput(event.target.value)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<label
|
||||
className={cn(
|
||||
"inline-flex h-12 cursor-pointer items-center justify-center rounded-[22px] border border-white/10 bg-white/[0.03] px-5 text-sm text-white transition-colors hover:bg-white/[0.08]",
|
||||
detailReferenceUploadTarget !== null && "pointer-events-none opacity-60"
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*,.heic,.heif"
|
||||
multiple
|
||||
className="hidden"
|
||||
disabled={detailReferenceUploadTarget !== null}
|
||||
onChange={(event) => {
|
||||
void handleAppendDetailReferenceFiles(event);
|
||||
}}
|
||||
/>
|
||||
{detailReferenceUploadTarget?.type === "append" ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
上传目标详情
|
||||
</label>
|
||||
<Input
|
||||
value={detailProductIdQueryInput}
|
||||
onChange={(event) => setDetailProductIdQueryInput(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
@ -7924,10 +8131,33 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
<div className="text-xs text-white/40">
|
||||
支持按 SPU / SKU 自动兜底查询任务整套候选,并切换某一套的{detailReferenceSourceLabel}参考图。
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{detailReplacementReferenceCards.length > 0 ? (
|
||||
<button
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label
|
||||
className={cn(
|
||||
"inline-flex cursor-pointer items-center rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-white/70 transition-colors hover:bg-white/[0.08] hover:text-white",
|
||||
detailReferenceUploadTarget !== null && "pointer-events-none opacity-60"
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*,.heic,.heif"
|
||||
multiple
|
||||
className="hidden"
|
||||
disabled={detailReferenceUploadTarget !== null}
|
||||
onChange={(event) => {
|
||||
void handleAppendDetailReferenceFiles(event);
|
||||
}}
|
||||
/>
|
||||
{detailReferenceUploadTarget?.type === "append" ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Upload className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
上传
|
||||
</label>
|
||||
{detailReplacementReferenceCards.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDetailReferenceStitchDialogOpen(true)}
|
||||
className="rounded-full border border-emerald-400/20 bg-emerald-500/[0.08] px-3 py-1 text-xs text-emerald-100 transition-colors hover:bg-emerald-500/[0.14]"
|
||||
@ -7943,11 +8173,14 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{detailReplacementReferenceCards.length > 0 ? (
|
||||
detailReplacementReferenceCards.map((card, index) => (
|
||||
(() => {
|
||||
const isActive = activeDetailReferenceCardId === card.id;
|
||||
return (
|
||||
<div
|
||||
detailReplacementReferenceCards.map((card, index) => (
|
||||
(() => {
|
||||
const isActive = activeDetailReferenceCardId === card.id;
|
||||
const isReplacingReference =
|
||||
detailReferenceUploadTarget?.type === "replace" &&
|
||||
detailReferenceUploadTarget.cardId === card.id;
|
||||
return (
|
||||
<div
|
||||
key={card.id}
|
||||
style={AGENT_DEFERRED_LIST_CARD_STYLE}
|
||||
className={cn(
|
||||
@ -7958,10 +8191,34 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
)}
|
||||
onClick={() => setActiveDetailReferenceCardId(card.id)}
|
||||
>
|
||||
<div className="relative min-h-[220px] overflow-hidden bg-white/95">
|
||||
<div className="absolute right-3 top-3 z-10 flex flex-col items-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
<div className="relative min-h-[220px] overflow-hidden bg-white/95">
|
||||
<div className="absolute right-3 top-3 z-10 flex flex-col items-end gap-2">
|
||||
<label
|
||||
className={cn(
|
||||
IMAGE_OVERLAY_ACTION_CLASS,
|
||||
"inline-flex cursor-pointer items-center gap-1.5",
|
||||
detailReferenceUploadTarget !== null && "pointer-events-none opacity-60"
|
||||
)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*,.heic,.heif"
|
||||
className="hidden"
|
||||
disabled={detailReferenceUploadTarget !== null}
|
||||
onChange={(event) => {
|
||||
void handleReplaceDetailReferenceCardFile(card.id, event);
|
||||
}}
|
||||
/>
|
||||
{isReplacingReference ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span>{isReplacingReference ? "更换中" : "更换"}</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleRemoveDetailReferenceCard(card.id);
|
||||
@ -8037,10 +8294,10 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
) : (
|
||||
<div className="flex min-h-[220px] flex-col items-center justify-center rounded-[22px] border border-dashed border-white/12 bg-black/20 px-5 text-center">
|
||||
<div className="text-sm font-medium text-white/72">等待{detailReferenceSourceLabel}参考图</div>
|
||||
<div className="mt-2 text-xs leading-6 text-white/40">
|
||||
点击“检索可用模版”后,这里会展示当前品牌下主 SPU 对应的真实{detailReferenceSourceLabel}参考图。
|
||||
删除不需要的参考图后,列表会自动上移补位。
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-6 text-white/40">
|
||||
点击“检索可用模版”后,这里会展示当前品牌下主 SPU 对应的真实{detailReferenceSourceLabel}参考图;也可以直接上传目标详情图。
|
||||
删除不需要的参考图后,列表会自动上移补位。
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -8119,6 +8376,13 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
<div className={cn("absolute left-3 top-3", IMAGE_OVERLAY_STATUS_BASE_CLASS, statusMeta.className)}>
|
||||
{statusMeta.label}
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{card.resultUrl && processedResultHintByUrl[card.resultUrl] ? (
|
||||
<ResultProcessedHintBubble
|
||||
label={processedResultHintByUrl[card.resultUrl]}
|
||||
/>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
{isActive ? (
|
||||
<div className={cn("absolute left-3 top-12", IMAGE_OVERLAY_ACTIVE_BADGE_CLASS)}>
|
||||
当前联动
|
||||
@ -8967,6 +9231,13 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
<span>套PSD</span>
|
||||
</button>
|
||||
) : null}
|
||||
<AnimatePresence>
|
||||
{card.url && processedResultHintByUrl[card.url] ? (
|
||||
<ResultProcessedHintBubble
|
||||
label={processedResultHintByUrl[card.url]}
|
||||
/>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
|
||||
129
components/agents/detail-reference-upload.ts
Normal file
129
components/agents/detail-reference-upload.ts
Normal file
@ -0,0 +1,129 @@
|
||||
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
||||
import type { AgentDetailReplacementReferenceItem } from "@/types/agent";
|
||||
|
||||
const DETAIL_REFERENCE_UPLOAD_MAX_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
export interface UploadedDetailReferenceImage {
|
||||
url: string;
|
||||
name: string;
|
||||
contentType: string | null;
|
||||
assetId: string | null;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
}
|
||||
|
||||
function isSupportedDetailReferenceImage(file: File) {
|
||||
return (
|
||||
file.type.startsWith("image/") ||
|
||||
/\.(jpe?g|png|webp|heic|heif)$/i.test(file.name)
|
||||
);
|
||||
}
|
||||
|
||||
function buildDetailReferenceUploadFilename(file: File) {
|
||||
const extension = file.name.toLowerCase().split(".").pop() || "jpg";
|
||||
const baseName =
|
||||
file.name
|
||||
.replace(/\.[^.]+$/, "")
|
||||
.replace(/[^\u4e00-\u9fa5a-zA-Z0-9_-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 48) || "target-detail";
|
||||
return `${baseName}-${Date.now()}.${extension}`;
|
||||
}
|
||||
|
||||
function normalizeDimension(value: unknown) {
|
||||
const numberValue = Math.round(Number(value));
|
||||
return Number.isFinite(numberValue) && numberValue > 0 ? numberValue : null;
|
||||
}
|
||||
|
||||
async function readLocalImageDimensions(file: File) {
|
||||
if (typeof window === "undefined") {
|
||||
return { width: null, height: null };
|
||||
}
|
||||
|
||||
return new Promise<{ width: number | null; height: number | null }>((resolve) => {
|
||||
const objectUrl = window.URL.createObjectURL(file);
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
const width = normalizeDimension(image.naturalWidth);
|
||||
const height = normalizeDimension(image.naturalHeight);
|
||||
window.URL.revokeObjectURL(objectUrl);
|
||||
resolve({ width, height });
|
||||
};
|
||||
image.onerror = () => {
|
||||
window.URL.revokeObjectURL(objectUrl);
|
||||
resolve({ width: null, height: null });
|
||||
};
|
||||
image.src = objectUrl;
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadDetailReferenceImageFile(
|
||||
file: File
|
||||
): Promise<UploadedDetailReferenceImage> {
|
||||
if (!isSupportedDetailReferenceImage(file)) {
|
||||
throw new Error("请上传图片文件");
|
||||
}
|
||||
if (file.size > DETAIL_REFERENCE_UPLOAD_MAX_BYTES) {
|
||||
throw new Error("目标详情图过大,请压缩到 50MB 以内");
|
||||
}
|
||||
|
||||
const dimensionsPromise = readLocalImageDimensions(file);
|
||||
const formData = new FormData();
|
||||
formData.append("file", file, buildDetailReferenceUploadFilename(file));
|
||||
formData.append("purpose", "canvas-upload");
|
||||
|
||||
const response = await fetchUploadWithRetry(formData);
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok || !payload?.url) {
|
||||
throw new Error(payload?.error || "目标详情图上传失败");
|
||||
}
|
||||
|
||||
const url = String(payload.url || "").trim();
|
||||
if (!/^https?:\/\//i.test(url)) {
|
||||
throw new Error("目标详情图上传后未返回可用地址");
|
||||
}
|
||||
|
||||
const localDimensions = await dimensionsPromise;
|
||||
return {
|
||||
url,
|
||||
name: file.name || "目标详情图",
|
||||
contentType: file.type || null,
|
||||
assetId: typeof payload.assetId === "string" ? payload.assetId : null,
|
||||
width: normalizeDimension(payload.width) || localDimensions.width,
|
||||
height: normalizeDimension(payload.height) || localDimensions.height,
|
||||
};
|
||||
}
|
||||
|
||||
export function createUploadedDetailReferenceCard(params: {
|
||||
upload: UploadedDetailReferenceImage;
|
||||
index: number;
|
||||
labelPrefix?: string;
|
||||
}): AgentDetailReplacementReferenceItem {
|
||||
const labelPrefix = params.labelPrefix || "上传目标详情";
|
||||
return {
|
||||
id: `manual-detail-reference-${Date.now()}-${params.index + 1}-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 8)}`,
|
||||
label: `${labelPrefix} ${String(params.index + 1).padStart(2, "0")}`,
|
||||
imageUrl: params.upload.url,
|
||||
previewUrl: params.upload.url,
|
||||
width: params.upload.width,
|
||||
height: params.upload.height,
|
||||
candidateId: null,
|
||||
sortOrder: params.index,
|
||||
passthrough: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyUploadedDetailReferenceToCard(
|
||||
card: AgentDetailReplacementReferenceItem,
|
||||
upload: UploadedDetailReferenceImage
|
||||
): AgentDetailReplacementReferenceItem {
|
||||
return {
|
||||
...card,
|
||||
imageUrl: upload.url,
|
||||
previewUrl: upload.url,
|
||||
width: upload.width,
|
||||
height: upload.height,
|
||||
};
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user