Compare commits
3 Commits
codex/qini
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8cf113b554 | |||
| 5fc3f1bb11 | |||
| 1342bf384c |
@ -3,6 +3,7 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { Check, ImageIcon, Loader2, UserRound } from "lucide-react";
|
import { Check, ImageIcon, Loader2, UserRound } from "lucide-react";
|
||||||
|
import { AgentPreviewImage } from "@/components/agents/AgentPreviewImage";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn, fetcher } from "@/lib/utils";
|
import { cn, fetcher } from "@/lib/utils";
|
||||||
import { normalizeAgentWorkflowBrandModelConfig } from "@/lib/agents/workflow-brand-model-config";
|
import { normalizeAgentWorkflowBrandModelConfig } from "@/lib/agents/workflow-brand-model-config";
|
||||||
@ -229,12 +230,11 @@ export function AgentWorkflowBrandModelPicker({
|
|||||||
isDark ? "bg-white/[0.04]" : "bg-muted/40"
|
isDark ? "bg-white/[0.04]" : "bg-muted/40"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<img
|
<AgentPreviewImage
|
||||||
src={image.imageUrl}
|
src={image.imageUrl}
|
||||||
alt={image.displayName || `${normalizedBrand} 模特图`}
|
alt={image.displayName || `${normalizedBrand} 模特图`}
|
||||||
className="h-full w-full object-cover"
|
className="h-full w-full object-cover"
|
||||||
loading="lazy"
|
previewWidth={360}
|
||||||
decoding="async"
|
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-x-0 top-0 flex items-center justify-between px-2 py-2">
|
<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">
|
<span className="rounded-full bg-black/60 px-2 py-1 text-[11px] text-white">
|
||||||
|
|||||||
@ -2482,14 +2482,11 @@ export function ImageNode({
|
|||||||
[compareButtonVisualSize]
|
[compareButtonVisualSize]
|
||||||
);
|
);
|
||||||
const shouldUseHighFidelityDisplayImage =
|
const shouldUseHighFidelityDisplayImage =
|
||||||
isSelected ||
|
|
||||||
isHovered ||
|
|
||||||
isCompareMode ||
|
isCompareMode ||
|
||||||
boxCutoutMode ||
|
boxCutoutMode ||
|
||||||
localEditMode ||
|
localEditMode ||
|
||||||
cropPanelOpen ||
|
cropPanelOpen ||
|
||||||
imageAdjustPanelOpen ||
|
imageAdjustPanelOpen ||
|
||||||
fullscreenPreviewOpen ||
|
|
||||||
isThisNodePicking;
|
isThisNodePicking;
|
||||||
const displayImageWidth = useMemo(() => {
|
const displayImageWidth = useMemo(() => {
|
||||||
const devicePixelRatio =
|
const devicePixelRatio =
|
||||||
|
|||||||
@ -99,6 +99,7 @@ const DEFAULT_POLL_INTERVAL_SECONDS = 8;
|
|||||||
const DEFAULT_MAX_POLL_SECONDS = 600;
|
const DEFAULT_MAX_POLL_SECONDS = 600;
|
||||||
const REFERENCE_DOWNLOAD_ATTEMPTS = 3;
|
const REFERENCE_DOWNLOAD_ATTEMPTS = 3;
|
||||||
const REFERENCE_DOWNLOAD_TIMEOUT_MS = 120_000;
|
const REFERENCE_DOWNLOAD_TIMEOUT_MS = 120_000;
|
||||||
|
const REFERENCE_CDN_TIMEOUT_MS = 10_000;
|
||||||
const REFERENCE_CACHE_LOOKUP_TIMEOUT_MS = 15_000;
|
const REFERENCE_CACHE_LOOKUP_TIMEOUT_MS = 15_000;
|
||||||
const MAX_REFERENCE_DOWNLOAD_BYTES = IMAGE_UPLOAD_MAX_BYTES;
|
const MAX_REFERENCE_DOWNLOAD_BYTES = IMAGE_UPLOAD_MAX_BYTES;
|
||||||
const REFERENCE_STAGING_CACHE_MS = 60_000;
|
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) {
|
async function downloadReferenceImage(mediaUrl: string) {
|
||||||
let lastError: unknown;
|
let lastError: unknown;
|
||||||
for (let attempt = 1; attempt <= REFERENCE_DOWNLOAD_ATTEMPTS; attempt += 1) {
|
for (let attempt = 1; attempt <= REFERENCE_DOWNLOAD_ATTEMPTS; attempt += 1) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(mediaUrl, {
|
return await fetchReferenceImage(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",
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
if (error instanceof Vip98kReferenceTooLargeError) throw 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(
|
async function downloadReferenceImageFromStorage(
|
||||||
mediaUrl: string,
|
mediaUrl: string,
|
||||||
stagingConfig: ReferenceStagingConfig,
|
stagingConfig: ReferenceStagingConfig,
|
||||||
@ -852,9 +870,10 @@ async function stageReferenceImage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const downloadStartedAt = Date.now();
|
const downloadStartedAt = Date.now();
|
||||||
|
const cdn = await downloadReferenceImageFromCdn(mediaUrl, stagingConfig);
|
||||||
|
const storage = cdn ? null : await downloadReferenceImageFromStorage(mediaUrl, stagingConfig);
|
||||||
const downloaded =
|
const downloaded =
|
||||||
(await downloadReferenceImageFromStorage(mediaUrl, stagingConfig)) ||
|
cdn || storage || (await downloadReferenceImage(mediaUrl));
|
||||||
(await downloadReferenceImage(mediaUrl));
|
|
||||||
const downloadMs = Date.now() - downloadStartedAt;
|
const downloadMs = Date.now() - downloadStartedAt;
|
||||||
const normalizeStartedAt = Date.now();
|
const normalizeStartedAt = Date.now();
|
||||||
const normalized = await normalizeReferenceImage(
|
const normalized = await normalizeReferenceImage(
|
||||||
@ -872,6 +891,7 @@ async function stageReferenceImage(
|
|||||||
cacheStagedReference(cacheKey, uploadedUrl, runtimeConfig);
|
cacheStagedReference(cacheKey, uploadedUrl, runtimeConfig);
|
||||||
logger.info("reference image staged", {
|
logger.info("reference image staged", {
|
||||||
sourceHost: getUrlHost(mediaUrl),
|
sourceHost: getUrlHost(mediaUrl),
|
||||||
|
sourceDelivery: cdn ? "cdn" : storage ? "storage" : "public-url",
|
||||||
targetHost: getUrlHost(uploadedUrl),
|
targetHost: getUrlHost(uploadedUrl),
|
||||||
sourceBytes: downloaded.buffer.byteLength,
|
sourceBytes: downloaded.buffer.byteLength,
|
||||||
stagedBytes: normalized.buffer.byteLength,
|
stagedBytes: normalized.buffer.byteLength,
|
||||||
|
|||||||
@ -1,6 +1,15 @@
|
|||||||
import { normalizeLegacyAliyunStorageUrl } from "./storage/retired-storage";
|
import { normalizeLegacyAliyunStorageUrl } from "./storage/retired-storage";
|
||||||
|
|
||||||
const IMAGE_PROXY_PATH = "/api/image-proxy";
|
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. */
|
/** Error fallbacks must keep unavailable legacy sources behind archive resolution. */
|
||||||
export function buildImageFallbackUrl(value: string | null | undefined): string {
|
export function buildImageFallbackUrl(value: string | null | undefined): string {
|
||||||
@ -102,10 +111,7 @@ export function isAlibabaModelImageUrl(value: string): boolean {
|
|||||||
|
|
||||||
function isTrustedDirectStorageImageHost(hostname: string): boolean {
|
function isTrustedDirectStorageImageHost(hostname: string): boolean {
|
||||||
const normalized = hostname.trim().toLowerCase();
|
const normalized = hostname.trim().toLowerCase();
|
||||||
let configuredCdnHost = "";
|
|
||||||
try { configuredCdnHost = new URL(process.env.NEXT_PUBLIC_CANVAS_CDN_ORIGIN || "").hostname; } catch { /* optional */ }
|
|
||||||
return (
|
return (
|
||||||
(configuredCdnHost !== "" && normalized === configuredCdnHost) ||
|
|
||||||
normalized === "yybb.top" ||
|
normalized === "yybb.top" ||
|
||||||
normalized.endsWith(".qiniucs.com") ||
|
normalized.endsWith(".qiniucs.com") ||
|
||||||
normalized.endsWith(".qiniudn.com") ||
|
normalized.endsWith(".qiniudn.com") ||
|
||||||
@ -127,20 +133,29 @@ function resolveTrustedDirectStorageDisplayUrl(
|
|||||||
): string | null {
|
): string | null {
|
||||||
try {
|
try {
|
||||||
const target = new URL(url);
|
const target = new URL(url);
|
||||||
|
const configuredCdn = getConfiguredCdnUrl();
|
||||||
|
const isConfiguredCdn = target.origin === configuredCdn?.origin;
|
||||||
if (
|
if (
|
||||||
|
target.hostname !== configuredCdn?.hostname &&
|
||||||
!isTrustedDirectStorageImageHost(target.hostname) &&
|
!isTrustedDirectStorageImageHost(target.hostname) &&
|
||||||
!isFirstPartyQiniuAsset(target)
|
!isFirstPartyQiniuAsset(target)
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const width = normalizeWidth(options.width);
|
const requestedWidth = normalizeWidth(options.width);
|
||||||
const quality = normalizeQuality(options.quality);
|
let width = requestedWidth;
|
||||||
|
let quality = normalizeQuality(options.quality);
|
||||||
const format = options.format;
|
const format = options.format;
|
||||||
if ((!width && !quality && !format) || target.search) {
|
if ((!width && !quality && !format) || target.search) {
|
||||||
return target.toString();
|
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"];
|
const operations = ["imageView2", "2"];
|
||||||
if (width) operations.push("w", String(width));
|
if (width) operations.push("w", String(width));
|
||||||
if (format) operations.push("format", format === "jpeg" ? "jpg" : format);
|
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";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
test.describe("Qiniu image delivery", () => {
|
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 [
|
for (const [source, reusesSource] of [
|
||||||
["https://cdn.example/media/uploads/source.png", true],
|
["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/direct/1a868070-0d5b-4c99-937d-f10d6dc59612/source.png", true],
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user