Compare commits
10 Commits
1eddc7e296
...
456c498c32
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
456c498c32 | ||
|
|
37e2b66725 | ||
|
|
0dcc759c80 | ||
|
|
0808f34021 | ||
|
|
2ea6f2e800 | ||
|
|
7ac5bdf44f | ||
|
|
87f23a0ab6 | ||
|
|
fb240616e0 | ||
|
|
ce7c2bff35 | ||
|
|
a154a28a4a |
File diff suppressed because it is too large
Load Diff
@ -3398,6 +3398,7 @@ export async function POST(request: Request) {
|
||||
reason: replacementExecutionPlan.reason,
|
||||
detectionSource: replacementExecutionPlan.detectionSource,
|
||||
scenePlanSource: replacementExecutionPlan.scenePlan.analysisSource,
|
||||
pageKind: replacementExecutionPlan.scenePlan.pageKind,
|
||||
generationRoute: replacementExecutionPlan.scenePlan.generationRoute,
|
||||
promptPackPolicy: replacementExecutionPlan.scenePlan.promptPackPolicy,
|
||||
packPolicy: replacementExecutionPlan.scenePlan.promptPackPolicy,
|
||||
@ -3453,6 +3454,7 @@ export async function POST(request: Request) {
|
||||
referenceIndex: referenceIndex + 1,
|
||||
totalReferences: replacementReferences.length,
|
||||
referenceLabel: reference.label,
|
||||
pageKind: replacementExecutionPlan.scenePlan.pageKind,
|
||||
generationRoute: replacementExecutionPlan.scenePlan.generationRoute,
|
||||
packPolicy: replacementExecutionPlan.scenePlan.promptPackPolicy,
|
||||
selectedProductImages: selectedProductContextImageUrls.length,
|
||||
@ -3479,6 +3481,17 @@ export async function POST(request: Request) {
|
||||
scenePlan: replacementExecutionPlan.scenePlan,
|
||||
referenceGeometry,
|
||||
});
|
||||
chatApiLogger.info("[Chat API] Direct detail replacement prompt assembled:", {
|
||||
referenceIndex: referenceIndex + 1,
|
||||
totalReferences: replacementReferences.length,
|
||||
referenceLabel: reference.label,
|
||||
pageKind: replacementExecutionPlan.scenePlan.pageKind,
|
||||
generationRoute: replacementExecutionPlan.scenePlan.generationRoute,
|
||||
promptPackPolicy: replacementExecutionPlan.scenePlan.promptPackPolicy,
|
||||
basePromptChars: baseReplacementPrompt.length,
|
||||
promptChars: replacementPrompt.length,
|
||||
hasPromptPackBlock: replacementPrompt.includes("【必须生效的前置场景词包】"),
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await generateCanvasImage({
|
||||
@ -4160,6 +4173,7 @@ export async function POST(request: Request) {
|
||||
replacementExecutionPlan.scenePlan.productSelectionReason,
|
||||
useModelImage: replacementExecutionPlan.scenePlan.useModelImage,
|
||||
modelImageReason: replacementExecutionPlan.scenePlan.modelImageReason,
|
||||
pageKind: replacementExecutionPlan.scenePlan.pageKind,
|
||||
generationRoute: replacementExecutionPlan.scenePlan.generationRoute,
|
||||
promptPackPolicy: replacementExecutionPlan.scenePlan.promptPackPolicy,
|
||||
routeReason: replacementExecutionPlan.scenePlan.routeReason,
|
||||
@ -4273,6 +4287,12 @@ export async function POST(request: Request) {
|
||||
referenceLabel: reference.label,
|
||||
pairedProductIndex:
|
||||
typeof pairedProductIndex === "number" ? pairedProductIndex + 1 : null,
|
||||
pageKind: replacementExecutionPlan.scenePlan.pageKind,
|
||||
generationRoute: replacementExecutionPlan.scenePlan.generationRoute,
|
||||
promptPackPolicy: replacementExecutionPlan.scenePlan.promptPackPolicy,
|
||||
basePromptChars: baseReplacementPrompt.length,
|
||||
promptChars: replacementPrompt.length,
|
||||
hasPromptPackBlock: replacementPrompt.includes("【必须生效的前置场景词包】"),
|
||||
sourceImageCount: sourceImageUrls.length,
|
||||
generationAspectRatio,
|
||||
imageOrder: {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
@ -28,14 +29,27 @@ type StitchSourceSegment = {
|
||||
};
|
||||
|
||||
const MAX_REFERENCE_COUNT = 120;
|
||||
const FETCH_TIMEOUT_MS = 10000;
|
||||
const FETCH_CONCURRENCY = 32;
|
||||
const SLICE_UPLOAD_CONCURRENCY = 10;
|
||||
const FETCH_TIMEOUT_MS = 20_000;
|
||||
const FETCH_MAX_ATTEMPTS = 3;
|
||||
const FETCH_RETRY_BASE_DELAY_MS = 350;
|
||||
const FETCH_CONCURRENCY = 12;
|
||||
const SLICE_UPLOAD_CONCURRENCY = 4;
|
||||
const PREVIEW_MAX_WIDTH = 720;
|
||||
const PREVIEW_MIN_WIDTH = 96;
|
||||
const PREVIEW_MAX_HEIGHT = 32000;
|
||||
const PREVIEW_MAX_PIXELS = 36_000_000;
|
||||
const SEGMENT_MATCH_TOLERANCE = 2;
|
||||
const logger = createLogger("detail-stitch");
|
||||
|
||||
class ImageFetchError extends Error {
|
||||
retryable: boolean;
|
||||
|
||||
constructor(message: string, retryable = false) {
|
||||
super(message);
|
||||
this.name = "ImageFetchError";
|
||||
this.retryable = retryable;
|
||||
}
|
||||
}
|
||||
|
||||
function bufferToArrayBuffer(buffer: Buffer): ArrayBuffer {
|
||||
return buffer.buffer.slice(
|
||||
@ -44,6 +58,38 @@ function bufferToArrayBuffer(buffer: Buffer): ArrayBuffer {
|
||||
) as ArrayBuffer;
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : String(error || "");
|
||||
}
|
||||
|
||||
function isTimeoutLikeError(error: unknown) {
|
||||
if (!(error instanceof Error)) return false;
|
||||
return (
|
||||
error.name === "TimeoutError" ||
|
||||
error.name === "AbortError" ||
|
||||
/timeout|aborted|operation was aborted/i.test(error.message)
|
||||
);
|
||||
}
|
||||
|
||||
function isRetryableFetchError(error: unknown) {
|
||||
if (error instanceof ImageFetchError) {
|
||||
return error.retryable;
|
||||
}
|
||||
return isTimeoutLikeError(error);
|
||||
}
|
||||
|
||||
function toPublicErrorMessage(error: unknown) {
|
||||
const message = getErrorMessage(error);
|
||||
if (isTimeoutLikeError(error) || /timeout|aborted|operation was aborted/i.test(message)) {
|
||||
return "切割图片下载超时,通常是源图过大、源站响应慢或网络抖动导致。请稍后重试,或减少本次切割图片数量后再应用。";
|
||||
}
|
||||
return message || "拼合切割失败";
|
||||
}
|
||||
|
||||
async function runBoundedParallel<T, R>(
|
||||
items: T[],
|
||||
workerCount: number,
|
||||
@ -148,31 +194,54 @@ function buildReferenceFetchUrl(url: string): string {
|
||||
}
|
||||
|
||||
async function fetchImageBuffer(url: string, requestOrigin: string, cookie: string): Promise<Buffer> {
|
||||
const response = await fetch(url, {
|
||||
cache: "no-store",
|
||||
redirect: "follow",
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
headers: buildFetchHeaders(url, requestOrigin, cookie),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
let lastError: unknown = null;
|
||||
|
||||
for (let attempt = 1; attempt <= FETCH_MAX_ATTEMPTS; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
cache: "no-store",
|
||||
redirect: "follow",
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
headers: buildFetchHeaders(url, requestOrigin, cookie),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new ImageFetchError(
|
||||
`图片下载失败:HTTP ${response.status}`,
|
||||
response.status === 408 || response.status === 429 || response.status >= 500,
|
||||
);
|
||||
}
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
const normalizedContentType = contentType.toLowerCase();
|
||||
if (
|
||||
normalizedContentType &&
|
||||
!normalizedContentType.startsWith("image/") &&
|
||||
(normalizedContentType.startsWith("text/") ||
|
||||
normalizedContentType.includes("json") ||
|
||||
normalizedContentType.includes("xml"))
|
||||
) {
|
||||
throw new ImageFetchError("返回内容不是图片");
|
||||
}
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
if (buffer.length === 0) {
|
||||
throw new ImageFetchError("图片内容为空");
|
||||
}
|
||||
return buffer;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt >= FETCH_MAX_ATTEMPTS || !isRetryableFetchError(error)) {
|
||||
break;
|
||||
}
|
||||
logger.warn("image_fetch_retry", {
|
||||
attempt,
|
||||
maxAttempts: FETCH_MAX_ATTEMPTS,
|
||||
timeoutMs: FETCH_TIMEOUT_MS,
|
||||
reason: getErrorMessage(error),
|
||||
});
|
||||
await sleep(FETCH_RETRY_BASE_DELAY_MS * attempt);
|
||||
}
|
||||
}
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
const normalizedContentType = contentType.toLowerCase();
|
||||
if (
|
||||
normalizedContentType &&
|
||||
!normalizedContentType.startsWith("image/") &&
|
||||
(normalizedContentType.startsWith("text/") ||
|
||||
normalizedContentType.includes("json") ||
|
||||
normalizedContentType.includes("xml"))
|
||||
) {
|
||||
throw new Error("返回内容不是图片");
|
||||
}
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
if (buffer.length === 0) {
|
||||
throw new Error("图片内容为空");
|
||||
}
|
||||
return buffer;
|
||||
|
||||
throw new ImageFetchError(toPublicErrorMessage(lastError), false);
|
||||
}
|
||||
|
||||
function resolvePreviewWidth(items: Array<{ width: number; height: number }>) {
|
||||
@ -477,6 +546,8 @@ async function applyStitch(params: {
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const startedAt = Date.now();
|
||||
let mode = "unknown";
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
@ -484,7 +555,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
const mode = String(body.mode || "").trim();
|
||||
mode = String(body.mode || "").trim();
|
||||
const requestOrigin = getRequestOrigin(request);
|
||||
const cookie = request.headers.get("cookie") || "";
|
||||
|
||||
@ -495,6 +566,12 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const data = await prepareStitch({ references, requestOrigin, cookie });
|
||||
logger.info("prepare_completed", {
|
||||
referenceCount: references.length,
|
||||
sourceSegmentCount: data.sourceSegments.length,
|
||||
failedCount: data.failed.length,
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
return NextResponse.json({ success: true, data });
|
||||
}
|
||||
|
||||
@ -560,16 +637,25 @@ export async function POST(request: NextRequest) {
|
||||
requestOrigin,
|
||||
cookie,
|
||||
});
|
||||
logger.info("apply_completed", {
|
||||
sourceSegmentCount: sourceSegments.length,
|
||||
cutLineCount: normalizeCutLines(body.cutLines, height).length,
|
||||
outputCount: data.cards.length,
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
return NextResponse.json({ success: true, data });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: false, error: "未知操作" }, { status: 400 });
|
||||
} catch (error) {
|
||||
console.error("[DetailStitch API] Failed:", error);
|
||||
logger.error("request_failed", error, {
|
||||
mode,
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "拼合切割失败",
|
||||
error: toPublicErrorMessage(error),
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
|
||||
367
app/api/canvas/download-zip-task/route.ts
Normal file
367
app/api/canvas/download-zip-task/route.ts
Normal file
@ -0,0 +1,367 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import JSZip from "jszip";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { fetchCanvasDownloadAsset } from "@/lib/canvas/photoshop-safe-download";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 300;
|
||||
|
||||
type DownloadFileItem = {
|
||||
url: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
type ZipTaskStatus = "pending" | "running" | "completed" | "failed";
|
||||
|
||||
type ZipTask = {
|
||||
id: string;
|
||||
userId: string;
|
||||
folderName: string;
|
||||
files: DownloadFileItem[];
|
||||
totalCount: number;
|
||||
processedCount: number;
|
||||
successCount: number;
|
||||
failedCount: number;
|
||||
compressPercent: number;
|
||||
status: ZipTaskStatus;
|
||||
stage: string;
|
||||
error?: string;
|
||||
zipBuffer?: Buffer;
|
||||
contentDisposition?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
const MAX_CONCURRENCY = 8;
|
||||
const MAX_DOWNLOAD_FILES = 200;
|
||||
const TASK_TTL_MS = 30 * 60 * 1000;
|
||||
|
||||
const globalTaskStore = globalThis as typeof globalThis & {
|
||||
__canvasDownloadZipTasks?: Map<string, ZipTask>;
|
||||
};
|
||||
|
||||
function getTaskStore() {
|
||||
if (!globalTaskStore.__canvasDownloadZipTasks) {
|
||||
globalTaskStore.__canvasDownloadZipTasks = new Map<string, ZipTask>();
|
||||
}
|
||||
return globalTaskStore.__canvasDownloadZipTasks;
|
||||
}
|
||||
|
||||
function sanitizeFolderName(value: string): string {
|
||||
const cleaned = String(value || "")
|
||||
.replace(/[\\/:*?"<>|]+/g, "-")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.trim();
|
||||
return cleaned || `canvas-images-${Date.now()}`;
|
||||
}
|
||||
|
||||
function buildContentDisposition(folderName: string): string {
|
||||
const encodedName = encodeURIComponent(`${folderName}.zip`);
|
||||
const asciiName =
|
||||
folderName
|
||||
.replace(/[^\x20-\x7E]+/g, "")
|
||||
.replace(/"/g, "")
|
||||
.trim() || "canvas-images";
|
||||
return `attachment; filename="${asciiName}.zip"; filename*=UTF-8''${encodedName}`;
|
||||
}
|
||||
|
||||
function getRequestOrigin(request: NextRequest): string {
|
||||
const forwardedHost = (request.headers.get("x-forwarded-host") || "")
|
||||
.split(",")[0]
|
||||
?.trim();
|
||||
const forwardedProto = (request.headers.get("x-forwarded-proto") || "")
|
||||
.split(",")[0]
|
||||
?.trim();
|
||||
if (forwardedHost && forwardedProto) {
|
||||
return `${forwardedProto}://${forwardedHost}`;
|
||||
}
|
||||
return new URL(request.url).origin;
|
||||
}
|
||||
|
||||
function toDownloadItems(input: unknown, origin: string): DownloadFileItem[] {
|
||||
if (!Array.isArray(input)) return [];
|
||||
return input
|
||||
.map((item) => {
|
||||
const raw = (item || {}) as Record<string, unknown>;
|
||||
const rawUrl = String(raw.url || "").trim();
|
||||
if (!rawUrl) return null;
|
||||
|
||||
let resolvedUrl = "";
|
||||
try {
|
||||
const parsed = new URL(rawUrl, origin);
|
||||
if (!/^https?:$/i.test(parsed.protocol)) return null;
|
||||
resolvedUrl = parsed.toString();
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const name = String(raw.name || "").trim();
|
||||
const nextItem: DownloadFileItem = { url: resolvedUrl };
|
||||
if (name) {
|
||||
nextItem.name = name;
|
||||
}
|
||||
return nextItem;
|
||||
})
|
||||
.filter((item): item is DownloadFileItem => item !== null)
|
||||
.slice(0, MAX_DOWNLOAD_FILES);
|
||||
}
|
||||
|
||||
function cleanupExpiredTasks() {
|
||||
const now = Date.now();
|
||||
const store = getTaskStore();
|
||||
for (const [taskId, task] of store.entries()) {
|
||||
if (task.expiresAt <= now) {
|
||||
store.delete(taskId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getTaskProgress(task: ZipTask) {
|
||||
if (task.status === "completed") {
|
||||
return 100;
|
||||
}
|
||||
if (task.status === "failed") {
|
||||
return Math.max(
|
||||
0,
|
||||
Math.min(100, Math.round((task.processedCount / Math.max(1, task.totalCount)) * 85))
|
||||
);
|
||||
}
|
||||
if (task.processedCount >= task.totalCount && task.totalCount > 0) {
|
||||
return Math.min(99, 85 + Math.round((task.compressPercent / 100) * 14));
|
||||
}
|
||||
return Math.max(
|
||||
4,
|
||||
Math.min(85, Math.round((task.processedCount / Math.max(1, task.totalCount)) * 85))
|
||||
);
|
||||
}
|
||||
|
||||
function serializeTask(task: ZipTask) {
|
||||
const downloadUrl =
|
||||
task.status === "completed"
|
||||
? `/api/canvas/download-zip-task?taskId=${encodeURIComponent(task.id)}&download=1`
|
||||
: "";
|
||||
return {
|
||||
id: task.id,
|
||||
folderName: task.folderName,
|
||||
status: task.status,
|
||||
stage: task.stage,
|
||||
totalCount: task.totalCount,
|
||||
processedCount: task.processedCount,
|
||||
successCount: task.successCount,
|
||||
failedCount: task.failedCount,
|
||||
progress: getTaskProgress(task),
|
||||
error: task.error || "",
|
||||
downloadUrl,
|
||||
createdAt: task.createdAt,
|
||||
updatedAt: task.updatedAt,
|
||||
expiresAt: task.expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
async function processZipTask(params: {
|
||||
task: ZipTask;
|
||||
requestOrigin: string;
|
||||
cookie: string;
|
||||
}) {
|
||||
const { task, requestOrigin, cookie } = params;
|
||||
const store = getTaskStore();
|
||||
try {
|
||||
task.status = "running";
|
||||
task.stage = "正在读取源图";
|
||||
task.updatedAt = Date.now();
|
||||
|
||||
const zip = new JSZip();
|
||||
const folder = zip.folder(task.folderName) || zip;
|
||||
const results: Array<
|
||||
| Awaited<ReturnType<typeof fetchCanvasDownloadAsset>>
|
||||
| null
|
||||
> = new Array(task.files.length).fill(null);
|
||||
let cursor = 0;
|
||||
const workerCount = Math.min(MAX_CONCURRENCY, task.files.length);
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: workerCount }, async () => {
|
||||
while (true) {
|
||||
const index = cursor;
|
||||
cursor += 1;
|
||||
if (index >= task.files.length) break;
|
||||
|
||||
const file = task.files[index]!;
|
||||
results[index] = await fetchCanvasDownloadAsset({
|
||||
file,
|
||||
index,
|
||||
requestOrigin,
|
||||
cookie,
|
||||
});
|
||||
|
||||
const result = results[index];
|
||||
task.processedCount += 1;
|
||||
if (result?.ok) {
|
||||
task.successCount += 1;
|
||||
} else {
|
||||
task.failedCount += 1;
|
||||
}
|
||||
task.stage = `正在处理 ${task.processedCount}/${task.totalCount} 张`;
|
||||
task.updatedAt = Date.now();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const failures: Array<{ index: number; url: string; name?: string; error: string }> = [];
|
||||
for (const [index, item] of results.entries()) {
|
||||
if (!item || !item.ok) {
|
||||
failures.push({
|
||||
index: index + 1,
|
||||
url: item?.ok === false ? item.url : task.files[index]?.url || "",
|
||||
name: item?.ok === false ? item.name : task.files[index]?.name,
|
||||
error: item?.ok === false ? item.error : "未知错误",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
folder.file(item.filename, item.bytes);
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
folder.file(
|
||||
"_download-failures.txt",
|
||||
[
|
||||
"以下图片暂时无法下载。可稍后重试,或复制链接检查源图是否仍可访问。",
|
||||
"",
|
||||
...failures.map((item) => {
|
||||
const label = item.name ? `${item.index}. ${item.name}` : `${item.index}. image`;
|
||||
return `${label}\nURL: ${item.url}\n原因: ${item.error}`;
|
||||
}),
|
||||
].join("\n\n")
|
||||
);
|
||||
console.warn("[CanvasDownloadZipTask] Partial download failures:", failures);
|
||||
}
|
||||
|
||||
task.stage = "正在生成 ZIP 文件";
|
||||
task.updatedAt = Date.now();
|
||||
task.zipBuffer = await zip.generateAsync(
|
||||
{
|
||||
type: "nodebuffer",
|
||||
compression: "DEFLATE",
|
||||
compressionOptions: { level: 6 },
|
||||
},
|
||||
(metadata) => {
|
||||
task.compressPercent = Math.max(0, Math.min(100, metadata.percent || 0));
|
||||
task.updatedAt = Date.now();
|
||||
}
|
||||
);
|
||||
task.contentDisposition = buildContentDisposition(task.folderName);
|
||||
task.status = "completed";
|
||||
task.stage = "打包完成";
|
||||
task.compressPercent = 100;
|
||||
task.updatedAt = Date.now();
|
||||
task.expiresAt = Date.now() + TASK_TTL_MS;
|
||||
} catch (error) {
|
||||
console.error("[CanvasDownloadZipTask] Failed:", error);
|
||||
task.status = "failed";
|
||||
task.stage = "打包失败";
|
||||
task.error = error instanceof Error ? error.message : "打包下载失败";
|
||||
task.updatedAt = Date.now();
|
||||
task.expiresAt = Date.now() + TASK_TTL_MS;
|
||||
} finally {
|
||||
store.set(task.id, task);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
cleanupExpiredTasks();
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
const requestOrigin = getRequestOrigin(request);
|
||||
const files = toDownloadItems(body.files, requestOrigin);
|
||||
const folderName = sanitizeFolderName(String(body.folderName || ""));
|
||||
if (files.length === 0) {
|
||||
return NextResponse.json({ success: false, error: "没有可下载的图片" }, { status: 400 });
|
||||
}
|
||||
|
||||
const task: ZipTask = {
|
||||
id: randomUUID(),
|
||||
userId: session.user.id,
|
||||
folderName,
|
||||
files,
|
||||
totalCount: files.length,
|
||||
processedCount: 0,
|
||||
successCount: 0,
|
||||
failedCount: 0,
|
||||
compressPercent: 0,
|
||||
status: "pending",
|
||||
stage: "等待服务端开始打包",
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
expiresAt: Date.now() + TASK_TTL_MS,
|
||||
};
|
||||
getTaskStore().set(task.id, task);
|
||||
|
||||
void processZipTask({
|
||||
task,
|
||||
requestOrigin,
|
||||
cookie: request.headers.get("cookie") || "",
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
task: serializeTask(task),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[CanvasDownloadZipTask] Create failed:", error);
|
||||
return NextResponse.json({ success: false, error: "创建打包任务失败" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
cleanupExpiredTasks();
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const taskId = String(request.nextUrl.searchParams.get("taskId") || "").trim();
|
||||
if (!taskId) {
|
||||
return NextResponse.json({ success: false, error: "缺少任务 ID" }, { status: 400 });
|
||||
}
|
||||
|
||||
const task = getTaskStore().get(taskId);
|
||||
if (!task || task.userId !== session.user.id) {
|
||||
return NextResponse.json({ success: false, error: "打包任务不存在或已过期" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (request.nextUrl.searchParams.get("download") === "1") {
|
||||
if (task.status !== "completed" || !task.zipBuffer) {
|
||||
return NextResponse.json({ success: false, error: "ZIP 文件尚未准备好" }, { status: 409 });
|
||||
}
|
||||
return new NextResponse(task.zipBuffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/zip",
|
||||
"Content-Disposition": task.contentDisposition || buildContentDisposition(task.folderName),
|
||||
"Content-Length": String(task.zipBuffer.length),
|
||||
"Cache-Control": "no-store",
|
||||
"X-Download-Success-Count": String(task.successCount),
|
||||
"X-Download-Failed-Count": String(task.failedCount),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
task: serializeTask(task),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[CanvasDownloadZipTask] Query failed:", error);
|
||||
return NextResponse.json({ success: false, error: "查询打包任务失败" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@ -21,12 +21,14 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||
import { createClientLogger } from "@/lib/observability/client-logger";
|
||||
import type { AgentDetailReplacementReferenceItem } from "@/types/agent";
|
||||
|
||||
const MIN_SLICE_HEIGHT = 120;
|
||||
const FAST_PREVIEW_WIDTH = 720;
|
||||
const ESTIMATED_REFERENCE_HEIGHT = 960;
|
||||
const SEGMENT_PRELOAD_CONCURRENCY = 4;
|
||||
const logger = createClientLogger("agent-detail-reference-stitch");
|
||||
|
||||
type StitchSourceSegment = {
|
||||
card: AgentDetailReplacementReferenceItem;
|
||||
@ -96,6 +98,18 @@ function getReferenceDimension(value: unknown) {
|
||||
return Number.isFinite(normalized) && normalized > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
function normalizeDetailStitchErrorMessage(error: unknown, fallback: string) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error || "").trim();
|
||||
if (/timeout|aborted|operation was aborted/i.test(message)) {
|
||||
return "切割图片下载超时,通常是源图过大、源站响应慢或网络抖动导致。请稍后重试,或减少本次切割图片数量后再应用。";
|
||||
}
|
||||
if (/failed to fetch|networkerror|network error/i.test(message)) {
|
||||
return "网络连接异常,部分参考图暂时无法下载,请稍后重试。";
|
||||
}
|
||||
return message || fallback;
|
||||
}
|
||||
|
||||
function resolveReferencePreviewHeight(card: AgentDetailReplacementReferenceItem) {
|
||||
const width = getReferenceDimension(card.width);
|
||||
const height = getReferenceDimension(card.height);
|
||||
@ -389,9 +403,13 @@ export function AgentDetailReferenceStitchDialog({
|
||||
);
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
console.error("[AgentDetailReferenceStitchDialog] Failed to prepare merged image:", error);
|
||||
const message = normalizeDetailStitchErrorMessage(error, "拼合参考图失败");
|
||||
logger.warn("prepare_failed", {
|
||||
cardCount: setupCards.length,
|
||||
message,
|
||||
});
|
||||
setWarningText(
|
||||
`精准尺寸校准失败,已先使用快速预览:${error instanceof Error ? error.message : "拼合参考图失败"}`
|
||||
`精准尺寸校准失败,已先使用快速预览:${message}`
|
||||
);
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
@ -692,8 +710,14 @@ export function AgentDetailReferenceStitchDialog({
|
||||
onOpenChange(false);
|
||||
toast.success(`已生成 ${orderedCards.length} 张切片参考图`);
|
||||
} catch (error) {
|
||||
console.error("[AgentDetailReferenceStitchDialog] Failed to apply slices:", error);
|
||||
toast.error(error instanceof Error ? error.message : "切割参考图失败");
|
||||
const message = normalizeDetailStitchErrorMessage(error, "切割参考图失败");
|
||||
logger.error("apply_failed", {
|
||||
cutLineCount: normalizedCutLines.length,
|
||||
sliceCount: sliceSegments.length,
|
||||
sourceSegmentCount: snapshot.sourceSegments.length,
|
||||
message,
|
||||
});
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsApplying(false);
|
||||
setApplyProgressText("");
|
||||
|
||||
@ -176,6 +176,46 @@ interface AgentWorkflowModelOption {
|
||||
|
||||
interface PickedAttachment extends Attachment {}
|
||||
|
||||
type BatchDownloadScope = "scene" | "detail" | "sku";
|
||||
type BatchDownloadTaskStatus =
|
||||
| "idle"
|
||||
| "starting"
|
||||
| "pending"
|
||||
| "running"
|
||||
| "completed"
|
||||
| "failed";
|
||||
|
||||
type BatchDownloadTaskSnapshot = {
|
||||
id: string;
|
||||
folderName: string;
|
||||
status: Exclude<BatchDownloadTaskStatus, "idle" | "starting">;
|
||||
stage: string;
|
||||
totalCount: number;
|
||||
processedCount: number;
|
||||
successCount: number;
|
||||
failedCount: number;
|
||||
progress: number;
|
||||
error: string;
|
||||
downloadUrl: string;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
type BatchDownloadDialogState = {
|
||||
scope: BatchDownloadScope;
|
||||
zipName: string;
|
||||
status: BatchDownloadTaskStatus;
|
||||
taskId: string;
|
||||
totalCount: number;
|
||||
processedCount: number;
|
||||
successCount: number;
|
||||
failedCount: number;
|
||||
progress: number;
|
||||
stage: string;
|
||||
error: string;
|
||||
downloadUrl: string;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
const MAX_CHAT_TEXT_PART_LENGTH = 2000;
|
||||
const MAX_CHAT_HIDDEN_PROMPT_LENGTH = 4000;
|
||||
const MAX_CHAT_ATTACHMENT_NAME_LENGTH = 100;
|
||||
@ -236,6 +276,38 @@ function normalizeChatMediaType(value?: string | null): string {
|
||||
return "image/jpeg";
|
||||
}
|
||||
|
||||
function normalizeBatchDownloadTaskSnapshot(
|
||||
value: unknown
|
||||
): BatchDownloadTaskSnapshot | null {
|
||||
const record = (value || {}) as Record<string, unknown>;
|
||||
const id = String(record.id || "").trim();
|
||||
const status = String(record.status || "").trim();
|
||||
if (
|
||||
!id ||
|
||||
(status !== "pending" &&
|
||||
status !== "running" &&
|
||||
status !== "completed" &&
|
||||
status !== "failed")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
folderName: String(record.folderName || "").trim(),
|
||||
status,
|
||||
stage: String(record.stage || "").trim(),
|
||||
totalCount: Math.max(0, Number(record.totalCount || 0)),
|
||||
processedCount: Math.max(0, Number(record.processedCount || 0)),
|
||||
successCount: Math.max(0, Number(record.successCount || 0)),
|
||||
failedCount: Math.max(0, Number(record.failedCount || 0)),
|
||||
progress: Math.max(0, Math.min(100, Number(record.progress || 0))),
|
||||
error: String(record.error || "").trim(),
|
||||
downloadUrl: String(record.downloadUrl || "").trim(),
|
||||
expiresAt: Math.max(0, Number(record.expiresAt || 0)),
|
||||
};
|
||||
}
|
||||
|
||||
function getPsdTemplateModeByResultMode(modeType: AgentGenerationResultModeType): AgentPsdTemplateMode {
|
||||
return modeType === "sku_replacement" ? "sku" : "main";
|
||||
}
|
||||
@ -1343,7 +1415,10 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
const [psdTemplateDialogMode, setPsdTemplateDialogMode] = useState<AgentPsdTemplateMode>("main");
|
||||
const [psdTemplateDialogResultModeType, setPsdTemplateDialogResultModeType] =
|
||||
useState<AgentGenerationResultModeType>("scene_generation");
|
||||
const [batchDownloadState, setBatchDownloadState] = useState<null | "scene" | "detail" | "sku">(null);
|
||||
const [batchDownloadState, setBatchDownloadState] =
|
||||
useState<null | BatchDownloadScope>(null);
|
||||
const [batchDownloadDialog, setBatchDownloadDialog] =
|
||||
useState<BatchDownloadDialogState | null>(null);
|
||||
const [detailReplacementSearchState, setDetailReplacementSearchState] = useState<{
|
||||
isLoading: boolean;
|
||||
lastQuery: string | null;
|
||||
@ -3481,7 +3556,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
const handleBatchDownloadImages = useCallback(async (params: {
|
||||
imageUrls: string[];
|
||||
zipName: string;
|
||||
scope: "scene" | "detail" | "sku";
|
||||
scope: BatchDownloadScope;
|
||||
}) => {
|
||||
const normalizedUrls = (params.imageUrls || [])
|
||||
.map((item) => String(item || "").trim())
|
||||
@ -3492,6 +3567,21 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
}
|
||||
|
||||
setBatchDownloadState(params.scope);
|
||||
setBatchDownloadDialog({
|
||||
scope: params.scope,
|
||||
zipName: params.zipName,
|
||||
status: "starting",
|
||||
taskId: "",
|
||||
totalCount: normalizedUrls.length,
|
||||
processedCount: 0,
|
||||
successCount: 0,
|
||||
failedCount: 0,
|
||||
progress: 4,
|
||||
stage: "正在提交服务端打包任务",
|
||||
error: "",
|
||||
downloadUrl: "",
|
||||
expiresAt: 0,
|
||||
});
|
||||
try {
|
||||
const batchFileBaseName =
|
||||
params.scope === "sku"
|
||||
@ -3499,35 +3589,182 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
: params.scope === "scene"
|
||||
? "scene-result"
|
||||
: "detail-result";
|
||||
const form = document.createElement("form");
|
||||
form.method = "POST";
|
||||
form.action = "/api/canvas/download-zip";
|
||||
form.style.display = "none";
|
||||
|
||||
const payloadInput = document.createElement("input");
|
||||
payloadInput.type = "hidden";
|
||||
payloadInput.name = "payload";
|
||||
payloadInput.value = JSON.stringify({
|
||||
folderName: params.zipName,
|
||||
files: normalizedUrls.map((imageUrl) => ({
|
||||
url: imageUrl,
|
||||
name: `${params.zipName}-${batchFileBaseName}`,
|
||||
})),
|
||||
const response = await fetch("/api/canvas/download-zip-task", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
cache: "no-store",
|
||||
body: JSON.stringify({
|
||||
folderName: params.zipName,
|
||||
files: normalizedUrls.map((imageUrl) => ({
|
||||
url: imageUrl,
|
||||
name: `${params.zipName}-${batchFileBaseName}`,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
form.appendChild(payloadInput);
|
||||
const payload = (await response.json().catch(() => null)) as
|
||||
| { success?: boolean; task?: unknown; error?: string }
|
||||
| null;
|
||||
const task = normalizeBatchDownloadTaskSnapshot(payload?.task);
|
||||
if (!response.ok || payload?.success !== true || !task) {
|
||||
throw new Error(payload?.error || "创建打包任务失败");
|
||||
}
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
form.remove();
|
||||
toast.success("已开始服务端打包下载,请留意浏览器下载栏");
|
||||
setBatchDownloadDialog((current) => ({
|
||||
scope: current?.scope || params.scope,
|
||||
zipName: current?.zipName || params.zipName,
|
||||
status: task.status,
|
||||
taskId: task.id,
|
||||
totalCount: task.totalCount,
|
||||
processedCount: task.processedCount,
|
||||
successCount: task.successCount,
|
||||
failedCount: task.failedCount,
|
||||
progress: task.progress,
|
||||
stage: task.stage || "服务端正在打包",
|
||||
error: task.error,
|
||||
downloadUrl: task.downloadUrl,
|
||||
expiresAt: task.expiresAt,
|
||||
}));
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "批量下载失败");
|
||||
} finally {
|
||||
window.setTimeout(() => {
|
||||
setBatchDownloadState(null);
|
||||
}, 1200);
|
||||
const message = error instanceof Error ? error.message : "批量下载失败";
|
||||
setBatchDownloadState(null);
|
||||
setBatchDownloadDialog((current) => ({
|
||||
scope: current?.scope || params.scope,
|
||||
zipName: current?.zipName || params.zipName,
|
||||
status: "failed",
|
||||
taskId: current?.taskId || "",
|
||||
totalCount: current?.totalCount || normalizedUrls.length,
|
||||
processedCount: current?.processedCount || 0,
|
||||
successCount: current?.successCount || 0,
|
||||
failedCount: current?.failedCount || 0,
|
||||
progress: current?.progress || 0,
|
||||
stage: "打包任务创建失败",
|
||||
error: message,
|
||||
downloadUrl: "",
|
||||
expiresAt: 0,
|
||||
}));
|
||||
toast.error(message);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const taskId = batchDownloadDialog?.taskId;
|
||||
const status = batchDownloadDialog?.status;
|
||||
if (!taskId || (status !== "pending" && status !== "running")) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let timeoutId: number | null = null;
|
||||
const pollTask = async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/canvas/download-zip-task?taskId=${encodeURIComponent(taskId)}`,
|
||||
{ cache: "no-store" }
|
||||
);
|
||||
const payload = (await response.json().catch(() => null)) as
|
||||
| { success?: boolean; task?: unknown; error?: string }
|
||||
| null;
|
||||
const task = normalizeBatchDownloadTaskSnapshot(payload?.task);
|
||||
if (!response.ok || payload?.success !== true || !task) {
|
||||
throw new Error(payload?.error || "查询打包进度失败");
|
||||
}
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setBatchDownloadDialog((current) => {
|
||||
if (!current || current.taskId !== taskId) {
|
||||
return current;
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
status: task.status,
|
||||
totalCount: task.totalCount,
|
||||
processedCount: task.processedCount,
|
||||
successCount: task.successCount,
|
||||
failedCount: task.failedCount,
|
||||
progress: task.progress,
|
||||
stage: task.stage || current.stage,
|
||||
error: task.error,
|
||||
downloadUrl: task.downloadUrl,
|
||||
expiresAt: task.expiresAt,
|
||||
};
|
||||
});
|
||||
|
||||
if (task.status === "completed") {
|
||||
setBatchDownloadState(null);
|
||||
toast.success("批量导出已打包完成,请在弹窗中下载");
|
||||
return;
|
||||
}
|
||||
if (task.status === "failed") {
|
||||
setBatchDownloadState(null);
|
||||
toast.error(task.error || "批量导出打包失败");
|
||||
return;
|
||||
}
|
||||
|
||||
timeoutId = window.setTimeout(pollTask, 1000);
|
||||
} catch (error) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const message =
|
||||
error instanceof Error ? error.message : "查询打包进度失败";
|
||||
setBatchDownloadState(null);
|
||||
setBatchDownloadDialog((current) =>
|
||||
current && current.taskId === taskId
|
||||
? {
|
||||
...current,
|
||||
status: "failed",
|
||||
stage: "查询打包进度失败",
|
||||
error: message,
|
||||
}
|
||||
: current
|
||||
);
|
||||
toast.error(message);
|
||||
}
|
||||
};
|
||||
|
||||
timeoutId = window.setTimeout(pollTask, 600);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timeoutId !== null) {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
}, [batchDownloadDialog?.status, batchDownloadDialog?.taskId]);
|
||||
|
||||
const handleDownloadPreparedBatchZip = useCallback(() => {
|
||||
if (!batchDownloadDialog?.downloadUrl) {
|
||||
toast.error("下载文件还没有准备好");
|
||||
return;
|
||||
}
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = batchDownloadDialog.downloadUrl;
|
||||
link.download = `${batchDownloadDialog.zipName || "batch-results"}.zip`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
}, [batchDownloadDialog?.downloadUrl, batchDownloadDialog?.zipName]);
|
||||
|
||||
const handleBatchDownloadDialogOpenChange = useCallback((open: boolean) => {
|
||||
if (open) {
|
||||
return;
|
||||
}
|
||||
setBatchDownloadDialog((current) => {
|
||||
if (
|
||||
current?.status === "starting" ||
|
||||
current?.status === "pending" ||
|
||||
current?.status === "running"
|
||||
) {
|
||||
toast.info("批量导出仍在处理中,完成后即可下载");
|
||||
return current;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}, []);
|
||||
const handleSendResultToCanvasTextEdit = useCallback((params: {
|
||||
imageUrl: string;
|
||||
title: string;
|
||||
@ -5567,6 +5804,23 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const batchDownloadProgress = Math.round(
|
||||
Math.max(0, Math.min(100, batchDownloadDialog?.progress || 0))
|
||||
);
|
||||
const isBatchDownloadRunning =
|
||||
batchDownloadDialog?.status === "starting" ||
|
||||
batchDownloadDialog?.status === "pending" ||
|
||||
batchDownloadDialog?.status === "running";
|
||||
const isBatchDownloadCompleted =
|
||||
batchDownloadDialog?.status === "completed";
|
||||
const isBatchDownloadFailed = batchDownloadDialog?.status === "failed";
|
||||
const batchDownloadScopeLabel =
|
||||
batchDownloadDialog?.scope === "sku"
|
||||
? "SKU 替换结果"
|
||||
: batchDownloadDialog?.scope === "scene"
|
||||
? "场景生图结果"
|
||||
: "详情替换结果";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={workspaceRootRef}
|
||||
@ -7706,6 +7960,143 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
onApplySuccess={handleApplyReplacementTextEditResult}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(batchDownloadDialog)}
|
||||
onOpenChange={handleBatchDownloadDialogOpenChange}
|
||||
>
|
||||
<DialogContent className="border-white/10 bg-[#101112] text-white shadow-[0_30px_90px_rgba(0,0,0,0.65)] sm:max-w-[520px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-white">
|
||||
{isBatchDownloadCompleted ? (
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-300" />
|
||||
) : isBatchDownloadFailed ? (
|
||||
<X className="h-5 w-5 text-red-300" />
|
||||
) : (
|
||||
<Loader2 className="h-5 w-5 animate-spin text-emerald-300" />
|
||||
)}
|
||||
批量导出结果
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-white/52">
|
||||
{isBatchDownloadCompleted
|
||||
? "服务端已完成打包,点击下方按钮下载 ZIP 文件。"
|
||||
: isBatchDownloadFailed
|
||||
? "本次打包没有完成,可关闭弹窗后重新尝试。"
|
||||
: `正在服务端打包 ${batchDownloadScopeLabel},页面可保持打开等待完成。`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.04] p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="font-semibold text-sm text-white">
|
||||
{batchDownloadDialog?.zipName || "批量导出"}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-white/45">
|
||||
{batchDownloadDialog?.stage || "准备打包"}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-full border px-3 py-1 text-xs",
|
||||
isBatchDownloadCompleted
|
||||
? "border-emerald-400/25 bg-emerald-500/10 text-emerald-100"
|
||||
: isBatchDownloadFailed
|
||||
? "border-red-400/25 bg-red-500/10 text-red-100"
|
||||
: "border-sky-400/25 bg-sky-500/10 text-sky-100"
|
||||
)}
|
||||
>
|
||||
{isBatchDownloadCompleted
|
||||
? "已完成"
|
||||
: isBatchDownloadFailed
|
||||
? "失败"
|
||||
: "处理中"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="mb-2 flex items-center justify-between text-xs">
|
||||
<span className="text-white/45">
|
||||
已处理 {batchDownloadDialog?.processedCount || 0}/
|
||||
{batchDownloadDialog?.totalCount || 0}
|
||||
</span>
|
||||
<span className="font-medium text-white/72">
|
||||
{batchDownloadProgress}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-white/10">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all duration-500",
|
||||
isBatchDownloadFailed
|
||||
? "bg-red-400"
|
||||
: "bg-gradient-to-r from-emerald-500 via-teal-300 to-sky-400"
|
||||
)}
|
||||
style={{ width: `${batchDownloadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-3 gap-2 text-center text-xs">
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-2">
|
||||
<div className="text-white/42">总数</div>
|
||||
<div className="mt-1 font-semibold text-white">
|
||||
{batchDownloadDialog?.totalCount || 0}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-emerald-400/15 bg-emerald-500/10 px-3 py-2">
|
||||
<div className="text-emerald-100/55">成功</div>
|
||||
<div className="mt-1 font-semibold text-emerald-100">
|
||||
{batchDownloadDialog?.successCount || 0}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-red-400/15 bg-red-500/10 px-3 py-2">
|
||||
<div className="text-red-100/55">失败</div>
|
||||
<div className="mt-1 font-semibold text-red-100">
|
||||
{batchDownloadDialog?.failedCount || 0}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isBatchDownloadCompleted &&
|
||||
(batchDownloadDialog?.failedCount || 0) > 0 ? (
|
||||
<div className="rounded-2xl border border-amber-400/20 bg-amber-500/10 px-4 py-3 text-amber-50/80 text-xs leading-relaxed">
|
||||
有 {batchDownloadDialog?.failedCount || 0} 张图片暂时无法下载,ZIP
|
||||
内已附带 _download-failures.txt,可用于排查源图链接。
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isBatchDownloadFailed ? (
|
||||
<div className="rounded-2xl border border-red-400/20 bg-red-500/10 px-4 py-3 text-red-50/80 text-xs leading-relaxed">
|
||||
{batchDownloadDialog?.error || "打包下载失败,请稍后重试。"}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="text-white/60 hover:bg-white/10 hover:text-white"
|
||||
disabled={isBatchDownloadRunning}
|
||||
onClick={() => setBatchDownloadDialog(null)}
|
||||
>
|
||||
{isBatchDownloadRunning ? "处理中" : "关闭"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!isBatchDownloadCompleted || !batchDownloadDialog?.downloadUrl}
|
||||
className="rounded-xl bg-emerald-500 text-white hover:bg-emerald-400 disabled:opacity-45"
|
||||
onClick={handleDownloadPreparedBatchZip}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
下载 ZIP
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(replacementRerunTarget?.open)}
|
||||
onOpenChange={(open) => {
|
||||
|
||||
@ -1713,6 +1713,27 @@ function getAssignedImageFrameLayer(
|
||||
return layers.find((item) => item.id === layer.clipMaskLayerId) || layer;
|
||||
}
|
||||
|
||||
function shouldRenderAssignedImageLayerPreview(params: {
|
||||
layer: AgentPsdTemplateImageLayerSlot;
|
||||
hiddenLayerIdSet: Set<string>;
|
||||
}) {
|
||||
const { layer, hiddenLayerIdSet } = params;
|
||||
if (hiddenLayerIdSet.has(layer.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
layer.forceClipMask &&
|
||||
layer.clipMaskLayerId &&
|
||||
layer.clipMaskLayerId !== layer.id &&
|
||||
hiddenLayerIdSet.has(layer.clipMaskLayerId)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function hasEditedTextValue(
|
||||
layer: AgentPsdTemplateTextLayerSlot,
|
||||
value: string
|
||||
@ -2253,15 +2274,14 @@ export function AgentPsdTemplateEditorDialog({
|
||||
])
|
||||
);
|
||||
}, [orderedPreviewLayers]);
|
||||
const defaultSourceImageLayerId =
|
||||
getDefaultSourceImageLayer(selectedTemplateLayers)?.id || "";
|
||||
const renderBaseZIndex = useMemo(() => {
|
||||
const defaultSourceImageLayer = getDefaultSourceImageLayer(
|
||||
selectedTemplateLayers
|
||||
);
|
||||
const defaultSourceImageZIndex = defaultSourceImageLayer
|
||||
? layerStackZIndexById.get(defaultSourceImageLayer.id) || 0
|
||||
const defaultSourceImageZIndex = defaultSourceImageLayerId
|
||||
? layerStackZIndexById.get(defaultSourceImageLayerId) || 0
|
||||
: 0;
|
||||
return defaultSourceImageZIndex + PSD_RENDER_BASE_Z_INDEX_OFFSET;
|
||||
}, [layerStackZIndexById, selectedTemplateLayers]);
|
||||
}, [defaultSourceImageLayerId, layerStackZIndexById]);
|
||||
const shouldShowCompositeFallback = useMemo(
|
||||
() =>
|
||||
orderedVisiblePreviewLayers.length === 0 ||
|
||||
@ -4393,6 +4413,15 @@ export function AgentPsdTemplateEditorDialog({
|
||||
if (isAgentPsdImageLayerSlot(layer)) {
|
||||
const assignedUrl =
|
||||
effectiveImageAssignments[layer.id];
|
||||
if (
|
||||
assignedUrl &&
|
||||
!shouldRenderAssignedImageLayerPreview({
|
||||
layer,
|
||||
hiddenLayerIdSet,
|
||||
})
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const layerImageUrl =
|
||||
assignedUrl ||
|
||||
getPsdLayerPreviewSourceUrl(layer);
|
||||
@ -4429,14 +4458,10 @@ export function AgentPsdTemplateEditorDialog({
|
||||
),
|
||||
}
|
||||
: layerBoxStyle;
|
||||
const assignedFrameBaseZIndex =
|
||||
layerStackZIndexById.get(
|
||||
assignedFrameLayer.id
|
||||
) || baseZIndex;
|
||||
const previewZIndex = assignedUrl
|
||||
? assignedFrameLayer.id === layer.id
|
||||
? layer.id === defaultSourceImageLayerId
|
||||
? Math.max(baseZIndex, renderBaseZIndex + 1)
|
||||
: assignedFrameBaseZIndex + 1
|
||||
: baseZIndex
|
||||
: baseZIndex;
|
||||
|
||||
return (
|
||||
|
||||
@ -3439,6 +3439,10 @@ export function RealtimeCanvasDialog({
|
||||
"h-9 w-9 rounded-[16px] border border-transparent text-[#666666] transition hover:border-[#e5e5e5] hover:bg-[#fafafa] hover:text-[#111111] dark:text-white/58 dark:hover:border-white/12 dark:hover:bg-white/10 dark:hover:text-white";
|
||||
const compactToolButtonActiveClass =
|
||||
"border-[#111111] bg-[#111111] text-white shadow-[0_10px_20px_rgba(0,0,0,0.12)] hover:bg-black hover:text-white dark:border-white dark:bg-white dark:text-[#111111] dark:hover:bg-white/90 dark:hover:text-[#111111]";
|
||||
const modeSwitchIconSlotClass =
|
||||
"inline-flex h-4 w-4 shrink-0 items-center justify-center leading-none [&>*]:!h-3.5 [&>*]:!w-3.5 [&>img]:object-contain";
|
||||
const modeSwitchIconClass =
|
||||
"h-3.5 w-3.5 max-h-3.5 max-w-3.5 object-contain transition group-hover:opacity-100";
|
||||
const currentModeLabel =
|
||||
brushMode === "select"
|
||||
? "鼠标"
|
||||
@ -3513,12 +3517,14 @@ export function RealtimeCanvasDialog({
|
||||
onClick={() => handleWorkspaceModeChange("realtime")}
|
||||
type="button"
|
||||
>
|
||||
<CanvasToolbarIcon
|
||||
active={workspaceMode === "realtime"}
|
||||
className="h-3.5 w-3.5 transition group-hover:opacity-100"
|
||||
fallback={Paintbrush}
|
||||
item={realtimeModeRealtimeItem}
|
||||
/>
|
||||
<span className={modeSwitchIconSlotClass}>
|
||||
<CanvasToolbarIcon
|
||||
active={workspaceMode === "realtime"}
|
||||
className={modeSwitchIconClass}
|
||||
fallback={Paintbrush}
|
||||
item={realtimeModeRealtimeItem}
|
||||
/>
|
||||
</span>
|
||||
<span className="max-w-[9rem] truncate">
|
||||
{realtimeModeRealtimeItem.label}
|
||||
</span>
|
||||
@ -3534,12 +3540,14 @@ export function RealtimeCanvasDialog({
|
||||
onClick={() => handleWorkspaceModeChange("enhance-pro")}
|
||||
type="button"
|
||||
>
|
||||
<CanvasToolbarIcon
|
||||
active={workspaceMode === "enhance-pro"}
|
||||
className="h-3.5 w-3.5 transition group-hover:opacity-100"
|
||||
fallback={Wand2}
|
||||
item={realtimeModeEnhanceItem}
|
||||
/>
|
||||
<span className={modeSwitchIconSlotClass}>
|
||||
<CanvasToolbarIcon
|
||||
active={workspaceMode === "enhance-pro"}
|
||||
className={modeSwitchIconClass}
|
||||
fallback={Wand2}
|
||||
item={realtimeModeEnhanceItem}
|
||||
/>
|
||||
</span>
|
||||
<span className="max-w-[9rem] truncate">
|
||||
{realtimeModeEnhanceItem.label}
|
||||
</span>
|
||||
@ -3556,12 +3564,14 @@ export function RealtimeCanvasDialog({
|
||||
onClick={() => handleWorkspaceModeChange("ai-wash")}
|
||||
type="button"
|
||||
>
|
||||
<CanvasToolbarIcon
|
||||
active={workspaceMode === "ai-wash"}
|
||||
className="h-3.5 w-3.5 transition group-hover:opacity-100"
|
||||
fallback={Sparkles}
|
||||
iconUrl={aiWashModeIconUrl}
|
||||
/>
|
||||
<span className={modeSwitchIconSlotClass}>
|
||||
<CanvasToolbarIcon
|
||||
active={workspaceMode === "ai-wash"}
|
||||
className={modeSwitchIconClass}
|
||||
fallback={Sparkles}
|
||||
iconUrl={aiWashModeIconUrl}
|
||||
/>
|
||||
</span>
|
||||
<span className="max-w-[9rem] truncate">{aiWashModeLabel}</span>
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
@ -132,6 +132,13 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
@ -167,6 +174,18 @@ import {
|
||||
isCanvasToolbarItemEnabled,
|
||||
type CanvasToolbarConfig,
|
||||
} from "@/lib/canvas-toolbar-config";
|
||||
import {
|
||||
AI_WASH_CONTROL_ORDER,
|
||||
AI_WASH_DEFAULT_MODE_LABEL,
|
||||
defaultAiWashConfig,
|
||||
getEnabledAiWashPresets,
|
||||
normalizeAiWashConfig,
|
||||
type AiWashConfig,
|
||||
type AiWashControl,
|
||||
type AiWashControlId,
|
||||
type AiWashControlLevel,
|
||||
type AiWashPreset,
|
||||
} from "@/lib/ai-wash/config";
|
||||
import { isCanvasImageUploadPending } from "@/lib/canvas/local-upload-state";
|
||||
import { cn, fetcher } from "@/lib/utils";
|
||||
import type {
|
||||
@ -222,6 +241,7 @@ interface LayerSplitBounds {
|
||||
}
|
||||
|
||||
const AI_CLOUD_IMAGE_ACTIONS = new Set([
|
||||
"ai-wash",
|
||||
"rmbg",
|
||||
"watermark",
|
||||
"enhance",
|
||||
@ -235,6 +255,41 @@ const AI_CLOUD_IMAGE_ACTIONS = new Set([
|
||||
"generate-3d-world",
|
||||
]);
|
||||
|
||||
type AiWashResultStatus =
|
||||
| "idle"
|
||||
| "submitting"
|
||||
| "running"
|
||||
| "succeeded"
|
||||
| "failed";
|
||||
|
||||
type AiWashModelOption = {
|
||||
id: string;
|
||||
modelId: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string | null;
|
||||
providerName?: string;
|
||||
providerType?: string;
|
||||
isDefault?: boolean;
|
||||
unitCost: number;
|
||||
creditDiscountLabel?: string | null;
|
||||
availabilityStatus?: "available" | "maintenance" | "testing";
|
||||
availabilityLabel?: string | null;
|
||||
selectable?: boolean;
|
||||
disabledReason?: string | null;
|
||||
};
|
||||
|
||||
type AiWashConfigResponse = {
|
||||
success?: boolean;
|
||||
config?: AiWashConfig;
|
||||
lockedModels?: AiWashModelOption[];
|
||||
};
|
||||
|
||||
type AiWashModelOptionsResponse = {
|
||||
success?: boolean;
|
||||
defaultModelId?: string | null;
|
||||
models?: AiWashModelOption[];
|
||||
};
|
||||
|
||||
type AngleEditPanelMode = "subject" | "camera";
|
||||
type FlipRotateEditAction = "horizontal" | "vertical" | "reset";
|
||||
type CropInteractionState = {
|
||||
@ -449,6 +504,8 @@ const CHAT_ATTACHMENT_MAX_UPLOAD_BYTES = 30 * 1024 * 1024;
|
||||
const CHAT_ATTACHMENT_MAX_DATA_URL_LENGTH = 42 * 1024 * 1024;
|
||||
const CANVAS_3D_POLL_INTERVAL_MS = 2000;
|
||||
const CANVAS_3D_MAX_POLL_ATTEMPTS = 360;
|
||||
const AI_WASH_TASK_POLL_INTERVAL_MS = 2500;
|
||||
const AI_WASH_TASK_POLL_LIMIT = 80;
|
||||
const DEFAULT_CANVAS_TRIPO_MODEL = getDefaultThreeDModelAliasId(
|
||||
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG.tripoModels
|
||||
);
|
||||
@ -459,6 +516,118 @@ const DEFAULT_CANVAS_WORLD_MODEL = getDefaultThreeDModelAliasId(
|
||||
const sleep = (ms: number) =>
|
||||
new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
|
||||
function getAiWashPresetControl(
|
||||
preset: AiWashPreset | null | undefined,
|
||||
controlId: AiWashControlId
|
||||
): AiWashControl | null {
|
||||
if (!preset) return null;
|
||||
return preset.controls.find((control) => control.id === controlId) || null;
|
||||
}
|
||||
|
||||
function getAiWashControlLevelByIndex(
|
||||
control: AiWashControl | null,
|
||||
selectedIndex: number
|
||||
): AiWashControlLevel | null {
|
||||
if (!control || control.levels.length === 0) return null;
|
||||
const clampedIndex = Math.max(
|
||||
0,
|
||||
Math.min(control.levels.length - 1, Math.floor(selectedIndex))
|
||||
);
|
||||
return control.levels[clampedIndex] || control.levels[0] || null;
|
||||
}
|
||||
|
||||
function composeAiWashQuickPrompt(params: {
|
||||
preset: AiWashPreset;
|
||||
selectedLevelIndexByControl: Record<string, number>;
|
||||
}) {
|
||||
const promptParts = [params.preset.prompt.trim()].filter(Boolean);
|
||||
|
||||
for (const controlId of AI_WASH_CONTROL_ORDER) {
|
||||
const level = getAiWashControlLevelByIndex(
|
||||
getAiWashPresetControl(params.preset, controlId),
|
||||
params.selectedLevelIndexByControl[controlId] ?? 0
|
||||
);
|
||||
const fragment = String(level?.promptFragment || "").trim();
|
||||
if (fragment) {
|
||||
promptParts.push(fragment);
|
||||
}
|
||||
}
|
||||
|
||||
return promptParts.join("\n");
|
||||
}
|
||||
|
||||
function resolveAiWashQuickAdminModelId(params: {
|
||||
preset: AiWashPreset;
|
||||
selectedLevelIndexByControl: Record<string, number>;
|
||||
}) {
|
||||
for (const controlId of AI_WASH_CONTROL_ORDER) {
|
||||
const level = getAiWashControlLevelByIndex(
|
||||
getAiWashPresetControl(params.preset, controlId),
|
||||
params.selectedLevelIndexByControl[controlId] ?? 0
|
||||
);
|
||||
const levelModelId = String(level?.modelId || "").trim();
|
||||
if (levelModelId) return levelModelId;
|
||||
}
|
||||
return String(params.preset.modelId || "").trim();
|
||||
}
|
||||
|
||||
function getAiWashStatusLabel(status?: AiWashResultStatus) {
|
||||
switch (status) {
|
||||
case "submitting":
|
||||
return "提交中";
|
||||
case "running":
|
||||
return "生成中";
|
||||
case "succeeded":
|
||||
return "已完成";
|
||||
case "failed":
|
||||
return "失败";
|
||||
case "idle":
|
||||
default:
|
||||
return "待生成";
|
||||
}
|
||||
}
|
||||
|
||||
async function pollAiWashQuickTask(taskId: string) {
|
||||
for (let attempt = 0; attempt < AI_WASH_TASK_POLL_LIMIT; attempt += 1) {
|
||||
const response = await fetch(
|
||||
`/api/canvas/generate/image?taskId=${encodeURIComponent(taskId)}`
|
||||
);
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok || payload?.success !== true || !payload?.task) {
|
||||
throw new Error(payload?.error || "查询 AI洗图任务失败");
|
||||
}
|
||||
const task = payload.task as {
|
||||
status: string;
|
||||
error?: string | null;
|
||||
result?: {
|
||||
images?: Array<{ thumbnailUrl?: string; originalUrl?: string }>;
|
||||
model?: string;
|
||||
modelRef?: string;
|
||||
prompt?: string;
|
||||
} | null;
|
||||
};
|
||||
const status = String(task.status || "").toLowerCase();
|
||||
if (status === "succeeded" || status === "completed") {
|
||||
const image = task.result?.images?.[0];
|
||||
if (!image?.originalUrl) {
|
||||
throw new Error("AI洗图已完成,但没有拿到结果图片");
|
||||
}
|
||||
return {
|
||||
imageUrl: image.originalUrl,
|
||||
thumbnailUrl: image.thumbnailUrl || image.originalUrl,
|
||||
modelId: String(task.result?.model || task.result?.modelRef || "").trim(),
|
||||
prompt: String(task.result?.prompt || "").trim(),
|
||||
};
|
||||
}
|
||||
if (["failed", "cancelled", "expired", "banned"].includes(status)) {
|
||||
throw new Error(task.error || "AI洗图任务失败");
|
||||
}
|
||||
await sleep(AI_WASH_TASK_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
throw new Error("AI洗图任务轮询超时,请稍后重试");
|
||||
}
|
||||
|
||||
const isTerminal3DFailureStatus = (status?: string | null) =>
|
||||
["failed", "banned", "expired", "cancelled"].includes(String(status || ""));
|
||||
|
||||
@ -850,6 +1019,30 @@ export function ImageNode({
|
||||
? routeParams.id
|
||||
: "";
|
||||
const data = node.data as ImageNodeData;
|
||||
const { data: aiWashConfigPayload, isLoading: aiWashConfigLoading } =
|
||||
useSWR<AiWashConfigResponse>("/api/ai-wash/config", fetcher);
|
||||
const aiWashQuickConfig = useMemo(
|
||||
() =>
|
||||
normalizeAiWashConfig(
|
||||
aiWashConfigPayload?.config || defaultAiWashConfig()
|
||||
),
|
||||
[aiWashConfigPayload?.config]
|
||||
);
|
||||
const aiWashQuickPresets = useMemo(
|
||||
() => getEnabledAiWashPresets(aiWashQuickConfig).filter((preset) => preset.enabled),
|
||||
[aiWashQuickConfig]
|
||||
);
|
||||
const imageAiWashQuickEnabled =
|
||||
aiWashQuickConfig.enabled && aiWashQuickPresets.length > 0;
|
||||
const aiWashQuickModeLabel =
|
||||
aiWashQuickConfig.modeLabel || AI_WASH_DEFAULT_MODE_LABEL;
|
||||
const {
|
||||
data: aiWashModelOptionsPayload,
|
||||
isLoading: aiWashModelOptionsLoading,
|
||||
} = useSWR<AiWashModelOptionsResponse>(
|
||||
aiWashQuickConfig.enabled ? "/api/ai-wash/model-options" : null,
|
||||
fetcher
|
||||
);
|
||||
const imageNodeMoreItem = getCanvasToolbarItem(canvasToolbarConfig, "imageNodeMore");
|
||||
const imageAddToChatItem = getCanvasToolbarItem(canvasToolbarConfig, "imageAddToChat");
|
||||
const imagePickObjectItem = getCanvasToolbarItem(canvasToolbarConfig, "imagePickObject");
|
||||
@ -1029,6 +1222,7 @@ export function ImageNode({
|
||||
imageMultiAngleEnabled ||
|
||||
imageGenerate3dModelEnabled ||
|
||||
imageGenerate3dWorldEnabled ||
|
||||
imageAiWashQuickEnabled ||
|
||||
imageLayerSplitEnabled;
|
||||
const hasSelectedImageSecondaryToolbarActions =
|
||||
hasSelectedImageMoreMenu || imageDownloadEnabled;
|
||||
@ -1162,6 +1356,15 @@ export function ImageNode({
|
||||
const [isAddingToChat, setIsAddingToChat] = useState(false);
|
||||
const [isCompareMode, setIsCompareMode] = useState(false);
|
||||
const [compareSplitPercent, setCompareSplitPercent] = useState(50);
|
||||
const [aiWashQuickPanelOpen, setAiWashQuickPanelOpen] = useState(false);
|
||||
const [aiWashQuickPresetId, setAiWashQuickPresetId] = useState("");
|
||||
const [aiWashQuickModelId, setAiWashQuickModelId] = useState("");
|
||||
const [aiWashQuickLevels, setAiWashQuickLevels] = useState<
|
||||
Record<string, number>
|
||||
>({});
|
||||
const [isSubmittingAiWashQuick, setIsSubmittingAiWashQuick] = useState(false);
|
||||
const [aiWashQuickStatus, setAiWashQuickStatus] =
|
||||
useState<AiWashResultStatus>("idle");
|
||||
const [toolbarSize, setToolbarSize] = useState({ width: 0, height: 0 });
|
||||
const [boxCutoutToolbarSize, setBoxCutoutToolbarSize] = useState({
|
||||
width: 0,
|
||||
@ -1207,12 +1410,128 @@ export function ImageNode({
|
||||
const hasActiveImageAdjustments = !isDefaultImageAdjustments(
|
||||
activeImageAdjustments
|
||||
);
|
||||
const selectedAiWashQuickPreset = useMemo(
|
||||
() =>
|
||||
aiWashQuickPresets.find((preset) => preset.id === aiWashQuickPresetId) ||
|
||||
aiWashQuickPresets.find((preset) => preset.isDefault) ||
|
||||
aiWashQuickPresets[0] ||
|
||||
null,
|
||||
[aiWashQuickPresetId, aiWashQuickPresets]
|
||||
);
|
||||
const aiWashQuickControls = useMemo(
|
||||
() =>
|
||||
AI_WASH_CONTROL_ORDER.map((controlId) =>
|
||||
getAiWashPresetControl(selectedAiWashQuickPreset, controlId)
|
||||
).filter(Boolean) as AiWashControl[],
|
||||
[selectedAiWashQuickPreset]
|
||||
);
|
||||
const aiWashQuickSelectableModels = useMemo(
|
||||
() => aiWashModelOptionsPayload?.models || [],
|
||||
[aiWashModelOptionsPayload?.models]
|
||||
);
|
||||
const aiWashQuickLockedModels = useMemo(
|
||||
() => aiWashConfigPayload?.lockedModels || [],
|
||||
[aiWashConfigPayload?.lockedModels]
|
||||
);
|
||||
const aiWashQuickModelOptionByRef = useMemo(() => {
|
||||
const map = new Map<string, AiWashModelOption>();
|
||||
[...aiWashQuickLockedModels, ...aiWashQuickSelectableModels].forEach((model) => {
|
||||
map.set(model.id, model);
|
||||
map.set(model.modelId, model);
|
||||
});
|
||||
return map;
|
||||
}, [aiWashQuickLockedModels, aiWashQuickSelectableModels]);
|
||||
const aiWashQuickAdminModelRef = useMemo(
|
||||
() =>
|
||||
selectedAiWashQuickPreset
|
||||
? resolveAiWashQuickAdminModelId({
|
||||
preset: selectedAiWashQuickPreset,
|
||||
selectedLevelIndexByControl: aiWashQuickLevels,
|
||||
})
|
||||
: "",
|
||||
[aiWashQuickLevels, selectedAiWashQuickPreset]
|
||||
);
|
||||
const shouldShowAiWashQuickModelSelector = !aiWashQuickAdminModelRef;
|
||||
const effectiveAiWashQuickModelRef =
|
||||
aiWashQuickAdminModelRef || String(aiWashQuickModelId || "").trim();
|
||||
const selectedAiWashQuickModel = useMemo(
|
||||
() =>
|
||||
aiWashQuickModelOptionByRef.get(effectiveAiWashQuickModelRef) ||
|
||||
aiWashQuickModelOptionByRef.get(aiWashQuickModelId) ||
|
||||
null,
|
||||
[aiWashQuickModelId, aiWashQuickModelOptionByRef, effectiveAiWashQuickModelRef]
|
||||
);
|
||||
const aiWashQuickPrompt = useMemo(
|
||||
() =>
|
||||
selectedAiWashQuickPreset
|
||||
? composeAiWashQuickPrompt({
|
||||
preset: selectedAiWashQuickPreset,
|
||||
selectedLevelIndexByControl: aiWashQuickLevels,
|
||||
})
|
||||
: "",
|
||||
[aiWashQuickLevels, selectedAiWashQuickPreset]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!batchProcessingEnabled) {
|
||||
setSceneFusionDialogOpen(false);
|
||||
}
|
||||
}, [batchProcessingEnabled]);
|
||||
useEffect(() => {
|
||||
if (aiWashQuickPresets.length === 0) {
|
||||
setAiWashQuickPresetId("");
|
||||
return;
|
||||
}
|
||||
setAiWashQuickPresetId((current) => {
|
||||
if (aiWashQuickPresets.some((preset) => preset.id === current)) {
|
||||
return current;
|
||||
}
|
||||
return (
|
||||
aiWashQuickPresets.find((preset) => preset.isDefault)?.id ||
|
||||
aiWashQuickPresets[0]?.id ||
|
||||
""
|
||||
);
|
||||
});
|
||||
}, [aiWashQuickPresets]);
|
||||
useEffect(() => {
|
||||
if (!selectedAiWashQuickPreset) {
|
||||
setAiWashQuickLevels({});
|
||||
return;
|
||||
}
|
||||
setAiWashQuickLevels(
|
||||
AI_WASH_CONTROL_ORDER.reduce<Record<string, number>>(
|
||||
(accumulator, controlId) => {
|
||||
accumulator[controlId] =
|
||||
getAiWashPresetControl(selectedAiWashQuickPreset, controlId)
|
||||
?.defaultLevel ?? 0;
|
||||
return accumulator;
|
||||
},
|
||||
{}
|
||||
)
|
||||
);
|
||||
setAiWashQuickStatus("idle");
|
||||
}, [selectedAiWashQuickPreset]);
|
||||
useEffect(() => {
|
||||
const fallbackModelId =
|
||||
String(aiWashModelOptionsPayload?.defaultModelId || "").trim() ||
|
||||
aiWashQuickSelectableModels.find((model) => model.isDefault)?.id ||
|
||||
aiWashQuickSelectableModels[0]?.id ||
|
||||
"";
|
||||
setAiWashQuickModelId((current) => {
|
||||
if (
|
||||
current &&
|
||||
aiWashQuickSelectableModels.some((model) => model.id === current)
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
return fallbackModelId;
|
||||
});
|
||||
}, [aiWashModelOptionsPayload?.defaultModelId, aiWashQuickSelectableModels]);
|
||||
useEffect(() => {
|
||||
if (!imageAiWashQuickEnabled || !isSelected) {
|
||||
setAiWashQuickPanelOpen(false);
|
||||
}
|
||||
}, [imageAiWashQuickEnabled, isSelected]);
|
||||
useEffect(() => {
|
||||
if (!imageAdjustPanelOpen) return;
|
||||
setImageAdjustmentsDraft(storedImageAdjustments);
|
||||
@ -4013,6 +4332,44 @@ export function ImageNode({
|
||||
return { top: 0, left: 0, maxWidth: 480 };
|
||||
};
|
||||
|
||||
const getAiWashQuickPanelPosition = (
|
||||
anchor: { top: number; left: number; maxWidth: number }
|
||||
) => {
|
||||
if (typeof window === "undefined") {
|
||||
return { top: 0, left: 0, width: 360, maxHeight: 520 };
|
||||
}
|
||||
|
||||
const panelWidth = 360;
|
||||
const panelHeight = 540;
|
||||
const gap = 10;
|
||||
const toolbarHeight = Math.max(
|
||||
42,
|
||||
(toolbarSize.height || 60) * FLOATING_TOOLBAR_SCALE
|
||||
);
|
||||
const { viewportPadding, safeBottom, safeLeft, safeRight, maxTop } =
|
||||
getFloatingViewportBounds(panelHeight);
|
||||
const width = Math.min(panelWidth, Math.max(260, safeRight - safeLeft));
|
||||
|
||||
let top = anchor.top + toolbarHeight + gap;
|
||||
if (top + panelHeight > safeBottom) {
|
||||
top = anchor.top - panelHeight - gap;
|
||||
}
|
||||
top = Math.min(Math.max(top, viewportPadding), maxTop);
|
||||
|
||||
const preferredLeft = anchor.left;
|
||||
const left = Math.min(
|
||||
Math.max(preferredLeft, safeLeft),
|
||||
Math.max(safeLeft, safeRight - width)
|
||||
);
|
||||
|
||||
return {
|
||||
top,
|
||||
left,
|
||||
width,
|
||||
maxHeight: Math.max(260, Math.min(panelHeight, safeBottom - top)),
|
||||
};
|
||||
};
|
||||
|
||||
const getBoxCutoutToolbarPosition = () => {
|
||||
if (containerRef.current && typeof window !== "undefined") {
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
@ -5204,6 +5561,195 @@ export function ImageNode({
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmitAiWashQuick = useCallback(async () => {
|
||||
if (!data.src) {
|
||||
toast.error("请先上传图片");
|
||||
return;
|
||||
}
|
||||
if (isCanvasImageUploadPending(node) || !isHttpImageUrl(data.src)) {
|
||||
toast.error("图片仍在本地,上传完成后再进行 AI洗图");
|
||||
return;
|
||||
}
|
||||
if (!aiWashQuickConfig.enabled) {
|
||||
toast.error("AI洗图当前未开启,请先在系统设置中启用");
|
||||
return;
|
||||
}
|
||||
if (!selectedAiWashQuickPreset) {
|
||||
toast.error("当前没有可执行的 AI洗图预设,请联系管理员完成配置");
|
||||
return;
|
||||
}
|
||||
if (!effectiveAiWashQuickModelRef) {
|
||||
toast.error("请先选择一个可用的生图模型");
|
||||
return;
|
||||
}
|
||||
if (isProcessing || isSubmittingAiWashQuick) {
|
||||
toast.info("正在处理中,请稍候...");
|
||||
return;
|
||||
}
|
||||
|
||||
setImageAdjustPanelOpen(false);
|
||||
setCropPanelOpen(false);
|
||||
setCropInteraction(null);
|
||||
setFlipRotatePanelOpen(false);
|
||||
setAnglePanelOpen(false);
|
||||
setTextEditPanelOpen(false);
|
||||
setNotePanelOpen(false);
|
||||
resetBoxCutoutState();
|
||||
resetLocalEditState();
|
||||
setIsProcessing(true);
|
||||
setIsSubmittingAiWashQuick(true);
|
||||
setAiWashQuickStatus("submitting");
|
||||
|
||||
const actionName = aiWashQuickModeLabel || "AI洗图";
|
||||
const resultTitle = `${displayTitle}-${selectedAiWashQuickPreset.title}`;
|
||||
const newNodeId = addNode({
|
||||
type: "image",
|
||||
position: {
|
||||
x: node.position.x + node.size.width + 20,
|
||||
y: node.position.y,
|
||||
},
|
||||
size: { width: node.size.width, height: node.size.height },
|
||||
data: {
|
||||
src: "",
|
||||
status: "generating",
|
||||
prompt: `${actionName}处理中...`,
|
||||
title: resultTitle,
|
||||
originalSrc: data.src,
|
||||
metadata: {
|
||||
editMode: "ai-wash",
|
||||
sourceNodeId: node.id,
|
||||
aiWashPresetId: selectedAiWashQuickPreset.id,
|
||||
aiWashPresetTitle: selectedAiWashQuickPreset.title,
|
||||
aiWashLevels: aiWashQuickLevels,
|
||||
},
|
||||
},
|
||||
connections: [node.id],
|
||||
});
|
||||
selectNode(newNodeId);
|
||||
|
||||
try {
|
||||
const submitResponse = await fetch("/api/ai-wash/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
prompt: aiWashQuickPrompt,
|
||||
referenceImage: data.src,
|
||||
selectedModelId: effectiveAiWashQuickModelRef,
|
||||
imageSize: aiWashQuickConfig.imageSize,
|
||||
promptExtend: aiWashQuickConfig.promptExtend,
|
||||
title: selectedAiWashQuickPreset.title,
|
||||
}),
|
||||
});
|
||||
const submitPayload = await submitResponse.json().catch(() => ({}));
|
||||
|
||||
if (
|
||||
!submitResponse.ok ||
|
||||
submitPayload?.success !== true ||
|
||||
!submitPayload?.taskId
|
||||
) {
|
||||
throw new Error(submitPayload?.error || "AI洗图提交失败");
|
||||
}
|
||||
|
||||
setAiWashQuickStatus("running");
|
||||
updateNode(newNodeId, {
|
||||
data: {
|
||||
src: "",
|
||||
status: "generating",
|
||||
prompt: aiWashQuickPrompt || `${actionName}处理中...`,
|
||||
title: resultTitle,
|
||||
originalSrc: data.src,
|
||||
metadata: {
|
||||
editMode: "ai-wash",
|
||||
sourceNodeId: node.id,
|
||||
taskId: submitPayload.taskId,
|
||||
aiWashPresetId: selectedAiWashQuickPreset.id,
|
||||
aiWashPresetTitle: selectedAiWashQuickPreset.title,
|
||||
aiWashLevels: aiWashQuickLevels,
|
||||
aiWashImageSize: aiWashQuickConfig.imageSize,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const completed = await pollAiWashQuickTask(submitPayload.taskId);
|
||||
const resolvedModelLabel =
|
||||
completed.modelId ||
|
||||
selectedAiWashQuickModel?.displayName ||
|
||||
selectedAiWashQuickModel?.modelId ||
|
||||
effectiveAiWashQuickModelRef;
|
||||
|
||||
updateNode(newNodeId, {
|
||||
data: {
|
||||
src: completed.imageUrl,
|
||||
status: "completed",
|
||||
prompt: completed.prompt || aiWashQuickPrompt || `${actionName}结果`,
|
||||
title: resultTitle,
|
||||
originalSrc: data.src,
|
||||
model: resolvedModelLabel,
|
||||
metadata: {
|
||||
editMode: "ai-wash",
|
||||
sourceNodeId: node.id,
|
||||
taskId: submitPayload.taskId,
|
||||
thumbnailUrl: completed.thumbnailUrl,
|
||||
aiWashPresetId: selectedAiWashQuickPreset.id,
|
||||
aiWashPresetTitle: selectedAiWashQuickPreset.title,
|
||||
aiWashLevels: aiWashQuickLevels,
|
||||
aiWashImageSize: aiWashQuickConfig.imageSize,
|
||||
modelId: resolvedModelLabel,
|
||||
},
|
||||
},
|
||||
});
|
||||
setAiWashQuickStatus("succeeded");
|
||||
setAiWashQuickPanelOpen(false);
|
||||
toast.success("AI洗图结果已生成到画布");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "AI洗图失败";
|
||||
console.error("[ImageNode] AI洗图快捷生成失败:", error);
|
||||
updateNode(newNodeId, {
|
||||
data: {
|
||||
src: "",
|
||||
status: "failed",
|
||||
prompt: `${actionName}失败`,
|
||||
title: `${resultTitle}-失败`,
|
||||
originalSrc: data.src,
|
||||
metadata: {
|
||||
editMode: "ai-wash",
|
||||
sourceNodeId: node.id,
|
||||
error: message,
|
||||
aiWashPresetId: selectedAiWashQuickPreset.id,
|
||||
aiWashPresetTitle: selectedAiWashQuickPreset.title,
|
||||
aiWashLevels: aiWashQuickLevels,
|
||||
},
|
||||
},
|
||||
});
|
||||
setAiWashQuickStatus("failed");
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
setIsSubmittingAiWashQuick(false);
|
||||
}
|
||||
}, [
|
||||
addNode,
|
||||
aiWashQuickConfig.enabled,
|
||||
aiWashQuickConfig.imageSize,
|
||||
aiWashQuickConfig.promptExtend,
|
||||
aiWashQuickLevels,
|
||||
aiWashQuickModeLabel,
|
||||
aiWashQuickPrompt,
|
||||
data.src,
|
||||
displayTitle,
|
||||
effectiveAiWashQuickModelRef,
|
||||
isProcessing,
|
||||
isSubmittingAiWashQuick,
|
||||
node,
|
||||
resetBoxCutoutState,
|
||||
resetLocalEditState,
|
||||
selectNode,
|
||||
selectedAiWashQuickModel?.displayName,
|
||||
selectedAiWashQuickModel?.modelId,
|
||||
selectedAiWashQuickPreset,
|
||||
updateNode,
|
||||
]);
|
||||
|
||||
// 处理图片编辑操作 - 集成我们现有的 API
|
||||
const handleAction = async (action: string) => {
|
||||
if (!data.src) {
|
||||
@ -5221,6 +5767,20 @@ export function ImageNode({
|
||||
toast.info("正在处理中,请稍候...");
|
||||
return;
|
||||
}
|
||||
if (action === "ai-wash") {
|
||||
setCropPanelOpen(false);
|
||||
setCropInteraction(null);
|
||||
setImageAdjustPanelOpen(false);
|
||||
setFlipRotatePanelOpen(false);
|
||||
setAnglePanelOpen(false);
|
||||
setTextEditPanelOpen(false);
|
||||
setNotePanelOpen(false);
|
||||
resetBoxCutoutState();
|
||||
resetLocalEditState();
|
||||
setAiWashQuickPanelOpen((current) => !current);
|
||||
return;
|
||||
}
|
||||
setAiWashQuickPanelOpen(false);
|
||||
if (action !== "local-edit") {
|
||||
resetLocalEditState();
|
||||
}
|
||||
@ -6897,6 +7457,7 @@ export function ImageNode({
|
||||
const cropPanelPosition = getCropPanelPosition();
|
||||
const imageAdjustPanelPosition = getImageAdjustPanelPosition();
|
||||
const toolbarPosition = getToolbarPosition();
|
||||
const aiWashQuickPanelPosition = getAiWashQuickPanelPosition(toolbarPosition);
|
||||
const boxCutoutToolbarPosition = getBoxCutoutToolbarPosition();
|
||||
const flipRotatePanelPosition = getFlipRotatePanelPosition();
|
||||
const anglePanelPosition = getAnglePanelPosition();
|
||||
@ -8076,6 +8637,252 @@ export function ImageNode({
|
||||
document.body
|
||||
)}
|
||||
|
||||
{aiWashQuickPanelOpen &&
|
||||
imageAiWashQuickEnabled &&
|
||||
isSelected &&
|
||||
!isMultiSelected &&
|
||||
data.src &&
|
||||
!isEditingTitle &&
|
||||
!sceneFusionDialogOpen &&
|
||||
!promptDialogOpen &&
|
||||
!threedGenerationDialogMode &&
|
||||
!notePanelOpen &&
|
||||
!boxCutoutMode &&
|
||||
!localEditMode &&
|
||||
!cropPanelOpen &&
|
||||
typeof document !== "undefined" &&
|
||||
createPortal(
|
||||
<div
|
||||
className="fixed z-[10000] rounded-[28px] border border-slate-200 bg-white/96 p-3.5 text-slate-950 shadow-[0_20px_54px_rgba(15,23,42,0.16)] backdrop-blur-xl dark:border-white/12 dark:bg-slate-950/94 dark:text-white"
|
||||
data-canvas-no-drag="true"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
style={{
|
||||
top: aiWashQuickPanelPosition.top,
|
||||
left: aiWashQuickPanelPosition.left,
|
||||
width: aiWashQuickPanelPosition.width,
|
||||
maxHeight: aiWashQuickPanelPosition.maxHeight,
|
||||
overflowY: "auto",
|
||||
scrollbarWidth: "thin",
|
||||
}}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[16px] border border-slate-200 bg-white shadow-[0_8px_18px_rgba(15,23,42,0.06)] dark:border-white/12 dark:bg-white/8">
|
||||
<CanvasToolbarIcon
|
||||
className="h-5 w-5"
|
||||
fallback={Sparkles}
|
||||
iconUrl={aiWashQuickConfig.modeIconUrl}
|
||||
/>
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-semibold text-[16px] tracking-[-0.03em]">
|
||||
{aiWashQuickModeLabel}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[12px] text-slate-500 dark:text-white/52">
|
||||
选中图片快捷洗图
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-500 transition hover:border-slate-950 hover:text-slate-950 dark:border-white/12 dark:bg-white/8 dark:text-white/60 dark:hover:border-white dark:hover:text-white"
|
||||
onClick={() => setAiWashQuickPanelOpen(false)}
|
||||
title="关闭"
|
||||
type="button"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{aiWashConfigLoading ? (
|
||||
<div className="mb-3 flex items-center gap-2 rounded-[18px] border border-slate-200 bg-slate-50 px-3 py-2 text-[13px] text-slate-500 dark:border-white/10 dark:bg-white/6 dark:text-white/58">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
正在加载 AI洗图 配置...
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-[22px] border border-slate-200 bg-slate-50/70 p-3 dark:border-white/10 dark:bg-white/[0.04]">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-[14px] font-semibold">
|
||||
<SlidersHorizontal className="h-4 w-4" />
|
||||
渲染目标
|
||||
</div>
|
||||
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[11px] text-slate-500 dark:border-white/10 dark:bg-white/8 dark:text-white/58">
|
||||
{getAiWashStatusLabel(aiWashQuickStatus)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{aiWashQuickPresets.map((preset) => {
|
||||
const isActive = selectedAiWashQuickPreset?.id === preset.id;
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"w-full rounded-[16px] border px-3 py-2.5 text-left transition",
|
||||
isActive
|
||||
? "border-slate-950 bg-slate-950 text-white shadow-[0_10px_22px_rgba(15,23,42,0.16)] dark:border-white dark:bg-white dark:text-slate-950"
|
||||
: "border-slate-200 bg-white text-slate-600 hover:border-slate-950 hover:text-slate-950 dark:border-white/10 dark:bg-white/7 dark:text-white/64 dark:hover:border-white dark:hover:text-white"
|
||||
)}
|
||||
key={preset.id}
|
||||
onClick={() => {
|
||||
setAiWashQuickPresetId(preset.id);
|
||||
setAiWashQuickStatus("idle");
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<div className="truncate font-semibold text-[13px]">
|
||||
{preset.title}
|
||||
</div>
|
||||
{preset.description ? (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1 line-clamp-2 text-[12px] leading-5",
|
||||
isActive ? "text-white/72 dark:text-slate-700" : "text-slate-500 dark:text-white/46"
|
||||
)}
|
||||
>
|
||||
{preset.description}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[22px] border border-slate-200 bg-white p-3 dark:border-white/10 dark:bg-white/[0.04]">
|
||||
<div className="mb-2 text-[14px] font-semibold">参数设置</div>
|
||||
{shouldShowAiWashQuickModelSelector ? (
|
||||
<div className="mb-3 space-y-1.5">
|
||||
<Label className="text-[12px] text-slate-500 dark:text-white/54">
|
||||
生图模型
|
||||
</Label>
|
||||
<Select
|
||||
onValueChange={setAiWashQuickModelId}
|
||||
value={selectedAiWashQuickModel?.id || aiWashQuickModelId}
|
||||
>
|
||||
<SelectTrigger className="h-10 rounded-[15px] border-slate-200 bg-white text-[13px] shadow-[0_1px_2px_rgba(15,23,42,0.04)] dark:border-white/10 dark:bg-white/8">
|
||||
<SelectValue placeholder="选择生图模型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="border-slate-200 bg-white text-slate-950 dark:border-white/10 dark:bg-slate-950 dark:text-white">
|
||||
{aiWashQuickSelectableModels.map((model) => (
|
||||
<SelectItem
|
||||
disabled={model.selectable === false}
|
||||
key={model.id}
|
||||
value={model.id}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{model.displayName}
|
||||
</span>
|
||||
<span className="shrink-0 text-slate-500 text-xs dark:text-white/52">
|
||||
{model.unitCost > 0
|
||||
? `${model.unitCost} 积分/张`
|
||||
: "未计费"}
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="text-[12px] leading-5 text-slate-500 dark:text-white/50">
|
||||
{selectedAiWashQuickModel
|
||||
? `当前模型:${selectedAiWashQuickModel.displayName} · ${
|
||||
selectedAiWashQuickModel.unitCost > 0
|
||||
? `${selectedAiWashQuickModel.unitCost} 积分/张`
|
||||
: "未设置积分"
|
||||
}`
|
||||
: aiWashModelOptionsLoading
|
||||
? "正在加载可选模型..."
|
||||
: "没有可选模型"}
|
||||
</div>
|
||||
</div>
|
||||
) : selectedAiWashQuickModel ? (
|
||||
<div className="mb-3 rounded-[15px] border border-slate-200 bg-slate-50 px-3 py-2 text-[12px] text-slate-500 dark:border-white/10 dark:bg-white/6 dark:text-white/52">
|
||||
管理员已锁定模型:{selectedAiWashQuickModel.displayName}
|
||||
{selectedAiWashQuickModel.unitCost > 0
|
||||
? ` · ${selectedAiWashQuickModel.unitCost} 积分/张`
|
||||
: ""}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2.5">
|
||||
{aiWashQuickControls.map((control) => {
|
||||
const selectedIndex =
|
||||
aiWashQuickLevels[control.id] ?? control.defaultLevel;
|
||||
const activeLevel =
|
||||
getAiWashControlLevelByIndex(control, selectedIndex) ||
|
||||
control.levels[0] ||
|
||||
null;
|
||||
return (
|
||||
<div
|
||||
className="rounded-[16px] border border-slate-200 bg-slate-50/70 px-3 py-2.5 dark:border-white/10 dark:bg-white/[0.04]"
|
||||
key={control.id}
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="text-[13px] font-semibold">
|
||||
{control.label}
|
||||
</div>
|
||||
<span className="min-w-9 rounded-full border border-slate-200 bg-white px-2 py-0.5 text-center text-[11px] text-slate-600 dark:border-white/10 dark:bg-white/8 dark:text-white/64">
|
||||
{activeLevel?.label || selectedIndex + 1}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
className="w-full"
|
||||
max={Math.max(0, control.levels.length - 1)}
|
||||
min={0}
|
||||
onValueChange={([next]) =>
|
||||
setAiWashQuickLevels((current) => ({
|
||||
...current,
|
||||
[control.id]: Math.round(next ?? 0),
|
||||
}))
|
||||
}
|
||||
rangeClassName="bg-slate-950 dark:bg-white"
|
||||
step={1}
|
||||
thumbClassName="h-3.5 w-3.5 border border-slate-300 bg-white shadow-[0_0_0_3px_rgba(15,23,42,0.06)]"
|
||||
trackClassName="h-1.5 bg-slate-200 dark:bg-white/14"
|
||||
value={[selectedIndex]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{aiWashQuickPrompt ? (
|
||||
<div className="max-h-20 overflow-hidden rounded-[18px] border border-slate-200 bg-slate-50 px-3 py-2 text-[12px] leading-5 text-slate-500 dark:border-white/10 dark:bg-white/[0.04] dark:text-white/48">
|
||||
{aiWashQuickPrompt}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
className="h-11 w-full rounded-[18px] bg-slate-950 text-[14px] font-semibold text-white shadow-[0_12px_26px_rgba(15,23,42,0.16)] hover:bg-slate-900 hover:text-white disabled:bg-slate-200 disabled:text-slate-400 disabled:shadow-none dark:bg-white dark:text-slate-950 dark:hover:bg-slate-100 dark:disabled:bg-white/12 dark:disabled:text-white/30"
|
||||
disabled={
|
||||
isSubmittingAiWashQuick ||
|
||||
!selectedAiWashQuickPreset ||
|
||||
!effectiveAiWashQuickModelRef
|
||||
}
|
||||
onClick={() => void handleSubmitAiWashQuick()}
|
||||
type="button"
|
||||
>
|
||||
{isSubmittingAiWashQuick ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{isSubmittingAiWashQuick
|
||||
? "正在 AI洗图"
|
||||
: `开始 ${aiWashQuickModeLabel}${
|
||||
selectedAiWashQuickModel?.unitCost
|
||||
? ` · ${selectedAiWashQuickModel.unitCost} 积分`
|
||||
: ""
|
||||
}`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{isSelected &&
|
||||
!isMultiSelected &&
|
||||
data.src &&
|
||||
@ -8221,6 +9028,33 @@ export function ImageNode({
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{imageAiWashQuickEnabled ? (
|
||||
<Button
|
||||
className={cn(
|
||||
"h-10 rounded-xl px-3 font-medium text-[13px] transition",
|
||||
aiWashQuickPanelOpen
|
||||
? "bg-slate-950 text-white hover:bg-slate-900 hover:text-white dark:bg-white dark:text-slate-950 dark:hover:bg-slate-100"
|
||||
: "text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||
)}
|
||||
disabled={isProcessing || isSubmittingAiWashQuick}
|
||||
onClick={() => handleAction("ai-wash")}
|
||||
title={aiWashQuickModeLabel}
|
||||
variant="ghost"
|
||||
>
|
||||
{isSubmittingAiWashQuick ? (
|
||||
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<CanvasToolbarIcon
|
||||
active={aiWashQuickPanelOpen}
|
||||
className="mr-1.5 h-4 w-4"
|
||||
fallback={Sparkles}
|
||||
iconUrl={aiWashQuickConfig.modeIconUrl}
|
||||
/>
|
||||
)}
|
||||
{aiWashQuickModeLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{imageRemoveWatermarkEnabled ? (
|
||||
<Button
|
||||
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||
|
||||
@ -912,30 +912,6 @@ export async function renderAgentPsdTemplate(params: {
|
||||
}
|
||||
}
|
||||
|
||||
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)) {
|
||||
@ -954,6 +930,13 @@ export async function renderAgentPsdTemplate(params: {
|
||||
layer.forceClipMask && layer.clipMaskLayerId
|
||||
? layerMap.get(layer.clipMaskLayerId)
|
||||
: null;
|
||||
if (
|
||||
clipMaskLayer &&
|
||||
clipMaskLayer.id !== layer.id &&
|
||||
hiddenLayerIdSet.has(clipMaskLayer.id)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const renderFrameLayer = clipMaskLayer || layer;
|
||||
const renderFrameContentBounds =
|
||||
clipMaskLayer && isAgentPsdImageLayerSlot(clipMaskLayer)
|
||||
@ -1093,14 +1076,6 @@ export async function renderAgentPsdTemplate(params: {
|
||||
});
|
||||
}
|
||||
|
||||
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))
|
||||
@ -1119,11 +1094,9 @@ export async function renderAgentPsdTemplate(params: {
|
||||
|
||||
if (!isAgentPsdTemplateLayerUserEditable(layer)) {
|
||||
if (shouldUseRenderBaseFallback || shouldUseRenderSegments) {
|
||||
await appendAssignedImagesForFrameLayer(layer.id);
|
||||
continue;
|
||||
}
|
||||
await appendStoredLayer(layer, { optional: shouldUseRenderBaseFallback });
|
||||
await appendAssignedImagesForFrameLayer(layer.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -1137,15 +1110,10 @@ export async function renderAgentPsdTemplate(params: {
|
||||
if (isDefaultSourceImageLayer) {
|
||||
await appendRenderBaseOverlay();
|
||||
}
|
||||
await appendAssignedImagesForFrameLayer(layer.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
const renderFrameLayer = getAssignedImageRenderFrameLayer(layer);
|
||||
if (renderFrameLayer.id === layer.id) {
|
||||
await appendAssignedImageLayer(layer);
|
||||
}
|
||||
await appendAssignedImagesForFrameLayer(layer.id);
|
||||
await appendAssignedImageLayer(layer);
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -1169,7 +1137,6 @@ export async function renderAgentPsdTemplate(params: {
|
||||
optional: shouldUseRenderBaseFallback,
|
||||
});
|
||||
}
|
||||
await appendAssignedImagesForFrameLayer(layer.id);
|
||||
continue;
|
||||
}
|
||||
const textAlign = resolveAgentPsdTextLayerEffectiveAlign(layer);
|
||||
@ -1279,7 +1246,6 @@ export async function renderAgentPsdTemplate(params: {
|
||||
height: overlayFrame.height,
|
||||
});
|
||||
if (!didAppend) continue;
|
||||
await appendAssignedImagesForFrameLayer(layer.id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user