Compare commits

..

5 Commits

9 changed files with 254 additions and 59 deletions

View File

@ -976,7 +976,8 @@ export async function POST(request: NextRequest) {
const annotatedFilename = `canvas-local-edit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.png`;
const annotatedUpload = await uploadFile(
annotatedFilename,
toArrayBuffer(annotatedBuffer)
toArrayBuffer(annotatedBuffer),
{ preferQiniuForm: true }
);
const prompt = buildLocalEditPrompt(regions);
const manualRegionReferenceImageUrls = regions.flatMap((region) =>

View File

@ -35,7 +35,9 @@ async function resolveStoredQiniuImageUrl(source: string): Promise<string | null
!url.pathname.startsWith(prefix)) return null;
const objectKey = url.pathname.slice(prefix.length);
return /^uploads\/[\w.-]+$/.test(objectKey) ? url.toString() : null;
return /^uploads\/(?:direct\/[0-9a-f-]{36}\/)?[\w.-]+$/i.test(objectKey)
? url.toString()
: null;
} catch {
return null;
}

View File

@ -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">

View File

@ -2482,14 +2482,11 @@ export function ImageNode({
[compareButtonVisualSize]
);
const shouldUseHighFidelityDisplayImage =
isSelected ||
isHovered ||
isCompareMode ||
boxCutoutMode ||
localEditMode ||
cropPanelOpen ||
imageAdjustPanelOpen ||
fullscreenPreviewOpen ||
isThisNodePicking;
const displayImageWidth = useMemo(() => {
const devicePixelRatio =

View File

@ -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,

View File

@ -3444,11 +3444,13 @@ async function applyLocalEditCompositeToImages(params: {
const [originalUpload, thumbnailUpload] = await Promise.all([
uploadFile(
`canvas-local-edit-composited-${suffix}.png`,
bufferToArrayBuffer(mergedBuffer)
bufferToArrayBuffer(mergedBuffer),
{ preferQiniuForm: true }
),
uploadFile(
`canvas-local-edit-composited-thumb-${suffix}.png`,
bufferToArrayBuffer(thumbnailBuffer)
bufferToArrayBuffer(thumbnailBuffer),
{ preferQiniuForm: true }
),
]);
mergedImages.push({

View File

@ -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);

View File

@ -3,6 +3,7 @@ import { isRetiredStorageUrl, isLegacyAliyunStorageConfig, RETIRED_STORAGE_MESSA
import { DeleteObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { put } from "@vercel/blob";
import { createHmac } from "node:crypto";
import {
getDefaultStorageConfig,
getStorageConfigById,
@ -239,12 +240,48 @@ export async function maybeEnqueueStorageMigrationTask(params: {
await maybeEnqueueAliyunMigrationTask(params);
}
async function uploadQiniuForm(
config: S3UploadConfig,
key: string,
buffer: ArrayBuffer,
contentType: string
) {
const encode = (value: Buffer) =>
value.toString("base64").replace(/\+/g, "-").replace(/\//g, "_");
const policy = encode(Buffer.from(JSON.stringify({
scope: `${config.bucket}:${key}`,
deadline: Math.floor(Date.now() / 1000) + 120,
insertOnly: 1,
})));
const signature = encode(createHmac("sha1", config.secretAccessKey).update(policy).digest());
const form = new FormData();
form.append("token", `${config.accessKeyId}:${signature}:${policy}`);
form.append("key", key);
form.append("file", new Blob([buffer], { type: contentType }), key.split("/").at(-1));
const startedAt = Date.now();
const response = await fetch("https://upload.qiniup.com/", {
method: "POST",
body: form,
redirect: "error",
signal: AbortSignal.timeout(45_000),
});
if (!response.ok) {
await response.body?.cancel();
throw new Error(`Qiniu form upload returned HTTP ${response.status}`);
}
const result = await response.json() as { key?: string };
if (result.key !== key) throw new Error("Qiniu form upload returned an unexpected key");
logger.info("qiniu_form_upload_completed", { bytes: buffer.byteLength, elapsedMs: Date.now() - startedAt });
}
// S3/MinIO 上传函数(使用 AWS SDK)
async function uploadToS3(
filename: string,
buffer: ArrayBuffer,
config: S3UploadConfig,
s3Client = createS3Client(config)
s3Client = createS3Client(config),
preferQiniuForm = false
) {
const {
endpoint,
@ -260,6 +297,14 @@ async function uploadToS3(
const contentType = getContentType(filename);
try {
if (preferQiniuForm && config.provider === "qiniu-kodo" && config.region === "cn-east-1") {
try {
await uploadQiniuForm(config, key, buffer, contentType);
return { url: buildS3PublicUrl(config, key), pathname: key, contentType };
} catch (error) {
logger.warn("[Upload] Qiniu form upload failed, retrying the same object through S3", error);
}
}
logger.debug(`[S3 Upload] Uploading to: ${normalizedEndpoint}/${bucket}/${key}`);
// 上传文件
@ -551,7 +596,8 @@ async function uploadWithAnyEnabledStorageConfig(
// 上传文件到存储服务
export async function uploadFile(
filename: string,
buffer: ArrayBuffer
buffer: ArrayBuffer,
options?: { preferQiniuForm?: boolean }
): Promise<UploadedFileResult> {
logger.debug(`[Upload] Starting upload for file: ${filename}`);
@ -572,7 +618,7 @@ export async function uploadFile(
logger.debug(`[Upload] Using database S3 config: ${selectedStorageConfig?.name}`);
logger.debug(`[Upload] Endpoint: ${dbS3Config.endpoint}`);
logger.debug(`[Upload] Bucket: ${dbS3Config.bucket}`);
const uploaded = await uploadToS3(filename, buffer, dbS3Config);
const uploaded = await uploadToS3(filename, buffer, dbS3Config, undefined, options?.preferQiniuForm);
lastKnownGoodDbS3Config = dbS3Config;
await maybeEnqueueAliyunMigrationTask({
uploaded,

View File

@ -7,15 +7,86 @@ import { serverLoader } from "../helpers/canvas-download-server-loader";
import { NextResponse } from "next/server";
test.describe("Qiniu image delivery", () => {
for (const source of [
"https://cdn.example/media/uploads/source.png",
"https://cdn.example/media/uploads/source.png?token=temporary",
"https://cdn.example/media-other/uploads/source.png",
]) {
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],
["https://cdn.example/media/uploads/source.png?token=temporary", false],
["https://cdn.example/media-other/uploads/source.png", false],
] as const) {
test(`watermark removal handles ${source} according to its storage origin and signature`, async () => {
const storedResult = "https://cdn.example/media/uploads/result.png";
const uploadedInput = "https://cdn.example/media/uploads/staged.png";
const reusesSource = source === "https://cdn.example/media/uploads/source.png";
const transferred: string[] = [];
const uploaded: string[] = [];
const saved: string[] = [];
@ -66,6 +137,47 @@ test.describe("Qiniu image delivery", () => {
});
}
for (const formWorks of [true, false]) {
test(`local edit storage ${formWorks ? "uses Qiniu form upload" : "reuses the same key on S3 fallback"}`, async () => {
const bytes = new Uint8Array([1, 2, 3]).buffer;
const config = {
id: "fixture", type: "qiniu-kodo", isEnabled: true, region: "cn-east-1",
endpoint: "https://s3.example", publicUrl: "https://cdn.example",
bucket: "images", accessKeyId: "fixture-ak", secretAccessKey: "fixture-sk",
};
let formKey = "";
const s3Keys: string[] = [];
const storage = serverLoader({
"@vercel/blob": {},
"@aws-sdk/client-s3": {
PutObjectCommand: class { constructor(public input: { Key: string }) {} },
S3Client: class {
async send(command: { input: { Key: string } }) { s3Keys.push(command.input.Key); }
},
},
"@/lib/db/queries": {
getDefaultStorageConfig: async () => config,
getSystemSetting: async () => ({ value: { enabled: false } }),
},
"@/lib/storage/migration-db": {},
"@/lib/observability/logger": { createLogger: () => ({ debug() {}, warn() {}, info() {}, error() {} }) },
fetch: async (url: string, init: RequestInit) => {
expect(url).toBe("https://upload.qiniup.com/");
const form = init.body as FormData;
formKey = String(form.get("key"));
const token = String(form.get("token"));
const policy = JSON.parse(Buffer.from(token.split(":")[2], "base64url").toString());
expect(policy).toMatchObject({ scope: `images:${formKey}`, insertOnly: 1 });
expect((form.get("file") as File).size).toBe(3);
return formWorks ? Response.json({ key: formKey }) : new Response(null, { status: 503 });
},
})("lib/storage/service.ts");
const saved = await storage.uploadFile("local-edit.png", bytes, { preferQiniuForm: true });
expect(saved).toMatchObject({ pathname: formKey, url: "https://cdn.example/" + formKey });
expect(s3Keys).toEqual(formWorks ? [] : [formKey]);
});
}
test("model result buckets use our thumbnail endpoint without forbidden OSS transforms", () => {
const source = "https://dashscope-a717.oss-accelerate.aliyuncs.com/output.png";
const preview = new URL(buildImageProxyUrl(source, { width: 960, format: "webp" }), "https://app.example");