Compare commits

...

26 Commits

Author SHA1 Message Date
Your Name
6937217634 fix: resolve detail reference sku to spu 2026-06-02 13:18:39 +08:00
Your Name
84f105d4c8 fix: streamline detail reference template lookup 2026-06-02 12:59:14 +08:00
Your Name
824806406a fix: show regenerating state for style batch results 2026-06-02 11:43:44 +08:00
Your Name
6b72771701 fix: keep PSD clipped replacements in target layer order 2026-06-01 20:57:25 +08:00
Your Name
97cc9e4a0a fix: anchor PSD replacements to clip targets 2026-06-01 20:38:45 +08:00
Your Name
154ec44a4c fix: avoid empty PSD replacement masks 2026-06-01 20:26:56 +08:00
Your Name
2f0c533b87 fix: support PSD clipping mask target layers 2026-06-01 20:03:11 +08:00
Your Name
3ac209d149 fix: improve PSD template management and clipping masks 2026-06-01 19:40:22 +08:00
Your Name
97d7604e7c feat: add PSD force clipping mask option 2026-06-01 18:43:42 +08:00
Your Name
641cd800b2 Revert "fix: preserve PSD super lock mask editing"
This reverts commit 6d60d53910cf8cb3d65aade75496cf514d3df617.
2026-06-01 18:37:26 +08:00
Your Name
f5ee1fe592 Revert "fix: clip PSD super lock replacements"
This reverts commit 6e647021cdc52a083cace27801044838f11a5864.
2026-06-01 18:37:26 +08:00
Your Name
674a8d143e Revert "fix: honor PSD super locked image slots"
This reverts commit b189ca1b58e92df28f46d0c99dd3ae86a7edb4bc.
2026-06-01 18:37:02 +08:00
Your Name
b189ca1b58 fix: honor PSD super locked image slots 2026-06-01 18:33:12 +08:00
Your Name
6e647021cd fix: clip PSD super lock replacements 2026-06-01 18:23:13 +08:00
Your Name
6d60d53910 fix: preserve PSD super lock mask editing 2026-06-01 17:59:37 +08:00
Your Name
f050c807a4 fix: precompile PSD template render segments 2026-06-01 17:52:25 +08:00
Your Name
c15f6d435f fix: proxy material card previews 2026-06-01 16:46:54 +08:00
Your Name
42adff8545 fix: polish ai wash layout and sidebar overflow 2026-06-01 16:37:09 +08:00
Your Name
a701ddde50 fix: preserve manual detail cutlines 2026-06-01 15:06:17 +08:00
Your Name
d14fc0c188 fix: stabilize material grid hook order 2026-06-01 14:51:37 +08:00
Your Name
f455b433ec fix: stabilize detail text edits and cutline deletion 2026-06-01 14:43:34 +08:00
Your Name
6e0e4a5454 fix: guard material search client crashes 2026-06-01 14:24:28 +08:00
Your Name
5761fa77b1 fix: load material previews from originals 2026-06-01 14:08:30 +08:00
Your Name
fab8826123 fix: improve material preview loading 2026-06-01 13:56:21 +08:00
Your Name
c83a16545c fix: avoid canvas redirect shell flash 2026-06-01 13:21:10 +08:00
Your Name
f7cae037d5 refactor: move ai wash toolbar right 2026-06-01 13:09:57 +08:00
25 changed files with 2104 additions and 991 deletions

View File

@ -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">

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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
View 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>
);
}

View File

@ -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>>;
};

View File

@ -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);
}}

View File

@ -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>

View File

@ -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}

View File

@ -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[

View File

@ -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
? "白底图会先按权重自动选入槽位,如需微调仍可手动调整。"

View File

@ -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"

View File

@ -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>

View File

@ -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
)}
>

View File

@ -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>
)}

View File

@ -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 && (

View File

@ -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,

View File

@ -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">
) {

View File

@ -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,
};
}

View File

@ -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,

View File

@ -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(),

View File

@ -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)

View File

@ -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,

View File

@ -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;