Compare commits

..

33 Commits

Author SHA1 Message Date
dc7a1ae0a4 Merge pull request '七牛侧准备 WebP 兼容下载并保留色彩配置' (#81) from codex/qiniu-png-preparation-20261112 into main 2026-11-12 16:44:21 +08:00
a6f79998bd Prepare uploaded WebP downloads in Qiniu with embedded colour profiles 2026-11-12 16:43:52 +08:00
d0653c2d51 Merge pull request '补齐图片直传七牛、CDN 展示和兼容原图下载' (#80) from codex/qiniu-direct-delivery-20261112 into main 2026-11-12 16:21:27 +08:00
f058012bb8 Complete direct Qiniu media upload, display and download paths 2026-11-12 16:20:56 +08:00
8aa9ebd061 Merge pull request '扩图结果直接归档到七牛存储' (#79) from codex/native-expansion-archive-20261112 into main 2026-11-12 15:09:46 +08:00
d32258e13b 扩图结果直接归档到七牛存储 2026-11-12 15:09:33 +08:00
6caf1b5cce Merge pull request '使用98K原生智能扩图,失败时保留明确错误' (#78) from codex/native-image-expansion-20261112 into main 2026-11-12 14:23:29 +08:00
78b111130b 使用98K原生智能扩图并保留完整输出 2026-11-12 14:23:09 +08:00
8fca3b25c5 Merge pull request '支持98K扩图线路替换 fal 上游' (#77) from codex/replace-fal-outpaint-vip98k-20261112 into main 2026-11-12 11:30:10 +08:00
b8799f4804 支持通过98K替换画布扩图上游 2026-11-12 11:25:57 +08:00
d1f9590d05 Merge pull request '避免页面更新打断图片下载' (#76) from codex/preserve-active-image-downloads-20261111 into main 2026-11-11 15:25:55 +08:00
74053490cc 避免页面更新打断图片下载 2026-11-11 15:25:48 +08:00
61b6eba4b4 Merge pull request '显示下载中的图片格式转换进度' (#75) from codex/download-conversion-progress-20261111 into main 2026-11-11 14:59:51 +08:00
bbecafc826 显示下载中的图片格式转换进度 2026-11-11 14:59:46 +08:00
ed3b1b5e8a Merge pull request '画布图片下载自动兼容 Photoshop' (#74) from codex/automatic-photoshop-download-20261111 into main 2026-11-11 14:29:51 +08:00
8f56b5a567 画布图片下载自动兼容 Photoshop 2026-11-11 14:29:42 +08:00
fd8d5404c6 Merge pull request '避免原图下载查询绕过七牛缓存' (#73) from codex/original-download-metadata-cache-20261111 into main 2026-11-11 13:50:43 +08:00
09db16f74c 避免原图下载查询绕过七牛缓存 2026-11-11 13:50:37 +08:00
70621f4261 Merge pull request '加快画布原图下载和 Agent 图片显示' (#72) from codex/original-delivery-speed-20261111 into main 2026-11-11 13:35:47 +08:00
5a95e7da49 加快画布原图下载和 Agent 图片显示 2026-11-11 13:35:37 +08:00
8cf113b554 Merge pull request '减少七牛 CDN 图片预览的重复回源' (#71) from codex/qiniu-cdn-preview-20261111 into main 2026-11-11 11:34:57 +08:00
5fc3f1bb11 减少七牛 CDN 图片预览的重复回源 2026-11-11 11:34:50 +08:00
1342bf384c Merge pull request '修复局部编辑合成图保存并覆盖直传原图' (#70) from codex/qiniu-local-edit-save-20261111 into main 2026-11-11 10:17:46 +08:00
759ee6af58 修复局部编辑合成图保存并覆盖直传原图 2026-11-11 10:17:27 +08:00
54eb35e21c Merge pull request '去水印直接复用已存七牛的图片' (#69) from codex/watermark-qiniu-direct-20261111 into main 2026-11-11 09:31:27 +08:00
f157161ad9 去水印直接复用已存七牛的图片 2026-11-11 09:31:22 +08:00
eb9ee0ce1e Merge pull request '对齐详情替换模型并支持整套图片' (#68) from codex/detail-replacement-full-set into main 2026-11-10 17:02:29 +08:00
2c30c9d7f9 对齐详情替换模型并支持整套图片 2026-11-10 17:02:20 +08:00
8428886a1d Merge pull request '品牌加载后再按需加载风格模板' (#67) from codex/stylization-defer-templates-20261110 into main 2026-11-10 15:17:31 +08:00
24cf77c9c2 品牌加载后再按需加载风格模板 2026-11-10 15:17:24 +08:00
9cbc32bb5d Merge pull request '改善画布扩图四周衔接' (#66) from codex/canvas-outpaint-20261110 into main 2026-11-10 15:12:36 +08:00
7ddaeb12a0 改善画布扩图四周衔接 2026-11-10 15:12:30 +08:00
5f24743bdb Merge pull request '分开加载风格化品牌与模板' (#65) from codex/stylization-load-20261110 into main 2026-11-10 15:04:19 +08:00
50 changed files with 1499 additions and 423 deletions

View File

@ -2378,9 +2378,9 @@ export function LocalAiImagePipelineSections({
{
label:
extendConfig.provider === "fal"
? "Bria 扩图服务"
? "fal · FLUX 2 Pro 扩图"
: extendConfig.provider === "vip98k"
? "VIP98K 服务商"
? `98K · ${extendConfig.modelId || "image-expansion"}`
: "阿里云服务商",
tone: "enabled",
},
@ -2395,11 +2395,11 @@ export function LocalAiImagePipelineSections({
>
{extendConfig.provider === "fal" ? (
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
使用 Bria 生成新增区域并保留原图主体和文字。密钥从已启用的 fal 官方服务商读取。
使用 FLUX 2 Pro Outpaint 扩展图片。密钥从已启用的 fal 官方服务商读取。
</div>
) : extendConfig.provider === "vip98k" ? (
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
使用 VIP98K 的 image-expansion 模型。API Key 从“AI 模型设置”中的
使用 98K 的 {extendConfig.modelId || "image-expansion"} 模型。API Key 从“AI 模型设置”中的
VIP98K 服务商读取,仅在这里维护扩展比例。
</div>
) : (

View File

@ -275,6 +275,8 @@ export type EnhanceConfig = {
export type ExtendConfig = {
provider: "vip98k" | "dashscope" | "fal";
modelId: string;
routeOptionId: string;
xScale: string;
yScale: string;
};

View File

@ -2577,6 +2577,8 @@ export function AdminSettingsClient() {
// 图像拓展API配置状态
const [extendConfig, setExtendConfig] = useState({
provider: "dashscope" as "vip98k" | "dashscope" | "fal",
modelId: "",
routeOptionId: "",
xScale: "1.5", // X轴放大倍数
yScale: "1.5", // Y轴放大倍数
});
@ -2892,11 +2894,15 @@ export function AdminSettingsClient() {
if (extendConfigData?.setting?.value) {
const config = extendConfigData.setting.value as {
provider?: "vip98k" | "dashscope" | "fal";
modelId?: string;
routeOptionId?: string;
xScale?: number;
yScale?: number;
};
setExtendConfig({
provider: config.provider === "vip98k" || config.provider === "fal" ? config.provider : "dashscope",
modelId: config.modelId || "",
routeOptionId: config.routeOptionId || "",
xScale: config.xScale ? String(config.xScale) : "1.5",
yScale: config.yScale ? String(config.yScale) : "1.5",
});
@ -5505,6 +5511,8 @@ export function AdminSettingsClient() {
key: "extend_api_config",
value: {
provider: extendConfig.provider,
modelId: extendConfig.modelId,
routeOptionId: extendConfig.routeOptionId,
xScale,
yScale,
},

View File

@ -4,7 +4,8 @@ import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service";
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
import {
DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY,
normalizeDetailReplacementImageModelConfig,
findConfiguredDetailReplacementImageModel,
isDetailReplacementImageModel,
} from "@/lib/ai/detail-replacement-image-model-config";
import {
compositeReplacementWithReferenceLocks,
@ -29,7 +30,6 @@ import {
} from "@/lib/db/queries";
import { uploadFile } from "@/lib/storage/service";
import { bufferToArrayBuffer } from "./persistence";
import { isVideoProviderType } from "./video-models";
import type { AgentPresetMessageImagePart } from "./agent-scene-workflow";
import type { PostRequestBody } from "../schema";
@ -2161,22 +2161,10 @@ export async function resolveDirectReplacementModelRef(params: {
models?: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>;
}) {
const models = params.models || (await getEnabledAiModelsWithProvider(false));
const imageModels = models.filter(
({ model, provider }) =>
model.supportsImageGen &&
!model.isSubModel &&
!isVideoProviderType(provider?.type, model),
);
const configuredModelSetting = await getSystemSetting({
key: DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY,
}).catch(() => null);
const configuredModelId = normalizeDetailReplacementImageModelConfig(
configuredModelSetting?.value,
).modelId;
const configuredModel = imageModels.find(
({ model }) =>
model.id === configuredModelId || model.modelId === configuredModelId,
);
const configuredModel = findConfiguredDetailReplacementImageModel(models, configuredModelSetting?.value);
if (configuredModel) {
return configuredModel.model.id;
}
@ -2190,6 +2178,7 @@ export async function resolveDirectReplacementModelRef(params: {
return routedModel;
}
const imageModels = models.filter(isDetailReplacementImageModel);
const preferred =
imageModels.find(({ model }) => model.isDefault) ||
imageModels.find(({ provider }) => String(provider?.type || "").includes("image")) ||

View File

@ -10,6 +10,10 @@ import {
} from "@/lib/db/queries";
import { getAiModelFrontendAvailability } from "@/lib/ai/model-frontend-availability";
import { buildVideoToolCapability } from "@/lib/ai/video-tool-capability";
import {
DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY,
findConfiguredDetailReplacementImageModel,
} from "@/lib/ai/detail-replacement-image-model-config";
import {
isImageGenerationModelDescriptor,
isVideoModelDescriptor,
@ -157,10 +161,17 @@ export async function GET(request: Request) {
: await getEnabledAiModelsWithProvider(false, true);
const agentModelWithProvider = await getAgentAiModelWithProvider();
const defaultModel = await getDefaultAiModel();
const [siteSettings, agentWorkflowUiConfigSetting] = await Promise.all([
const [siteSettings, agentWorkflowUiConfigSetting, detailReplacementModelSetting] = await Promise.all([
getSiteSettings(),
getSystemSetting({ key: AGENT_WORKFLOW_UI_CONFIG_KEY }).catch(() => null),
getSystemSetting({ key: DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY }).catch(() => null),
]);
const detailReplacementModel = findConfiguredDetailReplacementImageModel(
includeUnavailable ? modelsWithProvider.filter(({ model, provider }) =>
provider.isEnabled && model.isEnabled && getAiModelFrontendAvailability(model).selectable !== false
) : modelsWithProvider,
detailReplacementModelSetting?.value,
);
type ModelWithProvider = (typeof modelsWithProvider)[number];
const serializeModel = ({ model, provider }: ModelWithProvider) => ({
id: model.id,
@ -309,6 +320,7 @@ export async function GET(request: Request) {
: null,
agentWorkflowDefaultModelId: agentWorkflowDefaultModel?.model.id || null,
agentWorkflowConfiguredModels,
detailReplacementModel: detailReplacementModel ? serializeModel(detailReplacementModel) : null,
agentModel: agentModelWithProvider
? {
id: agentModelWithProvider.model.id,

View File

@ -4,6 +4,7 @@ import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
import { canvasImageTask, canvasProject, message } from "@/lib/db/schema";
import { extractAgentProjectStateFromCanvasData, isAgentProjectRow } from "@/lib/agents/agent-project-compat";
import { MAX_DETAIL_REPLACEMENT_REFERENCES } from "@/lib/agents/mode-workspace-state";
import {
createCanvasImageTask,
enqueueCanvasImageTask,
@ -146,6 +147,14 @@ export async function POST(request: Request) {
.map((item) => [String(item.id), String(item.imageUrl)]),
);
const requested = Array.isArray(body.references) ? body.references : [];
if (requested.length === 0) {
return NextResponse.json({ error: "请先选择详情参考图" }, { status: 400 });
}
if (requested.length > MAX_DETAIL_REPLACEMENT_REFERENCES) {
return NextResponse.json({
error: `本次选中了 ${requested.length} 张详情参考图,一次最多替换 ${MAX_DETAIL_REPLACEMENT_REFERENCES} 张,请减少本次选择后重试`,
}, { status: 400 });
}
const references: DetailReplacementBatchReference[] = requested.map((raw: unknown) => {
const item = raw && typeof raw === "object" ? raw as Record<string, unknown> : {};
return {
@ -160,7 +169,6 @@ export async function POST(request: Request) {
};
});
if (
references.length === 0 || references.length > 40 ||
new Set(references.map((item) => item.id)).size !== references.length ||
references.some((item) => !item.id || knownReferences.get(item.id) !== item.imageUrl)
) {
@ -187,6 +195,12 @@ export async function POST(request: Request) {
if (!model || model === "auto" || !models.some((entry) => entry.model.id === model)) {
return NextResponse.json({ error: "详情替换模型不可用,请检查模型配置" }, { status: 400 });
}
if (typeof body.expectedModelId === "string" && body.expectedModelId !== model) {
return NextResponse.json({
code: "DETAIL_REPLACEMENT_MODEL_CHANGED",
error: "详情替换模型已更新,请确认页面显示的模型后重新提交",
}, { status: 409 });
}
const quota = await checkUserImageQuota(session.user.id);
if (!quota.allowed) {
return NextResponse.json({ error: "本月图片生成配额已用完" }, { status: 429 });

View File

@ -976,7 +976,8 @@ export async function POST(request: NextRequest) {
const annotatedFilename = `canvas-local-edit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.png`;
const annotatedUpload = await uploadFile(
annotatedFilename,
toArrayBuffer(annotatedBuffer)
toArrayBuffer(annotatedBuffer),
{ preferQiniuForm: true }
);
const prompt = buildLocalEditPrompt(regions);
const manualRegionReferenceImageUrls = regions.flatMap((region) =>

View File

@ -133,15 +133,13 @@ function buildImageResponse(
etag: string;
vary?: string;
cacheStatus?: "HIT" | "MISS";
cacheControl?: string;
attachment?: boolean;
}
) {
const headers: Record<string, string> = {
"Content-Type": params.contentType,
"Content-Length": String(body.length),
ETag: params.etag,
"Cache-Control": params.cacheControl || "public, max-age=31536000, immutable, stale-while-revalidate=86400",
"Cache-Control": "public, max-age=31536000, immutable, stale-while-revalidate=86400",
};
if (params.vary) {
headers.Vary = params.vary;
@ -149,11 +147,6 @@ function buildImageResponse(
if (params.cacheStatus) {
headers["X-Image-Proxy-Cache"] = params.cacheStatus;
}
if (params.attachment) {
headers["Content-Disposition"] = "attachment";
headers["X-Content-Type-Options"] = "nosniff";
headers["Content-Security-Policy"] = "default-src 'none'; sandbox";
}
return new NextResponse(body, {
status: 200,
headers,
@ -338,7 +331,18 @@ export async function GET(request: NextRequest) {
);
}
if (!original && isAlibabaModelImageUrl(target.toString())) {
if (!original && !forceProxy && isBrowserImageRequest(request)) {
const direct = buildImageProxyUrl(target.toString(), {
width: width || undefined, quality: directQuality || undefined,
format: ["webp", "avif", "jpeg", "png"].includes(requestedFormat || "") ? requestedFormat as OutputFormat : undefined,
});
if (process.env.NEXT_PUBLIC_CANVAS_CDN_ORIGIN && /^https:\/\//.test(direct) &&
new URL(direct).origin === new URL(process.env.NEXT_PUBLIC_CANVAS_CDN_ORIGIN).origin) {
return redirectToDirectImage(new URL(direct));
}
}
if (!original && (isAlibabaModelImageUrl(target.toString()) || (!forceProxy && isBrowserImageRequest(request)))) {
try {
const { getArchivedImageUrlMappings } = await import("@/lib/storage/image-archive-db");
const mappings = await getArchivedImageUrlMappings([target.toString()]);
@ -460,20 +464,29 @@ export async function GET(request: NextRequest) {
if (original) return downloadFailure("Source is not an image");
return NextResponse.json({ error: "Upstream is not an image" }, { status: 400 });
}
if (original) {
if (upstreamResponse.status !== 200 || !upstreamResponse.body) {
await upstreamResponse.body?.cancel();
return downloadFailure("源图片传输中断,请重试");
}
const headers = new Headers({
"Content-Type": contentType,
"Content-Disposition": "attachment",
"Cache-Control": "private, no-store",
"X-Content-Type-Options": "nosniff",
"Content-Security-Policy": "default-src 'none'; sandbox",
});
const length = upstreamResponse.headers.get("content-length");
const encoding = upstreamResponse.headers.get("content-encoding");
if (length && (!encoding || encoding.toLowerCase() === "identity")) headers.set("Content-Length", length);
return new NextResponse(upstreamResponse.body, { headers });
}
try {
originalBuffer = Buffer.from(await upstreamResponse.arrayBuffer());
} catch (error) {
return upstreamFailure(error);
}
}
if (original) {
return buildImageResponse(originalBuffer, {
contentType,
etag: `"${crypto.createHash("sha256").update(originalBuffer).digest("hex")}"`,
cacheControl: "private, no-store",
attachment: true,
});
}
const isSvg = contentType.toLowerCase().includes("image/svg");
const isGif = contentType.toLowerCase().includes("image/gif");

View File

@ -6,6 +6,7 @@ import { eq } from "drizzle-orm";
import { uploadFile } from "@/lib/storage/service";
import {
createMediaAssetRelation,
getDefaultStorageConfig,
getPointsSystemConfig,
getMediaAssetsByUserAndUrls,
upsertMediaAsset,
@ -21,6 +22,27 @@ export const maxDuration = 600;
const WATERMARK_SYNC_WAIT_TIMEOUT_MS = 20_000;
const WATERMARK_POLL_INTERVAL_MS = 1200;
async function resolveStoredQiniuImageUrl(source: string): Promise<string | null> {
const config = await getDefaultStorageConfig().catch(() => null);
if (config?.type !== "qiniu-kodo" || !config.isEnabled || !config.publicUrl) return null;
try {
const base = new URL(config.publicUrl);
const url = new URL(source);
const prefix = base.pathname.replace(/\/$/, "") + "/";
if (base.protocol !== "https:" || url.origin !== base.origin ||
url.username || url.password || url.search || url.hash ||
!url.pathname.startsWith(prefix)) return null;
const objectKey = url.pathname.slice(prefix.length);
return /^uploads\/(?:direct\/[0-9a-f-]{36}\/)?[\w.-]+$/i.test(objectKey)
? url.toString()
: null;
} catch {
return null;
}
}
// 获取去水印API配置
async function getWatermarkRemoveConfig() {
try {
@ -131,13 +153,16 @@ async function persistWatermarkResult(params: {
processing_time?: number;
}> {
try {
const imageResponse = await fetch(params.processedImageUrl);
if (!imageResponse.ok) {
throw new Error("下载处理后的图片失败");
let resultUrl = await resolveStoredQiniuImageUrl(params.processedImageUrl);
if (!resultUrl) {
const imageResponse = await fetch(params.processedImageUrl);
if (!imageResponse.ok) {
throw new Error("下载处理后的图片失败");
}
const imageBuffer = await imageResponse.arrayBuffer();
const filename = `watermark-removed-${Date.now()}.png`;
resultUrl = (await uploadFile(filename, imageBuffer)).url;
}
const imageBuffer = await imageResponse.arrayBuffer();
const filename = `watermark-removed-${Date.now()}.png`;
const uploadResult = await uploadFile(filename, imageBuffer);
let saved = false;
let saveError: unknown = null;
@ -150,8 +175,8 @@ async function persistWatermarkResult(params: {
try {
([savedImage] = await db.insert(generatedImage).values({
userId: params.userId,
thumbnailUrl: uploadResult.url,
originalUrl: uploadResult.url,
thumbnailUrl: resultUrl,
originalUrl: resultUrl,
imageSize: "2K",
prompt: "去水印处理",
modelId: "watermark-remove",
@ -184,9 +209,9 @@ async function persistWatermarkResult(params: {
mediaType: "image",
sourceType: "edited",
status: "ready",
originalUrl: uploadResult.url,
previewUrl: uploadResult.url,
thumbnailUrl: uploadResult.url,
originalUrl: resultUrl,
previewUrl: resultUrl,
thumbnailUrl: resultUrl,
displayName: "去水印处理",
prompt: "去水印处理",
modelId: "watermark-remove",
@ -217,8 +242,8 @@ async function persistWatermarkResult(params: {
return {
success: true,
original_url: uploadResult.url,
thumbnail_url: uploadResult.url,
original_url: resultUrl,
thumbnail_url: resultUrl,
processing_time:
typeof params.startedAt === "number" ? (Date.now() - params.startedAt) / 1000 : undefined,
};
@ -362,7 +387,7 @@ export async function POST(request: NextRequest) {
const body = await request.json();
const { image_url } = body;
if (!image_url) {
if (typeof image_url !== "string" || !image_url) {
return NextResponse.json(
{ error: "请提供图片URL" },
{ status: 400 }
@ -413,24 +438,24 @@ export async function POST(request: NextRequest) {
);
}
}
// 如果是 HTTP/HTTPS URL,下载后重新上传确保公网可访问
// 七牛上的公开图片可以直接交给去水印服务读取。
else if (image_url.startsWith("http://") || image_url.startsWith("https://")) {
try {
console.log("[WatermarkRemove] 下载原图并上传到S3...");
const imageResponse = await fetch(image_url);
if (!imageResponse.ok) {
throw new Error("下载原图失败");
const storedImageUrl = await resolveStoredQiniuImageUrl(image_url);
publicImageUrl = storedImageUrl || image_url;
if (!storedImageUrl) {
try {
console.log("[WatermarkRemove] 下载原图并上传到S3...");
const imageResponse = await fetch(image_url);
if (!imageResponse.ok) {
throw new Error("下载原图失败");
}
const imageBuffer = await imageResponse.arrayBuffer();
const filename = `watermark-input-${Date.now()}.jpg`;
publicImageUrl = (await uploadFile(filename, imageBuffer)).url;
console.log("[WatermarkRemove] 图片已上传到S3:", publicImageUrl);
} catch (downloadError) {
console.error("[WatermarkRemove] 下载/上传原图失败,使用原始URL:", downloadError);
}
const imageBuffer = await imageResponse.arrayBuffer();
const filename = `watermark-input-${Date.now()}.jpg`;
const uploadResult = await uploadFile(filename, imageBuffer);
publicImageUrl = uploadResult.url;
console.log("[WatermarkRemove] 图片已上传到S3:", publicImageUrl);
} catch (downloadError) {
console.error("[WatermarkRemove] 下载/上传原图失败:", downloadError);
// 如果下载失败,尝试使用原始URL
console.log("[WatermarkRemove] 尝试使用原始URL");
publicImageUrl = image_url;
}
} else {
return NextResponse.json(

View File

@ -2294,8 +2294,9 @@ export default function CanvasEditorLegacyPage({
loadedAgentProjectId === agentProjectId;
const [workflowTemplateMarker, setWorkflowTemplateMarker] = useState<HTMLDivElement | null>(null);
const [workflowTemplatesRequested, setWorkflowTemplatesRequested] = useState(false);
const hasWorkflowBase = Boolean(siteSettingsData?.agentWorkflowUiConfig);
useEffect(() => {
if (!isAgentMode || !hasActiveAgentProject || workflowTemplatesRequested) return;
if (!isAgentMode || !hasActiveAgentProject || !hasWorkflowBase || workflowTemplatesRequested) return;
const element = workflowTemplateMarker;
if (!element) return;
const observer = new IntersectionObserver(([entry]) => {
@ -2303,11 +2304,11 @@ export default function CanvasEditorLegacyPage({
});
observer.observe(element);
return () => observer.disconnect();
}, [hasActiveAgentProject, isAgentMode, workflowTemplateMarker, workflowTemplatesRequested]);
}, [hasActiveAgentProject, hasWorkflowBase, isAgentMode, workflowTemplateMarker, workflowTemplatesRequested]);
const { data: workflowTemplates, error: workflowTemplatesError, isValidating: workflowTemplatesLoading, mutate: reloadWorkflowTemplates } = useSWR<{
steps: AgentWorkflowUiConfig["steps"];
}>(
isAgentMode && hasActiveAgentProject && siteSettingsData?.agentWorkflowUiConfig && workflowTemplatesRequested
isAgentMode && hasActiveAgentProject && hasWorkflowBase && workflowTemplatesRequested
? "/api/site-settings?scope=agent-workflow-templates"
: null,
fetchCanvasSiteSettings,

View File

@ -4,6 +4,7 @@ import { ThemeProvider } from "@/components/theme-provider";
import { FaviconUpdater } from "@/components/favicon-updater";
import { ImageDeliveryOptimizer } from "@/components/image-delivery-optimizer";
import { AppUpdateNotice } from "@/components/app-update-notice";
import { PAGE_DOWNLOAD_ACTIVE_ATTRIBUTE } from "@/lib/client/page-refresh";
import { getSiteSettings } from "@/lib/db/queries";
import "./globals.css";
@ -142,7 +143,7 @@ const CHUNK_ERROR_RECOVERY_SCRIPT = `\
showManualRecoveryNotice();
return;
}
if (lastReloadAt) return;
if (lastReloadAt || document.documentElement.hasAttribute('${PAGE_DOWNLOAD_ACTIVE_ATTRIBUTE}')) return;
var now = Date.now();
// If storage is blocked, a reload cannot remember its attempt and must not run.
if (!safeSetSessionStorage(RECOVERY_KEY, String(now))) return;
@ -153,6 +154,10 @@ const CHUNK_ERROR_RECOVERY_SCRIPT = `\
showManualRecoveryNotice();
return;
}
if (document.documentElement.hasAttribute('${PAGE_DOWNLOAD_ACTIVE_ATTRIBUTE}')) {
if (safeSetSessionStorage(RECOVERY_KEY, '0')) lastReloadAt = 0;
return;
}
window.location.reload();
}, 800);
}

View File

@ -159,6 +159,7 @@ import type {
} from "@/types/agent";
import {
createEmptyAgentModeWorkspaceState,
MAX_DETAIL_REPLACEMENT_REFERENCES,
} from "@/lib/agents/mode-workspace-state";
import {
createEmptyAgentGenerationProjectWorkspaceState,
@ -213,6 +214,7 @@ export interface AgentGenerationWorkspaceDarkV2Props {
interface AgentWorkflowModelOption {
id: string;
displayName: string;
providerName?: string;
providerType: string;
supportsImageGen?: boolean;
isMultimodal?: boolean;
@ -2643,7 +2645,9 @@ function getStoredAgentPendingGenerations(): LocalGenerationPendingState[] {
? record.requestedCardIds
.map((entry) => String(entry || "").trim())
.filter(Boolean)
.slice(0, MAX_AGENT_MODE_REFERENCE_COUNT)
.slice(0, record.modeType === "detail_replacement"
? MAX_DETAIL_REPLACEMENT_REFERENCES
: MAX_AGENT_MODE_REFERENCE_COUNT)
: null,
} satisfies LocalGenerationPendingState;
});
@ -3384,12 +3388,13 @@ export function AgentGenerationWorkspaceDarkV2({
}
);
const { data: modelsData } = useSWR<{
const { data: modelsData, error: modelsError, mutate: refreshModels } = useSWR<{
models: AgentWorkflowModelOption[];
defaultModelId?: string | null;
agentWorkflowConfiguredDefaultModelId?: string | null;
agentWorkflowDefaultModelId?: string | null;
agentWorkflowConfiguredModels?: AgentWorkflowModelOption[];
detailReplacementModel?: AgentWorkflowModelOption | null;
agentModel?: any;
}>("/api/models", fetcher);
@ -3435,17 +3440,6 @@ export function AgentGenerationWorkspaceDarkV2({
const activeBusinessModeDefaultModelId = !requiresPresetSelection
? String(activeBusinessMode?.defaultModelId || "").trim() || null
: null;
const lockedWorkflowModelId =
activeBusinessModeDefaultModelId ||
selectedAgentPreset?.defaultModelId ||
modelsData?.agentWorkflowConfiguredDefaultModelId ||
modelsData?.agentWorkflowDefaultModelId ||
null;
const isWorkflowModelLocked = Boolean(lockedWorkflowModelId);
const effectiveDefaultModelId =
lockedWorkflowModelId ||
modelsData?.defaultModelId ||
null;
const presetDrivenResultModeType: AgentGenerationResultModeType =
selectedAgentPreset?.type === "image" && presetModeConfig.modeType === "detail_replacement"
? detailReplacementMethod === "ai_detail_generate"
@ -3461,6 +3455,19 @@ export function AgentGenerationWorkspaceDarkV2({
const currentResultModeType: AgentGenerationResultModeType = requiresPresetSelection
? presetDrivenResultModeType
: businessModeResultModeType;
const detailReplacementModel = currentResultModeType === "detail_replacement"
? modelsData?.detailReplacementModel
: null;
const isDetailReplacementModelLoading = currentResultModeType === "detail_replacement" && !modelsData;
const lockedWorkflowModelId =
detailReplacementModel?.id ||
activeBusinessModeDefaultModelId ||
selectedAgentPreset?.defaultModelId ||
modelsData?.agentWorkflowConfiguredDefaultModelId ||
modelsData?.agentWorkflowDefaultModelId ||
null;
const isWorkflowModelLocked = Boolean(lockedWorkflowModelId);
const effectiveDefaultModelId = lockedWorkflowModelId || modelsData?.defaultModelId || null;
const isSceneGenerationPreset = currentResultModeType === "scene_generation";
const isDetailReplacementPreset =
currentResultModeType === "detail_replacement" ||
@ -4112,12 +4119,15 @@ export function AgentGenerationWorkspaceDarkV2({
);
const executableModelOptions = useMemo(() => {
const seen = new Set<string>();
return [...selectableModels, ...configuredWorkflowModels].filter((model) => {
const workflowModels = detailReplacementModel
? [detailReplacementModel, ...configuredWorkflowModels]
: configuredWorkflowModels;
return [...selectableModels, ...workflowModels].filter((model) => {
if (seen.has(model.id)) return false;
seen.add(model.id);
return true;
});
}, [configuredWorkflowModels, selectableModels]);
}, [configuredWorkflowModels, detailReplacementModel, selectableModels]);
const executableModelIdSet = useMemo(
() => new Set(executableModelOptions.map((model) => model.id)),
[executableModelOptions]
@ -4136,8 +4146,9 @@ export function AgentGenerationWorkspaceDarkV2({
selectableModels[0],
[executableModelOptions, lockedWorkflowModel, selectableModels, selectedModelId]
);
const lockedWorkflowModelLabel =
lockedWorkflowModel?.displayName ||
const lockedWorkflowModelLabel = detailReplacementModel?.providerName
? `${detailReplacementModel.displayName} 「${detailReplacementModel.providerName}」`
: lockedWorkflowModel?.displayName ||
selectedAgentPreset?.defaultModel?.displayName ||
(lockedWorkflowModelId ? `配置模型不可用(${lockedWorkflowModelId})` : "未配置");
const [activeToolStep, setActiveToolStep] = useState<ToolStepKey>("settings");
@ -6316,6 +6327,15 @@ export function AgentGenerationWorkspaceDarkV2({
toast.error(`请先查询并选择${detailReferenceSourceLabel}参考图`);
return;
}
if (isDetailReplacementModelLoading) {
if (modelsError) {
toast.error("详情替换模型加载失败,正在重新读取");
void refreshModels().catch(() => {});
} else {
toast.info("正在读取详情替换模型,请稍候");
}
return;
}
if (isSkuReplacementPreset && skuReplacementReferenceCards.length === 0) {
toast.error(`请先查询并选择${skuReferenceSourceLabel}参考图`);
return;
@ -6383,6 +6403,10 @@ export function AgentGenerationWorkspaceDarkV2({
}
if (replacementModeType === "detail_replacement") {
if (detailBatchSubmittingRef.current) return;
if (replacementExecutionCards.length > MAX_DETAIL_REPLACEMENT_REFERENCES) {
toast.error(`本次选中了 ${replacementExecutionCards.length} 张详情参考图,一次最多替换 ${MAX_DETAIL_REPLACEMENT_REFERENCES} 张,请减少本次选择后重试`);
return;
}
if (!isValidChatUuid(projectId)) {
toast.error("当前风格化项目尚未保存,请先保存项目后再执行详情替换");
return;
@ -6403,13 +6427,14 @@ export function AgentGenerationWorkspaceDarkV2({
const saved = await onPersistProject?.();
if (saved === false) throw new Error("项目尚未保存,请重试");
const requestId = generateUUID();
const response = await fetchWithErrorHandlers("/api/agents/detail-replacement/batches", {
const response = await fetch("/api/agents/detail-replacement/batches", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
projectId,
requestId,
model: resolvedModelId,
expectedModelId: resolvedModelId === "auto" ? undefined : resolvedModelId,
imageSize: imageParams.imageSize,
replacementStrategy: detailReplacementStrategy,
prompt: detailReplacementStrategy === "product_only"
@ -6433,6 +6458,7 @@ export function AgentGenerationWorkspaceDarkV2({
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || !payload?.task?.id) {
if (payload?.code === "DETAIL_REPLACEMENT_MODEL_CHANGED") void refreshModels().catch(() => {});
throw new Error(payload?.error || "详情替换任务提交失败");
}
setPendingGeneration({
@ -6620,6 +6646,8 @@ export function AgentGenerationWorkspaceDarkV2({
input,
isDetailAiGenerationMethod,
isDetailReplacementPreset,
isDetailReplacementModelLoading,
modelsError,
isSelectedBusinessModeUnavailable,
isSkuReplacementPreset,
effectiveDefaultModelId,
@ -6647,6 +6675,7 @@ export function AgentGenerationWorkspaceDarkV2({
onPersistProject,
projectId,
refreshDetailBatch,
refreshModels,
]);
const handleStopGeneration = useCallback(() => {
detailGenerationCancelRef.current = true;
@ -9419,9 +9448,11 @@ export function AgentGenerationWorkspaceDarkV2({
</Select>
) : (
<div className="rounded-[24px] border border-white/10 bg-black/30 px-4 py-3 text-sm text-white/70">
{isWorkflowModelLocked ? "后台已锁定生图模型:" : "当前生图模式使用默认模型:"}
{isDetailReplacementModelLoading
? modelsError ? "详情替换模型加载失败,点击批量替换可重试" : "正在读取详情替换模型"
: isWorkflowModelLocked ? "后台已锁定生图模型:" : "当前生图模式使用默认模型:"}
<span className="ml-2 font-medium text-white">
{isWorkflowModelLocked
{isDetailReplacementModelLoading ? null : isWorkflowModelLocked
? lockedWorkflowModelLabel
: currentModel?.displayName || "自动选择"}
</span>

View File

@ -3,6 +3,7 @@
import { useMemo } from "react";
import useSWR from "swr";
import { Check, ImageIcon, Loader2, UserRound } from "lucide-react";
import { AgentPreviewImage } from "@/components/agents/AgentPreviewImage";
import { Button } from "@/components/ui/button";
import { cn, fetcher } from "@/lib/utils";
import { normalizeAgentWorkflowBrandModelConfig } from "@/lib/agents/workflow-brand-model-config";
@ -229,12 +230,11 @@ export function AgentWorkflowBrandModelPicker({
isDark ? "bg-white/[0.04]" : "bg-muted/40"
)}
>
<img
<AgentPreviewImage
src={image.imageUrl}
alt={image.displayName || `${normalizedBrand} 模特图`}
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
previewWidth={360}
/>
<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">

View File

@ -1,14 +1,15 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useState, useSyncExternalStore } from "react";
import { Button } from "@/components/ui/button";
import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter } from "@/components/ui/alert-dialog";
import { preparePageRefresh } from "@/lib/client/page-refresh";
import { isPageDownloadActive, preparePageRefresh, subscribeToPageDownloadActivity } from "@/lib/client/page-refresh";
export function AppUpdateNotice() {
const [availableVersion, setAvailableVersion] = useState("");
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState("");
const downloading = useSyncExternalStore(subscribeToPageDownloadActivity, isPageDownloadActive, () => false);
useEffect(() => {
let disposed = false;
let controller: AbortController | undefined;
@ -60,7 +61,7 @@ export function AppUpdateNotice() {
setRefreshing(false);
}
};
return <AlertDialog open={Boolean(availableVersion)}>
return <AlertDialog open={Boolean(availableVersion) && !downloading}>
<AlertDialogContent className="z-[10001]" overlayClassName="z-[10000]" onEscapeKeyDown={event => event.preventDefault()}>
<AlertDialogHeader>
<AlertDialogTitle>平台已更新</AlertDialogTitle>

View File

@ -78,7 +78,7 @@ import {
type UploadProgress,
} from "@/lib/client/upload-with-retry";
import {
downloadCanvasImageAsPng,
downloadOriginalImage,
getCanvasImageDownloadErrorMessage,
type CanvasImageDownloadProgress,
} from "@/lib/client/canvas-image-download";
@ -1462,7 +1462,7 @@ function normalizeComposerPreviewTail(text: string, fixedPrefix: string) {
}
// 获取图片尺寸(保留原图分辨率 + 画布显示尺寸)
function getImageDimensions(url: string): Promise<{
function getImageDimensions(source: string | File): Promise<{
width: number;
height: number;
originalWidth: number;
@ -1470,6 +1470,7 @@ function getImageDimensions(url: string): Promise<{
}> {
return new Promise((resolve) => {
const img = new Image();
const objectUrl = typeof source === "string" ? null : URL.createObjectURL(source);
let settled = false;
const resolveOnce = (dimensions: {
width: number;
@ -1482,9 +1483,11 @@ function getImageDimensions(url: string): Promise<{
window.clearTimeout(timeout);
img.onload = null;
img.onerror = null;
if (objectUrl) URL.revokeObjectURL(objectUrl);
resolve(dimensions);
};
const timeout = window.setTimeout(() => {
img.removeAttribute("src");
resolveOnce(DEFAULT_CANVAS_IMAGE_DIMENSIONS);
}, CANVAS_IMAGE_DIMENSION_TIMEOUT_MS);
@ -1517,7 +1520,7 @@ function getImageDimensions(url: string): Promise<{
// 加载失败时使用默认尺寸
resolveOnce(DEFAULT_CANVAS_IMAGE_DIMENSIONS);
};
img.src = url;
img.src = objectUrl ?? String(source);
});
}
@ -4464,17 +4467,15 @@ export function CanvasChatPanel({
setDownloadProgress({
phase: "preparing",
message: "正在准备原图...",
percent: 5,
});
try {
await downloadCanvasImageAsPng({
await downloadOriginalImage({
url: originalUrl,
candidates: [raw, buildImageProxyUrl(originalUrl, { delivery: "proxy" })],
signal: controller.signal,
onProgress: setDownloadProgress,
});
toast.success("原图已开始下载");
toast.success("已开始下载");
} catch (error) {
if (controller.signal.aborted) {
toast.message("已取消下载");
@ -7806,7 +7807,6 @@ export function CanvasChatPanel({
existingMessageImageKeys.add(messageImageKey);
}
const dimensions = await getImageDimensions(url);
if (useCanvasStore.getState().projectId !== canvasId || activeChatIdRef.current !== activeChatId) return;
const compareSourceUrlRaw =
sourceImageUrls[Math.min(index, Math.max(0, sourceImageUrls.length - 1))] ||
@ -7839,7 +7839,7 @@ export function CanvasChatPanel({
const placeholderNode = reservedPlaceholderNode
? currentNodes.find((node) => node.id === reservedPlaceholderNode.id) || null
: null;
const size = { width: dimensions.width, height: dimensions.height };
const size = placeholderNode?.size || anchorNode?.size || { width: 300, height: 300 };
const position = placeholderNode
? getCenteredReplacementPosition(placeholderNode, size)
: getAnchoredGenerationPosition({
@ -7857,8 +7857,6 @@ export function CanvasChatPanel({
prompt: generationPrompt,
originalSrc: canUseCompareSource ? compareSourceUrl : undefined,
metadata: {
originalWidth: dimensions.originalWidth,
originalHeight: dimensions.originalHeight,
generationPrompt,
sourceMessageId: messageId || undefined,
sourceImageKey,
@ -9583,7 +9581,6 @@ export function CanvasChatPanel({
continue;
}
const dimensions = await getImageDimensions(url);
if (cancelled) return;
const compareSourceUrlRaw =
nearestUserImageUrls[Math.min(index, Math.max(0, nearestUserImageUrls.length - 1))] ||
@ -9616,20 +9613,7 @@ export function CanvasChatPanel({
const placeholderNode = reservedPlaceholderNode
? currentNodes.find((node) => node.id === reservedPlaceholderNode.id) || null
: null;
const size = placeholderNode
? { width: dimensions.width, height: dimensions.height }
: anchorNode
? {
width: Math.max(
1,
Math.round(Number(anchorNode.size?.width || dimensions.width))
),
height: Math.max(
1,
Math.round(Number(anchorNode.size?.height || dimensions.height))
),
}
: { width: dimensions.width, height: dimensions.height };
const size = placeholderNode?.size || anchorNode?.size || { width: 300, height: 300 };
const position = placeholderNode
? getCenteredReplacementPosition(placeholderNode, size)
: getAnchoredGenerationPosition({
@ -9648,8 +9632,6 @@ export function CanvasChatPanel({
prompt: generationPrompt || "对话生成",
originalSrc: canUseCompareSource ? compareSourceUrl : undefined,
metadata: {
originalWidth: dimensions.originalWidth,
originalHeight: dimensions.originalHeight,
generationPrompt: generationPrompt || undefined,
sourceMessageId: message.id,
sourceImageKey: imageEntry.sourceImageKey,
@ -10376,7 +10358,7 @@ export function CanvasChatPanel({
buildCanvasUploadedImageAttachment({ file, imageUrl, assetId })
);
const dimensions = await getImageDimensions(imageUrl);
const dimensions = await getImageDimensions(file);
const nodeIndex = nodes.length + slotIndex;
const spacing = 20;
const x = 100 + (nodeIndex % 3) * (dimensions.width + spacing);
@ -10454,7 +10436,7 @@ export function CanvasChatPanel({
buildCanvasUploadedImageAttachment({ file, imageUrl, assetId })
);
const dimensions = await getImageDimensions(imageUrl);
const dimensions = await getImageDimensions(file);
const nodeIndex = nodes.length + slotIndex;
const spacing = 20;
const x = 100 + (nodeIndex % 3) * (dimensions.width + spacing);
@ -13740,7 +13722,7 @@ export function CanvasChatPanel({
};
onUploaded?.(uploadedBase);
clearComposerUploadProgress(250);
const dimensions = await getImageDimensions(imageUrl);
const dimensions = await getImageDimensions(file);
const spacing = 20;
const x = 100 + (nodeIndex % 3) * (dimensions.width + spacing);
const y = 100 + Math.floor(nodeIndex / 3) * (dimensions.height + spacing);
@ -14929,7 +14911,7 @@ export function CanvasChatPanel({
firstImageUrl = imageUrl;
}
const dimensions = await getImageDimensions(imageUrl);
const dimensions = await getImageDimensions(file);
updateUploadedImageAttachmentDimensions(imageUrl, dimensions);
// 获取图片原始尺寸后添加到画布

View File

@ -28,6 +28,7 @@ export function CanvasImageDownloadProgressDialog({
: null;
const loadedLabel = formatBytes(progress.loadedBytes);
const totalLabel = formatBytes(progress.totalBytes);
const converting = progress.phase === "converting";
return createPortal(
<div
@ -41,12 +42,12 @@ export function CanvasImageDownloadProgressDialog({
{progress.phase === "saving" ? (
<Download className="h-4 w-4" />
) : (
<Loader2 className="h-4 w-4 animate-spin" />
<Loader2 className="h-4 w-4 motion-safe:animate-spin" />
)}
</span>
<div>
<div className="text-[14px] font-semibold">下载原图</div>
<div className="mt-0.5 text-[12px] text-white/58">{progress.message}</div>
<div className="mt-0.5 text-[12px] text-white/58" role="status">{progress.message}</div>
</div>
</div>
{onCancel ? (
@ -63,14 +64,19 @@ export function CanvasImageDownloadProgressDialog({
<div className="mt-4">
<div className="mb-2 flex items-center justify-between text-[12px] text-white/55">
<span>{loadedLabel && totalLabel ? `${loadedLabel} / ${totalLabel}` : "正在处理文件"}</span>
<span>{loadedLabel && totalLabel ? `${loadedLabel} / ${totalLabel}` : converting ? "格式转换中" : "正在处理文件"}</span>
<span>{percent !== null ? `${percent}%` : "..."}</span>
</div>
<div className="relative h-2 overflow-hidden rounded-full bg-white/10">
<div
className="relative h-2 overflow-hidden rounded-full bg-white/10"
role="progressbar"
aria-label={converting ? "图片格式转换" : "图片下载"}
aria-valuenow={percent ?? undefined}
>
<div
className={cn(
"absolute inset-y-0 left-0 rounded-full bg-gradient-to-r from-cyan-300 via-sky-400 to-emerald-300",
percent === null && "w-1/3 animate-[download-indeterminate_1.15s_ease-in-out_infinite]"
percent === null && "w-1/3 motion-safe:animate-[download-indeterminate_1.15s_ease-in-out_infinite]"
)}
style={percent !== null ? { width: `${percent}%` } : undefined}
/>

View File

@ -1,4 +1,5 @@
"use client";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import { useCanvasGroupRuns } from "@/hooks/use-canvas-group-runs";
import { hasRecoverableCanvasTask } from "@/lib/video-canvas/task-recovery";
import { GroupStoryboardPreview } from "./GroupStoryboardPreview";
@ -15104,7 +15105,7 @@ function DirectorNodeComposer({
if(file.size>20*1024*1024){setError("参考图需小于 20 MiB");return;}
setBusy(true);setError("");try{
const form=new FormData();form.append("file",file);form.append("purpose","director-studio");
const response=await fetch("/api/files/upload",{method:"POST",body:form,signal:AbortSignal.timeout(120000)});
const response=await fetchUploadWithRetry(form,{signal:AbortSignal.timeout(120000)});
const result=await response.json();const url=result.url||result.data?.url;
if(!response.ok||!url)throw new Error(result.error||"图片上传失败");
onDraft({directorReferenceUrl:String(url)});

View File

@ -1,4 +1,5 @@
"use client";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import { useState } from "react";
import { toast } from "sonner";
import { readDirectorScene, type DirectorScene } from "@/lib/video-canvas/director-scene";
@ -64,9 +65,7 @@ export function DirectorReconstructionPanel({
const form = new FormData();
form.append("file", file);
form.append("purpose", "director-studio");
const response = await fetch("/api/files/upload", {
method: "POST",
body: form,
const response = await fetchUploadWithRetry(form, {
signal: AbortSignal.timeout(120000),
});
const data = await response.json();

View File

@ -1,4 +1,5 @@
"use client";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import {
commitDirectorScene,
resolveDirectorCamera,
@ -43,14 +44,13 @@ async function upload(file: File) {
"purpose",
file.type.startsWith("video/") ? "canvas-video-process" : "director-studio",
);
const response = await fetch(
/\.glb$/i.test(file.name) ? "/api/canvas/director/upload-model" : "/api/files/upload",
{
const response = /\.glb$/i.test(file.name)
? await fetch("/api/canvas/director/upload-model", {
method: "POST",
body: form,
signal: AbortSignal.timeout(120000),
},
);
})
: await fetchUploadWithRetry(form, { signal: AbortSignal.timeout(120000) });
const result = await response.json();
const url = result.url || result.data?.url;
if (!response.ok || !url) throw new Error(result.error || "上传失败");

View File

@ -178,7 +178,7 @@ import {
} from "@/components/ui/tooltip";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import {
downloadCanvasImageAsPng,
downloadOriginalImage,
getCanvasImageDownloadErrorMessage,
type CanvasImageDownloadProgress,
} from "@/lib/client/canvas-image-download";
@ -2482,14 +2482,11 @@ export function ImageNode({
[compareButtonVisualSize]
);
const shouldUseHighFidelityDisplayImage =
isSelected ||
isHovered ||
isCompareMode ||
boxCutoutMode ||
localEditMode ||
cropPanelOpen ||
imageAdjustPanelOpen ||
fullscreenPreviewOpen ||
isThisNodePicking;
const displayImageWidth = useMemo(() => {
const devicePixelRatio =
@ -2522,15 +2519,6 @@ export function ImageNode({
}),
[data.src, displayImageQuality, displayImageWidth]
);
const fullscreenPreviewSrc = useMemo(() => {
const originalUrl = resolveOriginalImageUrl(data.src || "");
return buildImageProxyUrl(originalUrl || data.src || "", {
delivery: "proxy",
width: 4096,
quality: 92,
format: "webp",
});
}, [data.src]);
const displayCompareSrc = useMemo(
() =>
buildImageProxyUrl(compareSource, {
@ -8235,7 +8223,6 @@ export function ImageNode({
}
};
// 下载图片 - 服务端统一转为 Photoshop 兼容 PNG,避免后缀和真实格式错配。
const isDownloadingOriginalImage = Boolean(downloadProgress);
const handleDownload = async () => {
if (!data.src) return;
@ -8249,24 +8236,16 @@ export function ImageNode({
setDownloadProgress({
phase: "preparing",
message: "正在准备原图...",
percent: 5,
});
try {
await downloadCanvasImageAsPng({
url: data.src,
await downloadOriginalImage({
url: resolveOriginalImageUrl(data.src) || data.src,
name: displayTitle,
candidates: [
displayImageSrc,
fullscreenPreviewSrc,
buildImageProxyUrl(resolveOriginalImageUrl(data.src) || data.src, {
delivery: "proxy",
}),
],
signal: controller.signal,
onProgress: setDownloadProgress,
});
toast.success("原图已开始下载");
toast.success("已开始下载");
} catch (error) {
if (controller.signal.aborted) {
toast.message("已取消下载");

View File

@ -14,6 +14,8 @@ import {
sanitizeAgentDetailBrief,
} from "@/lib/agents/detail-generation-config";
export const MAX_DETAIL_REPLACEMENT_REFERENCES = 64;
export function createEmptyAgentModeWorkspaceState(): AgentModeWorkspaceState {
return {
detailReplacement: {

View File

@ -1,3 +1,5 @@
import { isVideoModelDescriptor } from "@/lib/ai/model-classification";
export const DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY =
"detail_replacement_image_model_config";
@ -20,3 +22,28 @@ export function normalizeDetailReplacementImageModelConfig(
modelId: modelId || DEFAULT_DETAIL_REPLACEMENT_IMAGE_MODEL_ID,
};
}
export function isDetailReplacementImageModel({
model,
provider,
}: {
model: { modelId: string; supportsImageGen: boolean; isSubModel: boolean; capabilities?: unknown };
provider?: { type?: string | null } | null;
}) {
return model.supportsImageGen && !model.isSubModel && !isVideoModelDescriptor({
providerType: provider?.type,
modelId: model.modelId,
capabilities: model.capabilities,
supportsImageGen: model.supportsImageGen,
});
}
export function findConfiguredDetailReplacementImageModel<
T extends Parameters<typeof isDetailReplacementImageModel>[0] & { model: { id: string } },
>(models: T[], setting: unknown): T | undefined {
const { modelId } = normalizeDetailReplacementImageModelConfig(setting);
return models.find((entry) =>
(entry.model.id === modelId || entry.model.modelId === modelId) &&
isDetailReplacementImageModel(entry)
);
}

View File

@ -1,37 +1,52 @@
import { createFalClient } from "@fal-ai/client";
import sharp from "sharp";
export const FIBO_EXPAND_MODEL = "bria/fibo-edit-1.5/expand";
export const FIBO_EXPAND_PROMPT = "Continue the same scene edge to edge, matching the existing lighting, colors, texture, perspective and cut lines. Preserve the original subject and all lettering; continue any caption background that reaches the side edges.";
export const FAL_OUTPAINT_MODEL = "fal-ai/flux-2-pro/outpaint";
export function getFiboGeometry(width: number, height: number, xScale: number, yScale: number) {
export function getOutpaintGeometry(width: number, height: number, xScale: number, yScale: number) {
const targetWidth = Math.round(width * xScale);
const targetHeight = Math.round(height * yScale);
const ratio = Math.min(1, Math.sqrt(24_000_000 / (targetWidth * targetHeight)));
const ratio = Math.min(1, 2048 / Math.max(targetWidth, targetHeight));
const modelWidth = Math.max(1, Math.floor(targetWidth * ratio));
const modelHeight = Math.max(1, Math.floor(targetHeight * ratio));
const sourceWidth = Math.max(1, Math.round(width * modelWidth / targetWidth));
const sourceHeight = Math.max(1, Math.round(height * modelHeight / targetHeight));
const left = Math.floor((targetWidth - width) / 2);
const top = Math.floor((targetHeight - height) / 2);
const modelLeft = Math.round(left * modelWidth / targetWidth);
const modelTop = Math.round(top * modelHeight / targetHeight);
// Align the model canvas to its 16-pixel grid, then remove the extra border.
const extraWidth = (16 - modelWidth % 16) % 16;
const extraHeight = (16 - modelHeight % 16) % 16;
const cropLeft = Math.floor(extraWidth / 2);
const cropTop = Math.floor(extraHeight / 2);
return {
targetWidth, targetHeight, left, top, modelWidth, modelHeight, sourceWidth, sourceHeight,
modelLeft: Math.round(left * modelWidth / targetWidth),
modelTop: Math.round(top * modelHeight / targetHeight),
paddedWidth: modelWidth + extraWidth,
paddedHeight: modelHeight + extraHeight,
cropLeft,
cropTop,
expandLeft: modelLeft + cropLeft,
expandRight: modelWidth - sourceWidth - modelLeft + extraWidth - cropLeft,
expandTop: modelTop + cropTop,
expandBottom: modelHeight - sourceHeight - modelTop + extraHeight - cropTop,
};
}
export async function composeFiboExpansion(source: Buffer, expanded: Buffer, geometry: ReturnType<typeof getFiboGeometry>) {
const { targetWidth, targetHeight, left, top, modelWidth, modelHeight } = geometry;
export async function composeOutpaint(source: Buffer, expanded: Buffer, geometry: ReturnType<typeof getOutpaintGeometry>) {
const { targetWidth, targetHeight, left, top, modelWidth, modelHeight, paddedWidth, paddedHeight, cropLeft, cropTop } = geometry;
const modelImage = sharp(expanded);
const info = await modelImage.metadata();
if (info.width !== modelWidth || info.height !== modelHeight) {
if (info.width !== paddedWidth || info.height !== paddedHeight) {
throw new Error("扩图服务返回的图片尺寸不正确");
}
// Copy the source rectangle, including transparent pixels, without blending or resizing it.
const { data: original, info: sourceInfo } = await sharp(source).toColourspace("srgb").ensureAlpha().raw().toBuffer({ resolveWithObject: true });
const result = await modelImage.resize(targetWidth, targetHeight, { fit: "fill" }).toColourspace("srgb").ensureAlpha().raw().toBuffer();
const result = await modelImage
.extract({ left: cropLeft, top: cropTop, width: modelWidth, height: modelHeight })
.resize(targetWidth, targetHeight, { fit: "fill" })
.toColourspace("srgb").ensureAlpha().raw().toBuffer();
const stride = sourceInfo.width * 4;
for (let row = 0; row < sourceInfo.height; row++) {
original.copy(result, ((top + row) * targetWidth + left) * 4, row * stride, (row + 1) * stride);
@ -39,7 +54,7 @@ export async function composeFiboExpansion(source: Buffer, expanded: Buffer, geo
return sharp(result, { raw: { width: targetWidth, height: targetHeight, channels: 4 } }).png().toBuffer();
}
export async function expandWithFibo({ imageUrl, apiKey, xScale, yScale }: {
export async function expandWithFalOutpaint({ imageUrl, apiKey, xScale, yScale }: {
imageUrl: string;
apiKey: string;
xScale: number;
@ -49,26 +64,30 @@ export async function expandWithFibo({ imageUrl, apiKey, xScale, yScale }: {
if (!response.ok) throw new Error("无法读取待扩展的原图");
const { data: source, info } = await sharp(Buffer.from(await response.arrayBuffer()))
.rotate().png().toBuffer({ resolveWithObject: true });
const geometry = getFiboGeometry(info.width, info.height, xScale, yScale);
const geometry = getOutpaintGeometry(info.width, info.height, xScale, yScale);
if (geometry.targetWidth * geometry.targetHeight > 64_000_000) {
throw new Error("扩图后的尺寸过大,请降低扩展倍数");
}
const fal = createFalClient({ credentials: apiKey });
const image = await fal.storage.upload(new Blob([new Uint8Array(source)], { type: "image/png" }));
const { data } = await fal.subscribe(FIBO_EXPAND_MODEL, {
const modelSource = geometry.sourceWidth === info.width && geometry.sourceHeight === info.height
? source
: await sharp(source).resize(geometry.sourceWidth, geometry.sourceHeight, { fit: "fill" }).png().toBuffer();
const image = await fal.storage.upload(new Blob([new Uint8Array(modelSource)], { type: "image/png" }));
const { data } = await fal.subscribe(FAL_OUTPAINT_MODEL, {
input: {
image_url: image,
canvas_size: [geometry.modelWidth, geometry.modelHeight],
original_image_size: [geometry.sourceWidth, geometry.sourceHeight],
original_image_location: [geometry.modelLeft, geometry.modelTop],
retain_original_quality: true,
prompt: FIBO_EXPAND_PROMPT,
expand_left: geometry.expandLeft,
expand_right: geometry.expandRight,
expand_top: geometry.expandTop,
expand_bottom: geometry.expandBottom,
mode: "high",
output_format: "png",
},
timeout: 90_000,
});
const outputUrl = (data as { image?: { url?: string } }).image?.url;
const outputUrl = (data as { images?: Array<{ url?: string }> }).images?.[0]?.url;
if (!outputUrl) throw new Error("扩图服务未返回图片");
const output = await fetch(outputUrl, { signal: AbortSignal.timeout(25_000) });
if (!output.ok) throw new Error("无法读取扩图结果");
return composeFiboExpansion(source, Buffer.from(await output.arrayBuffer()), geometry);
return composeOutpaint(source, Buffer.from(await output.arrayBuffer()), geometry);
}

View File

@ -6,21 +6,25 @@ import {
upsertMediaAsset,
} from "@/lib/db/queries";
import { type ExtendProvider, resolveAlibabaExtendApiKey, resolveFalExtendApiKey } from "@/lib/ai/extend/config";
import { expandWithFibo, FIBO_EXPAND_MODEL } from "@/lib/ai/extend/fibo";
import { executeConfiguredVip98kTask } from "@/lib/ai/vip98k-runtime";
import { expandWithFalOutpaint, FAL_OUTPAINT_MODEL } from "@/lib/ai/extend/fal-outpaint";
import { executeConfiguredVip98kTask, getVip98kRuntimeProvider } from "@/lib/ai/vip98k-runtime";
import { expandWithVip98kOutpaint, VIP98K_OUTPAINT_MODEL } from "@/lib/ai/extend/vip98k-outpaint";
import { db } from "@/lib/db";
import { generatedImage } from "@/lib/db/schema";
import { uploadFile } from "@/lib/storage/service";
import { archiveTemporaryImage } from "@/lib/storage/image-archive";
import { randomUUID } from "node:crypto";
type ExtendConfig = {
provider?: ExtendProvider;
apiKey?: string;
xScale?: number;
yScale?: number;
modelId?: string;
routeOptionId?: string;
};
const VIP98K_EXPANSION_FALLBACK_MODEL = "google/nano-banana-pro/edit";
const VIP98K_SOURCE_DOWNLOAD_TIMEOUT_MS = 120_000;
const SOURCE_DOWNLOAD_TIMEOUT_MS = 120_000;
const DASHSCOPE_MIN_IMAGE_EDGE = 512;
const DASHSCOPE_MAX_IMAGE_EDGE = 4096;
const DASHSCOPE_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
@ -143,7 +147,7 @@ async function normalizeDashscopeExpansionInput(imageUrl: string): Promise<strin
const response = await fetch(imageUrl, {
cache: "no-store",
redirect: "follow",
signal: AbortSignal.timeout(VIP98K_SOURCE_DOWNLOAD_TIMEOUT_MS),
signal: AbortSignal.timeout(SOURCE_DOWNLOAD_TIMEOUT_MS),
});
if (!response.ok) {
throw new ExtendServiceError(
@ -238,97 +242,6 @@ async function normalizeDashscopeExpansionInput(imageUrl: string): Promise<strin
return uploaded.url;
}
function isTemporaryVip98kExpansionError(error: unknown) {
const message = error instanceof Error ? error.message : String(error || "");
return /HTTP\s+(?:429|5\d\d)|temporar(?:y|ily)|timeout|timed out|unavailable/i.test(
message,
);
}
async function createVip98kExpansionCanvas(
imageUrl: string,
xScale: number,
yScale: number,
) {
const response = await fetch(imageUrl, {
cache: "no-store",
redirect: "follow",
signal: AbortSignal.timeout(VIP98K_SOURCE_DOWNLOAD_TIMEOUT_MS),
});
if (!response.ok) {
throw new ExtendServiceError(
"下载待拓展图片失败",
502,
`${response.status} ${response.statusText}`,
);
}
const sourceBuffer = Buffer.from(await response.arrayBuffer());
const sharp = await import("sharp");
const image = sharp.default(sourceBuffer, { failOn: "none" });
const metadata = await image.metadata();
const width = Number(metadata.width || 0);
const height = Number(metadata.height || 0);
if (!width || !height) {
throw new ExtendServiceError("无法识别待拓展图片尺寸", 422);
}
const targetWidth = Math.max(width, Math.round(width * xScale));
const targetHeight = Math.max(height, Math.round(height * yScale));
const left = Math.floor((targetWidth - width) / 2);
const top = Math.floor((targetHeight - height) / 2);
const canvas = await image
.ensureAlpha()
.extend({
left,
right: targetWidth - width - left,
top,
bottom: targetHeight - height - top,
background: { r: 0, g: 0, b: 0, alpha: 0 },
})
.png()
.toBuffer();
const arrayBuffer = canvas.buffer.slice(
canvas.byteOffset,
canvas.byteOffset + canvas.byteLength,
) as ArrayBuffer;
const uploaded = await uploadFile(
`extend-vip98k-canvas-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.png`,
arrayBuffer,
);
return {
imageUrl: uploaded.url,
width,
height,
targetWidth,
targetHeight,
};
}
async function extendImageWithVip98kEditFallback(
imageUrl: string,
xScale: number,
yScale: number,
) {
const canvas = await createVip98kExpansionCanvas(imageUrl, xScale, yScale);
const result = await executeConfiguredVip98kTask({
modelId: VIP98K_EXPANSION_FALLBACK_MODEL,
kind: "image",
prompt: [
"Outpaint only the transparent regions of this image.",
"Keep every original pixel, subject, scale, position, perspective, lighting and style unchanged.",
`Complete the ${canvas.targetWidth}x${canvas.targetHeight} canvas naturally from the centered ${canvas.width}x${canvas.height} source.`,
"Do not crop, zoom, move, replace or redraw the original content.",
].join(" "),
imageUrls: [canvas.imageUrl],
});
const outputUrl = result.outputUrls[0];
if (!outputUrl) {
throw new ExtendServiceError("VIP98K 生成式扩图未返回图片", 502);
}
return outputUrl;
}
async function extendImage(
imageUrl: string,
config: ExtendConfig,
@ -337,27 +250,17 @@ async function extendImage(
requestId?: string | null,
): Promise<string> {
if (config.provider === "vip98k") {
try {
const result = await executeConfiguredVip98kTask({
modelId: "image-expansion",
kind: "image-tool",
prompt: `Expand the image canvas naturally to ${xScale}x width and ${yScale}x height. Preserve the original subject, perspective, lighting and style.`,
imageUrls: [imageUrl],
imageParams: { xScale, yScale },
});
const outputUrl = result.outputUrls[0];
if (!outputUrl) {
throw new ExtendServiceError("VIP98K 图像拓展未返回图片", 502);
}
return outputUrl;
} catch (error) {
if (!isTemporaryVip98kExpansionError(error)) throw error;
console.warn(
"[Extend Service] VIP98K expansion tool is temporarily unavailable; using VIP98K generative outpainting fallback",
error instanceof Error ? error.message : String(error),
);
return extendImageWithVip98kEditFallback(imageUrl, xScale, yScale);
const result = await executeConfiguredVip98kTask({
modelId: "image-expansion",
kind: "image-tool",
imageUrls: [imageUrl],
imageParams: { xScale, yScale },
});
const outputUrl = result.outputUrls[0];
if (!outputUrl) {
throw new ExtendServiceError("VIP98K 图像拓展未返回图片", 502);
}
return outputUrl;
}
if (!config.apiKey) {
@ -533,29 +436,47 @@ export async function executeExtendJob(
}
const startTime = Date.now();
let buffer: Buffer;
let buffer!: Buffer;
let uploadResult: { thumbnail: string; original: string } | undefined;
let archivedBytes: number | undefined;
let downloadAsset: Awaited<ReturnType<typeof archiveTemporaryImage>>["downloadAsset"];
let modelId: string;
if (config.provider === "fal") {
buffer = await expandWithFibo({ imageUrl: inputImageUrl, apiKey: config.apiKey!, xScale, yScale });
modelId = FIBO_EXPAND_MODEL;
buffer = await expandWithFalOutpaint({ imageUrl: inputImageUrl, apiKey: config.apiKey!, xScale, yScale });
modelId = FAL_OUTPAINT_MODEL;
} else if (config.provider === "vip98k" && config.modelId === VIP98K_OUTPAINT_MODEL) {
if (!config.routeOptionId) throw new ExtendServiceError("98K 扩图线路未配置", 503);
const provider = await getVip98kRuntimeProvider();
buffer = await expandWithVip98kOutpaint({
imageUrl: inputImageUrl, baseUrl: provider.baseUrl, apiKey: provider.apiKey,
routeOptionId: config.routeOptionId, xScale, yScale,
});
modelId = VIP98K_OUTPAINT_MODEL;
} else {
const prepared = config.provider === "vip98k" ? inputImageUrl : await normalizeDashscopeExpansionInput(inputImageUrl);
const url = await extendImage(prepared, config, xScale, yScale, requestId);
const imageResponse = await fetch(url);
if (!imageResponse.ok) {
throw new ExtendServiceError("下载拓展图片失败", 500, `${imageResponse.status} ${imageResponse.statusText}`);
if (config.provider === "vip98k" && !includeBase64) {
const archived = await archiveTemporaryImage(url, requestId || randomUUID());
uploadResult = { original: archived.url, thumbnail: archived.thumbnailUrl };
archivedBytes = archived.bytes;
downloadAsset = archived.downloadAsset;
} else {
const imageResponse = await fetch(url);
if (!imageResponse.ok) {
throw new ExtendServiceError("下载拓展图片失败", 500, `${imageResponse.status} ${imageResponse.statusText}`);
}
buffer = Buffer.from(await imageResponse.arrayBuffer());
}
buffer = Buffer.from(await imageResponse.arrayBuffer());
modelId = config.provider === "vip98k" ? "image-expansion" : "extend-1.0";
}
const imageBase64Result = includeBase64 ? buffer.toString("base64") : undefined;
const uploadResult = await uploadBufferPairToS3(buffer, config.provider === "fal" ? "png" : "jpg");
uploadResult ??= await uploadBufferPairToS3(buffer, config.provider === "fal" || config.provider === "vip98k" ? "png" : "jpg");
console.log("[Extend Service] Result uploaded:", {
requestId,
includeBase64,
inputImageUrl: inputImageUrl.slice(0, 120),
modelId,
outputBytes: buffer.length,
outputBytes: archivedBytes ?? buffer.length,
});
let mediaAssetId: string | null = null;
let generatedImageId: string | null = null;
@ -604,6 +525,7 @@ export async function executeExtendJob(
xScale,
yScale,
imageSize: "2K",
...(downloadAsset ? { downloadAsset } : {}),
},
});
mediaAssetId = media?.id || null;

View File

@ -0,0 +1,85 @@
import sharp from "sharp";
export const VIP98K_OUTPAINT_MODEL = "gpt-image-2.5-flare";
const RATIOS = [[1, 1], [4, 3], [3, 4], [3, 2], [2, 3], [16, 9], [9, 16]];
export function getVip98kOutpaintGeometry(width: number, height: number, xScale: number, yScale: number) {
const targetWidth = Math.round(width * xScale);
const targetHeight = Math.round(height * yScale);
if (targetWidth * targetHeight > 64_000_000) throw new Error("扩图后的尺寸过大,请降低扩展倍数");
const ratio = targetWidth / targetHeight;
const [a, b] = RATIOS.reduce((best, next) =>
Math.abs(Math.log(ratio / (next[0] / next[1]))) < Math.abs(Math.log(ratio / (best[0] / best[1]))) ? next : best);
const paddedWidth = Math.max(targetWidth, Math.ceil(targetHeight * a / b));
const paddedHeight = Math.max(targetHeight, Math.ceil(targetWidth * b / a));
const factor = Math.min(1, 2048 / Math.max(paddedWidth, paddedHeight));
const modelWidth = Math.max(1, Math.round(paddedWidth * factor));
const modelHeight = Math.max(1, Math.round(paddedHeight * factor));
const sourceWidth = Math.max(1, Math.round(width * factor));
const sourceHeight = Math.max(1, Math.round(height * factor));
return {
targetWidth, targetHeight, modelWidth, modelHeight, sourceWidth, sourceHeight,
left: Math.floor((modelWidth - sourceWidth) / 2),
top: Math.floor((modelHeight - sourceHeight) / 2),
aspectRatio: `${a}:${b}`,
resolution: Math.max(targetWidth, targetHeight) > 1536 ? "2K" : "1K",
};
}
export async function expandWithVip98kOutpaint(params: {
imageUrl: string;
baseUrl: string;
apiKey: string;
routeOptionId: string;
xScale: number;
yScale: number;
}) {
const response = await fetch(params.imageUrl, { signal: AbortSignal.timeout(120_000) });
if (!response.ok) throw new Error("无法读取待扩展的原图");
const { data: source, info } = await sharp(Buffer.from(await response.arrayBuffer()))
.rotate().png().toBuffer({ resolveWithObject: true });
const g = getVip98kOutpaintGeometry(info.width, info.height, params.xScale, params.yScale);
const input = await sharp(source).resize(g.sourceWidth, g.sourceHeight).ensureAlpha().extend({
left: g.left, right: g.modelWidth - g.sourceWidth - g.left,
top: g.top, bottom: g.modelHeight - g.sourceHeight - g.top,
background: { r: 0, g: 0, b: 0, alpha: 0 },
}).png().toBuffer();
const body = new FormData();
const prompt = [
"Extend this product image outward on all four sides, continuing its background naturally.",
`The reference canvas is ${g.modelWidth}x${g.modelHeight}; the original image is centered at (${g.left},${g.top}) with size ${g.sourceWidth}x${g.sourceHeight}.`,
"Fill only the surrounding empty area as a continuation of the same scene.",
"Preserve the existing product identity, structure, colors, and all Chinese text and dimension numbers.",
"Keep approximately the same composition and the product centered.",
"Avoid adding new products or additional text. Return a complete seamless image.",
].join(" ");
for (const [key, value] of Object.entries({
model: VIP98K_OUTPAINT_MODEL, prompt, n: "1", size: g.aspectRatio,
resolution: g.resolution, quality: "high", response_format: "url",
})) body.append(key, value);
body.append("image[]", new Blob([new Uint8Array(input)], { type: "image/png" }), "outpaint.png");
const base = params.baseUrl.replace(/\/$/, "").replace(/\/v1$/, "");
const generated = await fetch(`${base}/v1/images/edits`, {
method: "POST", body,
headers: { Authorization: `Bearer ${params.apiKey}`, "X-Relay-Route-Option": params.routeOptionId },
signal: AbortSignal.timeout(180_000),
});
const result = await generated.json();
if (!generated.ok) throw new Error(`98K 扩图失败:${result?.error?.message || `HTTP ${generated.status}`}`);
const image = result?.data?.[0];
let expanded: Buffer;
if (typeof image?.b64_json === "string") {
expanded = Buffer.from(image.b64_json, "base64");
} else if (typeof image?.url === "string") {
const outputUrl = new URL(image.url);
const headers = new Headers();
if (outputUrl.origin === new URL(base).origin) headers.set("Authorization", `Bearer ${params.apiKey}`);
const output = await fetch(outputUrl, { headers, signal: AbortSignal.timeout(60_000) });
if (!output.ok) throw new Error(`无法读取扩图结果(HTTP ${output.status})`);
expanded = Buffer.from(await output.arrayBuffer());
} else {
throw new Error("98K 扩图未返回图片");
}
return sharp(expanded).resize(g.targetWidth, g.targetHeight, { fit: "cover", position: "centre" }).png().toBuffer();
}

View File

@ -99,6 +99,7 @@ const DEFAULT_POLL_INTERVAL_SECONDS = 8;
const DEFAULT_MAX_POLL_SECONDS = 600;
const REFERENCE_DOWNLOAD_ATTEMPTS = 3;
const REFERENCE_DOWNLOAD_TIMEOUT_MS = 120_000;
const REFERENCE_CDN_TIMEOUT_MS = 10_000;
const REFERENCE_CACHE_LOOKUP_TIMEOUT_MS = 15_000;
const MAX_REFERENCE_DOWNLOAD_BYTES = IMAGE_UPLOAD_MAX_BYTES;
const REFERENCE_STAGING_CACHE_MS = 60_000;
@ -682,43 +683,44 @@ async function findStagedReference(
}
}
async function fetchReferenceImage(mediaUrl: string, preferCdn = false) {
const response = await fetch(mediaUrl, {
cache: preferCdn ? "default" : "no-store",
redirect: preferCdn ? "error" : "follow",
headers: { "User-Agent": "yedir-ai-vip98k-staging/1.0" },
signal: AbortSignal.timeout(preferCdn ? REFERENCE_CDN_TIMEOUT_MS : REFERENCE_DOWNLOAD_TIMEOUT_MS),
});
if (!response.ok || (preferCdn && response.status !== 200)) {
await response.body?.cancel();
throw new Error(`source returned HTTP ${response.status}`);
}
const mimeType = response.headers.get("content-type") || "image/png";
if (preferCdn && !/^image\//i.test(mimeType) && !/^application\/octet-stream\b/i.test(mimeType)) {
await response.body?.cancel();
throw new Error("CDN response was not an image");
}
const declaredLength = Number(response.headers.get("content-length"));
if (declaredLength > MAX_REFERENCE_DOWNLOAD_BYTES) {
await response.body?.cancel();
throw new Vip98kReferenceTooLargeError();
}
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.byteLength > MAX_REFERENCE_DOWNLOAD_BYTES) {
throw new Vip98kReferenceTooLargeError();
}
if ((declaredLength > 0 && buffer.byteLength !== declaredLength) || (preferCdn && !buffer.byteLength)) {
throw new Error("source image download was incomplete");
}
return { buffer, mimeType };
}
async function downloadReferenceImage(mediaUrl: string) {
let lastError: unknown;
for (let attempt = 1; attempt <= REFERENCE_DOWNLOAD_ATTEMPTS; attempt += 1) {
try {
const response = await fetch(mediaUrl, {
cache: "no-store",
redirect: "follow",
headers: { "User-Agent": "yedir-ai-vip98k-staging/1.0" },
signal: AbortSignal.timeout(REFERENCE_DOWNLOAD_TIMEOUT_MS),
});
if (!response.ok) {
throw new Error(`source returned HTTP ${response.status}`);
}
const declaredLength = Number(response.headers.get("content-length"));
if (
Number.isFinite(declaredLength) &&
declaredLength > MAX_REFERENCE_DOWNLOAD_BYTES
) {
throw new Vip98kReferenceTooLargeError();
}
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.byteLength > MAX_REFERENCE_DOWNLOAD_BYTES) {
throw new Vip98kReferenceTooLargeError();
}
if (
Number.isFinite(declaredLength) &&
declaredLength > 0 &&
buffer.byteLength !== declaredLength
) {
throw new Error("source image download was incomplete");
}
return {
buffer,
mimeType: response.headers.get("content-type") || "image/png",
};
return await fetchReferenceImage(mediaUrl);
} catch (error) {
lastError = error;
if (error instanceof Vip98kReferenceTooLargeError) throw error;
@ -738,6 +740,22 @@ async function downloadReferenceImage(mediaUrl: string) {
);
}
async function downloadReferenceImageFromCdn(mediaUrl: string, stagingConfig: ReferenceStagingConfig) {
if (stagingConfig.storageConfig.provider !== "qiniu-kodo") return null;
const cdnUrl = process.env.NEXT_PUBLIC_CANVAS_CDN_ORIGIN || "";
if (!URL.canParse(cdnUrl) || !getStorageObjectKeyFromPublicUrl(mediaUrl, stagingConfig.sourcePublicUrl)) return null;
const source = new URL(mediaUrl);
if (source.origin !== new URL(cdnUrl).origin || source.search || source.username || source.password) return null;
try {
return await fetchReferenceImage(mediaUrl, true);
} catch (error) {
if (error instanceof Vip98kReferenceTooLargeError) throw error;
logger.warn("reference CDN download failed; trying source storage", { sourceHost: source.hostname });
return null;
}
}
async function downloadReferenceImageFromStorage(
mediaUrl: string,
stagingConfig: ReferenceStagingConfig,
@ -852,9 +870,10 @@ async function stageReferenceImage(
}
const downloadStartedAt = Date.now();
const cdn = await downloadReferenceImageFromCdn(mediaUrl, stagingConfig);
const storage = cdn ? null : await downloadReferenceImageFromStorage(mediaUrl, stagingConfig);
const downloaded =
(await downloadReferenceImageFromStorage(mediaUrl, stagingConfig)) ||
(await downloadReferenceImage(mediaUrl));
cdn || storage || (await downloadReferenceImage(mediaUrl));
const downloadMs = Date.now() - downloadStartedAt;
const normalizeStartedAt = Date.now();
const normalized = await normalizeReferenceImage(
@ -872,6 +891,7 @@ async function stageReferenceImage(
cacheStagedReference(cacheKey, uploadedUrl, runtimeConfig);
logger.info("reference image staged", {
sourceHost: getUrlHost(mediaUrl),
sourceDelivery: cdn ? "cdn" : storage ? "storage" : "public-url",
targetHost: getUrlHost(uploadedUrl),
sourceBytes: downloaded.buffer.byteLength,
stagedBytes: normalized.buffer.byteLength,
@ -1646,6 +1666,7 @@ export function buildTaskBody(params: Vip98kTaskParams, invocation: Vip98kInvoca
}
if (params.modelId.toLowerCase() === "image-expansion") {
bindVip98kExpansionParams(body, imageParams);
body.output_format = "png";
}
if (params.kind === "image-tool" && params.modelId.toLowerCase() === "ocr") {
// Old synced examples include generic image fields and both language aliases.

View File

@ -3444,11 +3444,13 @@ async function applyLocalEditCompositeToImages(params: {
const [originalUpload, thumbnailUpload] = await Promise.all([
uploadFile(
`canvas-local-edit-composited-${suffix}.png`,
bufferToArrayBuffer(mergedBuffer)
bufferToArrayBuffer(mergedBuffer),
{ preferQiniuForm: true }
),
uploadFile(
`canvas-local-edit-composited-thumb-${suffix}.png`,
bufferToArrayBuffer(thumbnailBuffer)
bufferToArrayBuffer(thumbnailBuffer),
{ preferQiniuForm: true }
),
]);
mergedImages.push({

View File

@ -221,7 +221,9 @@ export async function convertImageToPhotoshopPng(bytes: Buffer): Promise<Buffer>
|| (metadata.space === "rgb16" && metadata.depth === "ushort");
if (metadata.format === "png" && rgb
&& !metadata.isPalette && (metadata.pages || 1) === 1 && (!metadata.orientation || metadata.orientation === 1)) return bytes;
return image.rotate().toColorspace("srgb").png({ compressionLevel: 3, adaptiveFiltering: true }).toBuffer();
const output = image.rotate().toColorspace("srgb");
if (metadata.icc) output.withIccProfile("srgb");
return output.png({ compressionLevel: 3, adaptiveFiltering: true }).toBuffer();
}
function buildCanvasAssetFilename(params: {

View File

@ -1,8 +1,9 @@
import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery";
import { inferOriginalImageUrl } from "@/lib/image-url";
import { formatDownloadFailure, getDownloadFailure, type DownloadFailure } from "@/lib/canvas/download-error";
import { withActivePageDownload } from "@/lib/client/page-refresh";
export type CanvasImageDownloadPhase = "preparing" | "downloading" | "saving";
export type CanvasImageDownloadPhase = "preparing" | "converting" | "downloading" | "saving";
export type CanvasImageDownloadProgress = {
phase: CanvasImageDownloadPhase;
@ -189,8 +190,15 @@ function triggerCdnDownload(result: { downloadUrl: string; filename: string; byt
return { filename: result.filename, bytes: result.bytes || 0 };
}
/** Keep the source format and bytes for chat, gallery and reference downloads. */
export async function downloadOriginalImage({ url, name, signal, onProgress }: DownloadCanvasImageParams): Promise<CanvasImageDownloadResult> {
const PHOTOSHOP_DOWNLOAD_EXTENSIONS = new Set(["jpg", "png", "gif", "bmp", "tiff"]);
/** Preserve compatible originals and prepare a PNG for other stored images. */
export function downloadOriginalImage(params: DownloadCanvasImageParams): Promise<CanvasImageDownloadResult> {
return withActivePageDownload(() => downloadOriginalImageFile(params));
}
async function downloadOriginalImageFile(params: DownloadCanvasImageParams): Promise<CanvasImageDownloadResult> {
const { url, name, signal, onProgress } = params;
const source = String(url || "").trim();
if (!source) throw new Error("图片地址无效");
const proxy = buildImageProxyUrl(source, { delivery: "original" });
@ -218,6 +226,11 @@ export async function downloadOriginalImage({ url, name, signal, onProgress }: D
const metadata = await checkedFetch(target.pathname + target.search);
const delivery = await metadata.json();
if (delivery.delivery === "cdn") {
// The resolver names the attachment using the stored MIME type.
const extension = delivery.filename.split(".").pop();
if (!PHOTOSHOP_DOWNLOAD_EXTENSIONS.has(extension)) {
return downloadPngFile(params, "converting");
}
const result = triggerCdnDownload(delivery, signal);
onProgress?.({ phase: "downloading", message: "已开始下载,可在浏览器下载列表查看进度" });
return result;
@ -225,9 +238,16 @@ export async function downloadOriginalImage({ url, name, signal, onProgress }: D
if (delivery.delivery !== "proxy") throw new Error("下载链接无效");
}
const response = await checkedFetch(proxy);
const contentType = response.headers.get("content-type") || "";
if (!contentType.toLowerCase().startsWith("image/")) throw new Error("Source is not an image");
const extension = getImageDownloadExtension(contentType);
// A local blob can still be downloaded while its upload is in progress.
if (/^https?:$/.test(target.protocol) && !PHOTOSHOP_DOWNLOAD_EXTENSIONS.has(extension)) {
void response.body?.cancel().catch(() => {});
return downloadPngFile(params, "converting");
}
const blob = await readResponseBlobWithProgress(response, onProgress);
if (!blob.type.toLowerCase().startsWith("image/")) throw new Error("Source is not an image");
const extension = getImageDownloadExtension(blob.type);
signal?.throwIfAborted();
const filename = extension ? `${filenameBase}.${extension}` : filenameBase;
triggerBlobDownload(blob, filename);
return { filename, bytes: blob.size };
@ -252,14 +272,15 @@ function toDownloadCandidates(url: string, candidates: string[] = []) {
async function readResponseBlobWithProgress(
response: Response,
onProgress?: (progress: CanvasImageDownloadProgress) => void
onProgress?: (progress: CanvasImageDownloadProgress) => void,
message = "正在下载原图..."
) {
const totalBytes = Number(response.headers.get("content-length") || 0);
if (!response.body) {
const blob = await response.blob();
onProgress?.({
phase: "downloading",
message: "正在下载原图...",
message,
loadedBytes: blob.size,
totalBytes: totalBytes || blob.size,
percent: 100,
@ -279,7 +300,7 @@ async function readResponseBlobWithProgress(
loadedBytes += value.byteLength;
onProgress?.({
phase: "downloading",
message: "正在下载原图...",
message,
loadedBytes,
totalBytes: totalBytes || undefined,
percent: totalBytes > 0 ? Math.min(99, Math.round((loadedBytes / totalBytes) * 100)) : undefined,
@ -291,13 +312,17 @@ async function readResponseBlobWithProgress(
});
}
export async function downloadCanvasImageAsPng({
export function downloadCanvasImageAsPng(params: DownloadCanvasImageParams): Promise<CanvasImageDownloadResult> {
return withActivePageDownload(() => downloadPngFile(params, "preparing"));
}
async function downloadPngFile({
candidates,
name,
onProgress,
signal,
url,
}: DownloadCanvasImageParams): Promise<CanvasImageDownloadResult> {
}: DownloadCanvasImageParams, preparationPhase: "preparing" | "converting"): Promise<CanvasImageDownloadResult> {
const raw = String(url || "").trim();
if (!raw) {
throw new Error("图片地址无效");
@ -310,11 +335,9 @@ export async function downloadCanvasImageAsPng({
"canvas-image"
);
onProgress?.({
phase: "preparing",
message: "正在准备原图...",
percent: 8,
});
const isConverting = preparationPhase === "converting";
const conversionMessage = "正在转换为兼容 PNG,完成后自动下载";
onProgress?.({ phase: preparationPhase, message: isConverting ? conversionMessage : "正在准备原图..." });
let response: Response | null = null;
let errorPayload: DownloadErrorPayload | null = null;
@ -353,7 +376,10 @@ export async function downloadCanvasImageAsPng({
if (Date.now() >= preparationDeadline || preparationPolls >= 60) {
throw new CanvasImageDownloadError("原图仍在准备中", { code: "preparation_timeout", status: 202, requestId: preparing.requestId });
}
onProgress?.({ phase: "preparing", message: preparing.message || "正在准备原图,完成后将自动下载" });
onProgress?.({
phase: preparationPhase,
message: isConverting ? conversionMessage : preparing.message || "正在准备原图,完成后将自动下载",
});
const delaySeconds = Math.max(preparationPolls > 5 ? 5 : 1, Math.min(5, Number(preparing.retryAfter) || 3));
await waitForRetry(delaySeconds * 1000, signal);
continue;
@ -384,7 +410,6 @@ export async function downloadCanvasImageAsPng({
retryAfterSeconds > 0
? `请求较多,${retryAfterSeconds} 秒后自动重试...`
: "请求较多,正在自动重试...",
percent: 8,
});
await waitForRetry(retryAfterSeconds * 1000, signal);
}
@ -413,7 +438,9 @@ export async function downloadCanvasImageAsPng({
const filename =
parseAttachmentFilename(response.headers.get("content-disposition")) ||
`${filenameBase}.png`;
const blob = await readResponseBlobWithProgress(response, onProgress);
const downloadMessage = isConverting ? "正在下载 PNG..." : "正在下载原图...";
onProgress?.({ phase: "downloading", message: downloadMessage });
const blob = await readResponseBlobWithProgress(response, onProgress, downloadMessage);
onProgress?.({
phase: "saving",

View File

@ -1,10 +1,44 @@
export const BEFORE_PAGE_REFRESH = "app:before-page-refresh";
export const PAGE_DOWNLOAD_ACTIVE_ATTRIBUTE = "data-page-download-active";
export type PageRefreshRequest = {
save: (action: () => Promise<void>) => void;
block: (message: string) => void;
};
const downloadListeners = new Set<() => void>();
let activePageDownloads = 0;
export function isPageDownloadActive() {
return activePageDownloads > 0;
}
export function subscribeToPageDownloadActivity(listener: () => void) {
downloadListeners.add(listener);
return () => { downloadListeners.delete(listener); };
}
function publishDownloadActivity() {
if (typeof document !== "undefined") {
document.documentElement?.toggleAttribute(PAGE_DOWNLOAD_ACTIVE_ATTRIBUTE, isPageDownloadActive());
}
for (const listener of downloadListeners) listener();
}
export async function withActivePageDownload<T>(download: () => Promise<T>): Promise<T> {
if (++activePageDownloads === 1) publishDownloadActivity();
try {
return await download();
} finally {
if (--activePageDownloads === 0) publishDownloadActivity();
}
}
function assertPageDownloadFinished() {
if (isPageDownloadActive()) throw new Error("文件正在下载,请等下载完成后再刷新。");
}
export async function preparePageRefresh() {
assertPageDownloadFinished();
const actions: Array<() => Promise<void>> = [];
let blocked = "";
window.dispatchEvent(new CustomEvent<PageRefreshRequest>(BEFORE_PAGE_REFRESH, {
@ -12,4 +46,5 @@ export async function preparePageRefresh() {
}));
if (blocked) throw new Error(blocked);
for (const action of actions) await action();
assertPageDownloadFinished();
}

View File

@ -1,6 +1,15 @@
import { normalizeLegacyAliyunStorageUrl } from "./storage/retired-storage";
const IMAGE_PROXY_PATH = "/api/image-proxy";
const CDN_PREVIEW_WIDTHS = [64, 160, 192, 240, 320, 360, 480, 520, 640, 720, 840, 960, 1280, 1600, 2048, 2560, 2600, 3200, 4096];
function getConfiguredCdnUrl(): URL | undefined {
try {
return new URL(process.env.NEXT_PUBLIC_CANVAS_CDN_ORIGIN || "");
} catch {
return undefined;
}
}
/** Error fallbacks must keep unavailable legacy sources behind archive resolution. */
export function buildImageFallbackUrl(value: string | null | undefined): string {
@ -102,10 +111,7 @@ export function isAlibabaModelImageUrl(value: string): boolean {
function isTrustedDirectStorageImageHost(hostname: string): boolean {
const normalized = hostname.trim().toLowerCase();
let configuredCdnHost = "";
try { configuredCdnHost = new URL(process.env.NEXT_PUBLIC_CANVAS_CDN_ORIGIN || "").hostname; } catch { /* optional */ }
return (
(configuredCdnHost !== "" && normalized === configuredCdnHost) ||
normalized === "yybb.top" ||
normalized.endsWith(".qiniucs.com") ||
normalized.endsWith(".qiniudn.com") ||
@ -127,20 +133,36 @@ function resolveTrustedDirectStorageDisplayUrl(
): string | null {
try {
const target = new URL(url);
const configuredCdn = getConfiguredCdnUrl();
const storageOrigin = process.env.NEXT_PUBLIC_CANVAS_STORAGE_ORIGIN;
if (configuredCdn?.protocol === "https:" && storageOrigin &&
target.origin === new URL(storageOrigin).origin &&
!target.searchParams.has("token") && !target.searchParams.has("X-Amz-Signature")) {
target.host = configuredCdn.host;
target.protocol = configuredCdn.protocol;
}
const isConfiguredCdn = target.origin === configuredCdn?.origin;
if (
target.hostname !== configuredCdn?.hostname &&
!isTrustedDirectStorageImageHost(target.hostname) &&
!isFirstPartyQiniuAsset(target)
) {
return null;
}
const width = normalizeWidth(options.width);
const quality = normalizeQuality(options.quality);
const requestedWidth = normalizeWidth(options.width);
let width = requestedWidth;
let quality = normalizeQuality(options.quality);
const format = options.format;
if ((!width && !quality && !format) || target.search) {
return target.toString();
}
if (isConfiguredCdn && format === "webp" && requestedWidth) {
width = CDN_PREVIEW_WIDTHS.find((size) => size >= requestedWidth) ?? requestedWidth;
if (quality && quality >= 84 && quality <= 92) quality = 88;
}
const operations = ["imageView2", "2"];
if (width) operations.push("w", String(width));
if (format) operations.push("format", format === "jpeg" ? "jpg" : format);
@ -200,6 +222,23 @@ function resolveAliyunOssDisplayUrl(
export function buildImageProxyUrl(url: string, options: ImageProxyOptions = {}): string {
let source = String(url || "").trim();
if (options.delivery !== "original" && options.delivery !== "proxy") {
const base = typeof window === "undefined" ? "https://image-proxy.local" : window.location.origin;
try {
const saved = new URL(source, base);
if (saved.origin === base && saved.pathname === IMAGE_PROXY_PATH &&
!saved.searchParams.has("original") && saved.searchParams.get("proxy") !== "1") {
const nested = saved.searchParams.get("url") || "";
const format = options.format || saved.searchParams.get("f") as ImageProxyFormat | undefined;
const direct = resolveTrustedDirectStorageDisplayUrl(nested, {
width: options.width || Number(saved.searchParams.get("w")) || undefined,
quality: options.quality || Number(saved.searchParams.get("q")) || undefined,
format: ["webp", "avif", "jpeg", "png"].includes(format || "") ? format : undefined,
});
if (direct) return direct;
}
} catch { /* Keep non-storage proxies on their existing path. */ }
}
// Some messages retain our preview URL instead of its source URL. Rebuild
// that local proxy request without the preview's resize/format parameters.
if (options.delivery === "original") {

View File

@ -26,14 +26,14 @@ function getBatchSize() {
return Math.max(1, Math.min(50, Math.round(parsed)));
}
export async function runImageArchiveJob() {
export async function runImageArchiveJob({ preferredTaskId }: { preferredTaskId?: string } = {}) {
return trackRuntimeWork(async () => {
const batchSize = getBatchSize();
console.log(`[ImageArchiveJob] Start image archive job (batchSize=${batchSize})`);
await recoverStaleImageArchiveTasks();
scheduleImageDownloadPreparation();
const storeTasks = await getImageArchiveTasksToStore({ limit: batchSize });
const storeTasks = await getImageArchiveTasksToStore({ limit: batchSize, preferredTaskId });
let storeSucceeded = 0;
let storeFailed = 0;

View File

@ -1,6 +1,6 @@
import "server-only";
import { getDefaultStorageConfig } from "@/lib/db/queries";
import { findImageArchiveDownloadTask } from "./image-archive-db";
import { ensureStoredImageDownloadTask, findImageArchiveDownloadTask } from "./image-archive-db";
import { extractImageProxySourceUrl, inferOriginalImageUrl } from "@/lib/image-url";
export async function resolveCanvasCdnDownload(urls: string[], filename: string) {
@ -8,17 +8,30 @@ export async function resolveCanvasCdnDownload(urls: string[], filename: string)
const source = extractImageProxySourceUrl(url) || url;
return [source, inferOriginalImageUrl(source) || source];
})));
const task = await findImageArchiveDownloadTask(sources, true);
const asset = (task?.metadata as { downloadAsset?: { status?: string; url?: string; contentType?: string; bytes?: number } } | null)?.downloadAsset;
if (!task) return { task, direct: null, canPrepareDirect: false };
let task = await findImageArchiveDownloadTask(sources, true);
const config = await getDefaultStorageConfig();
if (config?.type !== "qiniu-kodo" || !config.publicUrl || !config.isEnabled) return { task, direct: null, canPrepareDirect: false };
const ready = asset?.status === "ready" && asset.url && asset.contentType === "image/png";
const source = ready ? asset.url : task.targetUrl;
if (!source) return { task, direct: null, canPrepareDirect: false };
const url = new URL(source);
const base = new URL(config.publicUrl);
const origin = process.env.CANVAS_QINIU_ORIGIN_URL;
if (!task && base.protocol === "https:") {
for (const candidate of sources) {
const stored = new URL(candidate);
if (origin && stored.origin === new URL(origin).origin && !stored.search) {
stored.host = base.host;
stored.protocol = base.protocol;
}
if (stored.origin === base.origin && !stored.search && !stored.hash &&
stored.pathname.startsWith(base.pathname.replace(/\/$/, "") + "/uploads/")) {
task = await ensureStoredImageDownloadTask(stored.toString());
break;
}
}
}
const asset = (task?.metadata as { downloadAsset?: { status?: string; url?: string; contentType?: string; bytes?: number } } | null)?.downloadAsset;
const ready = asset?.status === "ready" && asset.url && asset.contentType === "image/png";
const source = ready ? asset.url : task?.targetUrl;
if (!source) return { task, direct: null, canPrepareDirect: false };
const url = new URL(source);
if (origin && url.origin === new URL(origin).origin) url.host = base.host;
if (base.protocol !== "https:" || url.origin !== base.origin
|| !(url.pathname.startsWith(base.pathname.replace(/\/$/, "") + "/"))) return { task, direct: null, canPrepareDirect: false };

View File

@ -98,8 +98,11 @@ export async function enqueueImageArchiveTasks(params: {
return tasks;
}
export async function getImageArchiveTasksToStore(params: { limit: number }) {
export async function getImageArchiveTasksToStore(params: { limit: number; preferredTaskId?: string }) {
const now = new Date();
const priority = params.preferredTaskId
? [asc(sql`case when ${imageArchiveTask.id} = ${params.preferredTaskId} then 0 else 1 end`)]
: [];
return await db
.select()
.from(imageArchiveTask)
@ -110,7 +113,7 @@ export async function getImageArchiveTasksToStore(params: { limit: number }) {
lt(imageArchiveTask.attemptCount, MAX_IMAGE_ARCHIVE_ATTEMPTS),
),
)
.orderBy(asc(imageArchiveTask.createdAt), asc(imageArchiveTask.id))
.orderBy(...priority, asc(imageArchiveTask.createdAt), asc(imageArchiveTask.id))
.limit(Math.max(1, Math.min(50, params.limit)));
}
@ -350,6 +353,17 @@ export async function findImageArchiveDownloadTask(urls: string[], prepareOnDema
}
return task || null;
}
/** Reuse the download worker for stored uploads and synchronous tool results. */
export async function ensureStoredImageDownloadTask(url: string) {
const now = new Date();
const [created] = await db.insert(imageArchiveTask).values({
sourceUrl: url, targetUrl: url, status: "completed", archiveAfterAt: now,
replaceAfterAt: now, archivedAt: now, replacedAt: now, updatedAt: now,
metadata: { downloadAsset: { status: "pending" } },
}).onConflictDoNothing({ target: imageArchiveTask.sourceUrl }).returning();
return created || await findImageArchiveDownloadTask([url], true);
}
export async function hasPendingImageDownloads() {
const [task] = await db.select({ id: imageArchiveTask.id }).from(imageArchiveTask).where(
sql`${imageArchiveTask.metadata}->'downloadAsset'->>'status' in ('pending', 'preparing', 'failed')`,

View File

@ -6,6 +6,7 @@ type Worker = {
running: boolean;
scheduled: boolean;
requested: boolean;
preferredTaskId?: string;
retryTimer?: ReturnType<typeof setTimeout>;
};
function getWorker(): Worker {
@ -13,8 +14,9 @@ function getWorker(): Worker {
return (root[workerKey] ??= { running: false, scheduled: false, requested: false });
}
export function scheduleImageArchiveWorker(deliveryConfirmed = false) {
export function scheduleImageArchiveWorker(deliveryConfirmed = false, preferredTaskId?: string) {
const worker = getWorker();
worker.preferredTaskId ??= preferredTaskId;
worker.requested = true;
if (worker.running || (worker.scheduled && !deliveryConfirmed)) return;
worker.scheduled = true;
@ -31,7 +33,9 @@ export function scheduleImageArchiveWorker(deliveryConfirmed = false) {
const { runImageArchiveJob } = await import("@/lib/jobs/image-archive-job");
do {
worker.requested = false;
const result = await runImageArchiveJob();
const nextTaskId = worker.preferredTaskId;
worker.preferredTaskId = undefined;
const result = await runImageArchiveJob({ preferredTaskId: nextTaskId });
retryNeeded = result.stats.pendingStore > 0 || result.stats.pendingReplace > 0 || result.pendingDownloads === true;
// Stop when only backoff tasks remain; do not spin while storage is down.
if (!worker.requested && result.store.total === 0 && result.replace.total === 0 && result.downloadTasks === 0) break;

View File

@ -4,7 +4,7 @@ import { fetchImageBytes, cacheImageBytes } from "./image-byte-cache";
import { normalizeCanvasDownloadAsset } from "@/lib/canvas/photoshop-safe-download";
import { claimImageDownloadTask, saveImageDownloadAsset, getImageDownloadTasks, hasPendingImageDownloads } from "./image-archive-db";
import { uploadFile } from "./service";
import { getReadyQiniuImageDownload } from "./qiniu-image-archive";
import { getReadyQiniuImageDownload, prepareQiniuWebpDownload } from "./qiniu-image-archive";
import { getDownloadFailure } from "@/lib/canvas/download-error";
import { createLogger } from "@/lib/observability/logger";
import { trackRuntimeWork } from "@/lib/observability/active-work";
@ -21,6 +21,11 @@ export async function prepareImageArchiveDownload(id: string) {
await saveImageDownloadAsset(id, ready);
return;
}
const converted = await prepareQiniuWebpDownload(task.targetUrl, task.id).catch(() => undefined);
if (converted) {
await saveImageDownloadAsset(id, converted);
return;
}
const source = await fetchImageBytes(task.targetUrl);
phase = "convert";
const asset = await normalizeCanvasDownloadAsset({

View File

@ -4,6 +4,7 @@ import { findImageArchiveDownloadTask } from "./image-archive-db";
import { inferOriginalImageUrl } from "@/lib/image-url";
import { getImageDownloadExtension } from "@/lib/image-delivery";
import { sanitizeCanvasDownloadFileName } from "@/lib/canvas/photoshop-safe-download";
import { HeadObjectCommand } from "@aws-sdk/client-s3";
/** Resolve the archived original without waiting for a separate PNG conversion. */
export async function resolveOriginalCdnDownload(source: string, name: string, signal: AbortSignal) {
@ -14,6 +15,10 @@ export async function resolveOriginalCdnDownload(source: string, name: string, s
let target = new URL(source);
if (target.origin !== base.origin && (!origin || target.origin !== new URL(origin).origin)) {
const task = await findImageArchiveDownloadTask([...new Set([source, inferOriginalImageUrl(source)])]);
if (task?.status === "pending" && !task.targetUrl) {
const { scheduleImageArchiveWorker } = await import("./image-archive-worker");
scheduleImageArchiveWorker(false, task.id);
}
target = new URL(task?.targetUrl || source);
}
if (origin && target.origin === new URL(origin).origin && !target.searchParams.has("token")) {
@ -28,8 +33,32 @@ export async function resolveOriginalCdnDownload(source: string, name: string, s
// Only metadata crosses the application server. The browser receives the file
// from the CDN, including when cross-origin fetch is disabled by storage CORS.
const response = await fetch(target, {
method: "HEAD", cache: "no-store",
signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]),
method: "HEAD", cache: "default",
signal: AbortSignal.any([signal, AbortSignal.timeout(3_000)]),
}).catch(async (error) => {
signal.throwIfAborted();
if (target.search) throw error;
const { createS3Client, toDbS3UploadConfig } = await import("./service");
const storage = toDbS3UploadConfig(config);
if (!storage) throw error;
const key = decodeURIComponent(target.pathname.slice(base.pathname.replace(/\/$/, "").length + 1));
if (!key) throw error;
const client = createS3Client(storage);
try {
const object = await client.send(new HeadObjectCommand({ Bucket: storage.bucket, Key: key }), {
abortSignal: AbortSignal.any([signal, AbortSignal.timeout(5_000)]),
});
return new Response(null, { headers: {
"content-type": object.ContentType || "",
"content-length": String(object.ContentLength ?? 0),
} });
} catch (storageError) {
const status = (storageError as { $metadata?: { httpStatusCode?: number } })?.$metadata?.httpStatusCode;
if (status === 403 || status === 404) throw new Error(`HTTP ${status}`);
throw error;
} finally {
client.destroy();
}
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const contentType = response.headers.get("content-type") || "";

View File

@ -48,7 +48,7 @@ export async function getReadyQiniuImageDownload(url: string, target?: Awaited<R
const base = buildS3PublicUrl(config, "");
if (!url.startsWith(base)) return;
const key = url.slice(base.length);
if (!/^uploads\/[\w.-]+$/.test(key)) return;
if (!/^uploads\/(?:[\w.-]+\/)*[\w.-]+$/.test(key)) return;
const client = createS3Client(config);
try {
@ -74,17 +74,45 @@ export async function getReadyQiniuImageDownload(url: string, target?: Awaited<R
}
export async function archiveImageThroughQiniu(sourceUrl: string, archiveId: string) {
if (!/^https:\/\/api\.vip98k\.com\/api\/external\/assets\/[A-Za-z0-9_-]+$/.test(sourceUrl)) {
return null;
}
let source: URL;
try { source = new URL(sourceUrl); } catch { return null; }
if (!/^https?:$/.test(source.protocol) || source.username || source.password) return null;
const target = await resolveS3UploadTarget();
const config = target?.config;
if (config?.provider !== "qiniu-kodo" || config.region !== "cn-east-1" || !config.publicUrl) {
return null;
}
// Files already in our bucket and inline previews need no remote copy.
const localOrigins = [config.publicUrl, config.endpoint, process.env.CANVAS_QINIU_ORIGIN_URL,
process.env.NEXT_PUBLIC_APP_URL, process.env.AUTH_URL, process.env.NEXTAUTH_URL];
if (localOrigins.some(value => value && new URL(value).origin === source.origin)
|| source.hostname === "localhost" || source.hostname === "[::1]"
|| /^(?:127\.|10\.|192\.168\.|169\.254\.|172\.(?:1[6-9]|2\d|3[01])\.)/.test(source.hostname)) return null;
// The archive task owns this object key, including retries after a timeout.
const pathname = `uploads/ai-generated-archive-${archiveId}`;
return fetchImageIntoQiniu(sourceUrl, pathname, target!);
}
/** Prepare uploaded WebP images inside Kodo, retaining their embedded colour profile. */
export async function prepareQiniuWebpDownload(url: string, taskId: string) {
const target = await resolveS3UploadTarget();
const config = target?.config;
if (config?.provider !== "qiniu-kodo" || config.region !== "cn-east-1" || !config.publicUrl) return;
const base = buildS3PublicUrl(config, "");
if (!url.startsWith(base) || !/^uploads\/(?:[\w.-]+\/)*[\w.-]+\.webp$/i.test(url.slice(base.length))) return;
// No resizing or lossy re-encoding; apply orientation before removing EXIF.
const source = `${url}?imageMogr2/auto-orient/format/png/strip/meta-keep-list/${encodeQiniu("icc")}`;
const result = await fetchImageIntoQiniu(source, `uploads/canvas-download-${taskId}.png`, target!);
return result.downloadAsset;
}
async function fetchImageIntoQiniu(
sourceUrl: string,
pathname: string,
target: NonNullable<Awaited<ReturnType<typeof resolveS3UploadTarget>>>,
) {
const config = target.config;
const path = `/fetch/${encodeQiniu(sourceUrl)}/to/${encodeQiniu(`${config.bucket}:${pathname}`)}`;
const contentType = "application/x-www-form-urlencoded";
const signingData = `POST ${path}\nHost: ${QINIU_EAST_CHINA_IO}\nContent-Type: ${contentType}\n\n`;

View File

@ -3,6 +3,7 @@ import { isRetiredStorageUrl, isLegacyAliyunStorageConfig, RETIRED_STORAGE_MESSA
import { DeleteObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { put } from "@vercel/blob";
import { createHmac } from "node:crypto";
import {
getDefaultStorageConfig,
getStorageConfigById,
@ -239,12 +240,48 @@ export async function maybeEnqueueStorageMigrationTask(params: {
await maybeEnqueueAliyunMigrationTask(params);
}
async function uploadQiniuForm(
config: S3UploadConfig,
key: string,
buffer: ArrayBuffer,
contentType: string
) {
const encode = (value: Buffer) =>
value.toString("base64").replace(/\+/g, "-").replace(/\//g, "_");
const policy = encode(Buffer.from(JSON.stringify({
scope: `${config.bucket}:${key}`,
deadline: Math.floor(Date.now() / 1000) + 120,
insertOnly: 1,
})));
const signature = encode(createHmac("sha1", config.secretAccessKey).update(policy).digest());
const form = new FormData();
form.append("token", `${config.accessKeyId}:${signature}:${policy}`);
form.append("key", key);
form.append("file", new Blob([buffer], { type: contentType }), key.split("/").at(-1));
const startedAt = Date.now();
const response = await fetch("https://upload.qiniup.com/", {
method: "POST",
body: form,
redirect: "error",
signal: AbortSignal.timeout(45_000),
});
if (!response.ok) {
await response.body?.cancel();
throw new Error(`Qiniu form upload returned HTTP ${response.status}`);
}
const result = await response.json() as { key?: string };
if (result.key !== key) throw new Error("Qiniu form upload returned an unexpected key");
logger.info("qiniu_form_upload_completed", { bytes: buffer.byteLength, elapsedMs: Date.now() - startedAt });
}
// S3/MinIO 上传函数(使用 AWS SDK)
async function uploadToS3(
filename: string,
buffer: ArrayBuffer,
config: S3UploadConfig,
s3Client = createS3Client(config)
s3Client = createS3Client(config),
preferQiniuForm = false
) {
const {
endpoint,
@ -260,6 +297,14 @@ async function uploadToS3(
const contentType = getContentType(filename);
try {
if (preferQiniuForm && config.provider === "qiniu-kodo" && config.region === "cn-east-1") {
try {
await uploadQiniuForm(config, key, buffer, contentType);
return { url: buildS3PublicUrl(config, key), pathname: key, contentType };
} catch (error) {
logger.warn("[Upload] Qiniu form upload failed, retrying the same object through S3", error);
}
}
logger.debug(`[S3 Upload] Uploading to: ${normalizedEndpoint}/${bucket}/${key}`);
// 上传文件
@ -551,7 +596,8 @@ async function uploadWithAnyEnabledStorageConfig(
// 上传文件到存储服务
export async function uploadFile(
filename: string,
buffer: ArrayBuffer
buffer: ArrayBuffer,
options?: { preferQiniuForm?: boolean }
): Promise<UploadedFileResult> {
logger.debug(`[Upload] Starting upload for file: ${filename}`);
@ -572,7 +618,7 @@ export async function uploadFile(
logger.debug(`[Upload] Using database S3 config: ${selectedStorageConfig?.name}`);
logger.debug(`[Upload] Endpoint: ${dbS3Config.endpoint}`);
logger.debug(`[Upload] Bucket: ${dbS3Config.bucket}`);
const uploaded = await uploadToS3(filename, buffer, dbS3Config);
const uploaded = await uploadToS3(filename, buffer, dbS3Config, undefined, options?.preferQiniuForm);
lastKnownGoodDbS3Config = dbS3Config;
await maybeEnqueueAliyunMigrationTask({
uploaded,

View File

@ -49,7 +49,10 @@ function resolveDevMediaOrigins(isDev: boolean) {
}
const nextConfig: NextConfig = {
env: { NEXT_PUBLIC_APP_RELEASE: appRelease },
env: {
NEXT_PUBLIC_APP_RELEASE: appRelease,
NEXT_PUBLIC_CANVAS_STORAGE_ORIGIN: process.env.CANVAS_QINIU_ORIGIN_URL || "",
},
distDir: process.env.NEXT_DIST_DIR?.trim() || ".next",
poweredByHeader: false,
allowedDevOrigins: ["127.0.0.1"],

View File

@ -0,0 +1,9 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./routes",
testMatch: /(?:qiniu-direct-paths|image-delivery-qiniu|image-archive-flow|original-image-download|canvas-download-speed|canvas-download-recovery)\.test\.ts/,
workers: 1,
reporter: "list",
timeout: 30_000,
});

View File

@ -72,6 +72,85 @@ function projectRoute() {
return { route: load("app/api/agent-projects/route.ts"), projects, setUser: (value: string | null) => { userId = value; } };
}
test("a saved full detail set is queued, while overflow and changed references have distinct errors", async () => {
const references = Array.from({ length: 65 }, (_, index) => ({
id: `detail-${index + 1}`,
imageUrl: `https://example.test/detail-${index + 1}.webp`,
}));
const project = {
id, userId: owner, title: "全套详情", workspaceType: "agent",
canvasData: { agentProjectState: { workflowState: {
agentModeState: { detailReplacement: { references } },
} } },
};
const canvasProject = { id: "id", userId: "userId" };
const created: Array<{ id: string; request: { references: typeof references }; status: string }> = [];
const queued: string[] = [];
const db = {
select: () => ({ from: (table: unknown) => ({
where: () => ({
limit: async () => table === canvasProject ? [project] : [],
orderBy: () => ({ limit: async () => [] }),
}),
}) }),
insert: () => ({ values: () => ({ onConflictDoNothing: async () => {} }) }),
};
const route = loadRuntime({
"@/app/(auth)/auth": { auth: async () => ({ user: { id: owner } }) },
"@/lib/db": { db },
"@/lib/db/schema": { canvasProject, canvasImageTask: { request: "request" }, message: { id: "id" } },
"drizzle-orm": { eq: () => null, and: () => null, inArray: () => null, desc: () => null, sql: () => null },
"@/lib/canvas/image-task-queue": {
createCanvasImageTask: async ({ taskId, request }: { taskId: string; request: { references: typeof references } }) => {
const task = { id: taskId, request, status: "pending" };
created.push(task);
return task;
},
getCanvasImageTaskForUser: async ({ taskId }: { taskId: string }) => created.find((task) => task.id === taskId),
enqueueCanvasImageTask: async (taskId: string) => { queued.push(taskId); },
},
"@/lib/db/queries": {
getEnabledAiModelsWithProvider: async () => [{ model: { id: "fixture-model" } }],
checkUserImageQuota: async () => ({ allowed: true, limit: null }),
getPointsSystemConfig: async () => ({ enabled: false }),
getChatById: async () => ({ userId: owner }),
},
"@/app/(chat)/api/chat/_lib/detail-replacement": { resolveDirectReplacementModelRef: async () => "fixture-model" },
"@/app/(chat)/api/chat/_lib/agent-scene-workflow": {
buildAgentSlotPrompt: ({ workflowPrompt }: { workflowPrompt: string }) => workflowPrompt,
normalizeAgentSupplementPrompt: () => "",
},
})("app/api/agents/detail-replacement/batches/route.ts");
const post = (items: typeof references, replacementStrategy = "product_only", expectedModelId = "fixture-model") => route.POST(
new Request("http://localhost/api/agents/detail-replacement/batches", {
method: "POST",
body: JSON.stringify({
projectId: id, requestId: randomUUID(), references: items, replacementStrategy, expectedModelId,
prompt: "沿用品牌场景", productImageUrls: ["https://example.test/product.webp"],
}),
}),
);
for (const [count, strategy] of [[48, "product_only"], [64, "scene_replace"]] as const) {
const expectedIds = references.slice(0, count).map((ref) => ref.id);
const response = await post(references.slice(0, count), strategy);
expect(response.status).toBe(200);
expect((await response.json()).task.requestedCardIds).toEqual(expectedIds);
expect(created.at(-1)?.request.references.map((ref) => ref.id)).toEqual(expectedIds);
}
const overflow = await post(references);
expect(overflow.status).toBe(400);
expect((await overflow.json()).error).toContain("一次最多替换 64 张");
const changed = await post([{ ...references[0], imageUrl: "https://example.test/changed.webp" }]);
expect(changed.status).toBe(400);
expect((await changed.json()).error).toContain("尚未保存或已发生变化");
const changedModel = await post(references.slice(0, 48), "scene_replace", "outdated-model");
expect(changedModel.status).toBe(409);
expect((await changedModel.json()).code).toBe("DETAIL_REPLACEMENT_MODEL_CHANGED");
expect(queued).toEqual(created.map((task) => task.id));
expect(queued).toHaveLength(2);
});
test("duplicate POSTs return one project without overwriting saved state", async () => {
const { route, projects } = projectRoute();
const post = (title: string) => route.POST(new Request("http://localhost/api/agent-projects", { method: "POST", body: JSON.stringify({ id, title }) }));

View File

@ -124,7 +124,7 @@ test("requested downloads are prioritized with two workers before scanning backg
},
"./image-byte-cache": { fetchImageBytes: async () => { active++; maximum = Math.max(maximum, active); await new Promise<void>(resolve => releases.push(resolve)); active--; return { buffer: bytes, contentType: "image/png" }; }, cacheImageBytes: async () => {} },
"@/lib/canvas/photoshop-safe-download": { normalizeCanvasDownloadAsset: async () => ({ ok: true, bytes, contentType: "image/png" }) },
"./qiniu-image-archive": { getReadyQiniuImageDownload: async () => undefined },
"./qiniu-image-archive": { getReadyQiniuImageDownload: async () => undefined, prepareQiniuWebpDownload: async () => undefined },
"./service": { uploadFile() { throw new Error("Compatible PNG must retain its object"); } },
})("lib/storage/image-download-preparation.ts");
worker.scheduleImageDownloadPreparation("one"); worker.scheduleImageDownloadPreparation("two"); worker.scheduleImageDownloadPreparation("three");

View File

@ -112,6 +112,10 @@ test("compatible PNG remains byte-identical; JPEG becomes RGB PNG without resizi
const converted = await module.convertImageToPhotoshopPng(jpeg);
const metadata = await sharp(converted).metadata();
expect(metadata).toMatchObject({ format: "png", space: "srgb", width: 32, height: 48, depth: "uchar" });
const webp = await sharp(bytes).withIccProfile("p3").webp().toBuffer();
const webpPng = await module.convertImageToPhotoshopPng(webp);
const webpPngMetadata = await sharp(webpPng).metadata();
expect(webpPngMetadata).toMatchObject({ format: "png", width: 32, height: 48, hasAlpha: true, hasProfile: true });
});
test("CDN delivery requires a ready compatible asset and a configured HTTPS storage origin", async () => {
let url = "https://cdn.example/uploads/test.png";
@ -133,7 +137,7 @@ test("PNG preparation failure leaves the original archived and schedules a retry
const module = serverLoader({
"./image-archive-db": { claimImageDownloadTask: async () => ({ id: "task", targetUrl: "https://cdn.example/original.png", metadata: { downloadAsset: { attempts: 2 } } }), saveImageDownloadAsset: async (_id: string, asset: any) => { saved = asset; } },
"./image-byte-cache": { fetchImageBytes: async () => { throw new Error("storage offline"); } },
"./qiniu-image-archive": { getReadyQiniuImageDownload: async () => undefined },
"./qiniu-image-archive": { getReadyQiniuImageDownload: async () => undefined, prepareQiniuWebpDownload: async () => undefined },
"./service": {},
})("lib/storage/image-download-preparation.ts");
await module.prepareImageArchiveDownload("task");

View File

@ -1,7 +1,62 @@
import { expect, test } from "@playwright/test";
import sharp from "sharp";
import { resolveAlibabaExtendApiKey, resolveFalExtendApiKey } from "@/lib/ai/extend/config";
import { composeFiboExpansion, getFiboGeometry } from "@/lib/ai/extend/fibo";
import { composeOutpaint, getOutpaintGeometry } from "@/lib/ai/extend/fal-outpaint";
import { expandWithVip98kOutpaint, getVip98kOutpaintGeometry } from "@/lib/ai/extend/vip98k-outpaint";
import { serverLoader } from "../helpers/canvas-download-server-loader";
test("native expansion sends the original image and never switches models after an upstream failure", async () => {
for (const message of ["HTTP 503 unavailable", "request timed out"]) {
const calls: any[] = [];
const service = serverLoader({
"@/lib/db/queries": { getSystemSetting: async () => ({ value: { provider: "vip98k", modelId: "image-expansion" } }) },
"@/lib/db": {},
"@/lib/db/schema": {},
"@/lib/storage/service": {},
"@/lib/storage/image-archive": {},
"@/lib/ai/vip98k-runtime": {
executeConfiguredVip98kTask: async (params: unknown) => { calls.push(params); throw new Error(message); },
},
})("lib/ai/extend/service.ts");
await expect(service.executeExtendJob({ userId: "test", imageUrl: "https://source.test/original.jpg", xScale: 1.5, yScale: 2 })).rejects.toThrow(message);
expect(calls).toEqual([{ modelId: "image-expansion", kind: "image-tool", imageUrls: ["https://source.test/original.jpg"], imageParams: { xScale: 1.5, yScale: 2 } }]);
}
});
test("native expansion archives the completed image without transferring its bytes through the app", async () => {
const calls: string[] = [];
const service = serverLoader({
"@/lib/db/queries": { getSystemSetting: async () => ({ value: { provider: "vip98k", modelId: "image-expansion" } }) },
"@/lib/db": {},
"@/lib/db/schema": {},
"@/lib/storage/service": {},
"@/lib/ai/vip98k-runtime": { executeConfiguredVip98kTask: async () => ({ outputUrls: ["https://relay.test/result.png"] }) },
"@/lib/storage/image-archive": { archiveTemporaryImage: async (url: string, id: string) => {
calls.push(url, id);
return { url: "https://cdn.test/native.png", thumbnailUrl: "https://cdn.test/native.png", bytes: 12375212 };
} },
})("lib/ai/extend/service.ts");
const result = await service.executeExtendJob({ userId: "test", imageUrl: "https://source.test/original.jpg", requestId: "request-1", skipGallerySave: true });
expect(calls).toEqual(["https://relay.test/result.png", "request-1"]);
expect(result.original_url).toBe("https://cdn.test/native.png");
expect(result.image_base64).toBeUndefined();
});
test("native expansion still returns image bytes to callers requesting base64", async () => {
const image = await sharp({ create: { width: 12, height: 8, channels: 3, background: "red" } }).png().toBuffer();
const service = serverLoader({
"@/lib/db/queries": { getSystemSetting: async () => ({ value: { provider: "vip98k", modelId: "image-expansion" } }) },
"@/lib/db": {},
"@/lib/db/schema": {},
"@/lib/storage/service": { uploadFile: async (name: string) => ({ url: `https://cdn.test/${name}` }) },
"@/lib/storage/image-archive": { archiveTemporaryImage: () => { throw new Error("Base64 callers need the image bytes"); } },
"@/lib/ai/vip98k-runtime": { executeConfiguredVip98kTask: async () => ({ outputUrls: ["https://relay.test/result.png"] }) },
fetch: async () => new Response(new Uint8Array(image)),
})("lib/ai/extend/service.ts");
const result = await service.executeExtendJob({ userId: "test", imageUrl: "https://source.test/original.jpg", includeBase64: true, skipGallerySave: true });
expect(result.image_base64).toBe(image.toString("base64"));
expect(result.original_url).toMatch(/\.png$/);
});
test.describe("image extension provider configuration", () => {
test("uses the most recently updated Alibaba image provider key", () => {
@ -35,22 +90,90 @@ test.describe("image extension provider configuration", () => {
});
test("outpainting restores every original pixel in place and keeps the generated border", async () => {
const geometry = getFiboGeometry(13, 9, 1.5, 1.5);
const { targetWidth, targetHeight, left, top } = geometry;
const geometry = getOutpaintGeometry(13, 9, 1.5, 1.5);
const { paddedWidth, paddedHeight, left, top } = geometry;
const pixel = Buffer.from([42, 93, 190, 255, 30, 80, 20, 75]);
const raw = Buffer.concat(Array.from({ length: 59 }, () => pixel)).subarray(0, 13 * 9 * 4);
const source = await sharp(raw, { raw: { width: 13, height: 9, channels: 4 } }).png().toBuffer();
const expanded = await sharp({ create: { width: targetWidth, height: targetHeight, channels: 3, background: "#d8bc9e" } }).png().toBuffer();
const composed = await composeFiboExpansion(source, expanded, geometry);
const expanded = await sharp({ create: { width: paddedWidth, height: paddedHeight, channels: 3, background: "#d8bc9e" } }).png().toBuffer();
const composed = await composeOutpaint(source, expanded, geometry);
const area = await sharp(composed).extract({ left, top, width: 13, height: 9 }).raw().toBuffer();
expect(area.equals(raw)).toBe(true);
expect([...await sharp(composed).extract({ left: 0, top: 0, width: 1, height: 1 }).raw().toBuffer()]).toEqual([216, 188, 158, 255]);
await expect(composeFiboExpansion(source, source, geometry)).rejects.toThrow("尺寸不正确");
await expect(composeOutpaint(source, source, geometry)).rejects.toThrow("尺寸不正确");
});
test("a 4K source keeps its target output size while the model works within its limit", () => {
const g = getFiboGeometry(4096, 4096, 1.5, 1.5);
const g = getOutpaintGeometry(4096, 4096, 1.5, 1.5);
expect([g.targetWidth, g.targetHeight, g.left, g.top]).toEqual([6144, 6144, 1024, 1024]);
expect(g.modelWidth * g.modelHeight).toBeLessThan(25_000_000);
expect(g.modelLeft * 2 + g.sourceWidth).toBeCloseTo(g.modelWidth, -1);
expect([g.paddedWidth, g.paddedHeight]).toEqual([2048, 2048]);
expect(g.expandLeft + g.sourceWidth + g.expandRight).toBe(g.paddedWidth);
expect(g.expandTop + g.sourceHeight + g.expandBottom).toBe(g.paddedHeight);
expect(Math.abs(g.expandLeft - g.expandRight)).toBeLessThanOrEqual(1);
});
test("relay outpainting pads to a supported ratio without cutting the source", () => {
const g = getVip98kOutpaintGeometry(621, 515, 1.5, 1.5);
expect([g.targetWidth, g.targetHeight]).toEqual([932, 773]);
expect(g.aspectRatio).toBe("4:3");
expect(g.modelWidth).toBeGreaterThanOrEqual(g.targetWidth);
expect(g.modelHeight).toBeGreaterThanOrEqual(g.targetHeight);
expect(g.left + g.sourceWidth).toBeLessThanOrEqual(g.modelWidth);
const large = getVip98kOutpaintGeometry(4096, 4096, 1.5, 1.5);
expect([large.targetWidth, large.targetHeight]).toEqual([6144, 6144]);
expect(Math.max(large.modelWidth, large.modelHeight)).toBe(2048);
});
test("relay outpainting uses one image request and keeps the complete generated result", async () => {
const originalFetch = globalThis.fetch;
const source = await sharp({ create: { width: 40, height: 30, channels: 3, background: "red" } }).png().toBuffer();
const generated = await sharp({ create: { width: 80, height: 60, channels: 3, background: "blue" } }).png().toBuffer();
try {
for (const outputUrl of ["https://relay.test/api/external/assets/result", "https://cdn.test/result.png"]) {
let submitted = 0;
globalThis.fetch = async (input, init) => {
const url = String(input);
if (url === "https://source.test/input.png") return new Response(new Uint8Array(source));
if (url === "https://relay.test/v1/images/edits") {
submitted++;
const body = init?.body as FormData;
expect(body.get("model")).toBe("gpt-image-2.5-flare");
expect(body.get("n")).toBe("1");
expect(body.get("size")).toBe("4:3");
expect(body.get("response_format")).toBe("url");
expect(body.get("image[]")).toBeInstanceOf(Blob);
expect(new Headers(init?.headers).get("X-Relay-Route-Option")).toBe("selected-route");
return Response.json({ data: [{ url: outputUrl }] });
}
expect(url).toBe(outputUrl);
expect(new Headers(init?.headers).get("Authorization")).toBe(outputUrl.includes("relay.test") ? "Bearer test-key" : null);
return new Response(new Uint8Array(generated));
};
const result = await expandWithVip98kOutpaint({ imageUrl: "https://source.test/input.png", baseUrl: "https://relay.test/v1", apiKey: "test-key", routeOptionId: "selected-route", xScale: 1.5, yScale: 1.5 });
const metadata = await sharp(result).metadata();
expect([metadata.width, metadata.height]).toEqual([60, 45]);
const pixel = await sharp(result).extract({ left: 30, top: 22, width: 1, height: 1 }).removeAlpha().raw().toBuffer();
expect([...pixel]).toEqual([0, 0, 255]);
expect(submitted).toBe(1);
}
} finally {
globalThis.fetch = originalFetch;
}
});
test("relay outpainting does not resubmit a failed generation", async () => {
const originalFetch = globalThis.fetch;
const source = await sharp({ create: { width: 40, height: 30, channels: 3, background: "red" } }).png().toBuffer();
let submitted = 0;
try {
globalThis.fetch = async (input) => {
if (String(input) === "https://source.test/input.png") return new Response(new Uint8Array(source));
submitted++;
return Response.json({ error: { message: "service unavailable" } }, { status: 503 });
};
await expect(expandWithVip98kOutpaint({ imageUrl: "https://source.test/input.png", baseUrl: "https://relay.test", apiKey: "test-key", routeOptionId: "selected-route", xScale: 1.5, yScale: 1.5 })).rejects.toThrow("service unavailable");
expect(submitted).toBe(1);
} finally {
globalThis.fetch = originalFetch;
}
});

View File

@ -127,7 +127,7 @@ test("Qiniu archives 98K originals without relaying full image bytes through the
expect((await load.archiveImageThroughQiniu(source, id)).downloadAsset).toMatchObject({ status: "ready", url: archiveUrl, bytes: rgb16.length });
stored = indexed;
expect((await load.archiveImageThroughQiniu(source, id)).downloadAsset).toBeUndefined();
expect(await load.archiveImageThroughQiniu("https://another-provider.example/result.png", id)).toBeNull();
expect(await load.archiveImageThroughQiniu("data:image/png;base64,aW1hZ2U=", id)).toBeNull();
expect(cloudCalls).toHaveLength(3);
expect(reads).toHaveLength(3);
});
@ -242,7 +242,7 @@ test("inline outputs have short, stable preview identities and durable queue pay
test("background storage starts after delivery and coalesces repeated wakeups", async () => {
const callbacks: Array<() => Promise<void>> = [];
let runs = 0;
const preferences: Array<string | undefined> = [];
const module = serverLoader({
"next/server": {
after: (fn: () => Promise<void>) => {
@ -250,10 +250,10 @@ test("background storage starts after delivery and coalesces repeated wakeups",
},
},
"@/lib/jobs/image-archive-job": {
runImageArchiveJob: async () => {
runs += 1;
runImageArchiveJob: async ({ preferredTaskId }: { preferredTaskId?: string }) => {
preferences.push(preferredTaskId);
return {
store: { total: runs === 1 ? 1 : 0 },
store: { total: preferences.length === 1 ? 1 : 0 },
replace: { total: 0 },
stats: { pendingStore: 0, pendingReplace: 0 },
downloadTasks: 0,
@ -262,11 +262,11 @@ test("background storage starts after delivery and coalesces repeated wakeups",
},
})("lib/storage/image-archive-worker.ts");
module.scheduleImageArchiveWorker();
module.scheduleImageArchiveWorker();
module.scheduleImageArchiveWorker(false, "requested-image");
expect(callbacks).toHaveLength(1);
expect(runs).toBe(0);
expect(preferences).toEqual([]);
await callbacks[0]();
expect(runs).toBe(2);
expect(preferences).toEqual(["requested-image", undefined]);
});
test("bucket failure retains the provider URL and retry task", async () => {

View File

@ -7,6 +7,177 @@ import { serverLoader } from "../helpers/canvas-download-server-loader";
import { NextResponse } from "next/server";
test.describe("Qiniu image delivery", () => {
test("coalesces nearby configured CDN preview sizes and quality while preserving original URLs", () => {
const source = "https://cdn.example/uploads/sample.png";
const { buildImageProxyUrl: build } = serverLoader({}, {
NEXT_PUBLIC_CANVAS_CDN_ORIGIN: "https://cdn.example",
})("lib/image-delivery.ts");
expect(build(source, { width: 329, quality: 86, format: "webp" })).toBe(
`${source}?imageView2/2/w/360/format/webp/q/88`,
);
expect(build(source, { width: 331, quality: 92, format: "webp" })).toBe(
build(source, { width: 360, quality: 88, format: "webp" }),
);
expect(build(source, { width: 640, quality: 76, format: "webp" })).toContain("w/640/format/webp/q/76");
expect(build(source, { width: 960, quality: 92, format: "webp" })).toContain("w/960/format/webp/q/88");
expect(build(`${source}?token=signed`, { width: 331, format: "webp" })).toBe(`${source}?token=signed`);
expect(build(source)).toBe(source);
expect(new URL(build(source, { delivery: "original" }), "https://app.example").searchParams.get("url")).toBe(source);
expect(build("https://yybb.top/uploads/sample.png", { width: 331, quality: 92, format: "webp" })).toContain("w/331/format/webp/q/92");
});
for (const mode of ["cdn", "storage", "signed"] as const) {
test(`98K reference staging reads complete image bytes via ${mode}`, async () => {
const original = await sharp({ create: { width: 64, height: 64, channels: 3, background: "#a45623" } }).png().toBuffer();
const cdn = "https://cdn.example";
const source = `${cdn}/media/uploads/reference.png${mode === "signed" ? "?token=signed" : ""}`;
const calls: string[] = [];
const command = (operation: string) => class {
operation = operation;
constructor(public input: { Key: string; Body?: Buffer }) {}
};
const config = { provider: "qiniu-kodo", bucket: "images", publicUrl: `${cdn}/media` };
const load = serverLoader({
"./vip98k-chat": {},
"@/lib/db/queries": { getDefaultStorageConfig: async () => config, getSystemSetting: async () => null },
"@/lib/storage/image-archive": {},
"@aws-sdk/client-s3": {
GetObjectCommand: command("get"), HeadObjectCommand: command("head"), PutObjectCommand: command("put"),
},
"@/lib/storage/service": {
toDbS3UploadConfig: () => config,
buildS3PublicUrl: (_: unknown, key: string) => `${config.publicUrl}/${key}`,
createS3Client: () => ({ send: async ({ operation, input }: { operation: string; input: { Key: string; Body: Buffer } }) => {
calls.push(operation);
if (operation === "head") return {};
if (operation === "get") {
expect(input.Key).toBe("uploads/reference.png");
return { Body: { transformToByteArray: async () => original }, ContentLength: original.length };
}
expect((await sharp(input.Body).metadata()).format).toBe("jpeg");
return {};
} }),
},
"@/lib/observability/logger": { createLogger: () => ({ debug() {}, info() {}, warn() {}, error() {} }) },
fetch: async (url: string, init: RequestInit) => {
if (url === source) {
calls.push("cdn");
expect(init.redirect).toBe("error");
return mode === "cdn"
? new Response(original, { headers: { "content-type": "image/png", "content-length": String(original.length) } })
: new Response(original.subarray(0, 4), { headers: { "content-type": "image/png", "content-length": String(original.length) } });
}
calls.push("submit");
expect(String(init.body)).toContain("vip98k-cache-v2");
return Response.json({ taskId: "test-task", task_id: "test-task" });
},
}, { NEXT_PUBLIC_CANVAS_CDN_ORIGIN: cdn });
const { submitVip98kExternalTask } = load("lib/ai/providers/vip98k.ts");
await submitVip98kExternalTask({ apiKey: "test", modelId: "google/nano-banana-2", kind: "image", imageUrls: [source], durable: true });
expect(calls).toEqual(["head", ...(mode !== "signed" ? ["cdn"] : []), ...(mode !== "cdn" ? ["get"] : []), "put", "submit"]);
});
}
for (const [source, reusesSource] of [
["https://cdn.example/media/uploads/source.png", true],
["https://cdn.example/media/uploads/direct/1a868070-0d5b-4c99-937d-f10d6dc59612/source.png", true],
["https://cdn.example/media/uploads/source.png?token=temporary", false],
["https://cdn.example/media-other/uploads/source.png", false],
] as const) {
test(`watermark removal handles ${source} according to its storage origin and signature`, async () => {
const storedResult = "https://cdn.example/media/uploads/result.png";
const uploadedInput = "https://cdn.example/media/uploads/staged.png";
const transferred: string[] = [];
const uploaded: string[] = [];
const saved: string[] = [];
const route = serverLoader({
"next/server": { NextResponse: Response },
"@/app/(auth)/auth": { auth: async () => ({ user: { id: "user-1" } }) },
"@/lib/db": { db: {
select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ value: { apiKey: "fixture" } }] }) }) }),
insert: () => ({ values: (row: { originalUrl: string }) => ({
returning: async () => { saved.push(row.originalUrl); return [{ id: "image-1" }]; },
}) }),
} },
"@/lib/db/schema": { systemSettings: {}, generatedImage: {} },
"drizzle-orm": { eq: () => null },
"@/lib/db/queries": {
getDefaultStorageConfig: async () => ({ type: "qiniu-kodo", isEnabled: true, publicUrl: "https://cdn.example/media" }),
getPointsSystemConfig: async () => ({}),
getMediaAssetsByUserAndUrls: async () => [],
upsertMediaAsset: async () => null,
},
"@/lib/storage/service": {
uploadFile: async (filename: string) => { uploaded.push(filename); return { url: uploadedInput }; },
},
"@/lib/points/feature-charge": { prepareFeaturePointsCharge: async () => ({ ok: true, chargeable: false }) },
"@/lib/points/config": { getImageToolCreditCost: () => 0 },
"@/lib/canvas/generate-image": {},
fetch: async (url: string, init?: RequestInit) => {
if (new URL(url).hostname === "techsz.aoscdn.com") {
if (init?.method === "POST") {
expect((init.body as FormData).get("url")).toBe(reusesSource ? source : uploadedInput);
return Response.json({ data: { task_id: "fixture-task" } });
}
return Response.json({ data: { state: 1, file: storedResult } });
}
transferred.push(url);
return new Response(new Uint8Array([1, 2, 3]));
},
})("app/api/watermark-remove/route.ts");
const response = await route.POST(new Request("https://app.example/api/watermark-remove", {
method: "POST",
body: JSON.stringify({ image_url: source }),
}));
expect(await response.json()).toMatchObject({ success: true, original_url: storedResult });
expect(transferred).toEqual(reusesSource ? [] : [source]);
expect(uploaded).toHaveLength(reusesSource ? 0 : 1);
expect(saved).toEqual([storedResult]);
});
}
for (const formWorks of [true, false]) {
test(`local edit storage ${formWorks ? "uses Qiniu form upload" : "reuses the same key on S3 fallback"}`, async () => {
const bytes = new Uint8Array([1, 2, 3]).buffer;
const config = {
id: "fixture", type: "qiniu-kodo", isEnabled: true, region: "cn-east-1",
endpoint: "https://s3.example", publicUrl: "https://cdn.example",
bucket: "images", accessKeyId: "fixture-ak", secretAccessKey: "fixture-sk",
};
let formKey = "";
const s3Keys: string[] = [];
const storage = serverLoader({
"@vercel/blob": {},
"@aws-sdk/client-s3": {
PutObjectCommand: class { constructor(public input: { Key: string }) {} },
S3Client: class {
async send(command: { input: { Key: string } }) { s3Keys.push(command.input.Key); }
},
},
"@/lib/db/queries": {
getDefaultStorageConfig: async () => config,
getSystemSetting: async () => ({ value: { enabled: false } }),
},
"@/lib/storage/migration-db": {},
"@/lib/observability/logger": { createLogger: () => ({ debug() {}, warn() {}, info() {}, error() {} }) },
fetch: async (url: string, init: RequestInit) => {
expect(url).toBe("https://upload.qiniup.com/");
const form = init.body as FormData;
formKey = String(form.get("key"));
const token = String(form.get("token"));
const policy = JSON.parse(Buffer.from(token.split(":")[2], "base64url").toString());
expect(policy).toMatchObject({ scope: `images:${formKey}`, insertOnly: 1 });
expect((form.get("file") as File).size).toBe(3);
return formWorks ? Response.json({ key: formKey }) : new Response(null, { status: 503 });
},
})("lib/storage/service.ts");
const saved = await storage.uploadFile("local-edit.png", bytes, { preferQiniuForm: true });
expect(saved).toMatchObject({ pathname: formKey, url: "https://cdn.example/" + formKey });
expect(s3Keys).toEqual(formWorks ? [] : [formKey]);
});
}
test("model result buckets use our thumbnail endpoint without forbidden OSS transforms", () => {
const source = "https://dashscope-a717.oss-accelerate.aliyuncs.com/output.png";
const preview = new URL(buildImageProxyUrl(source, { width: 960, format: "webp" }), "https://app.example");

View File

@ -1,5 +1,6 @@
import { test, expect } from "@playwright/test";
import { serverLoader } from "../helpers/canvas-download-server-loader";
import type { CanvasImageDownloadProgress } from "@/lib/client/canvas-image-download";
const config = { type: "qiniu-kodo", isEnabled: true, publicUrl: "https://cdn.example" };
@ -10,7 +11,8 @@ test("original downloads use the archived JPEG rather than the prepared PNG and
"./image-archive-db": { findImageArchiveDownloadTask: async () => ({ targetUrl: "https://cdn.example/original.jpg", metadata: {
downloadAsset: { status: "ready", url: "https://cdn.example/converted.png", contentType: "image/png" },
} }) },
fetch: async (url: URL, options: { method: string }) => {
fetch: async (url: URL, options: { method: string; cache: string }) => {
expect(options.cache).toBe("default");
calls.push({ url: url.toString(), method: options.method });
return { ok: true, headers: new Headers({ "content-type": "image/jpeg", "content-length": "12345" }),
arrayBuffer() { throw new Error("Image bytes must bypass the application"); } };
@ -52,6 +54,40 @@ test("temporary third-party URLs are never checked with HEAD when no archive is
expect(await module.resolveOriginalCdnDownload("https://upstream.example/image.png", "image", new AbortController().signal)).toBeNull();
});
test("downloading a pending original prioritizes its existing archive task and keeps the proxy available", async () => {
const preferred: string[] = [];
const module = serverLoader({
"@/lib/db/queries": { getDefaultStorageConfig: async () => config },
"./image-archive-db": { findImageArchiveDownloadTask: async () => ({ id: "pending-image", status: "pending", targetUrl: null }) },
"./image-archive-worker": { scheduleImageArchiveWorker: (_delivery: boolean, id: string) => preferred.push(id) },
fetch() { throw new Error("The original must remain available through the proxy while it is queued"); },
})("lib/storage/original-image-download.ts");
expect(await module.resolveOriginalCdnDownload("https://upstream.example/queued.png", "image", new AbortController().signal)).toBeNull();
expect(preferred).toEqual(["pending-image"]);
});
test("a CDN metadata timeout falls back to the original Kodo object without reading its body", async () => {
let closed = false;
const module = serverLoader({
"@/lib/db/queries": { getDefaultStorageConfig: async () => config },
"./image-archive-db": {},
"./service": {
toDbS3UploadConfig: () => ({ bucket: "images" }),
createS3Client: () => ({
send: async (command: { input: { Bucket: string; Key: string } }) => {
expect(command.input).toEqual({ Bucket: "images", Key: "uploads/source.png" });
return { ContentType: "image/jpeg", ContentLength: 12345 };
},
destroy: () => { closed = true; },
}),
},
fetch: async () => { throw new DOMException("Timed out", "TimeoutError"); },
})("lib/storage/original-image-download.ts");
const result = await module.resolveOriginalCdnDownload("https://cdn.example/uploads/source.png", "图片.png", new AbortController().signal);
expect(result).toMatchObject({ delivery: "cdn", filename: "图片.jpg", bytes: 12345 });
expect(closed).toBe(true);
});
test("original download metadata reports missing sources with cause, advice and a feedback ID", async () => {
const route = serverLoader({
"next/server": { NextResponse: Response },
@ -66,6 +102,30 @@ test("original download metadata reports missing sources with cause, advice and
expect(data.error).toContain(data.requestId);
});
test("the original proxy returns the first bytes while the upstream is still sending the file", async () => {
const head = Uint8Array.from([255, 216, 255, 224]);
const tail = Uint8Array.from([4, 8, 255, 217]);
let upstream!: ReadableStreamDefaultController<Uint8Array>;
const body = new ReadableStream<Uint8Array>({
start(controller) { upstream = controller; controller.enqueue(head); },
});
const route = serverLoader({
"next/server": { NextResponse: Response },
fetch: async () => new Response(body, { headers: { "content-type": "image/jpeg", "content-length": "8" } }),
})("app/api/image-proxy/route.ts");
const response = await route.GET({
nextUrl: new URL("https://app.example/api/image-proxy?url=https%3A%2F%2Fimages.example%2Foriginal.jpg&original=1"),
signal: new AbortController().signal, headers: new Headers(),
});
expect(response.headers.get("content-length")).toBe("8");
const reader = response.body.getReader();
expect((await reader.read()).value).toEqual(head);
upstream.enqueue(tail);
upstream.close();
expect((await reader.read()).value).toEqual(tail);
expect((await reader.read()).done).toBe(true);
});
function browserMocks() {
const frames: any[] = []; const links: any[] = [];
return { frames, links,
@ -77,16 +137,18 @@ function browserMocks() {
test("chat original downloads use a native CDN download without fetching the image through CORS", async () => {
const dom = browserMocks(); const calls: string[] = [];
const progress: CanvasImageDownloadProgress[] = [];
const client = serverLoader({ ...dom, fetch: async (url: string) => {
calls.push(url);
return Response.json({ delivery: "cdn", downloadUrl: "https://cdn.example/image.jpg?attname=original.jpg", filename: "original.jpg", bytes: 5000000 });
} })("lib/client/canvas-image-download.ts");
const result = await client.downloadOriginalImage({ url: "https://cdn.example/image.jpg" });
const result = await client.downloadOriginalImage({ url: "https://cdn.example/image.jpg", onProgress: (event: CanvasImageDownloadProgress) => progress.push(event) });
expect(result).toEqual({ filename: "original.jpg", bytes: 5000000 });
expect(calls).toHaveLength(1);
expect(new URL(calls[0], "https://app.example").searchParams.get("download")).toBe("resolve");
expect(dom.frames[0].src).toBe("https://cdn.example/image.jpg?attname=original.jpg");
expect(dom.links).toHaveLength(0);
expect(progress.map(event => event.phase)).toEqual(["preparing", "downloading"]);
});
test("original fallback preserves JPEG bytes and the MIME-derived filename", async () => {
@ -99,6 +161,62 @@ test("original fallback preserves JPEG bytes and the MIME-derived filename", asy
URL.revokeObjectURL(dom.links[0].href);
});
test("one click prepares a compatible PNG when the CDN original is WebP", async () => {
const dom = browserMocks();
const calls: string[] = [];
const progress: CanvasImageDownloadProgress[] = [];
const url = "https://cdn.example/image.png";
const client = serverLoader({ ...dom, fetch: async (path: string, init?: RequestInit) => {
calls.push(path);
if (calls.length === 1) {
return Response.json({ delivery: "cdn", downloadUrl: url, filename: "商品.webp", bytes: 500 });
}
expect(init?.method).toBe("POST");
expect(JSON.parse(String(init?.body))).toMatchObject({ url, name: "商品", preparation: "async" });
if (calls.length === 2) {
return Response.json({ delivery: "preparing", retryAfter: 1, message: "正在准备原图" }, { status: 202 });
}
return Response.json({ delivery: "cdn", downloadUrl: "https://cdn.example/prepared.png", filename: "商品.png", bytes: 900 });
} })("lib/client/canvas-image-download.ts");
expect(await client.downloadOriginalImage({ url, name: "商品", onProgress: (event: CanvasImageDownloadProgress) => progress.push(event) }))
.toEqual({ filename: "商品.png", bytes: 900 });
expect(calls.slice(1)).toEqual(["/api/canvas/download-image", "/api/canvas/download-image"]);
expect(progress.map(event => event.phase)).toEqual(["preparing", "converting", "converting", "downloading"]);
for (const event of progress.filter(event => event.phase === "converting")) {
expect(event.message).toContain("正在转换为兼容 PNG");
expect(event.percent).toBeUndefined();
}
expect(dom.frames.map(frame => frame.src)).toEqual(["https://cdn.example/prepared.png"]);
expect(dom.links).toHaveLength(0);
});
test("one click uses the PNG preparation route without buffering an incompatible proxy response", async () => {
const dom = browserMocks();
const calls: string[] = [];
const progress: CanvasImageDownloadProgress[] = [];
let cancelled = false;
const bytes = Uint8Array.of(137, 80, 78, 71, 13, 10, 26, 10);
const client = serverLoader({ ...dom, fetch: async (path: string) => {
calls.push(path);
if (calls.length === 1) return Response.json({ delivery: "proxy" });
if (calls.length === 2) {
return new Response(new ReadableStream({ cancel() { cancelled = true; } }),
{ headers: { "content-type": "image/avif" } });
}
return new Response(bytes, { headers: { "content-type": "image/png", "content-length": String(bytes.length) } });
} })("lib/client/canvas-image-download.ts");
expect(await client.downloadOriginalImage({
url: "https://external.example/image", name: "商品", onProgress: (event: CanvasImageDownloadProgress) => progress.push(event),
})).toEqual({ filename: "商品.png", bytes: bytes.length });
expect(cancelled).toBe(true);
expect(calls[2]).toBe("/api/canvas/download-image");
expect(progress).toContainEqual({
phase: "downloading", message: "正在下载 PNG...", loadedBytes: bytes.length, totalBytes: bytes.length, percent: 99,
});
expect(dom.links).toHaveLength(1);
URL.revokeObjectURL(dom.links[0].href);
});
test("original downloads reject processed references before network access", async () => {
const client = serverLoader({ ...browserMocks(), fetch() { throw new Error("Must not fetch a processed image as an original"); } })("lib/client/canvas-image-download.ts");
await expect(client.downloadOriginalImage({ url: "https://cdn.example/image.jpg?imageView2/2/w/320&token=signed" })).rejects.toThrow("无法确认原图");

View File

@ -0,0 +1,166 @@
import { test, expect } from "@playwright/test";
import sharp from "sharp";
import { NextRequest, NextResponse } from "next/server";
import { serverLoader } from "../helpers/canvas-download-server-loader";
const config = { type: "qiniu-kodo", provider: "qiniu-kodo", isEnabled: true,
publicUrl: "https://cdn.example", endpoint: "https://s3.example", region: "cn-east-1",
bucket: "images", accessKeyId: "test-ak", secretAccessKey: "test-sk" };
test("external model results including signed OSS URLs are fetched by Qiniu without app image reads", async () => {
let source = "";
let calls = 0;
const load = serverLoader({
"@/lib/storage/service": { resolveS3UploadTarget: async () => ({ config }),
buildS3PublicUrl: (_: unknown, key: string) => `${config.publicUrl}/${key}` },
fetch: async (url: string, init: RequestInit) => {
calls++;
expect(new URL(url).hostname).toBe("iovip-z0.qiniuio.com");
expect(init.method).toBe("POST");
const encoded = new URL(url).pathname.split("/fetch/")[1].split("/to/")[0];
expect(Buffer.from(encoded, "base64url").toString()).toBe(source);
return Response.json({ key: "uploads/ai-generated-archive-task-1", fsize: 5000, mimeType: "image/jpeg" });
},
}, { NEXT_PUBLIC_APP_URL: "https://app.example" });
const { archiveImageThroughQiniu } = load("lib/storage/qiniu-image-archive.ts");
for (source of ["https://api.vip98k.com/api/external/assets/image",
"https://dashscope-a717.oss-accelerate.aliyuncs.com/result.png?Expires=123&Signature=a%2Bb%3D",
"https://images.provider.example/result.jpg"]) {
expect(await archiveImageThroughQiniu(source, "task-1")).toMatchObject({ bytes: 5000, contentType: "image/jpeg" });
}
for (const local of ["https://cdn.example/uploads/a.png", "https://app.example/api/generated-image-preview/id",
"http://127.0.0.1/a.png", "data:image/png;base64,abcd"]) {
expect(await archiveImageThroughQiniu(local, "task-1")).toBeNull();
}
expect(calls).toBe(3);
});
test("stored direct uploads prepare once and then download from CDN", async () => {
let task: any = null;
const ensured: string[] = [];
const url = "https://cdn.example/uploads/direct/user-1/image.webp";
const module = serverLoader({
"@/lib/db/queries": { getDefaultStorageConfig: async () => config },
"./image-archive-db": {
findImageArchiveDownloadTask: async () => task,
ensureStoredImageDownloadTask: async (stored: string) => {
ensured.push(stored); return task = { id: "stored", targetUrl: stored, metadata: { downloadAsset: { status: "pending" } } };
},
},
})("lib/storage/canvas-cdn-download.ts");
const pending = await module.resolveCanvasCdnDownload([url], "图片.png");
expect(pending).toMatchObject({ direct: null, canPrepareDirect: true });
task.metadata.downloadAsset = { status: "ready", url: "https://cdn.example/uploads/converted.png", contentType: "image/png", bytes: 1234 };
const ready = await module.resolveCanvasCdnDownload([url], "图片.png");
expect(ready.direct).toMatchObject({ delivery: "cdn", bytes: 1234 });
expect(new URL(ready.direct.downloadUrl).searchParams.get("attname")).toBe("图片.png");
expect(ensured).toEqual([url]);
});
test("download preparation only registers unsigned files in the configured bucket", async () => {
const stored: string[] = [];
const module = serverLoader({
"@/lib/db/queries": { getDefaultStorageConfig: async () => config },
"./image-archive-db": { findImageArchiveDownloadTask: async () => null,
ensureStoredImageDownloadTask: async (url: string) => { stored.push(url); return { id: "1", targetUrl: url }; } },
}, { CANVAS_QINIU_ORIGIN_URL: "https://origin.example" })("lib/storage/canvas-cdn-download.ts");
for (const url of ["https://other.example/uploads/a.png", "https://cdn.example/uploads/a.png?token=signed",
"https://cdn.example/uploads/a.png?imageView2/2/w/160", "https://cdn.example/private/a.png"]) {
expect((await module.resolveCanvasCdnDownload([url], "a.png")).canPrepareDirect).toBe(false);
}
expect((await module.resolveCanvasCdnDownload(["https://origin.example/uploads/direct/user/a.png"], "a.png")).canPrepareDirect).toBe(true);
expect(stored).toEqual(["https://cdn.example/uploads/direct/user/a.png"]);
});
test("nested uploaded PNGs use a bounded header read without stripping their colour profile", async () => {
const bytes = await sharp({ create: { width: 32, height: 24, channels: 4, background: "#1d785c80" } }).withIccProfile("p3").png().toBuffer();
let reads = 0;
const module = serverLoader({
"@/lib/storage/service": { resolveS3UploadTarget: async () => ({ config }),
buildS3PublicUrl: (_: unknown, key: string) => `${config.publicUrl}/${key}`,
createS3Client: () => ({ destroy() {}, send: async (cmd: any) => {
reads++; expect(cmd.input.Key).toBe("uploads/direct/user/a.png");
expect(cmd.input.Range).toBe("bytes=0-65535");
return { $metadata: { httpStatusCode: 206 }, ContentType: "image/png", ContentLength: bytes.length,
ContentRange: `bytes 0-${bytes.length - 1}/${bytes.length}`, Body: { transformToByteArray: async () => bytes } };
} }) },
})("lib/storage/qiniu-image-archive.ts");
const url = "https://cdn.example/uploads/direct/user/a.png";
expect(await module.getReadyQiniuImageDownload(url)).toMatchObject({ status: "ready", url, bytes: bytes.length });
expect(reads).toBe(1);
});
test("preview aliases use the configured CDN and retain processing and signed URL semantics", async () => {
const module = serverLoader({}, { NEXT_PUBLIC_CANVAS_CDN_ORIGIN: "https://cdn.example",
NEXT_PUBLIC_CANVAS_STORAGE_ORIGIN: "https://origin.example" })("lib/image-delivery.ts");
const source = "https://origin.example/uploads/a.png";
const options = { width: 331, quality: 86, format: "webp" };
const direct = "https://cdn.example/uploads/a.png?imageView2/2/w/360/format/webp/q/88";
expect(module.buildImageProxyUrl(source, options)).toBe(direct);
expect(module.buildImageProxyUrl(`/api/image-proxy?url=${encodeURIComponent(source)}&w=331&q=86&f=webp`)).toBe(direct);
expect(module.buildImageProxyUrl(source + "?token=signed", options)).toBe(source + "?token=signed");
expect(module.buildImageProxyUrl(`/api/image-proxy?url=${encodeURIComponent(source)}&proxy=1&w=331`)).toContain("proxy=1");
});
test("saved preview URLs redirect to an archived CDN thumbnail without reading the upstream image", async () => {
const source = "https://images.provider.example/result.png";
const route = serverLoader({
"next/server": { NextResponse },
"@/lib/storage/image-archive-db": { getArchivedImageUrlMappings: async () => ({ [source]: "https://cdn.example/uploads/a.png" }) },
fetch: () => { throw Error("No image bytes should pass through the app"); },
}, { NEXT_PUBLIC_CANVAS_CDN_ORIGIN: "https://cdn.example" })("app/api/image-proxy/route.ts");
const response = await route.GET(new NextRequest(`https://app.example/api/image-proxy?url=${encodeURIComponent(source)}&w=331&q=86&f=webp`, { headers: { "sec-fetch-dest": "image" } }));
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe("https://cdn.example/uploads/a.png?imageView2/2/w/360/format/webp/q/88");
});
test("uploaded WebP is converted inside Qiniu with orientation and ICC retention", async () => {
const png = await sharp({ create: { width: 20, height: 12, channels: 4, background: "#22887780" } }).withIccProfile("p3").png().toBuffer();
let calls = 0;
const module = serverLoader({
"@/lib/storage/service": { resolveS3UploadTarget: async () => ({ config }),
buildS3PublicUrl: (_: unknown, key: string) => `${config.publicUrl}/${key}`,
createS3Client: () => ({ destroy() {}, send: async (cmd: any) => {
expect(cmd.input.Key).toBe("uploads/canvas-download-task-1.png");
expect(cmd.input.Range).toBe("bytes=0-65535");
return { $metadata: { httpStatusCode: 206 }, ContentType: "image/png", ContentLength: png.length,
ContentRange: `bytes 0-${png.length - 1}/${png.length}`, Body: { transformToByteArray: async () => png } };
} }) },
fetch: async (url: string) => {
calls++;
const encoded = new URL(url).pathname.split("/fetch/")[1].split("/to/")[0];
const source = Buffer.from(encoded, "base64url").toString();
expect(source).toBe("https://cdn.example/uploads/direct/user/source.webp?imageMogr2/auto-orient/format/png/strip/meta-keep-list/aWNj");
return Response.json({ key: "uploads/canvas-download-task-1.png", fsize: png.length, mimeType: "image/png" });
},
})("lib/storage/qiniu-image-archive.ts");
const source = "https://cdn.example/uploads/direct/user/source.webp";
const ready = await module.prepareQiniuWebpDownload(source, "task-1");
expect(ready).toMatchObject({ status: "ready", url: "https://cdn.example/uploads/canvas-download-task-1.png", bytes: png.length });
for (const excluded of [source + "?token=signed", source.replace("cdn.example", "other.example"), source.replace(".webp", ".png")]) {
expect(await module.prepareQiniuWebpDownload(excluded, "task-1")).toBeUndefined();
}
expect(calls).toBe(1);
});
test("download worker uses a cloud-prepared PNG and retains local conversion on cloud failure", async () => {
const bytes = await sharp({ create: { width: 4, height: 4, channels: 4, background: "#bb442280" } }).png().toBuffer();
let cloudFails = false, reads = 0;
const saved: any[] = [];
const task = { id: "webp", targetUrl: "https://cdn.example/uploads/a.webp", metadata: {} };
const module = serverLoader({
"./image-archive-db": { claimImageDownloadTask: async () => task, saveImageDownloadAsset: async (_: string, asset: any) => saved.push(asset) },
"./qiniu-image-archive": { getReadyQiniuImageDownload: async () => undefined,
prepareQiniuWebpDownload: async () => { if (cloudFails) throw Error("cloud temporarily unavailable"); return { status: "ready", url: "https://cdn.example/uploads/converted.png", bytes: bytes.length, contentType: "image/png" }; } },
"./image-byte-cache": { fetchImageBytes: async () => { reads++; return { buffer: bytes, contentType: "image/png" }; }, cacheImageBytes: async () => undefined },
"@/lib/storage/image-byte-cache": { onceImageBytes: async (_: string, fn: any) => fn() },
"./service": { uploadFile: async () => ({ url: "https://cdn.example/uploads/fallback.png" }) },
})("lib/storage/image-download-preparation.ts");
await module.prepareImageArchiveDownload("webp");
expect(reads).toBe(0);
expect(saved[0]).toMatchObject({ status: "ready", url: "https://cdn.example/uploads/converted.png" });
cloudFails = true;
await module.prepareImageArchiveDownload("webp");
expect(reads).toBe(1);
expect(saved[1].status).toBe("ready");
});

View File

@ -54,6 +54,20 @@ const textToImageInvocation: Vip98kTaskInvocation = {
"/api/external/tools/result?tool_id=google%2Fnano-banana-2%2Ftext-to-image&taskId={task_id}",
};
test("native expansion maps final canvas scales to per-side ratios and requests PNG", () => {
const invocation: Vip98kTaskInvocation = {
kind: "task", method: "POST", path: "/extensions/v1/tasks", contentType: "application/json",
resultMethod: "GET", resultPath: "/extensions/v1/tasks/{task_id}/result",
requestExample: { model: "image-expansion", metadata: { top: 0.1, bottom: 0.1, left: 0.1, right: 0.1 }, output_format: "" },
inputSchema: { properties: { model: { type: "string" }, file_urls: { type: "array" }, metadata: { type: "object" }, output_format: { type: "string" } } },
};
const { body } = buildTaskBody({ apiKey: "test", modelId: "image-expansion", kind: "image-tool", prompt: "", imageUrls: ["https://source.test/original.jpg"], videoUrls: [], modelConfig: {}, imageParams: { xScale: 1.5, yScale: 2 } }, invocation);
expect(body).toMatchObject({ model: "image-expansion", file_urls: ["https://source.test/original.jpg"], metadata: { left: 0.25, right: 0.25, top: 0.5, bottom: 0.5 }, output_format: "png" });
expect(body).not.toHaveProperty("mask");
expect(body).not.toHaveProperty("prompt");
expect(body).not.toHaveProperty("x_scale");
});
test("text-to-image uses the advertised task contract with native size and ratio, without example reference images", () => {
const configured: Vip98kTaskInvocation = { ...textToImageInvocation, path: "/v1/images/generations", requestExample: { model: "nano-banana-pro", prompt: "sample" } };
const task: Vip98kTaskInvocation = { ...configured, kind: "task", path: "/extensions/v1/tasks",