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" )} > - {image.displayName
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],