Compare commits
33 Commits
codex/styl
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| dc7a1ae0a4 | |||
| a6f79998bd | |||
| d0653c2d51 | |||
| f058012bb8 | |||
| 8aa9ebd061 | |||
| d32258e13b | |||
| 6caf1b5cce | |||
| 78b111130b | |||
| 8fca3b25c5 | |||
| b8799f4804 | |||
| d1f9590d05 | |||
| 74053490cc | |||
| 61b6eba4b4 | |||
| bbecafc826 | |||
| ed3b1b5e8a | |||
| 8f56b5a567 | |||
| fd8d5404c6 | |||
| 09db16f74c | |||
| 70621f4261 | |||
| 5a95e7da49 | |||
| 8cf113b554 | |||
| 5fc3f1bb11 | |||
| 1342bf384c | |||
| 759ee6af58 | |||
| 54eb35e21c | |||
| f157161ad9 | |||
| eb9ee0ce1e | |||
| 2c30c9d7f9 | |||
| 8428886a1d | |||
| 24cf77c9c2 | |||
| 9cbc32bb5d | |||
| 7ddaeb12a0 | |||
| 5f24743bdb |
@ -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>
|
||||
) : (
|
||||
|
||||
@ -275,6 +275,8 @@ export type EnhanceConfig = {
|
||||
|
||||
export type ExtendConfig = {
|
||||
provider: "vip98k" | "dashscope" | "fal";
|
||||
modelId: string;
|
||||
routeOptionId: string;
|
||||
xScale: string;
|
||||
yScale: string;
|
||||
};
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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")) ||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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) =>
|
||||
|
||||
@ -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");
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
|
||||
// 获取图片原始尺寸后添加到画布
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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)});
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 || "上传失败");
|
||||
|
||||
@ -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("已取消下载");
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
85
lib/ai/extend/vip98k-outpaint.ts
Normal file
85
lib/ai/extend/vip98k-outpaint.ts
Normal 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();
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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") {
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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')`,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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") || "";
|
||||
|
||||
@ -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`;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"],
|
||||
|
||||
9
tests/qiniu-delivery.config.ts
Normal file
9
tests/qiniu-delivery.config.ts
Normal 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,
|
||||
});
|
||||
@ -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 }) }));
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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("无法确认原图");
|
||||
|
||||
166
tests/routes/qiniu-direct-paths.test.ts
Normal file
166
tests/routes/qiniu-direct-paths.test.ts
Normal 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");
|
||||
});
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user