Compare commits
26 Commits
main
...
codex/yedi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6937217634 | ||
|
|
84f105d4c8 | ||
|
|
824806406a | ||
|
|
6b72771701 | ||
|
|
97cc9e4a0a | ||
|
|
154ec44a4c | ||
|
|
2f0c533b87 | ||
|
|
3ac209d149 | ||
|
|
97d7604e7c | ||
|
|
641cd800b2 | ||
|
|
f5ee1fe592 | ||
|
|
674a8d143e | ||
|
|
b189ca1b58 | ||
|
|
6e647021cd | ||
|
|
6d60d53910 | ||
|
|
f050c807a4 | ||
|
|
c15f6d435f | ||
|
|
42adff8545 | ||
|
|
a701ddde50 | ||
|
|
d14fc0c188 | ||
|
|
f455b433ec | ||
|
|
6e0e4a5454 | ||
|
|
5761fa77b1 | ||
|
|
fab8826123 | ||
|
|
c83a16545c | ||
|
|
f7cae037d5 |
@ -95,6 +95,8 @@ const SYSTEM_FONT_OPTIONS = [
|
||||
|
||||
const EMPTY_CATEGORY_SELECT_VALUE = "__none__";
|
||||
const EMPTY_BRAND_SELECT_VALUE = "__unassigned__";
|
||||
const ALL_TEMPLATE_BRANDS_SELECT_VALUE = "__all_template_brands__";
|
||||
const SELF_CLIP_MASK_SELECT_VALUE = "__self_clip_mask__";
|
||||
const UNASSIGNED_BRAND_LABEL = "未分类";
|
||||
const MAX_PSD_TEMPLATE_FILE_SIZE = 80 * 1024 * 1024;
|
||||
const PSD_TEMPLATE_MODE_OPTIONS: Array<{
|
||||
@ -366,6 +368,7 @@ export function PsdTemplatesSettingsClient({
|
||||
const [templateBrandDrafts, setTemplateBrandDrafts] = useState<Record<string, string>>({});
|
||||
const [fontBrandDrafts, setFontBrandDrafts] = useState<Record<string, string>>({});
|
||||
const [parsingTemplateIds, setParsingTemplateIds] = useState<string[]>([]);
|
||||
const [templateBrandFilter, setTemplateBrandFilter] = useState(ALL_TEMPLATE_BRANDS_SELECT_VALUE);
|
||||
|
||||
const selectedTemplate = useMemo(
|
||||
() => templates.find((template) => template.id === selectedTemplateId) || null,
|
||||
@ -444,9 +447,42 @@ export function PsdTemplatesSettingsClient({
|
||||
const currentBrand = normalizeManagedBrandName(draftTemplate?.brandName);
|
||||
return Array.from(new Set([...managedBrandOptions, ...(currentBrand ? [currentBrand] : [])]));
|
||||
}, [draftTemplate?.brandName, managedBrandOptions]);
|
||||
const templateBrandFilterOptions = useMemo(() => {
|
||||
const brands = new Set<string>();
|
||||
let hasUnassigned = false;
|
||||
|
||||
templates.forEach((template) => {
|
||||
const brand = normalizeManagedBrandName(template.brandName);
|
||||
if (brand) {
|
||||
brands.add(brand);
|
||||
} else {
|
||||
hasUnassigned = true;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
brands: Array.from(brands).sort((left, right) => left.localeCompare(right, "zh-CN")),
|
||||
hasUnassigned,
|
||||
};
|
||||
}, [templates]);
|
||||
const filteredTemplates = useMemo(() => {
|
||||
if (templateBrandFilter === ALL_TEMPLATE_BRANDS_SELECT_VALUE) {
|
||||
return templates;
|
||||
}
|
||||
|
||||
if (templateBrandFilter === EMPTY_BRAND_SELECT_VALUE) {
|
||||
return templates.filter((template) => !normalizeManagedBrandName(template.brandName));
|
||||
}
|
||||
|
||||
return templates.filter(
|
||||
(template) =>
|
||||
normalizeManagedBrandName(template.brandName).toLowerCase() ===
|
||||
templateBrandFilter.toLowerCase()
|
||||
);
|
||||
}, [templateBrandFilter, templates]);
|
||||
const groupedTemplates = useMemo(() => {
|
||||
const grouped = new Map<string, AgentPsdTemplate[]>();
|
||||
templates.forEach((template) => {
|
||||
filteredTemplates.forEach((template) => {
|
||||
const label = normalizeManagedBrandName(template.brandName) || UNASSIGNED_BRAND_LABEL;
|
||||
const current = grouped.get(label) || [];
|
||||
current.push(template);
|
||||
@ -463,7 +499,7 @@ export function PsdTemplatesSettingsClient({
|
||||
label,
|
||||
items,
|
||||
}));
|
||||
}, [templates]);
|
||||
}, [filteredTemplates]);
|
||||
const groupedFonts = useMemo(() => {
|
||||
const grouped = new Map<string, AgentPsdFont[]>();
|
||||
fonts.forEach((font) => {
|
||||
@ -1644,7 +1680,22 @@ export function PsdTemplatesSettingsClient({
|
||||
: "当前筛选条件下没有图层,请换个关键词试试。"}
|
||||
</div>
|
||||
) : (
|
||||
filteredLayers.map((layer, index) => (
|
||||
filteredLayers.map((layer, index) => {
|
||||
const clipMaskLayerOptions =
|
||||
layer.slotType === "image"
|
||||
? draftTemplate.layers.filter(
|
||||
(candidate) =>
|
||||
candidate.id !== layer.id &&
|
||||
(candidate.previewUrl || candidate.sourceUrl) &&
|
||||
!(
|
||||
candidate.slotType === "text" &&
|
||||
!candidate.previewUrl &&
|
||||
!candidate.sourceUrl
|
||||
)
|
||||
)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div key={layer.id} className="rounded-2xl border p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
@ -1798,6 +1849,12 @@ export function PsdTemplatesSettingsClient({
|
||||
allowUserUploadOverride: checked
|
||||
? false
|
||||
: current.allowUserUploadOverride,
|
||||
forceClipMask: checked
|
||||
? false
|
||||
: current.forceClipMask,
|
||||
clipMaskLayerId: checked
|
||||
? undefined
|
||||
: current.clipMaskLayerId,
|
||||
};
|
||||
}
|
||||
|
||||
@ -1834,6 +1891,12 @@ export function PsdTemplatesSettingsClient({
|
||||
allowUserUploadOverride: checked
|
||||
? current.allowUserUploadOverride
|
||||
: false,
|
||||
forceClipMask: checked
|
||||
? current.forceClipMask
|
||||
: false,
|
||||
clipMaskLayerId: checked
|
||||
? current.clipMaskLayerId
|
||||
: undefined,
|
||||
}
|
||||
: {
|
||||
...current,
|
||||
@ -1949,6 +2012,84 @@ export function PsdTemplatesSettingsClient({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-xl border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-sm font-medium">强制剪切蒙版</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
开启后替换图会按指定目标图层的透明区域剪切,适合替换图需要贴合超级锁或固定形状的图片槽位。
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={layer.isEditable && layer.forceClipMask === true}
|
||||
disabled={!layer.isEditable || layer.isSuperLocked === true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateDraftLayer(layer.id, (current) =>
|
||||
current.slotType === "image"
|
||||
? {
|
||||
...current,
|
||||
forceClipMask: current.isEditable
|
||||
? checked
|
||||
: false,
|
||||
clipMaskLayerId:
|
||||
current.isEditable && checked
|
||||
? current.clipMaskLayerId
|
||||
: undefined,
|
||||
}
|
||||
: current
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{layer.forceClipMask ? (
|
||||
<div className="space-y-2 rounded-xl border p-3 md:col-span-2 xl:col-span-3">
|
||||
<div className="text-sm font-medium">剪切蒙版目标图层</div>
|
||||
<Select
|
||||
value={
|
||||
layer.clipMaskLayerId ||
|
||||
SELF_CLIP_MASK_SELECT_VALUE
|
||||
}
|
||||
disabled={
|
||||
!layer.isEditable ||
|
||||
layer.isSuperLocked === true
|
||||
}
|
||||
onValueChange={(value) =>
|
||||
updateDraftLayer(layer.id, (current) =>
|
||||
current.slotType === "image"
|
||||
? {
|
||||
...current,
|
||||
clipMaskLayerId:
|
||||
value === SELF_CLIP_MASK_SELECT_VALUE
|
||||
? undefined
|
||||
: value,
|
||||
}
|
||||
: current
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择用于裁切的目标图层" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={SELF_CLIP_MASK_SELECT_VALUE}>
|
||||
使用当前替换图层自身透明区域
|
||||
</SelectItem>
|
||||
{clipMaskLayerOptions.map((candidate) => (
|
||||
<SelectItem
|
||||
key={`${layer.id}-clip-mask-${candidate.id}`}
|
||||
value={candidate.id}
|
||||
>
|
||||
{candidate.layerName} · {candidate.width} x {candidate.height}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
如果 PSD 里目标形状是超级锁图层,请在这里选择那个目标图层;前台只能移动替换图,目标图仍保持锁定不可选中。
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2 rounded-xl border p-3">
|
||||
<div className="text-sm font-medium">填充模式</div>
|
||||
<Select
|
||||
@ -2261,7 +2402,8 @@ export function PsdTemplatesSettingsClient({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
@ -2929,9 +3071,44 @@ export function PsdTemplatesSettingsClient({
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">模板列表</CardTitle>
|
||||
<CardDescription>按品牌查看模板,选择一个模板继续配置槽位。</CardDescription>
|
||||
<CardDescription>按品牌筛选模板,选择一个模板继续配置槽位。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex flex-col gap-3 rounded-2xl border bg-muted/20 p-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">品牌筛选</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
当前显示 {filteredTemplates.length} / {templates.length} 个模板
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Select value={templateBrandFilter} onValueChange={setTemplateBrandFilter}>
|
||||
<SelectTrigger className="w-full sm:w-[260px]">
|
||||
<SelectValue placeholder="选择品牌" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_TEMPLATE_BRANDS_SELECT_VALUE}>全部品牌</SelectItem>
|
||||
{templateBrandFilterOptions.hasUnassigned ? (
|
||||
<SelectItem value={EMPTY_BRAND_SELECT_VALUE}>未分类</SelectItem>
|
||||
) : null}
|
||||
{templateBrandFilterOptions.brands.map((brand) => (
|
||||
<SelectItem key={`template-brand-filter-${brand}`} value={brand}>
|
||||
{brand}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{templateBrandFilter !== ALL_TEMPLATE_BRANDS_SELECT_VALUE ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setTemplateBrandFilter(ALL_TEMPLATE_BRANDS_SELECT_VALUE)}
|
||||
>
|
||||
清除筛选
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{templateListErrorMessage && templates.length > 0 ? (
|
||||
<div className="flex items-start gap-3 rounded-xl border border-amber-300/30 bg-amber-500/5 p-4 text-sm text-amber-100">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
@ -2977,6 +3154,10 @@ export function PsdTemplatesSettingsClient({
|
||||
<div className="rounded-xl border border-dashed p-4 text-sm text-muted-foreground">
|
||||
还没有 PSD 模板,先上传一个模板开始配置。
|
||||
</div>
|
||||
) : filteredTemplates.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed p-4 text-sm text-muted-foreground">
|
||||
当前品牌下没有 PSD 模板,可以切换品牌或清除筛选。
|
||||
</div>
|
||||
) : (
|
||||
groupedTemplates.map((group) => (
|
||||
<div key={group.label} className="space-y-3 rounded-2xl border bg-background/50 p-3">
|
||||
|
||||
@ -18,6 +18,7 @@ import { normalizeAgentPsdTemplateCategoryTags } from "@/lib/agents/psd-template
|
||||
import { resolveAgentPsdLocalCachePath } from "@/lib/agents/psd-local-cache";
|
||||
import {
|
||||
preparePsdTemplateRenderAssets,
|
||||
uploadPreparedPsdTemplateRenderSegments,
|
||||
} from "@/lib/agents/psd-template-parser";
|
||||
import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings";
|
||||
import { repairAgentPsdTemplateAssetsIfNeeded } from "@/lib/agents/psd-template-repair";
|
||||
@ -222,6 +223,11 @@ export async function PUT(
|
||||
preparedAssets.renderBaseBuffer.byteOffset + preparedAssets.renderBaseBuffer.byteLength
|
||||
) as ArrayBuffer
|
||||
);
|
||||
const uploadedRenderSegments = await uploadPreparedPsdTemplateRenderSegments({
|
||||
templateId: currentTemplate.id,
|
||||
segments: preparedAssets.renderSegments,
|
||||
uploadAsset,
|
||||
});
|
||||
|
||||
const updatedTemplate = {
|
||||
...currentTemplate,
|
||||
@ -234,6 +240,7 @@ export async function PUT(
|
||||
status: nextStatus,
|
||||
renderBaseUrl: uploadedRenderBase.url,
|
||||
renderBasePath: uploadedRenderBase.pathname,
|
||||
renderSegments: uploadedRenderSegments,
|
||||
layers: nextLayers,
|
||||
layerCount: nextLayers.length,
|
||||
assetVersion: AGENT_PSD_TEMPLATE_ASSET_VERSION,
|
||||
|
||||
@ -34,6 +34,7 @@ type ExternalImageRecord = {
|
||||
label?: string;
|
||||
fileName?: string;
|
||||
fileSize?: number;
|
||||
previewUrl?: string;
|
||||
downloadUrl?: string;
|
||||
};
|
||||
|
||||
@ -81,6 +82,7 @@ type MaterialSearchImage = {
|
||||
label: string;
|
||||
fileName: string;
|
||||
imageUrl: string;
|
||||
previewUrl: string | null;
|
||||
assetId: string | null;
|
||||
mimeType: string;
|
||||
group: "fixed" | "other";
|
||||
@ -611,6 +613,7 @@ function mapExternalImage(params: {
|
||||
label: String(params.image.label || "").trim() || `图片${params.sortOrder + 1}`,
|
||||
fileName: filename,
|
||||
imageUrl: downloadUrl,
|
||||
previewUrl: String(params.image.previewUrl || "").trim() || null,
|
||||
assetId: null,
|
||||
mimeType: inferMimeTypeFromFilename(filename),
|
||||
group: params.group,
|
||||
|
||||
@ -33,6 +33,7 @@ const querySchema = z.object({
|
||||
target: z.enum(["main", "detail", "sku"]),
|
||||
brandName: z.string().trim().max(64).optional(),
|
||||
channel: productDetailChannelSchema,
|
||||
strictQueryType: z.enum(["true", "false", "1", "0"]).optional(),
|
||||
});
|
||||
|
||||
function jsonNoStore(
|
||||
@ -235,7 +236,35 @@ function getGoodsInfoBrandName(data: Record<string, unknown>) {
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchGoodsBrandByKeyword(params: {
|
||||
function getGoodsInfoMatchedSpu(data: Record<string, unknown>) {
|
||||
return (
|
||||
sanitizeText(data["spu"], 64) ||
|
||||
sanitizeText(data["SPU"], 64) ||
|
||||
sanitizeText(data["productSpu"], 64) ||
|
||||
sanitizeText(data["ProductSpu"], 64) ||
|
||||
sanitizeText(data["商品SPU"], 64) ||
|
||||
sanitizeText(data["商品spu"], 64) ||
|
||||
sanitizeText(data["款号"], 64) ||
|
||||
sanitizeText(data["商品款号"], 64) ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function getGoodsInfoMatchedSku(data: Record<string, unknown>) {
|
||||
return (
|
||||
sanitizeText(data["sku"], 64) ||
|
||||
sanitizeText(data["SKU"], 64) ||
|
||||
sanitizeText(data["skuId"], 64) ||
|
||||
sanitizeText(data["SkuId"], 64) ||
|
||||
sanitizeText(data["skuoid"], 64) ||
|
||||
sanitizeText(data["SKUOID"], 64) ||
|
||||
sanitizeText(data["商品SKU"], 64) ||
|
||||
sanitizeText(data["商品sku"], 64) ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchGoodsLookupByKeyword(params: {
|
||||
keyword: string;
|
||||
config: ReturnType<typeof normalizeAgentWorkflowGoodsInfoApiConfig>;
|
||||
}) {
|
||||
@ -269,13 +298,20 @@ async function fetchGoodsBrandByKeyword(params: {
|
||||
});
|
||||
const result = (await response.json().catch(() => ({}))) as ExternalGoodsInfoResponse;
|
||||
if (response.ok && result.success === true && result.data) {
|
||||
const brandName = getGoodsInfoBrandName(result.data);
|
||||
if (brandName) {
|
||||
return brandName;
|
||||
const data = result.data;
|
||||
const brandName = getGoodsInfoBrandName(data);
|
||||
const matchedSpu = getGoodsInfoMatchedSpu(data);
|
||||
const matchedSku = getGoodsInfoMatchedSku(data);
|
||||
if (brandName || matchedSpu || matchedSku) {
|
||||
return {
|
||||
brandName,
|
||||
matchedSpu,
|
||||
matchedSku,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[Agent Workflow Reference Search] Goods brand query failed:", {
|
||||
console.warn("[Agent Workflow Reference Search] Goods info lookup failed:", {
|
||||
requestPath,
|
||||
error,
|
||||
});
|
||||
@ -854,6 +890,8 @@ export async function GET(request: NextRequest) {
|
||||
target: request.nextUrl.searchParams.get("target"),
|
||||
brandName: request.nextUrl.searchParams.get("brandName") || undefined,
|
||||
channel: request.nextUrl.searchParams.get("channel") || undefined,
|
||||
strictQueryType:
|
||||
request.nextUrl.searchParams.get("strictQueryType") || undefined,
|
||||
});
|
||||
|
||||
const setting = await getSystemSetting({ key: AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY });
|
||||
@ -907,15 +945,25 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const materialQueryType = queryType as AgentWorkflowMaterialQueryType;
|
||||
const requestedBrandName = String(parsed.brandName || "").trim();
|
||||
const verifiedGoodsBrandName = requestedBrandName
|
||||
? await fetchGoodsBrandByKeyword({
|
||||
keyword: normalizedKeyword,
|
||||
config: goodsInfoConfig,
|
||||
}).catch((error) => {
|
||||
console.warn("[Agent Workflow Reference Search] Goods brand lookup failed:", error);
|
||||
return null;
|
||||
})
|
||||
: null;
|
||||
const goodsLookup = await fetchGoodsLookupByKeyword({
|
||||
keyword: normalizedKeyword,
|
||||
config: goodsInfoConfig,
|
||||
}).catch((error) => {
|
||||
console.warn("[Agent Workflow Reference Search] Goods info lookup failed:", error);
|
||||
return null;
|
||||
});
|
||||
const verifiedGoodsBrandName = goodsLookup?.brandName || null;
|
||||
const resolvedMaterialKeyword =
|
||||
materialQueryType === "spu" && goodsLookup?.matchedSpu
|
||||
? goodsLookup.matchedSpu.trim().toUpperCase()
|
||||
: normalizedKeyword;
|
||||
if (materialQueryType === "spu" && goodsLookup?.matchedSpu) {
|
||||
console.info("[Agent Workflow Reference Search] Resolved SKU input to SPU:", {
|
||||
keyword: normalizedKeyword,
|
||||
resolvedSpu: resolvedMaterialKeyword,
|
||||
matchedSku: goodsLookup.matchedSku || null,
|
||||
});
|
||||
}
|
||||
if (
|
||||
requestedBrandName &&
|
||||
verifiedGoodsBrandName &&
|
||||
@ -928,12 +976,15 @@ export async function GET(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
const queryTypesToTry = Array.from(
|
||||
new Set<AgentWorkflowMaterialQueryType>([
|
||||
materialQueryType,
|
||||
materialQueryType === "spu" ? "sku" : "spu",
|
||||
])
|
||||
);
|
||||
const strictQueryType = parsed.strictQueryType === "true" || parsed.strictQueryType === "1";
|
||||
const queryTypesToTry = strictQueryType
|
||||
? [materialQueryType]
|
||||
: Array.from(
|
||||
new Set<AgentWorkflowMaterialQueryType>([
|
||||
materialQueryType,
|
||||
materialQueryType === "spu" ? "sku" : "spu",
|
||||
])
|
||||
);
|
||||
|
||||
let selectedAttempt:
|
||||
| Awaited<ReturnType<typeof requestTaskDetailAttempt>>
|
||||
@ -947,7 +998,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
for (const currentQueryType of queryTypesToTry) {
|
||||
const attempt = await requestTaskDetailAttempt({
|
||||
keyword: normalizedKeyword,
|
||||
keyword: resolvedMaterialKeyword,
|
||||
queryType: currentQueryType,
|
||||
target: parsed.target,
|
||||
requestedBrandName,
|
||||
@ -958,6 +1009,7 @@ export async function GET(request: NextRequest) {
|
||||
if (requestedBrandName) {
|
||||
console.info("[Agent Workflow Reference Search] Brand gate:", {
|
||||
keyword: normalizedKeyword,
|
||||
resolvedKeyword: resolvedMaterialKeyword,
|
||||
queryType: currentQueryType,
|
||||
target: parsed.target,
|
||||
requestedBrandName,
|
||||
@ -1042,6 +1094,7 @@ export async function GET(request: NextRequest) {
|
||||
if (selectedAttempt.queryType !== materialQueryType) {
|
||||
console.info("[Agent Workflow Reference Search] Query type fallback succeeded:", {
|
||||
keyword: normalizedKeyword,
|
||||
resolvedKeyword: resolvedMaterialKeyword,
|
||||
requestedQueryType: materialQueryType,
|
||||
resolvedQueryType: selectedAttempt.queryType,
|
||||
target: parsed.target,
|
||||
@ -1059,6 +1112,7 @@ export async function GET(request: NextRequest) {
|
||||
success: true,
|
||||
data: {
|
||||
query: normalizedKeyword,
|
||||
resolvedQuery: resolvedMaterialKeyword,
|
||||
queryType: selectedAttempt.queryType,
|
||||
target: parsed.target,
|
||||
matchedSpu: String(selectedAttempt.upstreamResult.data?.spu || "").trim() || null,
|
||||
|
||||
@ -16,11 +16,14 @@ import { createKrea } from "@/lib/ai/providers/krea";
|
||||
import { createVolcanoVision } from "@/lib/ai/providers/volcano-vision";
|
||||
import { isImageGenerationModelDescriptor } from "@/lib/ai/model-classification";
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 120;
|
||||
|
||||
const logger = createLogger("canvas-text-edit");
|
||||
const TEXT_EDIT_SOURCE_ARCHIVE_TIMEOUT_MS = 20_000;
|
||||
const TEXT_EDIT_MODEL_FALLBACK_LIMIT = 3;
|
||||
|
||||
const textItemSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
@ -50,8 +53,12 @@ const SUPPORTED_TEXT_EDIT_PROVIDER_TYPES = new Set([
|
||||
"volcano-vision",
|
||||
]);
|
||||
|
||||
function pickTextEditModel(models: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>) {
|
||||
const candidates = models
|
||||
type TextEditModelCandidate = Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>[number];
|
||||
|
||||
function getTextEditModelCandidates(
|
||||
models: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>
|
||||
) {
|
||||
return models
|
||||
.filter(
|
||||
(item) =>
|
||||
isImageGenerationModelDescriptor({
|
||||
@ -77,8 +84,6 @@ function pickTextEditModel(models: Awaited<ReturnType<typeof getEnabledAiModelsW
|
||||
(b.model.displayName?.includes("火山") ? 3 : 0);
|
||||
return bScore - aScore;
|
||||
});
|
||||
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
function createTextEditProvider(
|
||||
@ -118,6 +123,85 @@ function createTextEditProvider(
|
||||
}
|
||||
}
|
||||
|
||||
function bufferToArrayBuffer(buffer: Buffer): ArrayBuffer {
|
||||
return buffer.buffer.slice(
|
||||
buffer.byteOffset,
|
||||
buffer.byteOffset + buffer.byteLength
|
||||
) as ArrayBuffer;
|
||||
}
|
||||
|
||||
function inferImageExtension(contentType: string, imageUrl: string) {
|
||||
const normalizedContentType = contentType.toLowerCase();
|
||||
if (normalizedContentType.includes("image/jpeg")) return "jpg";
|
||||
if (normalizedContentType.includes("image/png")) return "png";
|
||||
if (normalizedContentType.includes("image/webp")) return "webp";
|
||||
if (normalizedContentType.includes("image/gif")) return "gif";
|
||||
const pathname = (() => {
|
||||
try {
|
||||
return new URL(imageUrl).pathname;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
})();
|
||||
const match = pathname.match(/\.([a-z0-9]{2,5})$/i);
|
||||
const ext = String(match?.[1] || "").toLowerCase();
|
||||
return ["jpg", "jpeg", "png", "webp", "gif"].includes(ext) ? ext : "png";
|
||||
}
|
||||
|
||||
async function archiveTextEditSourceImage(imageUrl: string) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), TEXT_EDIT_SOURCE_ARCHIVE_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(imageUrl, {
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`源图下载失败:${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
if (contentType && !contentType.toLowerCase().startsWith("image/")) {
|
||||
throw new Error(`源图类型异常:${contentType}`);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
if (buffer.length === 0) {
|
||||
throw new Error("源图为空文件");
|
||||
}
|
||||
|
||||
const ext = inferImageExtension(contentType, imageUrl);
|
||||
const uploaded = await uploadFile(
|
||||
`canvas-text-edit-source-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`,
|
||||
bufferToArrayBuffer(buffer)
|
||||
);
|
||||
return {
|
||||
bytes: buffer.length,
|
||||
url: uploaded.url,
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveTextEditSourceImageUrl(imageUrl: string) {
|
||||
try {
|
||||
const archived = await archiveTextEditSourceImage(imageUrl);
|
||||
logger.info("source_image_archived", {
|
||||
bytes: archived.bytes,
|
||||
sourceHost: new URL(imageUrl).host,
|
||||
targetHost: new URL(archived.url).host,
|
||||
});
|
||||
return archived.url;
|
||||
} catch (error) {
|
||||
logger.warn("source_image_archive_failed", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return imageUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeGeneratedImageUrl(value: unknown) {
|
||||
const raw = String(value || "").trim();
|
||||
if (!raw) return "";
|
||||
@ -271,6 +355,70 @@ function buildTextEditPrompt(
|
||||
return sections.join("\n");
|
||||
}
|
||||
|
||||
async function generateTextEditImageWithFailover(params: {
|
||||
candidates: TextEditModelCandidate[];
|
||||
imageUrl: string;
|
||||
prompt: string;
|
||||
}) {
|
||||
let lastError: unknown = null;
|
||||
const candidates = params.candidates.slice(0, TEXT_EDIT_MODEL_FALLBACK_LIMIT);
|
||||
|
||||
for (const selected of candidates) {
|
||||
try {
|
||||
logger.info("selected_model", {
|
||||
providerType: selected.provider.type,
|
||||
providerName: selected.provider.name,
|
||||
modelId: selected.model.modelId,
|
||||
displayName: selected.model.displayName,
|
||||
isDefault: selected.model.isDefault,
|
||||
isDefaultTextEditModel: selected.model.isDefaultTextEditModel,
|
||||
});
|
||||
|
||||
const provider = createTextEditProvider(selected.provider);
|
||||
const result = await provider.chat(selected.model.modelId).doGenerate({
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "image_url", image_url: { url: params.imageUrl } },
|
||||
{ type: "text", text: params.prompt },
|
||||
],
|
||||
},
|
||||
],
|
||||
imageParams: {
|
||||
imageCount: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const resultText = String(result?.text || "").trim();
|
||||
const finishReason = String(result?.finishReason || "").toLowerCase();
|
||||
if (!resultText) {
|
||||
throw new Error("模型未返回结果");
|
||||
}
|
||||
if (finishReason === "error" || resultText.startsWith("图片生成失败")) {
|
||||
throw new Error(resultText);
|
||||
}
|
||||
|
||||
const parsed = parseImageFromGenerationText(resultText);
|
||||
return {
|
||||
...parsed,
|
||||
model: selected.model.displayName,
|
||||
};
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
logger.warn("candidate_failed", {
|
||||
providerType: selected.provider.type,
|
||||
providerName: selected.provider.name,
|
||||
modelId: selected.model.modelId,
|
||||
displayName: selected.model.displayName,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError instanceof Error ? lastError : new Error("文字替换失败");
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
@ -327,9 +475,9 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
const models = await getEnabledAiModelsWithProvider();
|
||||
const selected = pickTextEditModel(models);
|
||||
const candidates = getTextEditModelCandidates(models);
|
||||
|
||||
if (!selected) {
|
||||
if (candidates.length === 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
@ -339,46 +487,20 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
logger.info("selected_model", {
|
||||
providerType: selected.provider.type,
|
||||
providerName: selected.provider.name,
|
||||
modelId: selected.model.modelId,
|
||||
displayName: selected.model.displayName,
|
||||
isDefault: selected.model.isDefault,
|
||||
isDefaultTextEditModel: selected.model.isDefaultTextEditModel,
|
||||
});
|
||||
|
||||
const provider = createTextEditProvider(selected.provider);
|
||||
|
||||
const prompt = buildTextEditPrompt(replacements, deletions);
|
||||
|
||||
const result = await provider.chat(selected.model.modelId).doGenerate({
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "image_url", image_url: { url: imageUrl } },
|
||||
{ type: "text", text: prompt },
|
||||
],
|
||||
},
|
||||
],
|
||||
imageParams: {
|
||||
imageCount: 1,
|
||||
},
|
||||
const generationSourceImageUrl = await resolveTextEditSourceImageUrl(imageUrl);
|
||||
const { originalUrl, thumbnailUrl, model } = await generateTextEditImageWithFailover({
|
||||
candidates,
|
||||
imageUrl: generationSourceImageUrl,
|
||||
prompt,
|
||||
});
|
||||
|
||||
if (!result?.text) {
|
||||
throw new Error("模型未返回结果");
|
||||
}
|
||||
|
||||
const { originalUrl, thumbnailUrl } = parseImageFromGenerationText(result.text);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
originalUrl,
|
||||
thumbnailUrl,
|
||||
model: selected.model.displayName,
|
||||
model,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
12
app/canvas/loading.tsx
Normal file
12
app/canvas/loading.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export default function CanvasLoading() {
|
||||
return (
|
||||
<div className="flex h-dvh w-screen items-center justify-center bg-[#050607] text-white/70">
|
||||
<div className="flex flex-col items-center gap-4 rounded-[32px] border border-white/10 bg-white/[0.04] px-8 py-7 shadow-[0_24px_90px_rgba(0,0,0,0.42)]">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-emerald-300" />
|
||||
<div className="text-sm tracking-[0.08em]">正在进入工作台...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -5,6 +5,8 @@ import { db } from "@/lib/db";
|
||||
import { canvasProject } from "@/lib/db/schema";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type CanvasRedirectPageProps = {
|
||||
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
||||
};
|
||||
@ -197,6 +197,12 @@ export function AgentDetailReferenceStitchDialog({
|
||||
const [warningText, setWarningText] = useState<string | null>(null);
|
||||
const [previewImageLoadFailed, setPreviewImageLoadFailed] = useState(false);
|
||||
const [passthroughSegmentIndexes, setPassthroughSegmentIndexes] = useState<number[]>([]);
|
||||
const hasManualCutLineChangesRef = useRef(false);
|
||||
const onPreparedRef = useRef(onPrepared);
|
||||
|
||||
useEffect(() => {
|
||||
onPreparedRef.current = onPrepared;
|
||||
}, [onPrepared]);
|
||||
|
||||
const validCards = useMemo(
|
||||
() =>
|
||||
@ -210,8 +216,8 @@ export function AgentDetailReferenceStitchDialog({
|
||||
() =>
|
||||
validCards
|
||||
.map(
|
||||
(card) =>
|
||||
`${card.id}::${String(card.imageUrl || "").trim()}::${String(card.previewUrl || "").trim()}::${card.width || ""}x${card.height || ""}`
|
||||
(card, index) =>
|
||||
`${card.id}::${String(card.imageUrl || "").trim()}::${card.sortOrder ?? index}`
|
||||
)
|
||||
.join("|"),
|
||||
[validCards]
|
||||
@ -265,12 +271,14 @@ export function AgentDetailReferenceStitchDialog({
|
||||
setWarningText(null);
|
||||
setPreviewImageLoadFailed(false);
|
||||
setPassthroughSegmentIndexes([]);
|
||||
hasManualCutLineChangesRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (setupCards.length === 0) {
|
||||
setSnapshot(null);
|
||||
setCutLines([]);
|
||||
hasManualCutLineChangesRef.current = false;
|
||||
setIsLoading(false);
|
||||
setIsPreparing(false);
|
||||
setErrorText(`当前没有可拼合的${sourceLabel}`);
|
||||
@ -282,6 +290,7 @@ export function AgentDetailReferenceStitchDialog({
|
||||
|
||||
setSnapshot(optimisticSnapshot);
|
||||
setCutLines(optimisticSnapshot.defaultCutLines);
|
||||
hasManualCutLineChangesRef.current = false;
|
||||
setPassthroughSegmentIndexes([]);
|
||||
setErrorText(null);
|
||||
setWarningText(null);
|
||||
@ -341,12 +350,15 @@ export function AgentDetailReferenceStitchDialog({
|
||||
};
|
||||
setSnapshot(preciseSnapshot);
|
||||
setPreviewImageLoadFailed(!previewUrl);
|
||||
setCutLines((current) =>
|
||||
areCutLinesEqual(current, optimisticSnapshot.defaultCutLines)
|
||||
setCutLines((current) => {
|
||||
if (hasManualCutLineChangesRef.current) {
|
||||
return scaleCutLines(current, optimisticSnapshot.height, height);
|
||||
}
|
||||
return areCutLinesEqual(current, optimisticSnapshot.defaultCutLines)
|
||||
? defaultCutLines
|
||||
: scaleCutLines(current, optimisticSnapshot.height, height)
|
||||
);
|
||||
if (onPrepared) {
|
||||
: scaleCutLines(current, optimisticSnapshot.height, height);
|
||||
});
|
||||
if (onPreparedRef.current) {
|
||||
const dimensionByCardId = new Map(
|
||||
sourceSegments.map((segment) => [
|
||||
segment.card.id,
|
||||
@ -357,7 +369,7 @@ export function AgentDetailReferenceStitchDialog({
|
||||
},
|
||||
])
|
||||
);
|
||||
onPrepared(
|
||||
onPreparedRef.current(
|
||||
setupCards.map((card, index) => {
|
||||
const dimensions = dimensionByCardId.get(card.id);
|
||||
return {
|
||||
@ -394,7 +406,7 @@ export function AgentDetailReferenceStitchDialog({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [onPrepared, open, setupCards, sourceLabel]);
|
||||
}, [open, setupCards, sourceLabel]);
|
||||
|
||||
useEffect(() => {
|
||||
const validIndexes = new Set(sliceSegments.map((segment) => segment.index));
|
||||
@ -532,10 +544,12 @@ export function AgentDetailReferenceStitchDialog({
|
||||
|
||||
const handleResetCutLines = useCallback(() => {
|
||||
if (!snapshot) return;
|
||||
hasManualCutLineChangesRef.current = false;
|
||||
setCutLines(snapshot.defaultCutLines);
|
||||
}, [snapshot]);
|
||||
|
||||
const handleClearCutLines = useCallback(() => {
|
||||
hasManualCutLineChangesRef.current = true;
|
||||
setCutLines([]);
|
||||
setDraggingLineIndex(null);
|
||||
setIsAddingCutLine(false);
|
||||
@ -543,6 +557,7 @@ export function AgentDetailReferenceStitchDialog({
|
||||
}, []);
|
||||
|
||||
const handleRemoveCutLine = useCallback((index: number) => {
|
||||
hasManualCutLineChangesRef.current = true;
|
||||
setCutLines((current) => current.filter((_, currentIndex) => currentIndex !== index));
|
||||
}, []);
|
||||
|
||||
@ -573,6 +588,7 @@ export function AgentDetailReferenceStitchDialog({
|
||||
toast.error("当前位置无法添加切线,请换一个位置");
|
||||
return;
|
||||
}
|
||||
hasManualCutLineChangesRef.current = true;
|
||||
setCutLines((current) => [...current, Math.round(nextY)].sort((left, right) => left - right));
|
||||
setIsAddingCutLine(false);
|
||||
setPreviewCutLineY(null);
|
||||
@ -589,6 +605,7 @@ export function AgentDetailReferenceStitchDialog({
|
||||
} catch {
|
||||
// Older browsers may not support pointer capture for this target.
|
||||
}
|
||||
hasManualCutLineChangesRef.current = true;
|
||||
setDraggingLineIndex(index);
|
||||
},
|
||||
[]
|
||||
@ -894,7 +911,13 @@ export function AgentDetailReferenceStitchDialog({
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setDraggingLineIndex(null);
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handleRemoveCutLine(index);
|
||||
}}
|
||||
|
||||
@ -1609,6 +1609,14 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
[getLatestWorkflowContext, workflowContext]
|
||||
);
|
||||
const workflowBrandName = getCurrentWorkflowBrandName();
|
||||
const getCurrentWorkflowPrimarySpu = useCallback(
|
||||
() =>
|
||||
String((getLatestWorkflowContext?.() ?? workflowContext)?.primarySku || "")
|
||||
.trim(),
|
||||
[getLatestWorkflowContext, workflowContext]
|
||||
);
|
||||
const workflowPrimarySpu = getCurrentWorkflowPrimarySpu();
|
||||
const normalizedWorkflowPrimarySpu = workflowPrimarySpu.toUpperCase();
|
||||
const shouldAllowModelOverride =
|
||||
!isWorkflowModelLocked &&
|
||||
(isDirectModelGenerationMode || !isSceneGenerationPreset || presetModeConfig.scene.allowModelOverride);
|
||||
@ -4836,19 +4844,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
setPendingGeneration(null);
|
||||
}
|
||||
}
|
||||
const handleDetailReplacementQueryTypeChange = useCallback(
|
||||
(queryType: AgentWorkflowReferenceQueryType) => {
|
||||
updateDetailReplacementState((current) => ({
|
||||
...current,
|
||||
queryType,
|
||||
candidates: current.queryType === queryType ? current.candidates : [],
|
||||
selectedCandidateId:
|
||||
current.queryType === queryType ? current.selectedCandidateId : null,
|
||||
references: current.queryType === queryType ? current.references : [],
|
||||
}));
|
||||
},
|
||||
[updateDetailReplacementState]
|
||||
);
|
||||
const handleDetailReplacementProductDetailChannelChange = useCallback(
|
||||
(channel: AgentWorkflowProductDetailChannel) => {
|
||||
updateDetailReplacementState((current) => ({
|
||||
@ -4960,25 +4955,22 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
},
|
||||
[detailReplacementReferenceCards.length, updateDetailReplacementState]
|
||||
);
|
||||
const handleDetailReplacementSearch = useCallback(async () => {
|
||||
const handleDetailReplacementSearch = useCallback(async (
|
||||
queryType: Extract<AgentWorkflowReferenceQueryType, "spu" | "productId">
|
||||
) => {
|
||||
const rawPrimarySpu = getCurrentWorkflowPrimarySpu();
|
||||
const normalized =
|
||||
detailReplacementQueryType === "productId"
|
||||
? detailReplacementQueryInput.trim()
|
||||
: detailReplacementQueryInput.trim().toUpperCase();
|
||||
queryType === "productId" ? rawPrimarySpu.trim() : rawPrimarySpu.trim().toUpperCase();
|
||||
const currentWorkflowBrandName = getCurrentWorkflowBrandName();
|
||||
if (!currentWorkflowBrandName && detailReplacementQueryType !== "productId") {
|
||||
toast.error("请先在第一步完成品牌选择后再查询参考图");
|
||||
if (!currentWorkflowBrandName && queryType !== "productId") {
|
||||
toast.error("请先在第一步完成品牌选择后再检索可用模版");
|
||||
return;
|
||||
}
|
||||
if (!normalized) {
|
||||
toast.error(
|
||||
`请输入${
|
||||
detailReplacementQueryType === "productId"
|
||||
? "商品ID"
|
||||
: detailReplacementQueryType === "sku"
|
||||
? "SKU"
|
||||
: "SPU"
|
||||
}后再查询${detailReferenceSourceLabel}`
|
||||
queryType === "productId"
|
||||
? "请先在第一步输入主商品市场ID后再查询详情"
|
||||
: "请先在第一步输入主商品SPU后再检索可用模版"
|
||||
);
|
||||
return;
|
||||
}
|
||||
@ -4991,14 +4983,16 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
try {
|
||||
const searchParams = new URLSearchParams({
|
||||
keyword: normalized,
|
||||
queryType: detailReplacementQueryType,
|
||||
queryType,
|
||||
target: detailReferenceTarget,
|
||||
});
|
||||
if (currentWorkflowBrandName) {
|
||||
searchParams.set("brandName", currentWorkflowBrandName);
|
||||
}
|
||||
if (detailReplacementQueryType === "productId") {
|
||||
if (queryType === "productId") {
|
||||
searchParams.set("channel", detailReplacementProductDetailChannel);
|
||||
} else {
|
||||
searchParams.set("strictQueryType", "true");
|
||||
}
|
||||
const response = await fetch(
|
||||
`/api/agent-workflow/reference-search?${searchParams.toString()}`,
|
||||
@ -5012,7 +5006,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
|
||||
updateDetailReplacementState({
|
||||
query: normalized,
|
||||
queryType: result.data.queryType || detailReplacementQueryType,
|
||||
queryType: result.data.queryType || queryType,
|
||||
productDetailChannel:
|
||||
result.data.channel || detailReplacementProductDetailChannel,
|
||||
candidates: result.data.candidates || [],
|
||||
@ -5036,8 +5030,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
detailReferenceSourceLabel,
|
||||
detailReferenceTarget,
|
||||
detailReplacementProductDetailChannel,
|
||||
detailReplacementQueryInput,
|
||||
detailReplacementQueryType,
|
||||
getCurrentWorkflowPrimarySpu,
|
||||
getCurrentWorkflowBrandName,
|
||||
updateDetailReplacementState,
|
||||
]);
|
||||
@ -6241,87 +6234,79 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 p-5">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{([
|
||||
["spu", "按SPU查询"],
|
||||
["sku", "按SKU查询"],
|
||||
["productId", "按商品ID查询"],
|
||||
] as Array<[AgentWorkflowReferenceQueryType, string]>).map(([queryType, label]) => (
|
||||
<button
|
||||
key={queryType}
|
||||
type="button"
|
||||
onClick={() => handleDetailReplacementQueryTypeChange(queryType)}
|
||||
className={cn(
|
||||
"rounded-full border px-3 py-1.5 text-xs transition-colors",
|
||||
detailReplacementQueryType === queryType
|
||||
? "border-emerald-400/45 bg-emerald-500/[0.12] text-emerald-200"
|
||||
: "border-white/10 bg-white/[0.03] text-white/58 hover:border-white/18 hover:text-white"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
<input
|
||||
ref={detailReferenceUploadInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleDetailReferenceUploadChange}
|
||||
/>
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs text-white/42">
|
||||
自动复用第一步主商品信息检索参考详情,不需要重复输入 SPU / SKU。
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<span className="rounded-full border border-emerald-400/20 bg-emerald-500/[0.08] px-3 py-1 text-xs text-emerald-100">
|
||||
主 SPU / {normalizedWorkflowPrimarySpu || "未填写"}
|
||||
</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs text-white/60">
|
||||
品牌 / {workflowBrandName || "未选择"}
|
||||
</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs text-white/60">
|
||||
目标 / {detailReferenceSourceLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Select
|
||||
value={detailReplacementProductDetailChannel}
|
||||
onValueChange={(value) =>
|
||||
handleDetailReplacementProductDetailChannelChange(
|
||||
value as AgentWorkflowProductDetailChannel
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-12 rounded-[22px] border-white/10 bg-black/20 text-sm text-white focus:border-emerald-400/30 sm:w-[150px]">
|
||||
<SelectValue placeholder="渠道" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PRODUCT_DETAIL_CHANNEL_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => handleDetailReplacementSearch("productId")}
|
||||
disabled={detailReplacementSearchState.isLoading}
|
||||
className="h-12 rounded-[22px] bg-white/[0.04] px-5 text-sm text-white hover:bg-white/[0.08]"
|
||||
>
|
||||
{detailReplacementSearchState.isLoading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
市场ID查询详情
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => handleDetailReplacementSearch("spu")}
|
||||
disabled={detailReplacementSearchState.isLoading}
|
||||
className="h-12 rounded-[22px] bg-emerald-500 px-5 font-semibold text-sm text-black shadow-[0_0_24px_rgba(16,185,129,0.22)] hover:bg-emerald-400"
|
||||
>
|
||||
{detailReplacementSearchState.isLoading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
检索可用模版
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 lg:flex-row">
|
||||
<input
|
||||
ref={detailReferenceUploadInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleDetailReferenceUploadChange}
|
||||
/>
|
||||
{detailReplacementQueryType === "productId" ? (
|
||||
<Select
|
||||
value={detailReplacementProductDetailChannel}
|
||||
onValueChange={(value) =>
|
||||
handleDetailReplacementProductDetailChannelChange(
|
||||
value as AgentWorkflowProductDetailChannel
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-12 rounded-[22px] border-white/10 bg-black/20 text-sm text-white focus:border-emerald-400/30 lg:w-[150px]">
|
||||
<SelectValue placeholder="渠道" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PRODUCT_DETAIL_CHANNEL_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : null}
|
||||
<Input
|
||||
value={detailReplacementQueryInput}
|
||||
onChange={(e) =>
|
||||
updateDetailReplacementState({
|
||||
query: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder={
|
||||
detailReplacementQueryType === "productId"
|
||||
? "输入商品ID,查询并拉取详情图片"
|
||||
: `输入 ${detailReplacementQueryType === "sku" ? "SKU" : "SPU"},查询任务整套中的${detailReferenceSourceLabel}参考图`
|
||||
}
|
||||
className="h-12 rounded-[22px] border-white/10 bg-black/20 text-sm text-white placeholder:text-white/22 focus:border-emerald-400/30"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleDetailReplacementSearch}
|
||||
disabled={detailReplacementSearchState.isLoading}
|
||||
className="h-12 rounded-[22px] bg-white/[0.04] px-5 text-sm text-white hover:bg-white/[0.08]"
|
||||
>
|
||||
{detailReplacementSearchState.isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
查询中
|
||||
</>
|
||||
) : (
|
||||
`查询${detailReferenceSourceLabel}`
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@ -6897,7 +6882,7 @@ 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 或 SKU 后,这里会展示任务整套里的真实{detailReferenceSourceLabel}参考图。
|
||||
点击“检索可用模版”后,这里会展示当前品牌下主 SPU 对应的真实{detailReferenceSourceLabel}参考图。
|
||||
删除不需要的参考图后,列表会自动上移补位。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -18,6 +18,9 @@ type AgentPreviewImageProps = Omit<
|
||||
previewWidth?: number;
|
||||
previewQuality?: number;
|
||||
previewFormat?: "webp" | "avif" | "jpeg" | "png";
|
||||
delivery?: "display" | "proxy";
|
||||
fallbackSrc?: string | null;
|
||||
fallbackToOriginal?: boolean;
|
||||
rootMargin?: string;
|
||||
eager?: boolean;
|
||||
raw?: boolean;
|
||||
@ -31,6 +34,7 @@ function buildPreviewUrl(params: {
|
||||
width: number;
|
||||
quality: number;
|
||||
format: "webp" | "avif" | "jpeg" | "png";
|
||||
delivery?: "display" | "proxy";
|
||||
raw?: boolean;
|
||||
}) {
|
||||
if (params.raw) return params.src;
|
||||
@ -39,6 +43,7 @@ function buildPreviewUrl(params: {
|
||||
width: params.width,
|
||||
quality: params.quality,
|
||||
format: params.format,
|
||||
delivery: params.delivery,
|
||||
}) || params.src
|
||||
);
|
||||
}
|
||||
@ -48,6 +53,9 @@ function AgentPreviewImageBase({
|
||||
previewWidth = 480,
|
||||
previewQuality = 78,
|
||||
previewFormat = "webp",
|
||||
delivery = "display",
|
||||
fallbackSrc,
|
||||
fallbackToOriginal = true,
|
||||
rootMargin = "640px 0px",
|
||||
eager = false,
|
||||
raw = false,
|
||||
@ -58,15 +66,22 @@ function AgentPreviewImageBase({
|
||||
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||
const [shouldLoad, setShouldLoad] = useState(eager);
|
||||
const [useOriginal, setUseOriginal] = useState(false);
|
||||
const [useFallbackSource, setUseFallbackSource] = useState(false);
|
||||
const normalizedSrc = String(src || "").trim();
|
||||
const normalizedFallbackSrc = String(fallbackSrc || "").trim();
|
||||
const currentSrc =
|
||||
useFallbackSource && normalizedFallbackSrc
|
||||
? normalizedFallbackSrc
|
||||
: normalizedSrc;
|
||||
|
||||
useEffect(() => {
|
||||
setUseOriginal(false);
|
||||
setUseFallbackSource(false);
|
||||
setShouldLoad(eager);
|
||||
}, [eager, normalizedSrc]);
|
||||
}, [eager, normalizedFallbackSrc, normalizedSrc]);
|
||||
|
||||
useEffect(() => {
|
||||
if (eager || shouldLoad || !normalizedSrc) return;
|
||||
if (eager || shouldLoad || !currentSrc) return;
|
||||
const image = imageRef.current;
|
||||
if (!image) return;
|
||||
|
||||
@ -86,20 +101,22 @@ function AgentPreviewImageBase({
|
||||
);
|
||||
observer.observe(image);
|
||||
return () => observer.disconnect();
|
||||
}, [eager, normalizedSrc, rootMargin, shouldLoad]);
|
||||
}, [currentSrc, eager, rootMargin, shouldLoad]);
|
||||
|
||||
const displaySrc = useMemo(() => {
|
||||
if (!normalizedSrc || !shouldLoad) return EMPTY_PIXEL;
|
||||
if (useOriginal) return normalizedSrc;
|
||||
if (!currentSrc || !shouldLoad) return EMPTY_PIXEL;
|
||||
if (useOriginal) return currentSrc;
|
||||
return buildPreviewUrl({
|
||||
src: normalizedSrc,
|
||||
src: currentSrc,
|
||||
width: previewWidth,
|
||||
quality: previewQuality,
|
||||
format: previewFormat,
|
||||
delivery,
|
||||
raw,
|
||||
});
|
||||
}, [
|
||||
normalizedSrc,
|
||||
currentSrc,
|
||||
delivery,
|
||||
previewFormat,
|
||||
previewQuality,
|
||||
previewWidth,
|
||||
@ -117,10 +134,20 @@ function AgentPreviewImageBase({
|
||||
loading={eager ? "eager" : "lazy"}
|
||||
decoding="async"
|
||||
onError={(event) => {
|
||||
if (normalizedSrc && !useOriginal && displaySrc !== normalizedSrc) {
|
||||
if (
|
||||
fallbackToOriginal &&
|
||||
currentSrc &&
|
||||
!useOriginal &&
|
||||
displaySrc !== currentSrc
|
||||
) {
|
||||
setUseOriginal(true);
|
||||
return;
|
||||
}
|
||||
if (!useFallbackSource && normalizedFallbackSrc) {
|
||||
setUseFallbackSource(true);
|
||||
setUseOriginal(false);
|
||||
return;
|
||||
}
|
||||
onError?.(event);
|
||||
}}
|
||||
onLoad={onLoad}
|
||||
|
||||
@ -383,11 +383,34 @@ function buildPsdLayerPreviewUrl(
|
||||
params?: {
|
||||
width?: number;
|
||||
quality?: number;
|
||||
delivery?: "proxy";
|
||||
}
|
||||
) {
|
||||
return buildTemplatePreviewThumbUrl(imageUrl, {
|
||||
width: Math.max(96, Math.min(1280, Math.round(params?.width || 720))),
|
||||
quality: Math.max(52, Math.min(82, Math.round(params?.quality || 68))),
|
||||
const width = Math.max(96, Math.min(1280, Math.round(params?.width || 720)));
|
||||
const quality = Math.max(52, Math.min(82, Math.round(params?.quality || 68)));
|
||||
if (params?.delivery === "proxy") {
|
||||
return buildImageProxyUrl(imageUrl, {
|
||||
width,
|
||||
quality,
|
||||
format: "webp",
|
||||
delivery: "proxy",
|
||||
});
|
||||
}
|
||||
|
||||
return buildTemplatePreviewThumbUrl(imageUrl, { width, quality });
|
||||
}
|
||||
|
||||
function buildPsdLayerMaskUrl(maskUrl: string, layerWidth: number) {
|
||||
const normalizedUrl = String(maskUrl || "").trim();
|
||||
if (!normalizedUrl) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return buildImageProxyUrl(normalizedUrl, {
|
||||
width: Math.max(96, Math.min(1280, Math.round(layerWidth || 720))),
|
||||
quality: 100,
|
||||
format: "png",
|
||||
delivery: "proxy",
|
||||
});
|
||||
}
|
||||
|
||||
@ -1474,35 +1497,55 @@ function renderTextLayerPreviewContent(
|
||||
}
|
||||
|
||||
function getAssignedImageMaskStyle(
|
||||
layer: AgentPsdTemplateImageLayerSlot
|
||||
layer: AgentPsdTemplateImageLayerSlot,
|
||||
frameLayer: AgentPsdTemplateLayerSlot,
|
||||
layers: AgentPsdTemplateLayerSlot[]
|
||||
): CSSProperties {
|
||||
const hasMask = Boolean(layer.maskUrl);
|
||||
const clipMaskLayer = layer.clipMaskLayerId
|
||||
? layers.find((item) => item.id === layer.clipMaskLayerId)
|
||||
: null;
|
||||
const fallbackMaskUrl =
|
||||
layer.forceClipMask && clipMaskLayer
|
||||
? getPsdLayerPreviewSourceUrl(clipMaskLayer)
|
||||
: "";
|
||||
const normalizedMaskUrl = String(
|
||||
layer.maskUrl || fallbackMaskUrl || ""
|
||||
).trim();
|
||||
const hasMask = Boolean(normalizedMaskUrl);
|
||||
const maskImageUrl = hasMask
|
||||
? `url(${buildPsdLayerMaskUrl(normalizedMaskUrl, frameLayer.width)})`
|
||||
: undefined;
|
||||
return {
|
||||
WebkitMaskImage: hasMask ? `url(${layer.maskUrl})` : undefined,
|
||||
maskImage: hasMask ? `url(${layer.maskUrl})` : undefined,
|
||||
WebkitMaskImage: maskImageUrl,
|
||||
maskImage: maskImageUrl,
|
||||
WebkitMaskSize: hasMask ? "100% 100%" : undefined,
|
||||
maskSize: hasMask ? "100% 100%" : undefined,
|
||||
WebkitMaskRepeat: hasMask ? "no-repeat" : undefined,
|
||||
maskRepeat: hasMask ? "no-repeat" : undefined,
|
||||
WebkitMaskPosition: hasMask ? "center" : undefined,
|
||||
maskPosition: hasMask ? "center" : undefined,
|
||||
maskMode: hasMask ? "alpha" : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function getPlacedAssignedImageStyle(params: {
|
||||
layer: AgentPsdTemplateImageLayerSlot;
|
||||
frameLayer?: AgentPsdTemplateLayerSlot | null;
|
||||
imageSize: AgentPsdImageSize;
|
||||
placement: AgentPsdImagePlacement;
|
||||
}): CSSProperties {
|
||||
const { layer, imageSize, placement } = params;
|
||||
const frameLayer = params.frameLayer || layer;
|
||||
const normalizedPlacement = normalizeAgentPsdImagePlacement(placement);
|
||||
const frameContentBounds =
|
||||
isAgentPsdImageLayerSlot(frameLayer) ? frameLayer.contentBounds : undefined;
|
||||
const baseFrame = resolveAgentPsdImageBaseFrame({
|
||||
containerWidth: layer.width,
|
||||
containerHeight: layer.height,
|
||||
containerWidth: frameLayer.width,
|
||||
containerHeight: frameLayer.height,
|
||||
imageWidth: imageSize.width,
|
||||
imageHeight: imageSize.height,
|
||||
replaceMode: layer.replaceMode,
|
||||
contentBounds: layer.contentBounds,
|
||||
contentBounds: frameContentBounds,
|
||||
});
|
||||
|
||||
return {
|
||||
@ -1520,6 +1563,17 @@ function getPlacedAssignedImageStyle(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function getAssignedImageFrameLayer(
|
||||
layer: AgentPsdTemplateImageLayerSlot,
|
||||
layers: AgentPsdTemplateLayerSlot[]
|
||||
) {
|
||||
if (!layer.forceClipMask || !layer.clipMaskLayerId) {
|
||||
return layer;
|
||||
}
|
||||
|
||||
return layers.find((item) => item.id === layer.clipMaskLayerId) || layer;
|
||||
}
|
||||
|
||||
function hasEditedTextValue(
|
||||
layer: AgentPsdTemplateTextLayerSlot,
|
||||
value: string
|
||||
@ -3394,15 +3448,18 @@ export function AgentPsdTemplateEditorDialog({
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setSelectedLayerId(layer.id);
|
||||
const shouldDragAssignedImageContent =
|
||||
isAgentPsdImageLayerSlot(layer) &&
|
||||
Boolean(String(effectiveImageAssignments[layer.id] || "").trim());
|
||||
|
||||
layerDragStateRef.current = {
|
||||
layerId: layer.id,
|
||||
pointerId: event.pointerId,
|
||||
startClientX: event.clientX,
|
||||
startClientY: event.clientY,
|
||||
startOffset: normalizeLayerPositionOffset(
|
||||
effectiveLayerPositionOffsets[layer.id]
|
||||
),
|
||||
startOffset: shouldDragAssignedImageContent
|
||||
? normalizeLayerPositionOffset(effectiveImagePlacements[layer.id])
|
||||
: normalizeLayerPositionOffset(effectiveLayerPositionOffsets[layer.id]),
|
||||
};
|
||||
|
||||
const nextTarget = event.currentTarget;
|
||||
@ -3421,6 +3478,19 @@ export function AgentPsdTemplateEditorDialog({
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldDragAssignedImageContent) {
|
||||
updateImagePlacement(layer.id, (current) => ({
|
||||
...current,
|
||||
offsetX:
|
||||
dragState.startOffset.offsetX +
|
||||
(moveEvent.clientX - dragState.startClientX) / stageScale,
|
||||
offsetY:
|
||||
dragState.startOffset.offsetY +
|
||||
(moveEvent.clientY - dragState.startClientY) / stageScale,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
updateLayerPositionOffset(layer.id, () => ({
|
||||
offsetX:
|
||||
dragState.startOffset.offsetX +
|
||||
@ -4146,12 +4216,45 @@ export function AgentPsdTemplateEditorDialog({
|
||||
if (!layerImageUrl) {
|
||||
return null;
|
||||
}
|
||||
const assignedFrameLayer = assignedUrl
|
||||
? getAssignedImageFrameLayer(
|
||||
layer,
|
||||
selectedTemplateLayers
|
||||
)
|
||||
: layer;
|
||||
const assignedFrameOffset =
|
||||
effectiveLayerPositionOffsets[
|
||||
assignedFrameLayer.id
|
||||
] || normalizeLayerPositionOffset();
|
||||
const assignedFrameBoxStyle = {
|
||||
left:
|
||||
assignedFrameLayer.x +
|
||||
assignedFrameOffset.offsetX,
|
||||
top:
|
||||
assignedFrameLayer.y +
|
||||
assignedFrameOffset.offsetY,
|
||||
width: assignedFrameLayer.width,
|
||||
height: assignedFrameLayer.height,
|
||||
} satisfies CSSProperties;
|
||||
const assignedImageContainerStyle = assignedUrl
|
||||
? {
|
||||
...layerBoxStyle,
|
||||
...getAssignedImageMaskStyle(layer),
|
||||
...assignedFrameBoxStyle,
|
||||
...getAssignedImageMaskStyle(
|
||||
layer,
|
||||
assignedFrameLayer,
|
||||
selectedTemplateLayers
|
||||
),
|
||||
}
|
||||
: layerBoxStyle;
|
||||
const assignedFrameBaseZIndex =
|
||||
layerStackZIndexById.get(
|
||||
assignedFrameLayer.id
|
||||
) || baseZIndex;
|
||||
const previewZIndex = assignedUrl
|
||||
? assignedFrameLayer.id === layer.id
|
||||
? Math.max(baseZIndex, renderBaseZIndex + 1)
|
||||
: assignedFrameBaseZIndex + 1
|
||||
: baseZIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -4176,7 +4279,7 @@ export function AgentPsdTemplateEditorDialog({
|
||||
}
|
||||
style={{
|
||||
...assignedImageContainerStyle,
|
||||
zIndex: baseZIndex,
|
||||
zIndex: previewZIndex,
|
||||
cursor:
|
||||
isLayerSuperLocked
|
||||
? "default"
|
||||
@ -4201,11 +4304,13 @@ export function AgentPsdTemplateEditorDialog({
|
||||
? Math.max(layer.width, layer.height)
|
||||
: layer.width,
|
||||
quality: assignedUrl ? 74 : 68,
|
||||
delivery: assignedUrl ? "proxy" : undefined,
|
||||
})}
|
||||
style={
|
||||
assignedUrl && imageSizes[layer.id]
|
||||
? getPlacedAssignedImageStyle({
|
||||
layer,
|
||||
frameLayer: assignedFrameLayer,
|
||||
imageSize: imageSizes[layer.id],
|
||||
placement:
|
||||
effectiveImagePlacements[
|
||||
|
||||
@ -63,6 +63,40 @@ const MATERIAL_GRID_MIN_CARD_HEIGHT = 258;
|
||||
const AGENT_STYLE_OPTIMIZED_RENDER_ENABLED =
|
||||
process.env.NEXT_PUBLIC_AGENT_STYLE_OPTIMIZED_RENDER !== "false";
|
||||
|
||||
function normalizeImageIdList(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.map((item) => String(item || "").trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeMaterialImages(value: unknown): AgentWorkflowMaterialImage[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.filter((item): item is AgentWorkflowMaterialImage => {
|
||||
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
||||
return false;
|
||||
}
|
||||
const image = item as Partial<AgentWorkflowMaterialImage>;
|
||||
return Boolean(String(image.id || "").trim());
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeSkuOptions(
|
||||
value: AgentWorkflowMaterialSearchResult["skuOptions"] | unknown
|
||||
): NonNullable<AgentWorkflowMaterialSearchResult["skuOptions"]> {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.filter((item): item is NonNullable<AgentWorkflowMaterialSearchResult["skuOptions"]>[number] => {
|
||||
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(String(item.skuName || "").trim());
|
||||
});
|
||||
}
|
||||
|
||||
const MaterialImageCard = memo(function MaterialImageCard({
|
||||
image,
|
||||
selected,
|
||||
@ -75,6 +109,15 @@ const MaterialImageCard = memo(function MaterialImageCard({
|
||||
onToggle: (imageId: string) => void;
|
||||
}) {
|
||||
const isPlaceholder = !String(image.imageUrl || "").trim();
|
||||
const displayImageUrl = String(image.imageUrl || "").trim();
|
||||
const [imageStatus, setImageStatus] = useState<"loading" | "loaded" | "error">(
|
||||
isPlaceholder ? "loaded" : "loading"
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setImageStatus(isPlaceholder ? "loaded" : "loading");
|
||||
}, [displayImageUrl, isPlaceholder]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@ -109,16 +152,39 @@ const MaterialImageCard = memo(function MaterialImageCard({
|
||||
</div>
|
||||
) : (
|
||||
<AgentPreviewImage
|
||||
src={image.imageUrl}
|
||||
src={displayImageUrl}
|
||||
alt={image.label}
|
||||
className="h-full w-full object-cover"
|
||||
previewWidth={480}
|
||||
className={cn(
|
||||
"h-full w-full object-contain transition-opacity duration-300",
|
||||
imageStatus === "loaded" ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
delivery="proxy"
|
||||
eager={selected || image.sortOrder < 4}
|
||||
previewWidth={720}
|
||||
previewQuality={74}
|
||||
onError={(event) => {
|
||||
event.currentTarget.style.display = "none";
|
||||
onError={() => {
|
||||
setImageStatus("error");
|
||||
}}
|
||||
onLoad={() => setImageStatus("loaded")}
|
||||
/>
|
||||
)}
|
||||
{!isPlaceholder && imageStatus !== "loaded" ? (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-[radial-gradient(circle_at_top,rgba(16,185,129,0.16),transparent_58%),linear-gradient(180deg,rgba(255,255,255,0.07),rgba(255,255,255,0.02))] px-4 text-center text-white/70">
|
||||
{imageStatus === "error" ? (
|
||||
<ImageIcon className="h-7 w-7 text-white/45" />
|
||||
) : (
|
||||
<Loader2 className="h-6 w-6 animate-spin text-emerald-200/80" />
|
||||
)}
|
||||
<div className="text-xs font-medium">
|
||||
{imageStatus === "error" ? "原图暂不可用" : "正在加载原图"}
|
||||
</div>
|
||||
<div className="max-w-[180px] text-[11px] leading-5 text-white/45">
|
||||
{imageStatus === "error"
|
||||
? "原图地址返回异常,可先稍后重试或联系素材服务检查。"
|
||||
: "已命中素材,正在直接拉取原图。"}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="absolute inset-x-0 top-0 flex items-center justify-between px-2 py-2">
|
||||
<span className="rounded-full bg-black/60 px-2 py-1 text-[11px] text-white">
|
||||
{isPlaceholder ? "加载中" : image.group === "fixed" ? "固定角度" : "其他图片"}
|
||||
@ -189,6 +255,19 @@ function MaterialImageGrid({
|
||||
if (containerWidth >= 900) return 3;
|
||||
return 2;
|
||||
}, [containerWidth]);
|
||||
const rowCount = Math.ceil(images.length / columnCount);
|
||||
const cardWidth =
|
||||
containerWidth > 0
|
||||
? Math.floor((containerWidth - MATERIAL_GRID_GAP * (columnCount - 1)) / columnCount)
|
||||
: 240;
|
||||
const rowHeight = Math.max(MATERIAL_GRID_MIN_CARD_HEIGHT, cardWidth + 74);
|
||||
// Keep hook order stable when SKU loading placeholders and final images cross the virtualization threshold.
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: rowCount,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => rowHeight + MATERIAL_GRID_GAP,
|
||||
overscan: 2,
|
||||
});
|
||||
|
||||
if (
|
||||
!AGENT_STYLE_OPTIMIZED_RENDER_ENABLED ||
|
||||
@ -209,19 +288,6 @@ function MaterialImageGrid({
|
||||
);
|
||||
}
|
||||
|
||||
const rowCount = Math.ceil(images.length / columnCount);
|
||||
const cardWidth =
|
||||
containerWidth > 0
|
||||
? Math.floor((containerWidth - MATERIAL_GRID_GAP * (columnCount - 1)) / columnCount)
|
||||
: 240;
|
||||
const rowHeight = Math.max(MATERIAL_GRID_MIN_CARD_HEIGHT, cardWidth + 74);
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: rowCount,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => rowHeight + MATERIAL_GRID_GAP,
|
||||
overscan: 2,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={parentRef}
|
||||
@ -296,10 +362,14 @@ export function AgentWorkflowMaterialPicker({
|
||||
const canUploadMaterialImage = Boolean(onUploadImage);
|
||||
const [previewDisplayMode, setPreviewDisplayMode] = useState<"selected" | "all">("all");
|
||||
const [hasManualSelectionInteraction, setHasManualSelectionInteraction] = useState(false);
|
||||
const selectedSet = useMemo(
|
||||
() => new Set((selectedImageIds || []).map((item) => String(item || "").trim()).filter(Boolean)),
|
||||
const normalizedSelectedImageIds = useMemo(
|
||||
() => normalizeImageIdList(selectedImageIds),
|
||||
[selectedImageIds]
|
||||
);
|
||||
const selectedSet = useMemo(
|
||||
() => new Set(normalizedSelectedImageIds),
|
||||
[normalizedSelectedImageIds]
|
||||
);
|
||||
const recommendedImageIds = useMemo(
|
||||
() =>
|
||||
Array.isArray(result?.recommendedImageIds)
|
||||
@ -307,7 +377,14 @@ export function AgentWorkflowMaterialPicker({
|
||||
: [],
|
||||
[result?.recommendedImageIds]
|
||||
);
|
||||
const allImages = result?.images || [];
|
||||
const allImages = useMemo(
|
||||
() => normalizeMaterialImages(result?.images),
|
||||
[result?.images]
|
||||
);
|
||||
const skuOptions = useMemo(
|
||||
() => normalizeSkuOptions(result?.skuOptions),
|
||||
[result?.skuOptions]
|
||||
);
|
||||
const uploadedImages = useMemo(
|
||||
() =>
|
||||
allImages
|
||||
@ -427,7 +504,7 @@ export function AgentWorkflowMaterialPicker({
|
||||
|
||||
const handleToggleImage = useCallback((imageId: string) => {
|
||||
setHasManualSelectionInteraction(true);
|
||||
const next = new Set(selectedImageIds);
|
||||
const next = new Set(normalizedSelectedImageIds);
|
||||
if (next.has(imageId)) {
|
||||
next.delete(imageId);
|
||||
} else {
|
||||
@ -444,7 +521,7 @@ export function AgentWorkflowMaterialPicker({
|
||||
}, [
|
||||
onSelectedImageIdsChange,
|
||||
resolvedSelectionLimit,
|
||||
selectedImageIds,
|
||||
normalizedSelectedImageIds,
|
||||
selectionLimitLabel,
|
||||
]);
|
||||
|
||||
@ -1011,9 +1088,9 @@ export function AgentWorkflowMaterialPicker({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{result.skuOptions?.length ? (
|
||||
{skuOptions.length ? (
|
||||
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
|
||||
{result.skuOptions.map((option) => (
|
||||
{skuOptions.map((option) => (
|
||||
<div
|
||||
key={`${option.skuName}-${option.skuId || "sku"}`}
|
||||
className={cn(
|
||||
@ -1152,7 +1229,7 @@ export function AgentWorkflowMaterialPicker({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(result?.images?.length || result?.skuOptions?.length) ? (
|
||||
{(allImages.length || skuOptions.length) ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-start gap-2 rounded-[20px] px-3 py-2 text-xs",
|
||||
@ -1160,7 +1237,7 @@ export function AgentWorkflowMaterialPicker({
|
||||
)}
|
||||
>
|
||||
<RefreshCw className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
{result?.skuOptions?.length
|
||||
{skuOptions.length
|
||||
? "当前是 SPU 命中结果,请先选择具体 SKU,再继续拉取白底图。"
|
||||
: totalImageCount > 0
|
||||
? "白底图会先按权重自动选入槽位,如需微调仍可手动调整。"
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Plus, Sparkles } from "lucide-react";
|
||||
import { AgentWorkflowMaterialPicker } from "@/components/agents/AgentWorkflowMaterialPicker";
|
||||
import { ClientErrorBoundary } from "@/components/ui/client-error-boundary";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { createEmptyAgentWorkflowMaterialSelection } from "@/lib/agents/workflow-material-config";
|
||||
import { cn } from "@/lib/utils";
|
||||
@ -31,6 +32,58 @@ interface AgentWorkflowMaterialSelectionPanelProps {
|
||||
theme?: "light" | "dark";
|
||||
}
|
||||
|
||||
function normalizeImageIdList(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.map((item) => String(item || "").trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeMaterialImages(value: unknown): AgentWorkflowMaterialImage[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.filter((item): item is AgentWorkflowMaterialImage => {
|
||||
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
||||
return false;
|
||||
}
|
||||
const image = item as Partial<AgentWorkflowMaterialImage>;
|
||||
return Boolean(String(image.id || "").trim());
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeMaterialSearchResult(
|
||||
result?: AgentWorkflowMaterialSearchResult | null
|
||||
): AgentWorkflowMaterialSearchResult | null {
|
||||
if (!result || typeof result !== "object" || Array.isArray(result)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
images: normalizeMaterialImages(result.images),
|
||||
recommendedImageIds: normalizeImageIdList(result.recommendedImageIds),
|
||||
skuOptions: Array.isArray(result.skuOptions)
|
||||
? result.skuOptions.filter((item) => Boolean(String(item?.skuName || "").trim()))
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMaterialSelections(
|
||||
value: AgentWorkflowMaterialSelections
|
||||
): AgentWorkflowMaterialSelections {
|
||||
return {
|
||||
primary: {
|
||||
result: normalizeMaterialSearchResult(value?.primary?.result),
|
||||
selectedImageIds: normalizeImageIdList(value?.primary?.selectedImageIds),
|
||||
},
|
||||
secondary: {
|
||||
result: normalizeMaterialSearchResult(value?.secondary?.result),
|
||||
selectedImageIds: normalizeImageIdList(value?.secondary?.selectedImageIds),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getUploadedWorkflowMaterialImages(
|
||||
result?: AgentWorkflowMaterialSearchResult | null
|
||||
): AgentWorkflowMaterialImage[] {
|
||||
@ -47,13 +100,15 @@ function getSelectedWorkflowMaterialImages(params: {
|
||||
selectedImageIds: string[];
|
||||
}): AgentWorkflowMaterialImage[] {
|
||||
const selectedIds = new Set(
|
||||
(params.selectedImageIds || []).map((item) => String(item || "").trim()).filter(Boolean)
|
||||
normalizeImageIdList(params.selectedImageIds)
|
||||
);
|
||||
if (!params.result || selectedIds.size === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return (params.result.images || []).filter((image) => selectedIds.has(image.id));
|
||||
return normalizeMaterialImages(params.result.images).filter((image) =>
|
||||
selectedIds.has(image.id)
|
||||
);
|
||||
}
|
||||
|
||||
function syncWorkflowMaterialAppearanceSummary(params: {
|
||||
@ -101,7 +156,7 @@ function syncWorkflowMaterialAppearanceSummary(params: {
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
const nextImages = result.images.map((image) => {
|
||||
const nextImages = normalizeMaterialImages(result.images).map((image) => {
|
||||
if (image.id !== targetImage.id) {
|
||||
return image;
|
||||
}
|
||||
@ -217,7 +272,9 @@ function mergeSearchResultWithUploadedImages(params: {
|
||||
currentResult?: AgentWorkflowMaterialSearchResult | null;
|
||||
}): AgentWorkflowMaterialSearchResult {
|
||||
const uploadedImages = getUploadedWorkflowMaterialImages(params.currentResult);
|
||||
const searchImages = markSearchWorkflowMaterialImages(params.searchResult.images || []);
|
||||
const searchImages = markSearchWorkflowMaterialImages(
|
||||
normalizeMaterialImages(params.searchResult.images)
|
||||
);
|
||||
return buildWorkflowMaterialResult({
|
||||
currentResult: {
|
||||
...params.searchResult,
|
||||
@ -229,10 +286,34 @@ function mergeSearchResultWithUploadedImages(params: {
|
||||
});
|
||||
}
|
||||
|
||||
function MaterialPickerErrorFallback({
|
||||
isDark,
|
||||
title,
|
||||
}: {
|
||||
isDark: boolean;
|
||||
title: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-[24px] border px-4 py-5 text-sm leading-6",
|
||||
isDark
|
||||
? "border-amber-300/20 bg-amber-300/[0.08] text-amber-50"
|
||||
: "border-amber-200 bg-amber-50 text-amber-900"
|
||||
)}
|
||||
>
|
||||
<div className="font-medium">{title}暂时无法渲染</div>
|
||||
<div className={cn("mt-1 text-xs", isDark ? "text-amber-50/70" : "text-amber-800/75")}>
|
||||
已拦截本次客户端异常,避免整个风格化工作台崩溃。请修改搜索词或刷新后重试。
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentWorkflowMaterialSelectionPanel({
|
||||
inputs,
|
||||
onInputsChange,
|
||||
materials,
|
||||
materials: rawMaterials,
|
||||
onMaterialsChange,
|
||||
appearanceRecognitionEnabled = false,
|
||||
onAppearanceRecognitionEnabledChange,
|
||||
@ -245,6 +326,10 @@ export function AgentWorkflowMaterialSelectionPanel({
|
||||
disabledHint = "请先在下方选择一个生图模式,再启用 SKU / SPU 搜图。",
|
||||
theme = "light",
|
||||
}: AgentWorkflowMaterialSelectionPanelProps) {
|
||||
const materials = useMemo(
|
||||
() => normalizeMaterialSelections(rawMaterials),
|
||||
[rawMaterials]
|
||||
);
|
||||
const hasSecondaryMaterialContent =
|
||||
Boolean(String(inputs.secondarySku || "").trim()) ||
|
||||
Boolean(materials.secondary.result) ||
|
||||
@ -890,174 +975,75 @@ export function AgentWorkflowMaterialSelectionPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AgentWorkflowMaterialPicker
|
||||
value={inputs.primarySku}
|
||||
queryMode={materialSearchMode.primary}
|
||||
placeholder={materialSearchMode.primary === "spu" ? "输入主 SPU" : "输入主 SKU"}
|
||||
buttonLabel={materialSearchMode.primary === "spu" ? "查SKU列表" : "搜索主图"}
|
||||
searchNote={
|
||||
materialSearchMode.primary === "spu"
|
||||
? "按 SPU 搜索时会先返回该 SPU 下的 SKU 列表,点选某个 SKU 后再继续拉取白底图。"
|
||||
: "按 SKU 搜索会直接拉取白底图,适合你已经知道具体 SKU 的情况。"
|
||||
}
|
||||
disabled={disabled}
|
||||
disabledHint={disabledHint}
|
||||
isLoading={materialSearchState.primary.isLoading}
|
||||
errorText={materialSearchState.primary.errorText}
|
||||
result={materials.primary.result}
|
||||
selectedImageIds={materials.primary.selectedImageIds}
|
||||
appearanceRecognitionEnabled={appearanceRecognitionEnabled}
|
||||
selectionLimit={primarySelectionLimit}
|
||||
selectionLimitLabel={selectionLimitLabel}
|
||||
onValueChange={(value) => handleMaterialInputChange("primary", value)}
|
||||
onQueryModeChange={(nextMode) =>
|
||||
setMaterialSearchMode((prev) => ({
|
||||
...prev,
|
||||
primary: nextMode,
|
||||
}))
|
||||
}
|
||||
onSearch={() => {
|
||||
void handleSearchMaterials("primary");
|
||||
}}
|
||||
onUploadImage={(file, options) => handleUploadMaterialImage("primary", file, options)}
|
||||
onRemoveUploadedImage={(imageId) =>
|
||||
handleRemoveUploadedMaterialImage("primary", imageId)
|
||||
}
|
||||
isUploadingNewImage={materialUploadState.primary.isUploadingNew}
|
||||
uploadingImageId={materialUploadState.primary.uploadingImageId}
|
||||
onSkuOptionSelect={(skuName) => {
|
||||
const selectedOption = materials.primary.result?.skuOptions?.find(
|
||||
(option) => option.skuName === skuName
|
||||
);
|
||||
const placeholderCount = selectedOption?.whiteBackgroundCount || 8;
|
||||
const uploadedImages = getUploadedWorkflowMaterialImages(materials.primary.result);
|
||||
onInputsChange({
|
||||
...inputs,
|
||||
primarySku: skuName,
|
||||
});
|
||||
onMaterialsChange({
|
||||
...materials,
|
||||
primary: {
|
||||
result: buildWorkflowMaterialResult({
|
||||
currentResult: {
|
||||
...buildLoadingMaterialResult({
|
||||
keyword: skuName,
|
||||
currentResult: materials.primary.result,
|
||||
placeholderCount,
|
||||
}),
|
||||
images: [
|
||||
...uploadedImages,
|
||||
...buildLoadingMaterialResult({
|
||||
keyword: skuName,
|
||||
currentResult: materials.primary.result,
|
||||
placeholderCount,
|
||||
}).images.map((image) => ({
|
||||
...image,
|
||||
sourceType: "search" as const,
|
||||
})),
|
||||
],
|
||||
},
|
||||
images: [
|
||||
...uploadedImages,
|
||||
...buildLoadingMaterialResult({
|
||||
keyword: skuName,
|
||||
currentResult: materials.primary.result,
|
||||
placeholderCount,
|
||||
}).images.map((image) => ({
|
||||
...image,
|
||||
sourceType: "search" as const,
|
||||
})),
|
||||
],
|
||||
fallbackQuery: skuName,
|
||||
fallbackQueryType: "sku",
|
||||
}),
|
||||
selectedImageIds: materials.primary.selectedImageIds.filter((id) =>
|
||||
uploadedImages.some((image) => image.id === id)
|
||||
),
|
||||
},
|
||||
});
|
||||
setMaterialSearchMode((prev) => ({
|
||||
...prev,
|
||||
primary: "sku",
|
||||
}));
|
||||
void handleSearchMaterials("primary", {
|
||||
keyword: skuName,
|
||||
queryType: "sku",
|
||||
});
|
||||
}}
|
||||
theme={theme}
|
||||
onSelectedImageIdsChange={(next) =>
|
||||
onMaterialsChange({
|
||||
...materials,
|
||||
primary: {
|
||||
...materials.primary,
|
||||
selectedImageIds: next,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
{isSecondaryMaterialVisible ? (
|
||||
<ClientErrorBoundary
|
||||
resetKeys={[
|
||||
"primary",
|
||||
inputs.primarySku,
|
||||
materials.primary.result?.matchedSku || "",
|
||||
materials.primary.selectedImageIds.join("|"),
|
||||
]}
|
||||
fallback={<MaterialPickerErrorFallback isDark={isDark} title="主商品素材搜索" />}
|
||||
>
|
||||
<AgentWorkflowMaterialPicker
|
||||
value={inputs.secondarySku}
|
||||
queryMode={materialSearchMode.secondary}
|
||||
placeholder={materialSearchMode.secondary === "spu" ? "输入搭配 SPU" : "输入搭配 SKU"}
|
||||
buttonLabel={materialSearchMode.secondary === "spu" ? "查SKU列表" : "搜索搭配图"}
|
||||
value={inputs.primarySku}
|
||||
queryMode={materialSearchMode.primary}
|
||||
placeholder={materialSearchMode.primary === "spu" ? "输入主 SPU" : "输入主 SKU"}
|
||||
buttonLabel={materialSearchMode.primary === "spu" ? "查SKU列表" : "搜索主图"}
|
||||
searchNote={
|
||||
materialSearchMode.secondary === "spu"
|
||||
? "按 SPU 搜索时会先返回该 SPU 下的 SKU 列表,再选具体 SKU 获取白底图。"
|
||||
: "选中的搭配图会进入第二个产品槽位,你也可以在下面继续手动替换。"
|
||||
materialSearchMode.primary === "spu"
|
||||
? "按 SPU 搜索时会先返回该 SPU 下的 SKU 列表,点选某个 SKU 后再继续拉取白底图。"
|
||||
: "按 SKU 搜索会直接拉取白底图,适合你已经知道具体 SKU 的情况。"
|
||||
}
|
||||
disabled={disabled}
|
||||
disabledHint={disabledHint}
|
||||
isLoading={materialSearchState.secondary.isLoading}
|
||||
errorText={materialSearchState.secondary.errorText}
|
||||
result={materials.secondary.result}
|
||||
selectedImageIds={materials.secondary.selectedImageIds}
|
||||
isLoading={materialSearchState.primary.isLoading}
|
||||
errorText={materialSearchState.primary.errorText}
|
||||
result={materials.primary.result}
|
||||
selectedImageIds={materials.primary.selectedImageIds}
|
||||
appearanceRecognitionEnabled={appearanceRecognitionEnabled}
|
||||
selectionLimit={secondarySelectionLimit}
|
||||
selectionLimit={primarySelectionLimit}
|
||||
selectionLimitLabel={selectionLimitLabel}
|
||||
onValueChange={(value) => handleMaterialInputChange("secondary", value)}
|
||||
onValueChange={(value) => handleMaterialInputChange("primary", value)}
|
||||
onQueryModeChange={(nextMode) =>
|
||||
setMaterialSearchMode((prev) => ({
|
||||
...prev,
|
||||
secondary: nextMode,
|
||||
primary: nextMode,
|
||||
}))
|
||||
}
|
||||
onSearch={() => {
|
||||
void handleSearchMaterials("secondary");
|
||||
void handleSearchMaterials("primary");
|
||||
}}
|
||||
onUploadImage={(file, options) => handleUploadMaterialImage("secondary", file, options)}
|
||||
onUploadImage={(file, options) => handleUploadMaterialImage("primary", file, options)}
|
||||
onRemoveUploadedImage={(imageId) =>
|
||||
handleRemoveUploadedMaterialImage("secondary", imageId)
|
||||
handleRemoveUploadedMaterialImage("primary", imageId)
|
||||
}
|
||||
isUploadingNewImage={materialUploadState.secondary.isUploadingNew}
|
||||
uploadingImageId={materialUploadState.secondary.uploadingImageId}
|
||||
isUploadingNewImage={materialUploadState.primary.isUploadingNew}
|
||||
uploadingImageId={materialUploadState.primary.uploadingImageId}
|
||||
onSkuOptionSelect={(skuName) => {
|
||||
const selectedOption = materials.secondary.result?.skuOptions?.find(
|
||||
const selectedOption = materials.primary.result?.skuOptions?.find(
|
||||
(option) => option.skuName === skuName
|
||||
);
|
||||
const placeholderCount = selectedOption?.whiteBackgroundCount || 8;
|
||||
const uploadedImages = getUploadedWorkflowMaterialImages(materials.secondary.result);
|
||||
const uploadedImages = getUploadedWorkflowMaterialImages(materials.primary.result);
|
||||
onInputsChange({
|
||||
...inputs,
|
||||
secondarySku: skuName,
|
||||
primarySku: skuName,
|
||||
});
|
||||
onMaterialsChange({
|
||||
...materials,
|
||||
secondary: {
|
||||
primary: {
|
||||
result: buildWorkflowMaterialResult({
|
||||
currentResult: {
|
||||
...buildLoadingMaterialResult({
|
||||
keyword: skuName,
|
||||
currentResult: materials.secondary.result,
|
||||
currentResult: materials.primary.result,
|
||||
placeholderCount,
|
||||
}),
|
||||
images: [
|
||||
...uploadedImages,
|
||||
...buildLoadingMaterialResult({
|
||||
keyword: skuName,
|
||||
currentResult: materials.secondary.result,
|
||||
currentResult: materials.primary.result,
|
||||
placeholderCount,
|
||||
}).images.map((image) => ({
|
||||
...image,
|
||||
@ -1069,7 +1055,7 @@ export function AgentWorkflowMaterialSelectionPanel({
|
||||
...uploadedImages,
|
||||
...buildLoadingMaterialResult({
|
||||
keyword: skuName,
|
||||
currentResult: materials.secondary.result,
|
||||
currentResult: materials.primary.result,
|
||||
placeholderCount,
|
||||
}).images.map((image) => ({
|
||||
...image,
|
||||
@ -1079,16 +1065,16 @@ export function AgentWorkflowMaterialSelectionPanel({
|
||||
fallbackQuery: skuName,
|
||||
fallbackQueryType: "sku",
|
||||
}),
|
||||
selectedImageIds: materials.secondary.selectedImageIds.filter((id) =>
|
||||
selectedImageIds: materials.primary.selectedImageIds.filter((id) =>
|
||||
uploadedImages.some((image) => image.id === id)
|
||||
),
|
||||
},
|
||||
});
|
||||
setMaterialSearchMode((prev) => ({
|
||||
...prev,
|
||||
secondary: "sku",
|
||||
primary: "sku",
|
||||
}));
|
||||
void handleSearchMaterials("secondary", {
|
||||
void handleSearchMaterials("primary", {
|
||||
keyword: skuName,
|
||||
queryType: "sku",
|
||||
});
|
||||
@ -1097,13 +1083,132 @@ export function AgentWorkflowMaterialSelectionPanel({
|
||||
onSelectedImageIdsChange={(next) =>
|
||||
onMaterialsChange({
|
||||
...materials,
|
||||
secondary: {
|
||||
...materials.secondary,
|
||||
primary: {
|
||||
...materials.primary,
|
||||
selectedImageIds: next,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</ClientErrorBoundary>
|
||||
|
||||
{isSecondaryMaterialVisible ? (
|
||||
<ClientErrorBoundary
|
||||
resetKeys={[
|
||||
"secondary",
|
||||
inputs.secondarySku,
|
||||
materials.secondary.result?.matchedSku || "",
|
||||
materials.secondary.selectedImageIds.join("|"),
|
||||
]}
|
||||
fallback={<MaterialPickerErrorFallback isDark={isDark} title="搭配商品素材搜索" />}
|
||||
>
|
||||
<AgentWorkflowMaterialPicker
|
||||
value={inputs.secondarySku}
|
||||
queryMode={materialSearchMode.secondary}
|
||||
placeholder={materialSearchMode.secondary === "spu" ? "输入搭配 SPU" : "输入搭配 SKU"}
|
||||
buttonLabel={materialSearchMode.secondary === "spu" ? "查SKU列表" : "搜索搭配图"}
|
||||
searchNote={
|
||||
materialSearchMode.secondary === "spu"
|
||||
? "按 SPU 搜索时会先返回该 SPU 下的 SKU 列表,再选具体 SKU 获取白底图。"
|
||||
: "选中的搭配图会进入第二个产品槽位,你也可以在下面继续手动替换。"
|
||||
}
|
||||
disabled={disabled}
|
||||
disabledHint={disabledHint}
|
||||
isLoading={materialSearchState.secondary.isLoading}
|
||||
errorText={materialSearchState.secondary.errorText}
|
||||
result={materials.secondary.result}
|
||||
selectedImageIds={materials.secondary.selectedImageIds}
|
||||
appearanceRecognitionEnabled={appearanceRecognitionEnabled}
|
||||
selectionLimit={secondarySelectionLimit}
|
||||
selectionLimitLabel={selectionLimitLabel}
|
||||
onValueChange={(value) => handleMaterialInputChange("secondary", value)}
|
||||
onQueryModeChange={(nextMode) =>
|
||||
setMaterialSearchMode((prev) => ({
|
||||
...prev,
|
||||
secondary: nextMode,
|
||||
}))
|
||||
}
|
||||
onSearch={() => {
|
||||
void handleSearchMaterials("secondary");
|
||||
}}
|
||||
onUploadImage={(file, options) => handleUploadMaterialImage("secondary", file, options)}
|
||||
onRemoveUploadedImage={(imageId) =>
|
||||
handleRemoveUploadedMaterialImage("secondary", imageId)
|
||||
}
|
||||
isUploadingNewImage={materialUploadState.secondary.isUploadingNew}
|
||||
uploadingImageId={materialUploadState.secondary.uploadingImageId}
|
||||
onSkuOptionSelect={(skuName) => {
|
||||
const selectedOption = materials.secondary.result?.skuOptions?.find(
|
||||
(option) => option.skuName === skuName
|
||||
);
|
||||
const placeholderCount = selectedOption?.whiteBackgroundCount || 8;
|
||||
const uploadedImages = getUploadedWorkflowMaterialImages(materials.secondary.result);
|
||||
onInputsChange({
|
||||
...inputs,
|
||||
secondarySku: skuName,
|
||||
});
|
||||
onMaterialsChange({
|
||||
...materials,
|
||||
secondary: {
|
||||
result: buildWorkflowMaterialResult({
|
||||
currentResult: {
|
||||
...buildLoadingMaterialResult({
|
||||
keyword: skuName,
|
||||
currentResult: materials.secondary.result,
|
||||
placeholderCount,
|
||||
}),
|
||||
images: [
|
||||
...uploadedImages,
|
||||
...buildLoadingMaterialResult({
|
||||
keyword: skuName,
|
||||
currentResult: materials.secondary.result,
|
||||
placeholderCount,
|
||||
}).images.map((image) => ({
|
||||
...image,
|
||||
sourceType: "search" as const,
|
||||
})),
|
||||
],
|
||||
},
|
||||
images: [
|
||||
...uploadedImages,
|
||||
...buildLoadingMaterialResult({
|
||||
keyword: skuName,
|
||||
currentResult: materials.secondary.result,
|
||||
placeholderCount,
|
||||
}).images.map((image) => ({
|
||||
...image,
|
||||
sourceType: "search" as const,
|
||||
})),
|
||||
],
|
||||
fallbackQuery: skuName,
|
||||
fallbackQueryType: "sku",
|
||||
}),
|
||||
selectedImageIds: materials.secondary.selectedImageIds.filter((id) =>
|
||||
uploadedImages.some((image) => image.id === id)
|
||||
),
|
||||
},
|
||||
});
|
||||
setMaterialSearchMode((prev) => ({
|
||||
...prev,
|
||||
secondary: "sku",
|
||||
}));
|
||||
void handleSearchMaterials("secondary", {
|
||||
keyword: skuName,
|
||||
queryType: "sku",
|
||||
});
|
||||
}}
|
||||
theme={theme}
|
||||
onSelectedImageIdsChange={(next) =>
|
||||
onMaterialsChange({
|
||||
...materials,
|
||||
secondary: {
|
||||
...materials.secondary,
|
||||
selectedImageIds: next,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</ClientErrorBoundary>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@ -24,7 +24,6 @@ import {
|
||||
} from "react";
|
||||
import useSWR from "swr";
|
||||
import { toast } from "sonner";
|
||||
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import {
|
||||
@ -53,8 +52,6 @@ const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
||||
|
||||
const PANEL_CLASS =
|
||||
"rounded-[28px] border border-[#e5e5e5] bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.03),0_20px_40px_rgba(0,0,0,0.06)]";
|
||||
const SUBTLE_PANEL_CLASS =
|
||||
"rounded-[22px] border border-[#ececec] bg-[#fbfbfb] shadow-[inset_0_1px_0_rgba(255,255,255,0.95)]";
|
||||
const PRIMARY_ACTION_BUTTON_CLASS =
|
||||
"border border-[#111111] bg-[#111111] text-white shadow-[0_16px_30px_rgba(0,0,0,0.18)] transition hover:bg-black hover:border-black disabled:border-[#e5e5e5] disabled:bg-[#f1f1f1] disabled:text-[#9a9a9a] disabled:shadow-none";
|
||||
const SECONDARY_ACTION_BUTTON_CLASS =
|
||||
@ -933,6 +930,13 @@ export function AiWashWorkspace({
|
||||
? comparePositionById[selectedPreviewResult.presetId] ?? 50
|
||||
: 50;
|
||||
const previewHasImage = Boolean(selectedPreviewResult?.imageUrl);
|
||||
const selectedExecutionState =
|
||||
presetExecutionStates.find(
|
||||
(item) => item.presetId === selectedPreviewResult?.presetId
|
||||
) ||
|
||||
presetExecutionStates.find((item) => item.preset.isDefault) ||
|
||||
presetExecutionStates[0] ||
|
||||
null;
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full bg-[#ffffff] text-[#111111]">
|
||||
@ -940,8 +944,13 @@ export function AiWashWorkspace({
|
||||
<div className="pattern-grid-lg pointer-events-none absolute inset-0 opacity-[0.025]" />
|
||||
|
||||
<div className="relative z-10 flex h-full w-full pt-24">
|
||||
<aside className="relative z-20 h-full w-[420px] max-w-[42vw] shrink-0 py-6 pl-6">
|
||||
<div className={cn("h-full space-y-3 overflow-y-auto pb-3", SIDEBAR_SCROLL_CLASS)}>
|
||||
<aside className="relative z-20 order-2 -mt-16 h-[calc(100%+4rem)] w-[430px] max-w-[42vw] shrink-0 pb-6 pr-6">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full space-y-4 overflow-y-auto rounded-[36px] border border-[#e7e7e7] bg-white/72 p-4 shadow-[0_24px_70px_rgba(0,0,0,0.08)] backdrop-blur-xl",
|
||||
SIDEBAR_SCROLL_CLASS
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div
|
||||
className={cn(
|
||||
@ -966,42 +975,36 @@ export function AiWashWorkspace({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className={cn(PANEL_CLASS, "space-y-3 p-3.5")}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-[30px] border border-[#e8e8e8] bg-[#fbfbfb]/90 p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.9)]">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[16px] border border-[#ececec] bg-white text-[#111111] shadow-[0_10px_22px_rgba(0,0,0,0.05)]">
|
||||
<CanvasToolbarIcon
|
||||
className="h-5 w-5"
|
||||
fallback={Sparkles}
|
||||
iconUrl={config.modeIconUrl}
|
||||
/>
|
||||
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-[22px] border border-[#ececec] bg-white text-[#111111] shadow-[0_12px_24px_rgba(0,0,0,0.06)]">
|
||||
<SlidersHorizontal className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-semibold text-[22px] text-[#111111]">
|
||||
{config.modeLabel || "AI洗图"}
|
||||
<div className="inline-flex rounded-full border border-[#e6e6e6] bg-white px-3 py-1 font-semibold text-[11px] text-[#777777] tracking-[0.18em]">
|
||||
STUDIO SETUP
|
||||
</div>
|
||||
<div className="mt-2 font-semibold text-[20px] text-[#111111]">
|
||||
设置
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-full border border-[#ebebeb] bg-white px-2.5 py-1 text-[11px] text-[#666666]">
|
||||
{enabledPresets.length} 个预设
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="rounded-full border border-[#e6e6e6] bg-white px-3 py-1.5 text-xs text-[#666666]">
|
||||
Redefine
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 rotate-180 text-[#666666]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className={cn(SUBTLE_PANEL_CLASS, "space-y-2 p-3.5")}>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[14px] border border-[#ececec] bg-white text-[#111111] shadow-[0_8px_18px_rgba(0,0,0,0.04)]">
|
||||
<SlidersHorizontal className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-[15px] text-[#111111]">
|
||||
渲染目标
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-[26px] border border-[#ececec] bg-[#fbfbfb]/92 p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.95)]">
|
||||
<div className="mb-3 font-semibold text-[17px] text-[#111111]">
|
||||
增强预设
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-2.5">
|
||||
{presetExecutionStates.map((item) => {
|
||||
const targetResult = results.find(
|
||||
(result) => result.presetId === item.preset.id
|
||||
@ -1014,72 +1017,46 @@ export function AiWashWorkspace({
|
||||
<button
|
||||
aria-pressed={isActive}
|
||||
className={cn(
|
||||
"w-full rounded-[16px] border px-3 py-2.5 text-left transition",
|
||||
"w-full rounded-[18px] border px-4 py-3 text-left transition",
|
||||
isActive
|
||||
? "border-[#111111] bg-[#111111] text-white shadow-[0_12px_24px_rgba(0,0,0,0.12)]"
|
||||
: "border-[#ececec] bg-white text-[#111111] hover:border-[#111111]"
|
||||
? "border-[#111111] bg-white text-[#111111] shadow-[0_10px_22px_rgba(0,0,0,0.08)]"
|
||||
: "border-[#e8e8e8] bg-white text-[#666666] hover:border-[#111111] hover:text-[#111111]"
|
||||
)}
|
||||
key={item.preset.id}
|
||||
onClick={() => setSelectedResultId(item.preset.id)}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0 truncate font-medium text-sm">
|
||||
<div className="min-w-0 truncate font-medium text-[15px]">
|
||||
{item.preset.title}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-full border px-2 py-1 text-[11px]",
|
||||
isActive
|
||||
? "border-white/20 bg-white/10 text-white"
|
||||
? "border-[#111111] bg-[#111111] text-white"
|
||||
: "border-[#ebebeb] bg-[#fafafa] text-[#666666]"
|
||||
)}
|
||||
>
|
||||
{getAiWashStatusLabel(targetResult?.status)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex min-w-0 items-center gap-2 text-[11px]">
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 truncate rounded-full border px-2 py-1",
|
||||
isActive
|
||||
? "border-white/15 bg-white/10 text-white/80"
|
||||
: "border-[#ebebeb] bg-[#fafafa] text-[#666666]"
|
||||
)}
|
||||
>
|
||||
{item.resolvedModel?.displayName ||
|
||||
item.resolvedModel?.modelId ||
|
||||
(item.usesUserSelectableModel
|
||||
? "前台自选模型"
|
||||
: "待配置模型")}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-full border px-2 py-1",
|
||||
isActive
|
||||
? "border-white/15 bg-white/10 text-white/80"
|
||||
: "border-[#ebebeb] bg-[#fafafa] text-[#666666]"
|
||||
)}
|
||||
>
|
||||
{item.unitCost > 0 ? `${item.unitCost} 积分/张` : "未计费"}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-3 max-h-24 overflow-hidden rounded-[18px] border border-[#e9e9e9] bg-white px-4 py-3 text-sm leading-6 text-[#666666]">
|
||||
{selectedExecutionState?.resolvedPrompt ||
|
||||
"选择预设后会使用管理员配置的提示词进行 AI洗图。"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cn(SUBTLE_PANEL_CLASS, "space-y-3 p-3.5")}>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[14px] border border-[#ececec] bg-white text-[#111111] shadow-[0_8px_18px_rgba(0,0,0,0.04)]">
|
||||
<Settings2 className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-[15px] text-[#111111]">
|
||||
参数设置
|
||||
</div>
|
||||
<div className="rounded-[26px] border border-[#ececec] bg-[#fbfbfb]/92 p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.95)]">
|
||||
<div className="mb-3 flex min-w-0 items-center justify-between gap-3">
|
||||
<div className="font-semibold text-[17px] text-[#111111]">
|
||||
参数设置
|
||||
</div>
|
||||
<Settings2 className="h-4 w-4 text-[#777777]" />
|
||||
</div>
|
||||
|
||||
{shouldShowUserModelSelector ? (
|
||||
@ -1148,7 +1125,7 @@ export function AiWashWorkspace({
|
||||
null;
|
||||
return (
|
||||
<div
|
||||
className="rounded-[16px] border border-[#ececec] bg-white px-3.5 py-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.94)]"
|
||||
className="rounded-[20px] border border-[#ececec] bg-white px-4 py-3.5 shadow-[0_8px_18px_rgba(0,0,0,0.03)]"
|
||||
key={control.id}
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
@ -1184,361 +1161,288 @@ export function AiWashWorkspace({
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="flex min-w-0 flex-1 flex-col gap-3 overflow-hidden px-6 pb-6 pt-6">
|
||||
<section
|
||||
className={cn(
|
||||
PANEL_CLASS,
|
||||
"flex min-h-0 flex-1 flex-col overflow-hidden p-3"
|
||||
)}
|
||||
>
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden rounded-[28px] border border-[#e9e9e9] bg-[radial-gradient(circle_at_50%_0%,#ffffff_0%,#f8f8f8_52%,#f2f2f2_100%)]">
|
||||
{compareSourceUrl ? (
|
||||
<button
|
||||
className={cn(
|
||||
"absolute right-4 top-4 z-20 inline-flex h-10 items-center rounded-full px-4 text-sm font-medium",
|
||||
SECONDARY_ACTION_BUTTON_CLASS
|
||||
)}
|
||||
onClick={() => onRequestAddImage?.()}
|
||||
type="button"
|
||||
>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
更换图片
|
||||
</button>
|
||||
) : null}
|
||||
{selectedPreviewResult ? (
|
||||
previewHasImage ? (
|
||||
<>
|
||||
<img
|
||||
alt={selectedPreviewResult.title}
|
||||
className="absolute inset-0 h-full w-full object-contain"
|
||||
src={getDisplayImageUrl(
|
||||
selectedPreviewResult.imageUrl || "",
|
||||
1800
|
||||
)}
|
||||
/>
|
||||
{compareSourceUrl ? (
|
||||
<>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 overflow-hidden"
|
||||
style={{
|
||||
clipPath: `inset(0 ${100 - previewComparePosition}% 0 0)`,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
alt={`${selectedPreviewResult.title} 原图对比`}
|
||||
className="h-full w-full object-contain"
|
||||
src={getDisplayImageUrl(compareSourceUrl, 1800)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-y-0 w-px bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.22),0_0_20px_rgba(0,0,0,0.18)]"
|
||||
style={{ left: `${previewComparePosition}%` }}
|
||||
<main className="order-1 relative min-w-0 flex-1 overflow-hidden px-8 pb-7 pt-6">
|
||||
<div className="relative h-full w-full">
|
||||
{compareSourceUrl ? (
|
||||
<button
|
||||
className={cn(
|
||||
"absolute right-4 top-2 z-20 inline-flex h-10 items-center rounded-full px-4 text-sm font-medium",
|
||||
SECONDARY_ACTION_BUTTON_CLASS
|
||||
)}
|
||||
onClick={() => onRequestAddImage?.()}
|
||||
type="button"
|
||||
>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
更换图片
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<div className="absolute inset-0 flex items-center justify-center px-8 pb-44 pt-8">
|
||||
{selectedPreviewResult && previewHasImage ? (
|
||||
<div className="relative max-h-full max-w-full overflow-hidden rounded-[24px] shadow-[0_18px_52px_rgba(0,0,0,0.08)]">
|
||||
<img
|
||||
alt={selectedPreviewResult.title}
|
||||
className="block max-h-[calc(100vh-320px)] max-w-[min(64vw,1120px)] object-contain"
|
||||
src={getDisplayImageUrl(selectedPreviewResult.imageUrl || "", 1800)}
|
||||
/>
|
||||
{compareSourceUrl ? (
|
||||
<>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 overflow-hidden"
|
||||
style={{
|
||||
clipPath: `inset(0 ${100 - previewComparePosition}% 0 0)`,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
alt={`${selectedPreviewResult.title} 原图对比`}
|
||||
className="h-full w-full object-contain"
|
||||
src={getDisplayImageUrl(compareSourceUrl, 1800)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-y-0 w-px bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.22),0_0_20px_rgba(0,0,0,0.18)]"
|
||||
style={{ left: `${previewComparePosition}%` }}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 cursor-col-resize touch-none"
|
||||
onPointerCancel={(event) =>
|
||||
finishCompareDrag(selectedPreviewResult.presetId, event)
|
||||
}
|
||||
onPointerDown={(event) =>
|
||||
handleComparePointerDown(
|
||||
selectedPreviewResult.presetId,
|
||||
event
|
||||
)
|
||||
}
|
||||
onPointerMove={(event) =>
|
||||
handleComparePointerMove(
|
||||
selectedPreviewResult.presetId,
|
||||
event
|
||||
)
|
||||
}
|
||||
onPointerUp={(event) =>
|
||||
finishCompareDrag(selectedPreviewResult.presetId, event)
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 cursor-col-resize touch-none"
|
||||
onPointerCancel={(event) =>
|
||||
finishCompareDrag(selectedPreviewResult.presetId, event)
|
||||
}
|
||||
onPointerDown={(event) =>
|
||||
handleComparePointerDown(
|
||||
selectedPreviewResult.presetId,
|
||||
event
|
||||
)
|
||||
}
|
||||
onPointerMove={(event) =>
|
||||
handleComparePointerMove(
|
||||
selectedPreviewResult.presetId,
|
||||
event
|
||||
)
|
||||
}
|
||||
onPointerUp={(event) =>
|
||||
finishCompareDrag(selectedPreviewResult.presetId, event)
|
||||
}
|
||||
className="pointer-events-none absolute inset-y-0 w-12 -translate-x-1/2"
|
||||
style={{ left: `${previewComparePosition}%` }}
|
||||
>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-y-0 w-12 -translate-x-1/2"
|
||||
style={{ left: `${previewComparePosition}%` }}
|
||||
>
|
||||
<div className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-white" />
|
||||
<div className="absolute left-1/2 top-1/2 flex h-12 w-12 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border border-white bg-black/72 text-white shadow-[0_14px_28px_rgba(0,0,0,0.24)]">
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-white" />
|
||||
<div className="absolute left-1/2 top-1/2 flex h-12 w-12 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border border-white bg-black/72 text-white shadow-[0_14px_28px_rgba(0,0,0,0.24)]">
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="pointer-events-none absolute left-4 top-4 rounded-full bg-black/65 px-3 py-1.5 text-[11px] text-white">
|
||||
原图
|
||||
</div>
|
||||
<div className="pointer-events-none absolute right-4 top-4 rounded-full bg-black/65 px-3 py-1.5 text-[11px] text-white">
|
||||
洗后
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
) : compareSourceUrl ? (
|
||||
<>
|
||||
<img
|
||||
alt="AI洗图底图"
|
||||
className="absolute inset-0 h-full w-full object-contain"
|
||||
src={getDisplayImageUrl(compareSourceUrl, 1800)}
|
||||
/>
|
||||
<div className="pointer-events-none absolute left-4 top-4 rounded-full bg-black/65 px-3 py-1.5 text-[11px] text-white">
|
||||
原图
|
||||
</div>
|
||||
{(selectedPreviewResult.status === "running" ||
|
||||
selectedPreviewResult.status === "submitting" ||
|
||||
selectedPreviewResult.status === "failed") ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/58 px-6 text-center backdrop-blur-[1px]">
|
||||
<div className="max-w-sm rounded-[24px] border border-[#ececec] bg-white/92 px-6 py-5 shadow-[0_18px_40px_rgba(0,0,0,0.08)]">
|
||||
{selectedPreviewResult.status === "running" ||
|
||||
selectedPreviewResult.status === "submitting" ? (
|
||||
<>
|
||||
<Loader2 className="mx-auto mb-3 h-8 w-8 animate-spin text-[#666666]" />
|
||||
<div className="text-base font-medium text-[#111111]">
|
||||
{selectedPreviewResult.status === "submitting"
|
||||
? "正在提交模型任务"
|
||||
: "模型正在生成结果"}
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-[#777777]">
|
||||
结果完成后会在同一区域切换为可拖动对比图。
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-base font-medium text-[#9b2c2c]">
|
||||
该目标生成失败
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-[#9b2c2c]">
|
||||
{selectedPreviewResult.error || "请调整配置后重新生成。"}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="pointer-events-none absolute bottom-5 left-1/2 -translate-x-1/2 rounded-full border border-[#e5e5e5] bg-white/92 px-4 py-2 text-xs text-[#666666] shadow-[0_12px_24px_rgba(0,0,0,0.06)]">
|
||||
原图已就绪,点击下方开始 AI洗图
|
||||
<div className="pointer-events-none absolute left-4 top-4 rounded-full bg-black/65 px-3 py-1.5 text-[11px] text-white">
|
||||
原图
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center px-6 text-center">
|
||||
<div>
|
||||
<button
|
||||
className="inline-flex h-16 items-center rounded-[26px] bg-[#111111] px-10 text-[18px] font-semibold text-white shadow-[0_18px_40px_rgba(0,0,0,0.16)] transition hover:bg-black"
|
||||
onClick={() => onRequestAddImage?.()}
|
||||
type="button"
|
||||
>
|
||||
<ImageIcon className="mr-3 h-5 w-5" />
|
||||
添加图片
|
||||
</button>
|
||||
<div className="mt-4 text-sm text-[#777777]">
|
||||
选择本地图后即可开始 AI洗图
|
||||
<div className="pointer-events-none absolute right-4 top-4 rounded-full bg-black/65 px-3 py-1.5 text-[11px] text-white">
|
||||
洗后
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : compareSourceUrl ? (
|
||||
<>
|
||||
<div className="relative max-h-full max-w-full">
|
||||
<img
|
||||
alt="AI洗图底图"
|
||||
className="absolute inset-0 h-full w-full object-contain"
|
||||
className="block max-h-[calc(100vh-320px)] max-w-[min(64vw,1120px)] rounded-[24px] object-contain shadow-[0_18px_52px_rgba(0,0,0,0.08)]"
|
||||
src={getDisplayImageUrl(compareSourceUrl, 1800)}
|
||||
/>
|
||||
<div className="pointer-events-none absolute left-4 top-4 rounded-full bg-black/65 px-3 py-1.5 text-[11px] text-white">
|
||||
原图
|
||||
</div>
|
||||
<div className="pointer-events-none absolute bottom-5 left-1/2 -translate-x-1/2 rounded-full border border-[#e5e5e5] bg-white/92 px-4 py-2 text-xs text-[#666666] shadow-[0_12px_24px_rgba(0,0,0,0.06)]">
|
||||
原图已就绪,点击下方开始 AI洗图
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center px-6 text-center">
|
||||
<div>
|
||||
<button
|
||||
className="inline-flex h-16 items-center rounded-[26px] bg-[#111111] px-10 text-[18px] font-semibold text-white shadow-[0_18px_40px_rgba(0,0,0,0.16)] transition hover:bg-black"
|
||||
onClick={() => onRequestAddImage?.()}
|
||||
type="button"
|
||||
>
|
||||
<ImageIcon className="mr-3 h-5 w-5" />
|
||||
添加图片
|
||||
</button>
|
||||
<div className="mt-4 text-sm text-[#777777]">
|
||||
选择本地图后即可开始 AI洗图
|
||||
{selectedPreviewResult?.status === "running" ||
|
||||
selectedPreviewResult?.status === "submitting" ||
|
||||
selectedPreviewResult?.status === "failed" ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-[24px] bg-white/58 px-6 text-center backdrop-blur-[1px]">
|
||||
<div className="max-w-sm rounded-[24px] border border-[#ececec] bg-white/92 px-6 py-5 shadow-[0_18px_40px_rgba(0,0,0,0.08)]">
|
||||
{selectedPreviewResult.status === "running" ||
|
||||
selectedPreviewResult.status === "submitting" ? (
|
||||
<>
|
||||
<Loader2 className="mx-auto mb-3 h-8 w-8 animate-spin text-[#666666]" />
|
||||
<div className="text-base font-medium text-[#111111]">
|
||||
{selectedPreviewResult.status === "submitting"
|
||||
? "正在提交模型任务"
|
||||
: "模型正在生成结果"}
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-[#777777]">
|
||||
结果完成后会在同一区域切换为可拖动对比图。
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-base font-medium text-[#9b2c2c]">
|
||||
该目标生成失败
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-[#9b2c2c]">
|
||||
{selectedPreviewResult.error || "请调整配置后重新生成。"}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center">
|
||||
<button
|
||||
className="inline-flex h-[92px] items-center rounded-[28px] bg-[#111111] px-12 text-[22px] font-semibold text-white shadow-[0_22px_56px_rgba(0,0,0,0.16)] transition hover:bg-black"
|
||||
onClick={() => onRequestAddImage?.()}
|
||||
type="button"
|
||||
>
|
||||
<ImageIcon className="mr-4 h-6 w-6" />
|
||||
添加图片
|
||||
</button>
|
||||
<div className="mt-5 text-[15px] text-[#777777]">
|
||||
选择本地图后即可开始 {config.modeLabel || "AI洗图"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="rounded-full border border-[#e5e5e5] bg-white px-4 py-2 text-xs text-[#666666] shadow-[0_0_0_1px_rgba(0,0,0,0.02),0_10px_20px_rgba(0,0,0,0.03)]">
|
||||
{selectedResult
|
||||
? `已选中 ${selectedResult.title},可直接发送回画布`
|
||||
: selectedPreviewResult
|
||||
? compareSourceUrl
|
||||
? `当前目标 ${selectedPreviewResult.title},生成完成后可发送回画布`
|
||||
: "请先添加一张底图"
|
||||
: compareSourceUrl
|
||||
? "原图已上传,点击开始 AI洗图生成结果"
|
||||
: "请先添加一张底图"}
|
||||
<div className="absolute bottom-0 left-1/2 z-30 flex -translate-x-1/2 flex-col items-center gap-2">
|
||||
<div className="rounded-full border border-[#e5e5e5] bg-white/92 px-5 py-2 text-xs text-[#666666] shadow-[0_10px_24px_rgba(0,0,0,0.06)] backdrop-blur">
|
||||
生成后可直接发送回画布
|
||||
</div>
|
||||
<div className="rounded-full border border-[#e5e5e5] bg-white/92 px-5 py-2 text-xs text-[#333333] shadow-[0_10px_24px_rgba(0,0,0,0.06)] backdrop-blur">
|
||||
{totalEstimatedCost > 0
|
||||
? `预计消耗 ${totalEstimatedCost} 积分`
|
||||
: "预计消耗按模型配置计算"}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 rounded-[34px] border border-[#e6e6e6] bg-white/88 p-3 shadow-[0_18px_46px_rgba(0,0,0,0.08)] backdrop-blur-xl">
|
||||
<Button
|
||||
className={cn(
|
||||
"h-14 min-w-[220px] rounded-[26px] px-8 text-[15px] font-semibold",
|
||||
PRIMARY_ACTION_BUTTON_CLASS
|
||||
)}
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
!compareSourceUrl ||
|
||||
enabledPresets.length === 0 ||
|
||||
(shouldShowUserModelSelector && !selectedUserModel)
|
||||
}
|
||||
onClick={handleSubmit}
|
||||
type="button"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{isSubmitting ? "正在洗图" : "开始 AI洗图"}
|
||||
</Button>
|
||||
<Button
|
||||
className={cn(
|
||||
"h-14 min-w-[220px] rounded-[26px] px-8 text-[15px] font-medium",
|
||||
SECONDARY_ACTION_BUTTON_CLASS
|
||||
)}
|
||||
disabled={!selectedResult?.imageUrl}
|
||||
onClick={handleApply}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
发送到画布
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
className={cn(
|
||||
"h-11 rounded-[18px] px-5 text-sm font-semibold",
|
||||
PRIMARY_ACTION_BUTTON_CLASS
|
||||
)}
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
!compareSourceUrl ||
|
||||
enabledPresets.length === 0 ||
|
||||
(shouldShowUserModelSelector && !selectedUserModel)
|
||||
}
|
||||
onClick={handleSubmit}
|
||||
type="button"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{isSubmitting
|
||||
? "正在洗图"
|
||||
: totalEstimatedCost > 0
|
||||
? `开始 AI洗图 · ${totalEstimatedCost} 积分`
|
||||
: "开始 AI洗图"}
|
||||
</Button>
|
||||
<Button
|
||||
className={cn(
|
||||
"h-11 rounded-[18px] px-5 text-sm font-medium",
|
||||
SECONDARY_ACTION_BUTTON_CLASS
|
||||
)}
|
||||
disabled={!selectedResult?.imageUrl}
|
||||
onClick={handleApply}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
发送到画布
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cn(PANEL_CLASS, "overflow-hidden p-3")}>
|
||||
<button
|
||||
className="flex w-full items-center justify-between gap-3 rounded-[22px] px-1 py-1 text-left transition hover:bg-[#fafafa]"
|
||||
className="absolute bottom-2 left-2 z-30 inline-flex h-10 items-center gap-2 rounded-full border border-[#e5e5e5] bg-white/90 px-4 text-xs text-[#666666] shadow-[0_10px_24px_rgba(0,0,0,0.06)] backdrop-blur transition hover:border-[#111111] hover:text-[#111111]"
|
||||
onClick={() => setIsHistoryExpanded((current) => !current)}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-[16px] border border-[#ececec] bg-white shadow-[0_10px_22px_rgba(0,0,0,0.05)]">
|
||||
<HistoryIcon className="h-5 w-5 text-[#111111]" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-semibold text-[#111111]">
|
||||
历史洗图记录
|
||||
</div>
|
||||
<div className="text-sm text-[#666666]">
|
||||
保留最近的 AI洗图批次,支持一键回看并继续发送结果到画布。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<div className="rounded-full border border-[#ebebeb] bg-[#fafafa] px-3 py-1 text-xs text-[#666666]">
|
||||
最近 {visibleHistoryBatches.length} 条
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-1 rounded-full border border-[#ebebeb] bg-white px-3 py-1 text-xs text-[#666666]">
|
||||
{isHistoryExpanded ? "收起" : "展开"}
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 transition-transform",
|
||||
isHistoryExpanded && "rotate-180"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<HistoryIcon className="h-4 w-4" />
|
||||
历史 {visibleHistoryBatches.length}
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 transition-transform",
|
||||
isHistoryExpanded && "rotate-180"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isHistoryExpanded ? (
|
||||
visibleHistoryBatches.length > 0 ? (
|
||||
<div className="mt-3 flex gap-3 overflow-x-auto border-t border-[#eeeeee] pt-3 pb-1">
|
||||
{visibleHistoryBatches.map((entry) => {
|
||||
const cover = entry.items[0];
|
||||
return (
|
||||
<div
|
||||
className="min-w-[260px] max-w-[260px] rounded-[22px] border border-[#ececec] bg-[#fbfbfb] p-3"
|
||||
key={entry.id}
|
||||
>
|
||||
<button
|
||||
className="w-full text-left"
|
||||
onClick={() => handleLoadHistoryBatch(entry)}
|
||||
type="button"
|
||||
>
|
||||
<div className="overflow-hidden rounded-[18px] border border-[#ececec] bg-white">
|
||||
<img
|
||||
alt={cover?.title || "历史洗图结果"}
|
||||
className="aspect-[4/3] w-full object-cover"
|
||||
src={getDisplayImageUrl(
|
||||
cover?.thumbnailUrl || cover?.imageUrl || "",
|
||||
520
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-sm text-[#111111]">
|
||||
{cover?.title || "AI洗图结果"}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-[11px] text-[#666666]">
|
||||
<Clock3 className="h-3.5 w-3.5" />
|
||||
{new Date(entry.createdAt).toLocaleString("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-full border border-[#ebebeb] bg-white px-2 py-1 text-[11px] text-[#666666]">
|
||||
{entry.items.length} 张
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{cover?.modelId ? (
|
||||
<span className="rounded-full border border-[#ebebeb] bg-white px-2 py-1 text-[11px] text-[#666666]">
|
||||
{cover.modelId}
|
||||
</span>
|
||||
) : null}
|
||||
{entry.sourceImageUrl ? (
|
||||
<span className="rounded-full border border-[#dcebdd] bg-[#f4fbf5] px-2 py-1 text-[11px] text-[#2f6f3b]">
|
||||
可对比
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-[#ececec] bg-white text-[#666666] transition hover:border-[#111111] hover:text-[#111111]"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleDeleteHistoryBatch(entry.id);
|
||||
}}
|
||||
title="删除历史记录"
|
||||
type="button"
|
||||
<div className="absolute bottom-16 left-2 z-40 w-[380px] rounded-[28px] border border-[#e5e5e5] bg-white/94 p-3 shadow-[0_24px_60px_rgba(0,0,0,0.12)] backdrop-blur-xl">
|
||||
{visibleHistoryBatches.length > 0 ? (
|
||||
<div className="flex max-h-[360px] flex-col gap-3 overflow-y-auto pr-1">
|
||||
{visibleHistoryBatches.map((entry) => {
|
||||
const cover = entry.items[0];
|
||||
return (
|
||||
<div
|
||||
className="rounded-[20px] border border-[#ececec] bg-[#fbfbfb] p-3"
|
||||
key={entry.id}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 rounded-[22px] border border-dashed border-[#dcdcdc] bg-[#fafafa] px-5 py-8 text-center text-sm text-[#8a8a8a]">
|
||||
还没有历史洗图记录,生成一次后会自动出现在这里。
|
||||
</div>
|
||||
)
|
||||
<button
|
||||
className="flex w-full items-center gap-3 text-left"
|
||||
onClick={() => handleLoadHistoryBatch(entry)}
|
||||
type="button"
|
||||
>
|
||||
<div className="h-16 w-20 shrink-0 overflow-hidden rounded-[14px] border border-[#ececec] bg-white">
|
||||
<img
|
||||
alt={cover?.title || "历史洗图结果"}
|
||||
className="h-full w-full object-cover"
|
||||
src={getDisplayImageUrl(
|
||||
cover?.thumbnailUrl || cover?.imageUrl || "",
|
||||
360
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium text-sm text-[#111111]">
|
||||
{cover?.title || "AI洗图结果"}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-[11px] text-[#666666]">
|
||||
<Clock3 className="h-3.5 w-3.5" />
|
||||
{new Date(entry.createdAt).toLocaleString("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-full border border-[#ebebeb] bg-white px-2 py-1 text-[11px] text-[#666666]">
|
||||
{entry.items.length} 张
|
||||
</div>
|
||||
</button>
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
{cover?.modelId ? (
|
||||
<span className="max-w-[240px] truncate rounded-full border border-[#ebebeb] bg-white px-2 py-1 text-[11px] text-[#666666]">
|
||||
{cover.modelId}
|
||||
</span>
|
||||
) : null}
|
||||
{entry.sourceImageUrl ? (
|
||||
<span className="rounded-full border border-[#dcebdd] bg-[#f4fbf5] px-2 py-1 text-[11px] text-[#2f6f3b]">
|
||||
可对比
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-[#ececec] bg-white text-[#666666] transition hover:border-[#111111] hover:text-[#111111]"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleDeleteHistoryBatch(entry.id);
|
||||
}}
|
||||
title="删除历史记录"
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-[20px] border border-dashed border-[#dcdcdc] bg-[#fafafa] px-5 py-8 text-center text-sm text-[#8a8a8a]">
|
||||
还没有历史洗图记录,生成一次后会自动出现在这里。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@ -122,7 +122,7 @@ export function FloatingSidebarRail({
|
||||
: "border border-white/8 bg-[#17181d]/94 text-white shadow-[0_22px_60px_rgba(6,8,14,0.34),inset_0_1px_0_rgba(255,255,255,0.05)]",
|
||||
isHorizontal
|
||||
? "h-[56px] w-auto flex-row overflow-hidden rounded-[22px] px-3 py-2"
|
||||
: "w-[56px] flex-col overflow-y-auto rounded-[24px] px-2 py-2",
|
||||
: "w-[56px] flex-col overflow-x-hidden overflow-y-auto overscroll-contain rounded-[24px] px-2 py-2 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@ -119,6 +119,20 @@ interface ProcessItem {
|
||||
};
|
||||
}
|
||||
|
||||
function markProcessItemRegenerating(
|
||||
item: ProcessItem,
|
||||
status: Extract<ItemStatus, "pending" | "processing">
|
||||
): ProcessItem {
|
||||
return {
|
||||
...item,
|
||||
status,
|
||||
result: undefined,
|
||||
originalUrl: undefined,
|
||||
queueStatus: undefined,
|
||||
error: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
interface SceneFusionViewProps {
|
||||
initialProductImage?: string | null;
|
||||
initialProductName?: string;
|
||||
@ -1081,11 +1095,12 @@ export function SceneFusionView({
|
||||
setBatchItems((prev) =>
|
||||
prev.map((item) =>
|
||||
ids.includes(item.id)
|
||||
? {
|
||||
...item,
|
||||
status,
|
||||
error: status === "processing" ? undefined : item.error,
|
||||
}
|
||||
? status === "pending" || status === "processing"
|
||||
? markProcessItemRegenerating(item, status)
|
||||
: {
|
||||
...item,
|
||||
status,
|
||||
}
|
||||
: item
|
||||
)
|
||||
);
|
||||
@ -1099,11 +1114,12 @@ export function SceneFusionView({
|
||||
setter((prev) =>
|
||||
prev.map((item) =>
|
||||
ids.includes(item.id)
|
||||
? {
|
||||
...item,
|
||||
status,
|
||||
error: status === "processing" ? undefined : item.error,
|
||||
}
|
||||
? status === "pending" || status === "processing"
|
||||
? markProcessItemRegenerating(item, status)
|
||||
: {
|
||||
...item,
|
||||
status,
|
||||
}
|
||||
: item
|
||||
)
|
||||
);
|
||||
@ -1217,12 +1233,17 @@ export function SceneFusionView({
|
||||
setter((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === data.id
|
||||
? {
|
||||
...item,
|
||||
status: data.status,
|
||||
queueStatus: data.queueStatus || undefined,
|
||||
error: data.status === "failed" ? item.error : undefined,
|
||||
}
|
||||
? data.status === "pending" || data.status === "processing"
|
||||
? {
|
||||
...markProcessItemRegenerating(item, data.status),
|
||||
queueStatus: data.queueStatus || undefined,
|
||||
}
|
||||
: {
|
||||
...item,
|
||||
status: data.status,
|
||||
queueStatus: data.queueStatus || undefined,
|
||||
error: data.status === "failed" ? item.error : undefined,
|
||||
}
|
||||
: item
|
||||
)
|
||||
);
|
||||
@ -1454,12 +1475,21 @@ export function SceneFusionView({
|
||||
setMultiProductItems((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === params.itemId
|
||||
? {
|
||||
...item,
|
||||
status: msg.data?.status || item.status,
|
||||
queueStatus: msg.data?.queueStatus || undefined,
|
||||
error: undefined,
|
||||
}
|
||||
? msg.data?.status === "pending" ||
|
||||
msg.data?.status === "processing"
|
||||
? {
|
||||
...markProcessItemRegenerating(
|
||||
item,
|
||||
msg.data.status
|
||||
),
|
||||
queueStatus: msg.data.queueStatus || undefined,
|
||||
}
|
||||
: {
|
||||
...item,
|
||||
status: msg.data?.status || item.status,
|
||||
queueStatus: msg.data?.queueStatus || undefined,
|
||||
error: undefined,
|
||||
}
|
||||
: item
|
||||
)
|
||||
);
|
||||
@ -1556,7 +1586,7 @@ export function SceneFusionView({
|
||||
setMultiProductItems((prev) =>
|
||||
prev.map((item) =>
|
||||
itemsToProcess.some((target) => target.id === item.id)
|
||||
? { ...item, status: "pending", queueStatus: undefined, error: undefined }
|
||||
? markProcessItemRegenerating(item, "pending")
|
||||
: item
|
||||
)
|
||||
);
|
||||
@ -1577,7 +1607,7 @@ export function SceneFusionView({
|
||||
setMultiProductItems((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === current.id
|
||||
? { ...item, status: "processing", error: undefined }
|
||||
? markProcessItemRegenerating(item, "processing")
|
||||
: item
|
||||
)
|
||||
);
|
||||
@ -1743,7 +1773,7 @@ export function SceneFusionView({
|
||||
setBatchItems((prev) =>
|
||||
prev.map((item) =>
|
||||
itemsToProcess.some((target) => target.id === item.id)
|
||||
? { ...item, status: "pending", error: undefined }
|
||||
? markProcessItemRegenerating(item, "pending")
|
||||
: item
|
||||
)
|
||||
);
|
||||
@ -1763,7 +1793,7 @@ export function SceneFusionView({
|
||||
setBatchItems((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === current.id
|
||||
? { ...item, status: "processing", error: undefined }
|
||||
? markProcessItemRegenerating(item, "processing")
|
||||
: item
|
||||
)
|
||||
);
|
||||
@ -1825,7 +1855,7 @@ export function SceneFusionView({
|
||||
setBatchItems((prev) =>
|
||||
prev.map((batchItem) =>
|
||||
batchItem.id === item.id
|
||||
? { ...batchItem, status: "processing", error: undefined }
|
||||
? markProcessItemRegenerating(batchItem, "processing")
|
||||
: batchItem
|
||||
)
|
||||
);
|
||||
@ -1955,7 +1985,7 @@ export function SceneFusionView({
|
||||
setMultiProductItems((prev) =>
|
||||
prev.map((productItem) =>
|
||||
productItem.id === item.id
|
||||
? { ...productItem, status: "pending", error: undefined }
|
||||
? markProcessItemRegenerating(productItem, "pending")
|
||||
: productItem
|
||||
)
|
||||
);
|
||||
@ -2915,6 +2945,12 @@ export function SceneFusionView({
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 xl:grid-cols-4">
|
||||
{resultItems.map((item, index) => (
|
||||
(() => {
|
||||
const completedResultUrl =
|
||||
item.status === "completed"
|
||||
? item.result || item.originalUrl || ""
|
||||
: "";
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm"
|
||||
key={item.id}
|
||||
@ -2947,15 +2983,12 @@ export function SceneFusionView({
|
||||
<div
|
||||
className={cn(
|
||||
"group relative aspect-[3/4] bg-muted/20",
|
||||
item.status === "completed" &&
|
||||
(item.result || item.originalUrl) &&
|
||||
"cursor-zoom-in"
|
||||
completedResultUrl && "cursor-zoom-in"
|
||||
)}
|
||||
onClick={() => {
|
||||
const previewUrl = item.originalUrl || item.result;
|
||||
if (item.status === "completed" && previewUrl) {
|
||||
if (completedResultUrl) {
|
||||
openResultPreview(
|
||||
previewUrl,
|
||||
completedResultUrl,
|
||||
`任务 ${index + 1} 预览`
|
||||
);
|
||||
}
|
||||
@ -2963,28 +2996,24 @@ export function SceneFusionView({
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ")
|
||||
return;
|
||||
const previewUrl = item.originalUrl || item.result;
|
||||
if (item.status === "completed" && previewUrl) {
|
||||
if (completedResultUrl) {
|
||||
event.preventDefault();
|
||||
openResultPreview(
|
||||
previewUrl,
|
||||
completedResultUrl,
|
||||
`任务 ${index + 1} 预览`
|
||||
);
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={
|
||||
item.status === "completed" &&
|
||||
(item.result || item.originalUrl)
|
||||
? 0
|
||||
: -1
|
||||
completedResultUrl ? 0 : -1
|
||||
}
|
||||
>
|
||||
{item.result ? (
|
||||
{completedResultUrl ? (
|
||||
<img
|
||||
alt="处理结果"
|
||||
className="h-full w-full object-cover"
|
||||
src={item.result}
|
||||
src={completedResultUrl}
|
||||
/>
|
||||
) : (
|
||||
(() => {
|
||||
@ -3080,7 +3109,7 @@ export function SceneFusionView({
|
||||
})()
|
||||
)}
|
||||
|
||||
{item.status === "completed" && (
|
||||
{completedResultUrl && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
@ -3107,7 +3136,7 @@ export function SceneFusionView({
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
downloadImage(
|
||||
item.originalUrl || item.result!,
|
||||
completedResultUrl,
|
||||
`batch-${index + 1}.png`
|
||||
);
|
||||
}}
|
||||
@ -3131,6 +3160,8 @@ export function SceneFusionView({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -271,7 +271,7 @@ export function TopNavbar({
|
||||
};
|
||||
|
||||
const navContent = (
|
||||
<nav className="fixed top-0 bottom-0 left-0 z-[100] flex w-16 flex-col items-center border-gray-200 border-r bg-white/90 text-gray-900 shadow-sm backdrop-blur-sm dark:border-white/10 dark:bg-black/90 dark:text-white">
|
||||
<nav className="fixed top-0 bottom-0 left-0 z-[100] flex w-16 flex-col items-center overflow-x-hidden border-gray-200 border-r bg-white/90 text-gray-900 shadow-sm backdrop-blur-sm dark:border-white/10 dark:bg-black/90 dark:text-white">
|
||||
{/* 顶部:LOGO + 网站名称 */}
|
||||
<a
|
||||
className="mt-3 flex flex-col items-center gap-1 transition-opacity hover:opacity-80"
|
||||
@ -294,7 +294,7 @@ export function TopNavbar({
|
||||
</a>
|
||||
|
||||
{/* 侧边功能区 */}
|
||||
<div className="mt-12 flex w-full flex-1 flex-col items-center gap-3 overflow-y-auto px-2 pb-4">
|
||||
<div className="mt-12 flex w-full min-w-0 flex-1 flex-col items-center gap-3 overflow-x-hidden overflow-y-auto overscroll-contain px-2 pb-4 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
{/* 工具箱1 (如果启用且有链接) */}
|
||||
{toolbox1Enabled &&
|
||||
customLinks.filter((l) => (l.toolboxGroup || 1) === 1).length > 0 && (
|
||||
|
||||
@ -10,7 +10,10 @@ import {
|
||||
type AgentPsdTemplateLayerSlot,
|
||||
} from "@/lib/agents/psd-template-config";
|
||||
import { resolveAgentPsdLocalCachePath } from "@/lib/agents/psd-local-cache";
|
||||
import { preparePsdTemplateRenderAssets } from "@/lib/agents/psd-template-parser";
|
||||
import {
|
||||
preparePsdTemplateRenderAssets,
|
||||
uploadPreparedPsdTemplateRenderSegments,
|
||||
} from "@/lib/agents/psd-template-parser";
|
||||
import { readAgentPsdTemplates } from "@/lib/agents/psd-template-settings";
|
||||
import { createStorageUploadSession } from "@/lib/storage/service";
|
||||
|
||||
@ -181,7 +184,7 @@ async function backfillTemplateAssetsOnce(params: {
|
||||
...currentTemplate,
|
||||
parseStatus: "processing",
|
||||
parseProgress: Math.max(88, Number(currentTemplate.parseProgress || 0)),
|
||||
parseStage: "正在后台生成模板底图",
|
||||
parseStage: "正在后台生成模板底图和快速导出分层",
|
||||
parseError: undefined,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}),
|
||||
@ -191,6 +194,11 @@ async function backfillTemplateAssetsOnce(params: {
|
||||
`agent-psd-base-${template.id}-${Date.now()}.png`,
|
||||
toArrayBuffer(preparedAssets.renderBaseBuffer)
|
||||
);
|
||||
const uploadedRenderSegments = await uploadPreparedPsdTemplateRenderSegments({
|
||||
templateId: template.id,
|
||||
segments: preparedAssets.renderSegments,
|
||||
uploadAsset,
|
||||
});
|
||||
|
||||
await updateTemplateById({
|
||||
templateId: template.id,
|
||||
@ -205,6 +213,7 @@ async function backfillTemplateAssetsOnce(params: {
|
||||
...currentTemplate,
|
||||
renderBaseUrl: uploadedRenderBase.url,
|
||||
renderBasePath: uploadedRenderBase.pathname,
|
||||
renderSegments: uploadedRenderSegments,
|
||||
layers: mergedLayers,
|
||||
layerCount: mergedLayers.length,
|
||||
assetVersion: AGENT_PSD_TEMPLATE_ASSET_VERSION,
|
||||
|
||||
@ -4,7 +4,7 @@ import type { AgentPsdTemplateCategoryTagConfig } from "@/lib/agents/psd-templat
|
||||
import { normalizeAgentPsdTemplateCategoryTags } from "@/lib/agents/psd-template-category-tag-config";
|
||||
|
||||
export const AGENT_PSD_TEMPLATES_KEY = "agent_psd_templates";
|
||||
export const AGENT_PSD_TEMPLATE_ASSET_VERSION = 6;
|
||||
export const AGENT_PSD_TEMPLATE_ASSET_VERSION = 7;
|
||||
|
||||
export type AgentPsdTemplateStatus = "active" | "disabled";
|
||||
export type AgentPsdTemplateParseStatus =
|
||||
@ -49,6 +49,8 @@ export interface AgentPsdTemplateImageLayerSlot
|
||||
slotType: "image";
|
||||
isDefaultResultSource: boolean;
|
||||
allowUserUploadOverride: boolean;
|
||||
forceClipMask?: boolean;
|
||||
clipMaskLayerId?: string;
|
||||
replaceMode: AgentPsdTemplateReplaceMode;
|
||||
contentBounds?: AgentPsdTemplateImageContentBounds;
|
||||
maskUrl?: string;
|
||||
@ -83,6 +85,13 @@ export type AgentPsdTemplateLayerSlot =
|
||||
| AgentPsdTemplateImageLayerSlot
|
||||
| AgentPsdTemplateTextLayerSlot;
|
||||
|
||||
export interface AgentPsdTemplateRenderSegment {
|
||||
id: string;
|
||||
beforeLayerId?: string;
|
||||
url: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface AgentPsdTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
@ -103,6 +112,7 @@ export interface AgentPsdTemplate {
|
||||
previewPath?: string;
|
||||
renderBaseUrl?: string;
|
||||
renderBasePath?: string;
|
||||
renderSegments?: AgentPsdTemplateRenderSegment[];
|
||||
width: number;
|
||||
height: number;
|
||||
layerCount: number;
|
||||
@ -407,6 +417,11 @@ export function parseAgentPsdTemplateLayers(value: unknown) {
|
||||
isEditable && !isSuperLocked && record.isDefaultResultSource === true,
|
||||
allowUserUploadOverride:
|
||||
isEditable && !isSuperLocked ? record.allowUserUploadOverride !== false : false,
|
||||
forceClipMask: isEditable && !isSuperLocked && record.forceClipMask === true,
|
||||
clipMaskLayerId:
|
||||
isEditable && !isSuperLocked && record.forceClipMask === true
|
||||
? normalizeText(record.clipMaskLayerId) || undefined
|
||||
: undefined,
|
||||
replaceMode: normalizeAgentPsdTemplateReplaceMode(record.replaceMode),
|
||||
contentBounds: normalizeAgentPsdTemplateImageContentBounds(
|
||||
record.contentBounds,
|
||||
@ -443,6 +458,36 @@ export function parseAgentPsdTemplateLayers(value: unknown) {
|
||||
});
|
||||
}
|
||||
|
||||
function parseAgentPsdTemplateRenderSegments(value: unknown) {
|
||||
if (!Array.isArray(value)) return [] as AgentPsdTemplateRenderSegment[];
|
||||
|
||||
const segments: AgentPsdTemplateRenderSegment[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
|
||||
for (const entry of value) {
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const record = entry as Record<string, unknown>;
|
||||
const id = normalizeText(record.id);
|
||||
const url = normalizeUrl(record.url);
|
||||
if (!id || !url || seenIds.has(id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenIds.add(id);
|
||||
segments.push({
|
||||
id,
|
||||
beforeLayerId: normalizeText(record.beforeLayerId) || undefined,
|
||||
url,
|
||||
path: normalizeText(record.path) || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
export function parseAgentPsdTemplates(value: unknown) {
|
||||
if (!Array.isArray(value)) return [] as AgentPsdTemplate[];
|
||||
|
||||
@ -512,6 +557,7 @@ export function parseAgentPsdTemplates(value: unknown) {
|
||||
renderBaseUrl:
|
||||
normalizeOptionalUrl(record.renderBaseUrl) || normalizeOptionalUrl(record.previewUrl),
|
||||
renderBasePath: normalizeText(record.renderBasePath) || undefined,
|
||||
renderSegments: parseAgentPsdTemplateRenderSegments(record.renderSegments),
|
||||
width,
|
||||
height,
|
||||
layerCount,
|
||||
@ -547,6 +593,14 @@ export function hasAgentPsdTemplateStoredLayerSource(
|
||||
);
|
||||
}
|
||||
|
||||
export function hasAgentPsdTemplateRenderSegments(
|
||||
template: Pick<AgentPsdTemplate, "renderSegments">
|
||||
) {
|
||||
return Boolean(
|
||||
template.renderSegments?.some((segment) => String(segment.url || "").trim())
|
||||
);
|
||||
}
|
||||
|
||||
export function hasAgentPsdTemplateDedicatedRenderBase(
|
||||
template: Pick<AgentPsdTemplate, "renderBaseUrl" | "renderBasePath" | "previewUrl">
|
||||
) {
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
isAgentPsdTextLayerSlot,
|
||||
type AgentPsdTemplateImageContentBounds,
|
||||
type AgentPsdTemplateLayerSlot,
|
||||
type AgentPsdTemplateRenderSegment,
|
||||
} from "@/lib/agents/psd-template-config";
|
||||
|
||||
const DEFAULT_PSD_TEMPLATE_ASSET_CONCURRENCY = 4;
|
||||
@ -119,6 +120,12 @@ export interface PsdTemplateSourceLayerEntry {
|
||||
composite: () => Promise<Uint8ClampedArray>;
|
||||
}
|
||||
|
||||
export interface PreparedPsdTemplateRenderSegment {
|
||||
id: string;
|
||||
beforeLayerId?: string;
|
||||
buffer: Buffer;
|
||||
}
|
||||
|
||||
function resolveVisibleContentBounds(params: {
|
||||
rgba: Uint8ClampedArray;
|
||||
width: number;
|
||||
@ -666,6 +673,35 @@ function toArrayBuffer(buffer: Buffer) {
|
||||
) as ArrayBuffer;
|
||||
}
|
||||
|
||||
export async function uploadPreparedPsdTemplateRenderSegments(params: {
|
||||
templateId: string;
|
||||
segments: PreparedPsdTemplateRenderSegment[];
|
||||
uploadAsset: StorageUploadFunction;
|
||||
}) {
|
||||
const uploadedSegments = await mapWithConcurrency(
|
||||
params.segments,
|
||||
DEFAULT_PSD_TEMPLATE_ASSET_CONCURRENCY,
|
||||
async (segment) => {
|
||||
const uploaded = await params.uploadAsset(
|
||||
`agent-psd-segment-${params.templateId}-${sanitizeFileSegment(
|
||||
segment.id,
|
||||
"segment"
|
||||
)}-${Date.now()}.png`,
|
||||
toArrayBuffer(segment.buffer)
|
||||
);
|
||||
|
||||
return {
|
||||
id: segment.id,
|
||||
beforeLayerId: segment.beforeLayerId,
|
||||
url: uploaded.url,
|
||||
path: uploaded.pathname,
|
||||
} satisfies AgentPsdTemplateRenderSegment;
|
||||
}
|
||||
);
|
||||
|
||||
return uploadedSegments;
|
||||
}
|
||||
|
||||
async function mapWithConcurrency<T, R>(
|
||||
items: T[],
|
||||
concurrency: number,
|
||||
@ -727,8 +763,13 @@ export async function preparePsdTemplateRenderAssets(params: {
|
||||
fileBuffer: params.fileBuffer,
|
||||
});
|
||||
const psd = await parsePsdBuffer(psdBuffer);
|
||||
const canvasWidth = Math.max(1, Math.round(Number(psd.width || 0)));
|
||||
const canvasHeight = Math.max(1, Math.round(Number(psd.height || 0)));
|
||||
const layerEntries = collectPsdLayerEntries(Array.isArray(psd.children) ? psd.children : []);
|
||||
const nodeMap = new Map(layerEntries.map((entry) => [entry.layer.layerId, entry]));
|
||||
const layerSlotByPsdLayerId = new Map(
|
||||
params.layers.map((layer) => [layer.layerId, layer])
|
||||
);
|
||||
const editableLayerIdSet = new Set(
|
||||
params.layers
|
||||
.filter(isAgentPsdTemplateLayerUserEditable)
|
||||
@ -900,19 +941,28 @@ export async function preparePsdTemplateRenderAssets(params: {
|
||||
nextLayers[index] = layer;
|
||||
});
|
||||
|
||||
const baseEntries = [...layerEntries]
|
||||
const drawableEntries = [...layerEntries]
|
||||
.reverse()
|
||||
.filter(
|
||||
(entry) =>
|
||||
!editableLayerIdSet.has(entry.layer.layerId) &&
|
||||
entry.node &&
|
||||
typeof entry.node.composite === "function"
|
||||
);
|
||||
.filter((entry) => entry.node && typeof entry.node.composite === "function");
|
||||
|
||||
const baseCompositeResults = await mapWithConcurrency(
|
||||
baseEntries,
|
||||
const layerCompositeResults = await mapWithConcurrency(
|
||||
drawableEntries,
|
||||
assetConcurrency,
|
||||
async (entry, index) => {
|
||||
const layerSlot = layerSlotByPsdLayerId.get(entry.layer.layerId);
|
||||
const isEditableBoundary = layerSlot
|
||||
? isAgentPsdTemplateLayerUserEditable(layerSlot)
|
||||
: editableLayerIdSet.has(entry.layer.layerId);
|
||||
|
||||
if (isEditableBoundary) {
|
||||
return {
|
||||
index,
|
||||
input: null,
|
||||
beforeLayerId: layerSlot?.id || "",
|
||||
editableBoundary: true,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const rgba = await entry.node.composite!();
|
||||
const overlayBuffer = await rgbaToPngBuffer(
|
||||
@ -926,8 +976,8 @@ export async function preparePsdTemplateRenderAssets(params: {
|
||||
top: entry.layer.y,
|
||||
width: entry.layer.width,
|
||||
height: entry.layer.height,
|
||||
canvasWidth: Math.max(1, Math.round(Number(psd.width || 0))),
|
||||
canvasHeight: Math.max(1, Math.round(Number(psd.height || 0))),
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
});
|
||||
if (!fittedOverlay) {
|
||||
return null;
|
||||
@ -936,6 +986,8 @@ export async function preparePsdTemplateRenderAssets(params: {
|
||||
return {
|
||||
index,
|
||||
input: fittedOverlay,
|
||||
beforeLayerId: "",
|
||||
editableBoundary: false,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn("[PSD Template] Failed to compose base layer:", {
|
||||
@ -947,27 +999,77 @@ export async function preparePsdTemplateRenderAssets(params: {
|
||||
}
|
||||
);
|
||||
|
||||
const baseCompositeInputs = baseCompositeResults
|
||||
const sortedLayerCompositeResults = layerCompositeResults
|
||||
.filter(
|
||||
(
|
||||
result
|
||||
): result is {
|
||||
index: number;
|
||||
input: { input: Buffer; left: number; top: number } | null;
|
||||
beforeLayerId: string;
|
||||
editableBoundary: boolean;
|
||||
} => Boolean(result)
|
||||
)
|
||||
.sort((left, right) => left.index - right.index);
|
||||
const baseCompositeInputs = sortedLayerCompositeResults
|
||||
.filter(
|
||||
(
|
||||
result
|
||||
): result is {
|
||||
index: number;
|
||||
input: { input: Buffer; left: number; top: number };
|
||||
} => Boolean(result)
|
||||
beforeLayerId: string;
|
||||
editableBoundary: false;
|
||||
} => Boolean(result.input) && !result.editableBoundary
|
||||
)
|
||||
.sort((left, right) => left.index - right.index)
|
||||
.map((result) => result.input);
|
||||
|
||||
const sharp = await getSharp();
|
||||
const transparentBaseBuffer = await createTransparentCanvasBuffer(psd.width, psd.height);
|
||||
const transparentBaseBuffer = await createTransparentCanvasBuffer(
|
||||
canvasWidth,
|
||||
canvasHeight
|
||||
);
|
||||
const composeSegmentBuffer = (
|
||||
inputs: Array<{ input: Buffer; left: number; top: number }>
|
||||
) => sharp(transparentBaseBuffer).composite(inputs).png().toBuffer();
|
||||
const renderBaseBuffer = await sharp(transparentBaseBuffer)
|
||||
.composite(baseCompositeInputs)
|
||||
.png()
|
||||
.toBuffer();
|
||||
const renderSegments: PreparedPsdTemplateRenderSegment[] = [];
|
||||
let pendingSegmentInputs: Array<{ input: Buffer; left: number; top: number }> = [];
|
||||
|
||||
for (const result of sortedLayerCompositeResults) {
|
||||
if (result.editableBoundary) {
|
||||
if (pendingSegmentInputs.length > 0) {
|
||||
renderSegments.push({
|
||||
id: `before-${sanitizeFileSegment(
|
||||
result.beforeLayerId,
|
||||
`layer-${result.index}`
|
||||
)}`,
|
||||
beforeLayerId: result.beforeLayerId,
|
||||
buffer: await composeSegmentBuffer(pendingSegmentInputs),
|
||||
});
|
||||
pendingSegmentInputs = [];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result.input) {
|
||||
pendingSegmentInputs.push(result.input);
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingSegmentInputs.length > 0) {
|
||||
renderSegments.push({
|
||||
id: "above-editable-layers",
|
||||
buffer: await composeSegmentBuffer(pendingSegmentInputs),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
layers: nextLayers,
|
||||
renderBaseBuffer,
|
||||
renderSegments,
|
||||
};
|
||||
}
|
||||
|
||||
@ -2,9 +2,11 @@ import "server-only";
|
||||
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
import { fetchRemoteAssetBuffer } from "@/lib/agents/psd-remote-asset";
|
||||
import { loadPsdTemplateSource } from "@/lib/agents/psd-template-parser";
|
||||
import {
|
||||
hasAgentPsdTemplateStoredLayerSource,
|
||||
isAgentPsdImageLayerSlot,
|
||||
isAgentPsdLayerSuperLocked,
|
||||
isAgentPsdTemplateLayerUserEditable,
|
||||
isAgentPsdTextLayerSlot,
|
||||
resolveAgentPsdTextLayerEffectiveAlign,
|
||||
@ -181,8 +183,7 @@ function getDefaultSourceImageLayer(
|
||||
) {
|
||||
const editableImageLayers = layers.filter(
|
||||
(layer): layer is AgentPsdTemplateImageLayerSlot =>
|
||||
isAgentPsdTemplateLayerUserEditable(layer) &&
|
||||
isAgentPsdImageLayerSlot(layer)
|
||||
layer.isEditable && isAgentPsdImageLayerSlot(layer)
|
||||
);
|
||||
|
||||
return (
|
||||
@ -462,18 +463,68 @@ export async function renderAgentPsdTemplate(params: {
|
||||
const hasDedicatedRenderBase =
|
||||
Boolean(renderBaseUrl) &&
|
||||
renderBaseUrl !== String(template.previewUrl || "").trim();
|
||||
const templateLayerOrderIds = template.layers.map((layer) => layer.id);
|
||||
const isUsingTemplateLayerOrder =
|
||||
orderedTemplateLayers.length === templateLayerOrderIds.length &&
|
||||
orderedTemplateLayers.every(
|
||||
(layer, index) => layer.id === templateLayerOrderIds[index]
|
||||
);
|
||||
const defaultSourceImageLayerId =
|
||||
getDefaultSourceImageLayer(orderedTemplateLayers)?.id || "";
|
||||
const hasImageAssignment = (layer: AgentPsdTemplate["layers"][number]) =>
|
||||
isAgentPsdImageLayerSlot(layer) &&
|
||||
Boolean(String(imageAssignments[layer.id] || "").trim());
|
||||
const shouldRenderAsStaticLayer = (
|
||||
layer: AgentPsdTemplate["layers"][number]
|
||||
) => !isAgentPsdTemplateLayerUserEditable(layer) && !hasImageAssignment(layer);
|
||||
const hasHiddenStaticLayer = orderedTemplateLayers.some(
|
||||
(layer) =>
|
||||
!isAgentPsdTemplateLayerUserEditable(layer) &&
|
||||
shouldRenderAsStaticLayer(layer) &&
|
||||
hiddenLayerIdSet.has(layer.id)
|
||||
);
|
||||
const hasVisibleAssignedImageLayer = orderedTemplateLayers.some(
|
||||
(layer) =>
|
||||
!hiddenLayerIdSet.has(layer.id) && hasImageAssignment(layer)
|
||||
);
|
||||
const hasStaticLayerPositionOffset = orderedTemplateLayers.some((layer) => {
|
||||
if (
|
||||
isAgentPsdTemplateLayerUserEditable(layer) ||
|
||||
isAgentPsdLayerSuperLocked(layer)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const offset = normalizeTextPositionOffset(layerPositionOffsets[layer.id]);
|
||||
return Math.abs(offset.offsetX) > 0.01 || Math.abs(offset.offsetY) > 0.01;
|
||||
});
|
||||
const renderSegments = (template.renderSegments || []).filter((segment) =>
|
||||
Boolean(String(segment.url || "").trim())
|
||||
);
|
||||
const shouldUseRenderSegments =
|
||||
hasVisibleAssignedImageLayer &&
|
||||
renderSegments.length > 0 &&
|
||||
isUsingTemplateLayerOrder &&
|
||||
!hasHiddenStaticLayer &&
|
||||
!hasStaticLayerPositionOffset;
|
||||
const shouldUseRenderBaseFallback =
|
||||
hasDedicatedRenderBase &&
|
||||
!hasHiddenStaticLayer;
|
||||
!hasHiddenStaticLayer &&
|
||||
!hasVisibleAssignedImageLayer;
|
||||
const shouldHydrateLayerSourcesFromPsd =
|
||||
hasVisibleAssignedImageLayer && !shouldUseRenderSegments;
|
||||
const renderSegmentByBeforeLayerId = new Map(
|
||||
renderSegments
|
||||
.filter((segment) => String(segment.beforeLayerId || "").trim())
|
||||
.map((segment) => [String(segment.beforeLayerId || "").trim(), segment])
|
||||
);
|
||||
const topRenderSegments = renderSegments.filter(
|
||||
(segment) => !String(segment.beforeLayerId || "").trim()
|
||||
);
|
||||
|
||||
function getLayerPositionOffset(layerId: string) {
|
||||
const layer = layerMap.get(layerId);
|
||||
if (layer && isAgentPsdLayerSuperLocked(layer)) {
|
||||
return normalizeTextPositionOffset();
|
||||
}
|
||||
return normalizeTextPositionOffset(layerPositionOffsets[layerId]);
|
||||
}
|
||||
|
||||
@ -516,6 +567,11 @@ export async function renderAgentPsdTemplate(params: {
|
||||
if (shouldUseRenderBaseFallback) {
|
||||
pushAsset(renderBaseUrl, "PSD 模板底图");
|
||||
}
|
||||
if (shouldUseRenderSegments) {
|
||||
for (const segment of renderSegments) {
|
||||
pushAsset(segment.url, "PSD 模板快速导出分层");
|
||||
}
|
||||
}
|
||||
|
||||
for (const layer of orderedTemplateLayers) {
|
||||
if (hiddenLayerIdSet.has(layer.id)) {
|
||||
@ -523,8 +579,8 @@ export async function renderAgentPsdTemplate(params: {
|
||||
}
|
||||
|
||||
const storedLayerSourceUrl = getStoredLayerSourceUrl(layer);
|
||||
if (!isAgentPsdTemplateLayerUserEditable(layer)) {
|
||||
if (shouldUseRenderBaseFallback) {
|
||||
if (shouldRenderAsStaticLayer(layer)) {
|
||||
if (shouldUseRenderBaseFallback || shouldUseRenderSegments) {
|
||||
continue;
|
||||
}
|
||||
pushAsset(
|
||||
@ -540,6 +596,13 @@ export async function renderAgentPsdTemplate(params: {
|
||||
if (assignmentUrl) {
|
||||
pushAsset(assignmentUrl, "PSD 套版素材");
|
||||
pushAsset(layer.maskUrl, "PSD 蒙版资源", true);
|
||||
if (layer.forceClipMask && layer.clipMaskLayerId) {
|
||||
pushAsset(
|
||||
getStoredLayerSourceUrl(layerMap.get(layer.clipMaskLayerId) || layer),
|
||||
"PSD 剪切蒙版目标资源",
|
||||
true
|
||||
);
|
||||
}
|
||||
} else {
|
||||
pushAsset(
|
||||
storedLayerSourceUrl,
|
||||
@ -601,6 +664,111 @@ export async function renderAgentPsdTemplate(params: {
|
||||
|
||||
const compositeInputs: Array<{ input: Buffer; left: number; top: number }> =
|
||||
[];
|
||||
let psdSourceLayerEntryMapPromise: Promise<
|
||||
Map<
|
||||
string,
|
||||
{
|
||||
layer: { width: number; height: number };
|
||||
composite: () => Promise<Uint8ClampedArray>;
|
||||
}
|
||||
>
|
||||
> | null = null;
|
||||
const psdSourceLayerBufferPromiseMap = new Map<string, Promise<Buffer | null>>();
|
||||
|
||||
async function getPsdSourceLayerEntryMap() {
|
||||
if (!shouldHydrateLayerSourcesFromPsd) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!psdSourceLayerEntryMapPromise) {
|
||||
psdSourceLayerEntryMapPromise = loadPsdTemplateSource({
|
||||
fileUrl: template.fileUrl,
|
||||
filePath: template.filePath,
|
||||
}).then((source) => {
|
||||
const entryMap = new Map<
|
||||
string,
|
||||
{
|
||||
layer: { width: number; height: number };
|
||||
composite: () => Promise<Uint8ClampedArray>;
|
||||
}
|
||||
>();
|
||||
for (const entry of source.layers) {
|
||||
entryMap.set(entry.layer.layerId, {
|
||||
layer: {
|
||||
width: entry.layer.width,
|
||||
height: entry.layer.height,
|
||||
},
|
||||
composite: entry.composite,
|
||||
});
|
||||
}
|
||||
return entryMap;
|
||||
});
|
||||
}
|
||||
|
||||
return psdSourceLayerEntryMapPromise;
|
||||
}
|
||||
|
||||
async function readPsdSourceLayerBuffer(
|
||||
layer: AgentPsdTemplate["layers"][number]
|
||||
) {
|
||||
if (!shouldHydrateLayerSourcesFromPsd) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!psdSourceLayerBufferPromiseMap.has(layer.layerId)) {
|
||||
psdSourceLayerBufferPromiseMap.set(
|
||||
layer.layerId,
|
||||
(async () => {
|
||||
try {
|
||||
const entryMap = await getPsdSourceLayerEntryMap();
|
||||
const entry = entryMap?.get(layer.layerId);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rgba = await entry.composite();
|
||||
return sharp(Buffer.from(rgba), {
|
||||
raw: {
|
||||
width: Math.max(1, Math.round(entry.layer.width || layer.width)),
|
||||
height: Math.max(1, Math.round(entry.layer.height || layer.height)),
|
||||
channels: 4,
|
||||
},
|
||||
})
|
||||
.png(PSD_RENDER_PNG_OPTIONS)
|
||||
.toBuffer();
|
||||
} catch (error) {
|
||||
console.warn("[PSD Template] failed to hydrate layer from PSD source:", {
|
||||
templateId: template.id,
|
||||
layerId: layer.layerId,
|
||||
error,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
})()
|
||||
);
|
||||
}
|
||||
|
||||
return psdSourceLayerBufferPromiseMap.get(layer.layerId)!;
|
||||
}
|
||||
|
||||
async function readReusableLayerBuffer(
|
||||
layer: AgentPsdTemplate["layers"][number]
|
||||
) {
|
||||
const sourceUrl = getStoredLayerSourceUrl(layer);
|
||||
if (sourceUrl) {
|
||||
try {
|
||||
return await readCachedBuffer(sourceUrl, "PSD 图层素材");
|
||||
} catch (error) {
|
||||
const hydratedBuffer = await readPsdSourceLayerBuffer(layer);
|
||||
if (hydratedBuffer) {
|
||||
return hydratedBuffer;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return readPsdSourceLayerBuffer(layer);
|
||||
}
|
||||
|
||||
async function appendBufferOverlay(params: {
|
||||
input: Buffer;
|
||||
@ -631,10 +799,19 @@ export async function renderAgentPsdTemplate(params: {
|
||||
options: { optional?: boolean } = {}
|
||||
) {
|
||||
const sourceUrl = getStoredLayerSourceUrl(layer);
|
||||
if (
|
||||
(!hasAgentPsdTemplateStoredLayerSource(layer) && !sourceUrl) ||
|
||||
!sourceUrl
|
||||
) {
|
||||
if ((!hasAgentPsdTemplateStoredLayerSource(layer) && !sourceUrl) || !sourceUrl) {
|
||||
const hydratedBuffer = await readPsdSourceLayerBuffer(layer);
|
||||
if (hydratedBuffer) {
|
||||
const layerPositionOffset = getLayerPositionOffset(layer.id);
|
||||
return appendBufferOverlay({
|
||||
input: hydratedBuffer,
|
||||
left: layer.x + layerPositionOffset.offsetX,
|
||||
top: layer.y + layerPositionOffset.offsetY,
|
||||
width: layer.width,
|
||||
height: layer.height,
|
||||
});
|
||||
}
|
||||
|
||||
if (options.optional) {
|
||||
return false;
|
||||
}
|
||||
@ -645,7 +822,13 @@ export async function renderAgentPsdTemplate(params: {
|
||||
|
||||
const layerPositionOffset = getLayerPositionOffset(layer.id);
|
||||
try {
|
||||
const overlayBuffer = await readCachedBuffer(sourceUrl, "PSD 图层素材");
|
||||
const overlayBuffer = await readReusableLayerBuffer(layer);
|
||||
if (!overlayBuffer) {
|
||||
if (options.optional) {
|
||||
return false;
|
||||
}
|
||||
throw new Error("Missing PSD layer asset");
|
||||
}
|
||||
const didAppend = await appendBufferOverlay({
|
||||
input: overlayBuffer,
|
||||
left: layer.x + layerPositionOffset.offsetX,
|
||||
@ -693,6 +876,231 @@ export async function renderAgentPsdTemplate(params: {
|
||||
});
|
||||
}
|
||||
|
||||
const appendedRenderSegmentIds = new Set<string>();
|
||||
async function appendRenderSegmentOverlay(
|
||||
segment: (typeof renderSegments)[number] | undefined
|
||||
) {
|
||||
if (
|
||||
!shouldUseRenderSegments ||
|
||||
!segment ||
|
||||
appendedRenderSegmentIds.has(segment.id)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
appendedRenderSegmentIds.add(segment.id);
|
||||
const segmentBuffer = await readCachedBuffer(
|
||||
segment.url,
|
||||
"PSD 模板快速导出分层"
|
||||
);
|
||||
await appendBufferOverlay({
|
||||
input: segmentBuffer,
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: canvasWidth,
|
||||
height: canvasHeight,
|
||||
});
|
||||
}
|
||||
|
||||
async function appendRenderSegmentBeforeLayer(layerId: string) {
|
||||
await appendRenderSegmentOverlay(renderSegmentByBeforeLayerId.get(layerId));
|
||||
}
|
||||
|
||||
async function appendTopRenderSegments() {
|
||||
for (const segment of topRenderSegments) {
|
||||
await appendRenderSegmentOverlay(segment);
|
||||
}
|
||||
}
|
||||
|
||||
function getAssignedImageRenderFrameLayer(layer: AgentPsdTemplateImageLayerSlot) {
|
||||
if (!layer.forceClipMask || !layer.clipMaskLayerId) {
|
||||
return layer;
|
||||
}
|
||||
|
||||
return layerMap.get(layer.clipMaskLayerId) || layer;
|
||||
}
|
||||
|
||||
const assignedImageLayersByFrameId = new Map<string, AgentPsdTemplateImageLayerSlot[]>();
|
||||
for (const layer of orderedTemplateLayers) {
|
||||
if (
|
||||
hiddenLayerIdSet.has(layer.id) ||
|
||||
!isAgentPsdImageLayerSlot(layer) ||
|
||||
!String(imageAssignments[layer.id] || "").trim()
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const frameLayer = getAssignedImageRenderFrameLayer(layer);
|
||||
const currentLayers = assignedImageLayersByFrameId.get(frameLayer.id) || [];
|
||||
currentLayers.push(layer);
|
||||
assignedImageLayersByFrameId.set(frameLayer.id, currentLayers);
|
||||
}
|
||||
|
||||
const appendedImageAssignmentLayerIds = new Set<string>();
|
||||
async function appendAssignedImageLayer(layer: AgentPsdTemplateImageLayerSlot) {
|
||||
if (appendedImageAssignmentLayerIds.has(layer.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isDefaultSourceImageLayer = layer.id === defaultSourceImageLayerId;
|
||||
const assignmentUrl = String(imageAssignments[layer.id] || "").trim();
|
||||
if (!assignmentUrl || hiddenLayerIdSet.has(layer.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
appendedImageAssignmentLayerIds.add(layer.id);
|
||||
const sourceBuffer = await readCachedBuffer(assignmentUrl, "PSD 套版素材");
|
||||
const clipMaskLayer =
|
||||
layer.forceClipMask && layer.clipMaskLayerId
|
||||
? layerMap.get(layer.clipMaskLayerId)
|
||||
: null;
|
||||
const renderFrameLayer = clipMaskLayer || layer;
|
||||
const renderFrameContentBounds =
|
||||
clipMaskLayer && isAgentPsdImageLayerSlot(clipMaskLayer)
|
||||
? clipMaskLayer.contentBounds
|
||||
: layer.contentBounds;
|
||||
const sourceImage = sharp(sourceBuffer, { failOn: "none" }).rotate();
|
||||
const metadata = await sourceImage.clone().metadata();
|
||||
const placementFrame = resolveAgentPsdImagePlacementFrame({
|
||||
containerWidth: renderFrameLayer.width,
|
||||
containerHeight: renderFrameLayer.height,
|
||||
imageWidth: Math.max(1, Number(metadata.width || 1)),
|
||||
imageHeight: Math.max(1, Number(metadata.height || 1)),
|
||||
replaceMode: layer.replaceMode,
|
||||
contentBounds: renderFrameContentBounds,
|
||||
placement: imagePlacements[layer.id],
|
||||
});
|
||||
|
||||
const resizedOverlayBuffer = await sourceImage
|
||||
.clone()
|
||||
.resize({
|
||||
width: Math.max(1, Math.round(placementFrame.width)),
|
||||
height: Math.max(1, Math.round(placementFrame.height)),
|
||||
fit:
|
||||
layer.replaceMode === "contain"
|
||||
? "contain"
|
||||
: layer.replaceMode === "stretch"
|
||||
? "fill"
|
||||
: "cover",
|
||||
background: {
|
||||
r: 0,
|
||||
g: 0,
|
||||
b: 0,
|
||||
alpha: 0,
|
||||
},
|
||||
})
|
||||
.png(PSD_RENDER_PNG_OPTIONS)
|
||||
.toBuffer();
|
||||
|
||||
const slotOverlay = await fitOverlayBufferIntoCanvas({
|
||||
input: resizedOverlayBuffer,
|
||||
left: placementFrame.left,
|
||||
top: placementFrame.top,
|
||||
width: placementFrame.width,
|
||||
height: placementFrame.height,
|
||||
canvasWidth: renderFrameLayer.width,
|
||||
canvasHeight: renderFrameLayer.height,
|
||||
});
|
||||
|
||||
if (!slotOverlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
const layerPositionOffset = getLayerPositionOffset(renderFrameLayer.id);
|
||||
const shouldApplyMask = Boolean(layer.maskUrl || clipMaskLayer);
|
||||
if (!shouldApplyMask) {
|
||||
if (isDefaultSourceImageLayer) {
|
||||
await appendRenderBaseOverlay();
|
||||
}
|
||||
await appendBufferOverlay({
|
||||
input: slotOverlay.input,
|
||||
left: renderFrameLayer.x + layerPositionOffset.offsetX + slotOverlay.left,
|
||||
top: renderFrameLayer.y + layerPositionOffset.offsetY + slotOverlay.top,
|
||||
width: slotOverlay.width,
|
||||
height: slotOverlay.height,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let overlayBuffer = await sharp({
|
||||
create: {
|
||||
width: Math.max(1, Math.round(renderFrameLayer.width)),
|
||||
height: Math.max(1, Math.round(renderFrameLayer.height)),
|
||||
channels: 4,
|
||||
background: {
|
||||
r: 0,
|
||||
g: 0,
|
||||
b: 0,
|
||||
alpha: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
.composite([slotOverlay])
|
||||
.png(PSD_RENDER_PNG_OPTIONS)
|
||||
.toBuffer();
|
||||
|
||||
try {
|
||||
const rawMaskBuffer = layer.maskUrl
|
||||
? await readCachedBuffer(layer.maskUrl, "PSD 蒙版资源")
|
||||
: await readReusableLayerBuffer(clipMaskLayer!);
|
||||
if (!rawMaskBuffer) {
|
||||
throw new Error("Missing PSD layer mask source");
|
||||
}
|
||||
const maskBuffer = await sharp(rawMaskBuffer)
|
||||
.resize({
|
||||
width: Math.max(1, Math.round(renderFrameLayer.width)),
|
||||
height: Math.max(1, Math.round(renderFrameLayer.height)),
|
||||
fit: "fill",
|
||||
background: {
|
||||
r: 0,
|
||||
g: 0,
|
||||
b: 0,
|
||||
alpha: 0,
|
||||
},
|
||||
})
|
||||
.png(PSD_RENDER_PNG_OPTIONS)
|
||||
.toBuffer();
|
||||
overlayBuffer = await sharp(overlayBuffer)
|
||||
.composite([
|
||||
{
|
||||
input: maskBuffer,
|
||||
blend: "dest-in",
|
||||
},
|
||||
])
|
||||
.png(PSD_RENDER_PNG_OPTIONS)
|
||||
.toBuffer();
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[PSD Template] failed to apply layer mask during render:",
|
||||
{
|
||||
templateId: template.id,
|
||||
layerId: layer.layerId,
|
||||
error,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (isDefaultSourceImageLayer) {
|
||||
await appendRenderBaseOverlay();
|
||||
}
|
||||
|
||||
await appendBufferOverlay({
|
||||
input: overlayBuffer,
|
||||
left: renderFrameLayer.x + layerPositionOffset.offsetX,
|
||||
top: renderFrameLayer.y + layerPositionOffset.offsetY,
|
||||
width: renderFrameLayer.width,
|
||||
height: renderFrameLayer.height,
|
||||
});
|
||||
}
|
||||
|
||||
async function appendAssignedImagesForFrameLayer(frameLayerId: string) {
|
||||
for (const assignedLayer of assignedImageLayersByFrameId.get(frameLayerId) || []) {
|
||||
if (assignedLayer.id !== frameLayerId) {
|
||||
await appendAssignedImageLayer(assignedLayer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
shouldUseRenderBaseFallback &&
|
||||
(!defaultSourceImageLayerId || hiddenLayerIdSet.has(defaultSourceImageLayerId))
|
||||
@ -701,15 +1109,21 @@ export async function renderAgentPsdTemplate(params: {
|
||||
}
|
||||
|
||||
for (const layer of [...orderedTemplateLayers].reverse()) {
|
||||
if (shouldUseRenderSegments && isAgentPsdTemplateLayerUserEditable(layer)) {
|
||||
await appendRenderSegmentBeforeLayer(layer.id);
|
||||
}
|
||||
|
||||
if (hiddenLayerIdSet.has(layer.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isAgentPsdTemplateLayerUserEditable(layer)) {
|
||||
if (shouldUseRenderBaseFallback) {
|
||||
if (shouldUseRenderBaseFallback || shouldUseRenderSegments) {
|
||||
await appendAssignedImagesForFrameLayer(layer.id);
|
||||
continue;
|
||||
}
|
||||
await appendStoredLayer(layer, { optional: shouldUseRenderBaseFallback });
|
||||
await appendAssignedImagesForFrameLayer(layer.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -723,149 +1137,15 @@ export async function renderAgentPsdTemplate(params: {
|
||||
if (isDefaultSourceImageLayer) {
|
||||
await appendRenderBaseOverlay();
|
||||
}
|
||||
await appendAssignedImagesForFrameLayer(layer.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourceBuffer = await readCachedBuffer(
|
||||
assignmentUrl,
|
||||
"PSD 套版素材"
|
||||
);
|
||||
const sourceImage = sharp(sourceBuffer, { failOn: "none" }).rotate();
|
||||
const metadata = await sourceImage.clone().metadata();
|
||||
const placementFrame = resolveAgentPsdImagePlacementFrame({
|
||||
containerWidth: layer.width,
|
||||
containerHeight: layer.height,
|
||||
imageWidth: Math.max(1, Number(metadata.width || 1)),
|
||||
imageHeight: Math.max(1, Number(metadata.height || 1)),
|
||||
replaceMode: layer.replaceMode,
|
||||
contentBounds: layer.contentBounds,
|
||||
placement: imagePlacements[layer.id],
|
||||
});
|
||||
|
||||
const resizedOverlayBuffer = await sourceImage
|
||||
.clone()
|
||||
.resize({
|
||||
width: Math.max(1, Math.round(placementFrame.width)),
|
||||
height: Math.max(1, Math.round(placementFrame.height)),
|
||||
fit:
|
||||
layer.replaceMode === "contain"
|
||||
? "contain"
|
||||
: layer.replaceMode === "stretch"
|
||||
? "fill"
|
||||
: "cover",
|
||||
background: {
|
||||
r: 0,
|
||||
g: 0,
|
||||
b: 0,
|
||||
alpha: 0,
|
||||
},
|
||||
})
|
||||
.png(PSD_RENDER_PNG_OPTIONS)
|
||||
.toBuffer();
|
||||
|
||||
const slotOverlay = await fitOverlayBufferIntoCanvas({
|
||||
input: resizedOverlayBuffer,
|
||||
left: placementFrame.left,
|
||||
top: placementFrame.top,
|
||||
width: placementFrame.width,
|
||||
height: placementFrame.height,
|
||||
canvasWidth: layer.width,
|
||||
canvasHeight: layer.height,
|
||||
});
|
||||
|
||||
if (!slotOverlay) {
|
||||
continue;
|
||||
const renderFrameLayer = getAssignedImageRenderFrameLayer(layer);
|
||||
if (renderFrameLayer.id === layer.id) {
|
||||
await appendAssignedImageLayer(layer);
|
||||
}
|
||||
|
||||
const layerPositionOffset = getLayerPositionOffset(layer.id);
|
||||
if (!layer.maskUrl) {
|
||||
const didAppend = await appendBufferOverlay({
|
||||
input: slotOverlay.input,
|
||||
left:
|
||||
layer.x +
|
||||
layerPositionOffset.offsetX +
|
||||
slotOverlay.left,
|
||||
top:
|
||||
layer.y +
|
||||
layerPositionOffset.offsetY +
|
||||
slotOverlay.top,
|
||||
width: slotOverlay.width,
|
||||
height: slotOverlay.height,
|
||||
});
|
||||
if (isDefaultSourceImageLayer) {
|
||||
await appendRenderBaseOverlay();
|
||||
}
|
||||
if (!didAppend) continue;
|
||||
continue;
|
||||
}
|
||||
|
||||
let overlayBuffer = await sharp({
|
||||
create: {
|
||||
width: Math.max(1, Math.round(layer.width)),
|
||||
height: Math.max(1, Math.round(layer.height)),
|
||||
channels: 4,
|
||||
background: {
|
||||
r: 0,
|
||||
g: 0,
|
||||
b: 0,
|
||||
alpha: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
.composite([slotOverlay])
|
||||
.png(PSD_RENDER_PNG_OPTIONS)
|
||||
.toBuffer();
|
||||
|
||||
if (layer.maskUrl) {
|
||||
try {
|
||||
const maskBuffer = await sharp(
|
||||
await readCachedBuffer(layer.maskUrl, "PSD 蒙版资源")
|
||||
)
|
||||
.resize({
|
||||
width: Math.max(1, Math.round(layer.width)),
|
||||
height: Math.max(1, Math.round(layer.height)),
|
||||
fit: "fill",
|
||||
background: {
|
||||
r: 0,
|
||||
g: 0,
|
||||
b: 0,
|
||||
alpha: 0,
|
||||
},
|
||||
})
|
||||
.png(PSD_RENDER_PNG_OPTIONS)
|
||||
.toBuffer();
|
||||
overlayBuffer = await sharp(overlayBuffer)
|
||||
.composite([
|
||||
{
|
||||
input: maskBuffer,
|
||||
blend: "dest-in",
|
||||
},
|
||||
])
|
||||
.png(PSD_RENDER_PNG_OPTIONS)
|
||||
.toBuffer();
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[PSD Template] failed to apply layer mask during render:",
|
||||
{
|
||||
templateId: template.id,
|
||||
layerId: layer.layerId,
|
||||
error,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const didAppend = await appendBufferOverlay({
|
||||
input: overlayBuffer,
|
||||
left: layer.x + layerPositionOffset.offsetX,
|
||||
top: layer.y + layerPositionOffset.offsetY,
|
||||
width: layer.width,
|
||||
height: layer.height,
|
||||
});
|
||||
if (isDefaultSourceImageLayer) {
|
||||
await appendRenderBaseOverlay();
|
||||
}
|
||||
if (!didAppend) continue;
|
||||
await appendAssignedImagesForFrameLayer(layer.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -889,6 +1169,7 @@ export async function renderAgentPsdTemplate(params: {
|
||||
optional: shouldUseRenderBaseFallback,
|
||||
});
|
||||
}
|
||||
await appendAssignedImagesForFrameLayer(layer.id);
|
||||
continue;
|
||||
}
|
||||
const textAlign = resolveAgentPsdTextLayerEffectiveAlign(layer);
|
||||
@ -998,9 +1279,12 @@ export async function renderAgentPsdTemplate(params: {
|
||||
height: overlayFrame.height,
|
||||
});
|
||||
if (!didAppend) continue;
|
||||
await appendAssignedImagesForFrameLayer(layer.id);
|
||||
}
|
||||
}
|
||||
|
||||
await appendTopRenderSegments();
|
||||
|
||||
const transparentCanvas = await sharp({
|
||||
create: {
|
||||
width: canvasWidth,
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
import { readAgentPsdFonts } from "@/lib/agents/psd-font-settings";
|
||||
import {
|
||||
AGENT_PSD_TEMPLATE_ASSET_VERSION,
|
||||
hasAgentPsdTemplateRenderSegments,
|
||||
isAgentPsdImageLayerSlot,
|
||||
isAgentPsdLayerSuperLocked,
|
||||
isAgentPsdTemplateLayerUserEditable,
|
||||
@ -21,6 +22,7 @@ import {
|
||||
import {
|
||||
parsePsdTemplateFileFromSource,
|
||||
preparePsdTemplateRenderAssets,
|
||||
uploadPreparedPsdTemplateRenderSegments,
|
||||
} from "@/lib/agents/psd-template-parser";
|
||||
import { resolveAgentPsdLocalCachePath } from "@/lib/agents/psd-local-cache";
|
||||
import { createStorageUploadSession } from "@/lib/storage/service";
|
||||
@ -66,6 +68,10 @@ function hasDedicatedRenderBase(template: AgentPsdTemplate) {
|
||||
return Boolean(renderBaseUrl) && renderBaseUrl !== previewUrl;
|
||||
}
|
||||
|
||||
function hasStaticTemplateLayers(template: AgentPsdTemplate) {
|
||||
return template.layers.some((layer) => !isAgentPsdTemplateLayerUserEditable(layer));
|
||||
}
|
||||
|
||||
function repairEditableImageLayerSelection(template: AgentPsdTemplate) {
|
||||
const imageLayers = template.layers.filter(isAgentPsdImageLayerSlot);
|
||||
const unlockedImageLayers = imageLayers.filter(
|
||||
@ -255,6 +261,8 @@ export async function repairAgentPsdTemplateAssetsIfNeeded(
|
||||
Number(template.assetVersion || 0) !== AGENT_PSD_TEMPLATE_ASSET_VERSION;
|
||||
const needsRepair = needsAssetVersionRefresh || hasMissingLayerAssets(template);
|
||||
const needsRenderBaseRepair = !hasDedicatedRenderBase(template);
|
||||
const needsRenderSegmentRepair =
|
||||
hasStaticTemplateLayers(template) && !hasAgentPsdTemplateRenderSegments(template);
|
||||
const fonts = await readAgentPsdFonts().catch(() => []);
|
||||
const needsFontDetection = needsTextMetadataRepair(template);
|
||||
|
||||
@ -311,7 +319,12 @@ export async function repairAgentPsdTemplateAssetsIfNeeded(
|
||||
(layer, index) => JSON.stringify(layer) !== JSON.stringify(template.layers[index])
|
||||
);
|
||||
|
||||
if (!needsRepair && !hasFontRepair && !needsRenderBaseRepair) {
|
||||
if (
|
||||
!needsRepair &&
|
||||
!hasFontRepair &&
|
||||
!needsRenderBaseRepair &&
|
||||
!needsRenderSegmentRepair
|
||||
) {
|
||||
return template;
|
||||
}
|
||||
|
||||
@ -335,11 +348,20 @@ export async function repairAgentPsdTemplateAssetsIfNeeded(
|
||||
toArrayBuffer(preparedAssets.renderBaseBuffer)
|
||||
)
|
||||
: null;
|
||||
const uploadedRenderSegments =
|
||||
needsRenderSegmentRepair || needsAssetVersionRefresh
|
||||
? await uploadPreparedPsdTemplateRenderSegments({
|
||||
templateId: template.id,
|
||||
segments: preparedAssets.renderSegments,
|
||||
uploadAsset,
|
||||
})
|
||||
: null;
|
||||
|
||||
return {
|
||||
...template,
|
||||
renderBaseUrl: uploadedRenderBase?.url || template.renderBaseUrl,
|
||||
renderBasePath: uploadedRenderBase?.pathname || template.renderBasePath,
|
||||
renderSegments: uploadedRenderSegments || template.renderSegments,
|
||||
layers: preparedAssets.layers,
|
||||
assetVersion: AGENT_PSD_TEMPLATE_ASSET_VERSION,
|
||||
updatedAt: new Date().toISOString(),
|
||||
|
||||
@ -26,6 +26,7 @@ export async function tryDeleteAgentPsdTemplateAssets(template: AgentPsdTemplate
|
||||
template.filePath,
|
||||
template.previewPath,
|
||||
template.renderBasePath,
|
||||
...(template.renderSegments || []).map((segment) => segment.path),
|
||||
...template.layers.map((layer) => layer.previewPath),
|
||||
...template.layers
|
||||
.filter(isAgentPsdImageLayerSlot)
|
||||
|
||||
@ -41,6 +41,7 @@ function normalizeMaterialImage(
|
||||
|
||||
const id = sanitizeText(raw.id, 128) || `image-${index + 1}`;
|
||||
const group = raw.group === "other" ? "other" : "fixed";
|
||||
const previewUrl = sanitizeText(raw.previewUrl, 2000) || null;
|
||||
|
||||
return {
|
||||
id,
|
||||
@ -48,6 +49,7 @@ function normalizeMaterialImage(
|
||||
label: sanitizeText(raw.label, 128) || `图片${index + 1}`,
|
||||
fileName: sanitizeText(raw.fileName, 255) || `image-${index + 1}`,
|
||||
imageUrl,
|
||||
previewUrl,
|
||||
assetId: sanitizeText(raw.assetId, 128) || null,
|
||||
mimeType: sanitizeText(raw.mimeType, 128) || null,
|
||||
appearanceSummary: sanitizeText(raw.appearanceSummary, 255) || null,
|
||||
|
||||
@ -272,6 +272,7 @@ export interface AgentWorkflowMaterialImage {
|
||||
label: string;
|
||||
fileName: string;
|
||||
imageUrl: string;
|
||||
previewUrl?: string | null;
|
||||
assetId?: string | null;
|
||||
mimeType?: string | null;
|
||||
appearanceSummary?: string | null;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user