Compare commits
3 Commits
codex/qini
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8cf113b554 | |||
| 5fc3f1bb11 | |||
| 1342bf384c |
@ -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">
|
||||
|
||||
@ -2482,14 +2482,11 @@ export function ImageNode({
|
||||
[compareButtonVisualSize]
|
||||
);
|
||||
const shouldUseHighFidelityDisplayImage =
|
||||
isSelected ||
|
||||
isHovered ||
|
||||
isCompareMode ||
|
||||
boxCutoutMode ||
|
||||
localEditMode ||
|
||||
cropPanelOpen ||
|
||||
imageAdjustPanelOpen ||
|
||||
fullscreenPreviewOpen ||
|
||||
isThisNodePicking;
|
||||
const displayImageWidth = useMemo(() => {
|
||||
const devicePixelRatio =
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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],
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user