From 5fc3f1bb11705b3c757a18770164b81f51b214b7 Mon Sep 17 00:00:00 2001
From: WuYongJie <549727955@qq.com>
Date: Wed, 11 Nov 2026 11:34:50 +0800
Subject: [PATCH] =?UTF-8?q?=E5=87=8F=E5=B0=91=E4=B8=83=E7=89=9B=20CDN=20?=
=?UTF-8?q?=E5=9B=BE=E7=89=87=E9=A2=84=E8=A7=88=E7=9A=84=E9=87=8D=E5=A4=8D?=
=?UTF-8?q?=E5=9B=9E=E6=BA=90?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../agents/AgentWorkflowBrandModelPicker.tsx | 6 +-
components/canvas/nodes/ImageNode.tsx | 3 -
lib/ai/providers/vip98k.ts | 90 +++++++++++--------
lib/image-delivery.ts | 25 ++++--
tests/routes/image-delivery-qiniu.test.ts | 71 +++++++++++++++
5 files changed, 149 insertions(+), 46 deletions(-)
diff --git a/components/agents/AgentWorkflowBrandModelPicker.tsx b/components/agents/AgentWorkflowBrandModelPicker.tsx
index b41466178..83470222b 100644
--- a/components/agents/AgentWorkflowBrandModelPicker.tsx
+++ b/components/agents/AgentWorkflowBrandModelPicker.tsx
@@ -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"
)}
>
-
diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx
index 96c73e007..fcaa93a67 100644
--- a/components/canvas/nodes/ImageNode.tsx
+++ b/components/canvas/nodes/ImageNode.tsx
@@ -2482,14 +2482,11 @@ export function ImageNode({
[compareButtonVisualSize]
);
const shouldUseHighFidelityDisplayImage =
- isSelected ||
- isHovered ||
isCompareMode ||
boxCutoutMode ||
localEditMode ||
cropPanelOpen ||
imageAdjustPanelOpen ||
- fullscreenPreviewOpen ||
isThisNodePicking;
const displayImageWidth = useMemo(() => {
const devicePixelRatio =
diff --git a/lib/ai/providers/vip98k.ts b/lib/ai/providers/vip98k.ts
index 5afd52c48..00e543d3d 100644
--- a/lib/ai/providers/vip98k.ts
+++ b/lib/ai/providers/vip98k.ts
@@ -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,
diff --git a/lib/image-delivery.ts b/lib/image-delivery.ts
index 1846b3799..bd2a961bf 100644
--- a/lib/image-delivery.ts
+++ b/lib/image-delivery.ts
@@ -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,29 @@ function resolveTrustedDirectStorageDisplayUrl(
): string | null {
try {
const target = new URL(url);
+ const configuredCdn = getConfiguredCdnUrl();
+ 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);
diff --git a/tests/routes/image-delivery-qiniu.test.ts b/tests/routes/image-delivery-qiniu.test.ts
index c57094d7d..8a149a076 100644
--- a/tests/routes/image-delivery-qiniu.test.ts
+++ b/tests/routes/image-delivery-qiniu.test.ts
@@ -7,6 +7,77 @@ 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],