Compare commits
No commits in common. "main" and "codex/qiniu-local-edit-save-20261111" have entirely different histories.
main
...
codex/qini
@ -3,7 +3,6 @@
|
|||||||
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";
|
||||||
@ -230,11 +229,12 @@ export function AgentWorkflowBrandModelPicker({
|
|||||||
isDark ? "bg-white/[0.04]" : "bg-muted/40"
|
isDark ? "bg-white/[0.04]" : "bg-muted/40"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<AgentPreviewImage
|
<img
|
||||||
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"
|
||||||
previewWidth={360}
|
loading="lazy"
|
||||||
|
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,11 +2482,14 @@ 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,7 +99,6 @@ 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;
|
||||||
@ -683,44 +682,43 @@ 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 {
|
||||||
return await fetchReferenceImage(mediaUrl);
|
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",
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
if (error instanceof Vip98kReferenceTooLargeError) throw error;
|
if (error instanceof Vip98kReferenceTooLargeError) throw error;
|
||||||
@ -740,22 +738,6 @@ 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,
|
||||||
@ -870,10 +852,9 @@ 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 =
|
||||||
cdn || storage || (await downloadReferenceImage(mediaUrl));
|
(await downloadReferenceImageFromStorage(mediaUrl, stagingConfig)) ||
|
||||||
|
(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(
|
||||||
@ -891,7 +872,6 @@ 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,15 +1,6 @@
|
|||||||
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 {
|
||||||
@ -111,7 +102,10 @@ 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") ||
|
||||||
@ -133,29 +127,20 @@ 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 requestedWidth = normalizeWidth(options.width);
|
const width = normalizeWidth(options.width);
|
||||||
let width = requestedWidth;
|
const quality = normalizeQuality(options.quality);
|
||||||
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,77 +7,6 @@ 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