Compare commits
7 Commits
codex/repl
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d0653c2d51 | |||
| f058012bb8 | |||
| 8aa9ebd061 | |||
| d32258e13b | |||
| 6caf1b5cce | |||
| 78b111130b | |||
| 8fca3b25c5 |
@ -331,7 +331,18 @@ export async function GET(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
if (!original && isAlibabaModelImageUrl(target.toString())) {
|
||||
if (!original && !forceProxy && isBrowserImageRequest(request)) {
|
||||
const direct = buildImageProxyUrl(target.toString(), {
|
||||
width: width || undefined, quality: directQuality || undefined,
|
||||
format: ["webp", "avif", "jpeg", "png"].includes(requestedFormat || "") ? requestedFormat as OutputFormat : undefined,
|
||||
});
|
||||
if (process.env.NEXT_PUBLIC_CANVAS_CDN_ORIGIN && /^https:\/\//.test(direct) &&
|
||||
new URL(direct).origin === new URL(process.env.NEXT_PUBLIC_CANVAS_CDN_ORIGIN).origin) {
|
||||
return redirectToDirectImage(new URL(direct));
|
||||
}
|
||||
}
|
||||
|
||||
if (!original && (isAlibabaModelImageUrl(target.toString()) || (!forceProxy && isBrowserImageRequest(request)))) {
|
||||
try {
|
||||
const { getArchivedImageUrlMappings } = await import("@/lib/storage/image-archive-db");
|
||||
const mappings = await getArchivedImageUrlMappings([target.toString()]);
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
||||
import { useCanvasGroupRuns } from "@/hooks/use-canvas-group-runs";
|
||||
import { hasRecoverableCanvasTask } from "@/lib/video-canvas/task-recovery";
|
||||
import { GroupStoryboardPreview } from "./GroupStoryboardPreview";
|
||||
@ -15104,7 +15105,7 @@ function DirectorNodeComposer({
|
||||
if(file.size>20*1024*1024){setError("参考图需小于 20 MiB");return;}
|
||||
setBusy(true);setError("");try{
|
||||
const form=new FormData();form.append("file",file);form.append("purpose","director-studio");
|
||||
const response=await fetch("/api/files/upload",{method:"POST",body:form,signal:AbortSignal.timeout(120000)});
|
||||
const response=await fetchUploadWithRetry(form,{signal:AbortSignal.timeout(120000)});
|
||||
const result=await response.json();const url=result.url||result.data?.url;
|
||||
if(!response.ok||!url)throw new Error(result.error||"图片上传失败");
|
||||
onDraft({directorReferenceUrl:String(url)});
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { readDirectorScene, type DirectorScene } from "@/lib/video-canvas/director-scene";
|
||||
@ -64,9 +65,7 @@ export function DirectorReconstructionPanel({
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
form.append("purpose", "director-studio");
|
||||
const response = await fetch("/api/files/upload", {
|
||||
method: "POST",
|
||||
body: form,
|
||||
const response = await fetchUploadWithRetry(form, {
|
||||
signal: AbortSignal.timeout(120000),
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
||||
import {
|
||||
commitDirectorScene,
|
||||
resolveDirectorCamera,
|
||||
@ -43,14 +44,13 @@ async function upload(file: File) {
|
||||
"purpose",
|
||||
file.type.startsWith("video/") ? "canvas-video-process" : "director-studio",
|
||||
);
|
||||
const response = await fetch(
|
||||
/\.glb$/i.test(file.name) ? "/api/canvas/director/upload-model" : "/api/files/upload",
|
||||
{
|
||||
const response = /\.glb$/i.test(file.name)
|
||||
? await fetch("/api/canvas/director/upload-model", {
|
||||
method: "POST",
|
||||
body: form,
|
||||
signal: AbortSignal.timeout(120000),
|
||||
},
|
||||
);
|
||||
})
|
||||
: await fetchUploadWithRetry(form, { signal: AbortSignal.timeout(120000) });
|
||||
const result = await response.json();
|
||||
const url = result.url || result.data?.url;
|
||||
if (!response.ok || !url) throw new Error(result.error || "上传失败");
|
||||
|
||||
@ -12,6 +12,8 @@ import { expandWithVip98kOutpaint, VIP98K_OUTPAINT_MODEL } from "@/lib/ai/extend
|
||||
import { db } from "@/lib/db";
|
||||
import { generatedImage } from "@/lib/db/schema";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
import { archiveTemporaryImage } from "@/lib/storage/image-archive";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
type ExtendConfig = {
|
||||
provider?: ExtendProvider;
|
||||
@ -22,8 +24,7 @@ type ExtendConfig = {
|
||||
routeOptionId?: string;
|
||||
};
|
||||
|
||||
const VIP98K_EXPANSION_FALLBACK_MODEL = "google/nano-banana-pro/edit";
|
||||
const VIP98K_SOURCE_DOWNLOAD_TIMEOUT_MS = 120_000;
|
||||
const SOURCE_DOWNLOAD_TIMEOUT_MS = 120_000;
|
||||
const DASHSCOPE_MIN_IMAGE_EDGE = 512;
|
||||
const DASHSCOPE_MAX_IMAGE_EDGE = 4096;
|
||||
const DASHSCOPE_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
||||
@ -146,7 +147,7 @@ async function normalizeDashscopeExpansionInput(imageUrl: string): Promise<strin
|
||||
const response = await fetch(imageUrl, {
|
||||
cache: "no-store",
|
||||
redirect: "follow",
|
||||
signal: AbortSignal.timeout(VIP98K_SOURCE_DOWNLOAD_TIMEOUT_MS),
|
||||
signal: AbortSignal.timeout(SOURCE_DOWNLOAD_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new ExtendServiceError(
|
||||
@ -241,97 +242,6 @@ async function normalizeDashscopeExpansionInput(imageUrl: string): Promise<strin
|
||||
return uploaded.url;
|
||||
}
|
||||
|
||||
function isTemporaryVip98kExpansionError(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error || "");
|
||||
return /HTTP\s+(?:429|5\d\d)|temporar(?:y|ily)|timeout|timed out|unavailable/i.test(
|
||||
message,
|
||||
);
|
||||
}
|
||||
|
||||
async function createVip98kExpansionCanvas(
|
||||
imageUrl: string,
|
||||
xScale: number,
|
||||
yScale: number,
|
||||
) {
|
||||
const response = await fetch(imageUrl, {
|
||||
cache: "no-store",
|
||||
redirect: "follow",
|
||||
signal: AbortSignal.timeout(VIP98K_SOURCE_DOWNLOAD_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new ExtendServiceError(
|
||||
"下载待拓展图片失败",
|
||||
502,
|
||||
`${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const sourceBuffer = Buffer.from(await response.arrayBuffer());
|
||||
const sharp = await import("sharp");
|
||||
const image = sharp.default(sourceBuffer, { failOn: "none" });
|
||||
const metadata = await image.metadata();
|
||||
const width = Number(metadata.width || 0);
|
||||
const height = Number(metadata.height || 0);
|
||||
if (!width || !height) {
|
||||
throw new ExtendServiceError("无法识别待拓展图片尺寸", 422);
|
||||
}
|
||||
|
||||
const targetWidth = Math.max(width, Math.round(width * xScale));
|
||||
const targetHeight = Math.max(height, Math.round(height * yScale));
|
||||
const left = Math.floor((targetWidth - width) / 2);
|
||||
const top = Math.floor((targetHeight - height) / 2);
|
||||
const canvas = await image
|
||||
.ensureAlpha()
|
||||
.extend({
|
||||
left,
|
||||
right: targetWidth - width - left,
|
||||
top,
|
||||
bottom: targetHeight - height - top,
|
||||
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
const arrayBuffer = canvas.buffer.slice(
|
||||
canvas.byteOffset,
|
||||
canvas.byteOffset + canvas.byteLength,
|
||||
) as ArrayBuffer;
|
||||
const uploaded = await uploadFile(
|
||||
`extend-vip98k-canvas-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.png`,
|
||||
arrayBuffer,
|
||||
);
|
||||
return {
|
||||
imageUrl: uploaded.url,
|
||||
width,
|
||||
height,
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
};
|
||||
}
|
||||
|
||||
async function extendImageWithVip98kEditFallback(
|
||||
imageUrl: string,
|
||||
xScale: number,
|
||||
yScale: number,
|
||||
) {
|
||||
const canvas = await createVip98kExpansionCanvas(imageUrl, xScale, yScale);
|
||||
const result = await executeConfiguredVip98kTask({
|
||||
modelId: VIP98K_EXPANSION_FALLBACK_MODEL,
|
||||
kind: "image",
|
||||
prompt: [
|
||||
"Outpaint only the transparent regions of this image.",
|
||||
"Keep every original pixel, subject, scale, position, perspective, lighting and style unchanged.",
|
||||
`Complete the ${canvas.targetWidth}x${canvas.targetHeight} canvas naturally from the centered ${canvas.width}x${canvas.height} source.`,
|
||||
"Do not crop, zoom, move, replace or redraw the original content.",
|
||||
].join(" "),
|
||||
imageUrls: [canvas.imageUrl],
|
||||
});
|
||||
const outputUrl = result.outputUrls[0];
|
||||
if (!outputUrl) {
|
||||
throw new ExtendServiceError("VIP98K 生成式扩图未返回图片", 502);
|
||||
}
|
||||
return outputUrl;
|
||||
}
|
||||
|
||||
async function extendImage(
|
||||
imageUrl: string,
|
||||
config: ExtendConfig,
|
||||
@ -340,27 +250,17 @@ async function extendImage(
|
||||
requestId?: string | null,
|
||||
): Promise<string> {
|
||||
if (config.provider === "vip98k") {
|
||||
try {
|
||||
const result = await executeConfiguredVip98kTask({
|
||||
modelId: "image-expansion",
|
||||
kind: "image-tool",
|
||||
prompt: `Expand the image canvas naturally to ${xScale}x width and ${yScale}x height. Preserve the original subject, perspective, lighting and style.`,
|
||||
imageUrls: [imageUrl],
|
||||
imageParams: { xScale, yScale },
|
||||
});
|
||||
const outputUrl = result.outputUrls[0];
|
||||
if (!outputUrl) {
|
||||
throw new ExtendServiceError("VIP98K 图像拓展未返回图片", 502);
|
||||
}
|
||||
return outputUrl;
|
||||
} catch (error) {
|
||||
if (!isTemporaryVip98kExpansionError(error)) throw error;
|
||||
console.warn(
|
||||
"[Extend Service] VIP98K expansion tool is temporarily unavailable; using VIP98K generative outpainting fallback",
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
return extendImageWithVip98kEditFallback(imageUrl, xScale, yScale);
|
||||
const result = await executeConfiguredVip98kTask({
|
||||
modelId: "image-expansion",
|
||||
kind: "image-tool",
|
||||
imageUrls: [imageUrl],
|
||||
imageParams: { xScale, yScale },
|
||||
});
|
||||
const outputUrl = result.outputUrls[0];
|
||||
if (!outputUrl) {
|
||||
throw new ExtendServiceError("VIP98K 图像拓展未返回图片", 502);
|
||||
}
|
||||
return outputUrl;
|
||||
}
|
||||
|
||||
if (!config.apiKey) {
|
||||
@ -536,7 +436,10 @@ export async function executeExtendJob(
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
let buffer: Buffer;
|
||||
let buffer!: Buffer;
|
||||
let uploadResult: { thumbnail: string; original: string } | undefined;
|
||||
let archivedBytes: number | undefined;
|
||||
let downloadAsset: Awaited<ReturnType<typeof archiveTemporaryImage>>["downloadAsset"];
|
||||
let modelId: string;
|
||||
if (config.provider === "fal") {
|
||||
buffer = await expandWithFalOutpaint({ imageUrl: inputImageUrl, apiKey: config.apiKey!, xScale, yScale });
|
||||
@ -552,21 +455,28 @@ export async function executeExtendJob(
|
||||
} else {
|
||||
const prepared = config.provider === "vip98k" ? inputImageUrl : await normalizeDashscopeExpansionInput(inputImageUrl);
|
||||
const url = await extendImage(prepared, config, xScale, yScale, requestId);
|
||||
const imageResponse = await fetch(url);
|
||||
if (!imageResponse.ok) {
|
||||
throw new ExtendServiceError("下载拓展图片失败", 500, `${imageResponse.status} ${imageResponse.statusText}`);
|
||||
if (config.provider === "vip98k" && !includeBase64) {
|
||||
const archived = await archiveTemporaryImage(url, requestId || randomUUID());
|
||||
uploadResult = { original: archived.url, thumbnail: archived.thumbnailUrl };
|
||||
archivedBytes = archived.bytes;
|
||||
downloadAsset = archived.downloadAsset;
|
||||
} else {
|
||||
const imageResponse = await fetch(url);
|
||||
if (!imageResponse.ok) {
|
||||
throw new ExtendServiceError("下载拓展图片失败", 500, `${imageResponse.status} ${imageResponse.statusText}`);
|
||||
}
|
||||
buffer = Buffer.from(await imageResponse.arrayBuffer());
|
||||
}
|
||||
buffer = Buffer.from(await imageResponse.arrayBuffer());
|
||||
modelId = config.provider === "vip98k" ? "image-expansion" : "extend-1.0";
|
||||
}
|
||||
const imageBase64Result = includeBase64 ? buffer.toString("base64") : undefined;
|
||||
const uploadResult = await uploadBufferPairToS3(buffer, config.provider === "fal" || modelId === VIP98K_OUTPAINT_MODEL ? "png" : "jpg");
|
||||
uploadResult ??= await uploadBufferPairToS3(buffer, config.provider === "fal" || config.provider === "vip98k" ? "png" : "jpg");
|
||||
console.log("[Extend Service] Result uploaded:", {
|
||||
requestId,
|
||||
includeBase64,
|
||||
inputImageUrl: inputImageUrl.slice(0, 120),
|
||||
modelId,
|
||||
outputBytes: buffer.length,
|
||||
outputBytes: archivedBytes ?? buffer.length,
|
||||
});
|
||||
let mediaAssetId: string | null = null;
|
||||
let generatedImageId: string | null = null;
|
||||
@ -615,6 +525,7 @@ export async function executeExtendJob(
|
||||
xScale,
|
||||
yScale,
|
||||
imageSize: "2K",
|
||||
...(downloadAsset ? { downloadAsset } : {}),
|
||||
},
|
||||
});
|
||||
mediaAssetId = media?.id || null;
|
||||
|
||||
@ -1666,6 +1666,7 @@ export function buildTaskBody(params: Vip98kTaskParams, invocation: Vip98kInvoca
|
||||
}
|
||||
if (params.modelId.toLowerCase() === "image-expansion") {
|
||||
bindVip98kExpansionParams(body, imageParams);
|
||||
body.output_format = "png";
|
||||
}
|
||||
if (params.kind === "image-tool" && params.modelId.toLowerCase() === "ocr") {
|
||||
// Old synced examples include generic image fields and both language aliases.
|
||||
|
||||
@ -134,6 +134,13 @@ function resolveTrustedDirectStorageDisplayUrl(
|
||||
try {
|
||||
const target = new URL(url);
|
||||
const configuredCdn = getConfiguredCdnUrl();
|
||||
const storageOrigin = process.env.NEXT_PUBLIC_CANVAS_STORAGE_ORIGIN;
|
||||
if (configuredCdn?.protocol === "https:" && storageOrigin &&
|
||||
target.origin === new URL(storageOrigin).origin &&
|
||||
!target.searchParams.has("token") && !target.searchParams.has("X-Amz-Signature")) {
|
||||
target.host = configuredCdn.host;
|
||||
target.protocol = configuredCdn.protocol;
|
||||
}
|
||||
const isConfiguredCdn = target.origin === configuredCdn?.origin;
|
||||
if (
|
||||
target.hostname !== configuredCdn?.hostname &&
|
||||
@ -215,6 +222,23 @@ function resolveAliyunOssDisplayUrl(
|
||||
|
||||
export function buildImageProxyUrl(url: string, options: ImageProxyOptions = {}): string {
|
||||
let source = String(url || "").trim();
|
||||
if (options.delivery !== "original" && options.delivery !== "proxy") {
|
||||
const base = typeof window === "undefined" ? "https://image-proxy.local" : window.location.origin;
|
||||
try {
|
||||
const saved = new URL(source, base);
|
||||
if (saved.origin === base && saved.pathname === IMAGE_PROXY_PATH &&
|
||||
!saved.searchParams.has("original") && saved.searchParams.get("proxy") !== "1") {
|
||||
const nested = saved.searchParams.get("url") || "";
|
||||
const format = options.format || saved.searchParams.get("f") as ImageProxyFormat | undefined;
|
||||
const direct = resolveTrustedDirectStorageDisplayUrl(nested, {
|
||||
width: options.width || Number(saved.searchParams.get("w")) || undefined,
|
||||
quality: options.quality || Number(saved.searchParams.get("q")) || undefined,
|
||||
format: ["webp", "avif", "jpeg", "png"].includes(format || "") ? format : undefined,
|
||||
});
|
||||
if (direct) return direct;
|
||||
}
|
||||
} catch { /* Keep non-storage proxies on their existing path. */ }
|
||||
}
|
||||
// Some messages retain our preview URL instead of its source URL. Rebuild
|
||||
// that local proxy request without the preview's resize/format parameters.
|
||||
if (options.delivery === "original") {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import "server-only";
|
||||
import { getDefaultStorageConfig } from "@/lib/db/queries";
|
||||
import { findImageArchiveDownloadTask } from "./image-archive-db";
|
||||
import { ensureStoredImageDownloadTask, findImageArchiveDownloadTask } from "./image-archive-db";
|
||||
import { extractImageProxySourceUrl, inferOriginalImageUrl } from "@/lib/image-url";
|
||||
|
||||
export async function resolveCanvasCdnDownload(urls: string[], filename: string) {
|
||||
@ -8,17 +8,30 @@ export async function resolveCanvasCdnDownload(urls: string[], filename: string)
|
||||
const source = extractImageProxySourceUrl(url) || url;
|
||||
return [source, inferOriginalImageUrl(source) || source];
|
||||
})));
|
||||
const task = await findImageArchiveDownloadTask(sources, true);
|
||||
const asset = (task?.metadata as { downloadAsset?: { status?: string; url?: string; contentType?: string; bytes?: number } } | null)?.downloadAsset;
|
||||
if (!task) return { task, direct: null, canPrepareDirect: false };
|
||||
let task = await findImageArchiveDownloadTask(sources, true);
|
||||
const config = await getDefaultStorageConfig();
|
||||
if (config?.type !== "qiniu-kodo" || !config.publicUrl || !config.isEnabled) return { task, direct: null, canPrepareDirect: false };
|
||||
const ready = asset?.status === "ready" && asset.url && asset.contentType === "image/png";
|
||||
const source = ready ? asset.url : task.targetUrl;
|
||||
if (!source) return { task, direct: null, canPrepareDirect: false };
|
||||
const url = new URL(source);
|
||||
const base = new URL(config.publicUrl);
|
||||
const origin = process.env.CANVAS_QINIU_ORIGIN_URL;
|
||||
if (!task && base.protocol === "https:") {
|
||||
for (const candidate of sources) {
|
||||
const stored = new URL(candidate);
|
||||
if (origin && stored.origin === new URL(origin).origin && !stored.search) {
|
||||
stored.host = base.host;
|
||||
stored.protocol = base.protocol;
|
||||
}
|
||||
if (stored.origin === base.origin && !stored.search && !stored.hash &&
|
||||
stored.pathname.startsWith(base.pathname.replace(/\/$/, "") + "/uploads/")) {
|
||||
task = await ensureStoredImageDownloadTask(stored.toString());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const asset = (task?.metadata as { downloadAsset?: { status?: string; url?: string; contentType?: string; bytes?: number } } | null)?.downloadAsset;
|
||||
const ready = asset?.status === "ready" && asset.url && asset.contentType === "image/png";
|
||||
const source = ready ? asset.url : task?.targetUrl;
|
||||
if (!source) return { task, direct: null, canPrepareDirect: false };
|
||||
const url = new URL(source);
|
||||
if (origin && url.origin === new URL(origin).origin) url.host = base.host;
|
||||
if (base.protocol !== "https:" || url.origin !== base.origin
|
||||
|| !(url.pathname.startsWith(base.pathname.replace(/\/$/, "") + "/"))) return { task, direct: null, canPrepareDirect: false };
|
||||
|
||||
@ -353,6 +353,17 @@ export async function findImageArchiveDownloadTask(urls: string[], prepareOnDema
|
||||
}
|
||||
return task || null;
|
||||
}
|
||||
|
||||
/** Reuse the download worker for stored uploads and synchronous tool results. */
|
||||
export async function ensureStoredImageDownloadTask(url: string) {
|
||||
const now = new Date();
|
||||
const [created] = await db.insert(imageArchiveTask).values({
|
||||
sourceUrl: url, targetUrl: url, status: "completed", archiveAfterAt: now,
|
||||
replaceAfterAt: now, archivedAt: now, replacedAt: now, updatedAt: now,
|
||||
metadata: { downloadAsset: { status: "pending" } },
|
||||
}).onConflictDoNothing({ target: imageArchiveTask.sourceUrl }).returning();
|
||||
return created || await findImageArchiveDownloadTask([url], true);
|
||||
}
|
||||
export async function hasPendingImageDownloads() {
|
||||
const [task] = await db.select({ id: imageArchiveTask.id }).from(imageArchiveTask).where(
|
||||
sql`${imageArchiveTask.metadata}->'downloadAsset'->>'status' in ('pending', 'preparing', 'failed')`,
|
||||
|
||||
@ -48,7 +48,7 @@ export async function getReadyQiniuImageDownload(url: string, target?: Awaited<R
|
||||
const base = buildS3PublicUrl(config, "");
|
||||
if (!url.startsWith(base)) return;
|
||||
const key = url.slice(base.length);
|
||||
if (!/^uploads\/[\w.-]+$/.test(key)) return;
|
||||
if (!/^uploads\/(?:[\w.-]+\/)*[\w.-]+$/.test(key)) return;
|
||||
|
||||
const client = createS3Client(config);
|
||||
try {
|
||||
@ -74,14 +74,20 @@ export async function getReadyQiniuImageDownload(url: string, target?: Awaited<R
|
||||
}
|
||||
|
||||
export async function archiveImageThroughQiniu(sourceUrl: string, archiveId: string) {
|
||||
if (!/^https:\/\/api\.vip98k\.com\/api\/external\/assets\/[A-Za-z0-9_-]+$/.test(sourceUrl)) {
|
||||
return null;
|
||||
}
|
||||
let source: URL;
|
||||
try { source = new URL(sourceUrl); } catch { return null; }
|
||||
if (!/^https?:$/.test(source.protocol) || source.username || source.password) return null;
|
||||
const target = await resolveS3UploadTarget();
|
||||
const config = target?.config;
|
||||
if (config?.provider !== "qiniu-kodo" || config.region !== "cn-east-1" || !config.publicUrl) {
|
||||
return null;
|
||||
}
|
||||
// Files already in our bucket and inline previews need no remote copy.
|
||||
const localOrigins = [config.publicUrl, config.endpoint, process.env.CANVAS_QINIU_ORIGIN_URL,
|
||||
process.env.NEXT_PUBLIC_APP_URL, process.env.AUTH_URL, process.env.NEXTAUTH_URL];
|
||||
if (localOrigins.some(value => value && new URL(value).origin === source.origin)
|
||||
|| source.hostname === "localhost" || source.hostname === "[::1]"
|
||||
|| /^(?:127\.|10\.|192\.168\.|169\.254\.|172\.(?:1[6-9]|2\d|3[01])\.)/.test(source.hostname)) return null;
|
||||
|
||||
// The archive task owns this object key, including retries after a timeout.
|
||||
const pathname = `uploads/ai-generated-archive-${archiveId}`;
|
||||
|
||||
@ -49,7 +49,10 @@ function resolveDevMediaOrigins(isDev: boolean) {
|
||||
}
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
env: { NEXT_PUBLIC_APP_RELEASE: appRelease },
|
||||
env: {
|
||||
NEXT_PUBLIC_APP_RELEASE: appRelease,
|
||||
NEXT_PUBLIC_CANVAS_STORAGE_ORIGIN: process.env.CANVAS_QINIU_ORIGIN_URL || "",
|
||||
},
|
||||
distDir: process.env.NEXT_DIST_DIR?.trim() || ".next",
|
||||
poweredByHeader: false,
|
||||
allowedDevOrigins: ["127.0.0.1"],
|
||||
|
||||
9
tests/qiniu-delivery.config.ts
Normal file
9
tests/qiniu-delivery.config.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./routes",
|
||||
testMatch: /(?:qiniu-direct-paths|image-delivery-qiniu|image-archive-flow|original-image-download|canvas-download-speed)\.test\.ts/,
|
||||
workers: 1,
|
||||
reporter: "list",
|
||||
timeout: 30_000,
|
||||
});
|
||||
@ -3,6 +3,60 @@ import sharp from "sharp";
|
||||
import { resolveAlibabaExtendApiKey, resolveFalExtendApiKey } from "@/lib/ai/extend/config";
|
||||
import { composeOutpaint, getOutpaintGeometry } from "@/lib/ai/extend/fal-outpaint";
|
||||
import { expandWithVip98kOutpaint, getVip98kOutpaintGeometry } from "@/lib/ai/extend/vip98k-outpaint";
|
||||
import { serverLoader } from "../helpers/canvas-download-server-loader";
|
||||
|
||||
test("native expansion sends the original image and never switches models after an upstream failure", async () => {
|
||||
for (const message of ["HTTP 503 unavailable", "request timed out"]) {
|
||||
const calls: any[] = [];
|
||||
const service = serverLoader({
|
||||
"@/lib/db/queries": { getSystemSetting: async () => ({ value: { provider: "vip98k", modelId: "image-expansion" } }) },
|
||||
"@/lib/db": {},
|
||||
"@/lib/db/schema": {},
|
||||
"@/lib/storage/service": {},
|
||||
"@/lib/storage/image-archive": {},
|
||||
"@/lib/ai/vip98k-runtime": {
|
||||
executeConfiguredVip98kTask: async (params: unknown) => { calls.push(params); throw new Error(message); },
|
||||
},
|
||||
})("lib/ai/extend/service.ts");
|
||||
await expect(service.executeExtendJob({ userId: "test", imageUrl: "https://source.test/original.jpg", xScale: 1.5, yScale: 2 })).rejects.toThrow(message);
|
||||
expect(calls).toEqual([{ modelId: "image-expansion", kind: "image-tool", imageUrls: ["https://source.test/original.jpg"], imageParams: { xScale: 1.5, yScale: 2 } }]);
|
||||
}
|
||||
});
|
||||
|
||||
test("native expansion archives the completed image without transferring its bytes through the app", async () => {
|
||||
const calls: string[] = [];
|
||||
const service = serverLoader({
|
||||
"@/lib/db/queries": { getSystemSetting: async () => ({ value: { provider: "vip98k", modelId: "image-expansion" } }) },
|
||||
"@/lib/db": {},
|
||||
"@/lib/db/schema": {},
|
||||
"@/lib/storage/service": {},
|
||||
"@/lib/ai/vip98k-runtime": { executeConfiguredVip98kTask: async () => ({ outputUrls: ["https://relay.test/result.png"] }) },
|
||||
"@/lib/storage/image-archive": { archiveTemporaryImage: async (url: string, id: string) => {
|
||||
calls.push(url, id);
|
||||
return { url: "https://cdn.test/native.png", thumbnailUrl: "https://cdn.test/native.png", bytes: 12375212 };
|
||||
} },
|
||||
})("lib/ai/extend/service.ts");
|
||||
const result = await service.executeExtendJob({ userId: "test", imageUrl: "https://source.test/original.jpg", requestId: "request-1", skipGallerySave: true });
|
||||
expect(calls).toEqual(["https://relay.test/result.png", "request-1"]);
|
||||
expect(result.original_url).toBe("https://cdn.test/native.png");
|
||||
expect(result.image_base64).toBeUndefined();
|
||||
});
|
||||
|
||||
test("native expansion still returns image bytes to callers requesting base64", async () => {
|
||||
const image = await sharp({ create: { width: 12, height: 8, channels: 3, background: "red" } }).png().toBuffer();
|
||||
const service = serverLoader({
|
||||
"@/lib/db/queries": { getSystemSetting: async () => ({ value: { provider: "vip98k", modelId: "image-expansion" } }) },
|
||||
"@/lib/db": {},
|
||||
"@/lib/db/schema": {},
|
||||
"@/lib/storage/service": { uploadFile: async (name: string) => ({ url: `https://cdn.test/${name}` }) },
|
||||
"@/lib/storage/image-archive": { archiveTemporaryImage: () => { throw new Error("Base64 callers need the image bytes"); } },
|
||||
"@/lib/ai/vip98k-runtime": { executeConfiguredVip98kTask: async () => ({ outputUrls: ["https://relay.test/result.png"] }) },
|
||||
fetch: async () => new Response(new Uint8Array(image)),
|
||||
})("lib/ai/extend/service.ts");
|
||||
const result = await service.executeExtendJob({ userId: "test", imageUrl: "https://source.test/original.jpg", includeBase64: true, skipGallerySave: true });
|
||||
expect(result.image_base64).toBe(image.toString("base64"));
|
||||
expect(result.original_url).toMatch(/\.png$/);
|
||||
});
|
||||
|
||||
test.describe("image extension provider configuration", () => {
|
||||
test("uses the most recently updated Alibaba image provider key", () => {
|
||||
|
||||
@ -127,7 +127,7 @@ test("Qiniu archives 98K originals without relaying full image bytes through the
|
||||
expect((await load.archiveImageThroughQiniu(source, id)).downloadAsset).toMatchObject({ status: "ready", url: archiveUrl, bytes: rgb16.length });
|
||||
stored = indexed;
|
||||
expect((await load.archiveImageThroughQiniu(source, id)).downloadAsset).toBeUndefined();
|
||||
expect(await load.archiveImageThroughQiniu("https://another-provider.example/result.png", id)).toBeNull();
|
||||
expect(await load.archiveImageThroughQiniu("data:image/png;base64,aW1hZ2U=", id)).toBeNull();
|
||||
expect(cloudCalls).toHaveLength(3);
|
||||
expect(reads).toHaveLength(3);
|
||||
});
|
||||
|
||||
115
tests/routes/qiniu-direct-paths.test.ts
Normal file
115
tests/routes/qiniu-direct-paths.test.ts
Normal file
@ -0,0 +1,115 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import sharp from "sharp";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { serverLoader } from "../helpers/canvas-download-server-loader";
|
||||
|
||||
const config = { type: "qiniu-kodo", provider: "qiniu-kodo", isEnabled: true,
|
||||
publicUrl: "https://cdn.example", endpoint: "https://s3.example", region: "cn-east-1",
|
||||
bucket: "images", accessKeyId: "test-ak", secretAccessKey: "test-sk" };
|
||||
|
||||
test("external model results including signed OSS URLs are fetched by Qiniu without app image reads", async () => {
|
||||
let source = "";
|
||||
let calls = 0;
|
||||
const load = serverLoader({
|
||||
"@/lib/storage/service": { resolveS3UploadTarget: async () => ({ config }),
|
||||
buildS3PublicUrl: (_: unknown, key: string) => `${config.publicUrl}/${key}` },
|
||||
fetch: async (url: string, init: RequestInit) => {
|
||||
calls++;
|
||||
expect(new URL(url).hostname).toBe("iovip-z0.qiniuio.com");
|
||||
expect(init.method).toBe("POST");
|
||||
const encoded = new URL(url).pathname.split("/fetch/")[1].split("/to/")[0];
|
||||
expect(Buffer.from(encoded, "base64url").toString()).toBe(source);
|
||||
return Response.json({ key: "uploads/ai-generated-archive-task-1", fsize: 5000, mimeType: "image/jpeg" });
|
||||
},
|
||||
}, { NEXT_PUBLIC_APP_URL: "https://app.example" });
|
||||
const { archiveImageThroughQiniu } = load("lib/storage/qiniu-image-archive.ts");
|
||||
for (source of ["https://api.vip98k.com/api/external/assets/image",
|
||||
"https://dashscope-a717.oss-accelerate.aliyuncs.com/result.png?Expires=123&Signature=a%2Bb%3D",
|
||||
"https://images.provider.example/result.jpg"]) {
|
||||
expect(await archiveImageThroughQiniu(source, "task-1")).toMatchObject({ bytes: 5000, contentType: "image/jpeg" });
|
||||
}
|
||||
for (const local of ["https://cdn.example/uploads/a.png", "https://app.example/api/generated-image-preview/id",
|
||||
"http://127.0.0.1/a.png", "data:image/png;base64,abcd"]) {
|
||||
expect(await archiveImageThroughQiniu(local, "task-1")).toBeNull();
|
||||
}
|
||||
expect(calls).toBe(3);
|
||||
});
|
||||
|
||||
test("stored direct uploads prepare once and then download from CDN", async () => {
|
||||
let task: any = null;
|
||||
const ensured: string[] = [];
|
||||
const url = "https://cdn.example/uploads/direct/user-1/image.webp";
|
||||
const module = serverLoader({
|
||||
"@/lib/db/queries": { getDefaultStorageConfig: async () => config },
|
||||
"./image-archive-db": {
|
||||
findImageArchiveDownloadTask: async () => task,
|
||||
ensureStoredImageDownloadTask: async (stored: string) => {
|
||||
ensured.push(stored); return task = { id: "stored", targetUrl: stored, metadata: { downloadAsset: { status: "pending" } } };
|
||||
},
|
||||
},
|
||||
})("lib/storage/canvas-cdn-download.ts");
|
||||
const pending = await module.resolveCanvasCdnDownload([url], "图片.png");
|
||||
expect(pending).toMatchObject({ direct: null, canPrepareDirect: true });
|
||||
task.metadata.downloadAsset = { status: "ready", url: "https://cdn.example/uploads/converted.png", contentType: "image/png", bytes: 1234 };
|
||||
const ready = await module.resolveCanvasCdnDownload([url], "图片.png");
|
||||
expect(ready.direct).toMatchObject({ delivery: "cdn", bytes: 1234 });
|
||||
expect(new URL(ready.direct.downloadUrl).searchParams.get("attname")).toBe("图片.png");
|
||||
expect(ensured).toEqual([url]);
|
||||
});
|
||||
|
||||
test("download preparation only registers unsigned files in the configured bucket", async () => {
|
||||
const stored: string[] = [];
|
||||
const module = serverLoader({
|
||||
"@/lib/db/queries": { getDefaultStorageConfig: async () => config },
|
||||
"./image-archive-db": { findImageArchiveDownloadTask: async () => null,
|
||||
ensureStoredImageDownloadTask: async (url: string) => { stored.push(url); return { id: "1", targetUrl: url }; } },
|
||||
}, { CANVAS_QINIU_ORIGIN_URL: "https://origin.example" })("lib/storage/canvas-cdn-download.ts");
|
||||
for (const url of ["https://other.example/uploads/a.png", "https://cdn.example/uploads/a.png?token=signed",
|
||||
"https://cdn.example/uploads/a.png?imageView2/2/w/160", "https://cdn.example/private/a.png"]) {
|
||||
expect((await module.resolveCanvasCdnDownload([url], "a.png")).canPrepareDirect).toBe(false);
|
||||
}
|
||||
expect((await module.resolveCanvasCdnDownload(["https://origin.example/uploads/direct/user/a.png"], "a.png")).canPrepareDirect).toBe(true);
|
||||
expect(stored).toEqual(["https://cdn.example/uploads/direct/user/a.png"]);
|
||||
});
|
||||
|
||||
test("nested uploaded PNGs use a bounded header read without stripping their colour profile", async () => {
|
||||
const bytes = await sharp({ create: { width: 32, height: 24, channels: 4, background: "#1d785c80" } }).withIccProfile("p3").png().toBuffer();
|
||||
let reads = 0;
|
||||
const module = serverLoader({
|
||||
"@/lib/storage/service": { resolveS3UploadTarget: async () => ({ config }),
|
||||
buildS3PublicUrl: (_: unknown, key: string) => `${config.publicUrl}/${key}`,
|
||||
createS3Client: () => ({ destroy() {}, send: async (cmd: any) => {
|
||||
reads++; expect(cmd.input.Key).toBe("uploads/direct/user/a.png");
|
||||
expect(cmd.input.Range).toBe("bytes=0-65535");
|
||||
return { $metadata: { httpStatusCode: 206 }, ContentType: "image/png", ContentLength: bytes.length,
|
||||
ContentRange: `bytes 0-${bytes.length - 1}/${bytes.length}`, Body: { transformToByteArray: async () => bytes } };
|
||||
} }) },
|
||||
})("lib/storage/qiniu-image-archive.ts");
|
||||
const url = "https://cdn.example/uploads/direct/user/a.png";
|
||||
expect(await module.getReadyQiniuImageDownload(url)).toMatchObject({ status: "ready", url, bytes: bytes.length });
|
||||
expect(reads).toBe(1);
|
||||
});
|
||||
|
||||
test("preview aliases use the configured CDN and retain processing and signed URL semantics", async () => {
|
||||
const module = serverLoader({}, { NEXT_PUBLIC_CANVAS_CDN_ORIGIN: "https://cdn.example",
|
||||
NEXT_PUBLIC_CANVAS_STORAGE_ORIGIN: "https://origin.example" })("lib/image-delivery.ts");
|
||||
const source = "https://origin.example/uploads/a.png";
|
||||
const options = { width: 331, quality: 86, format: "webp" };
|
||||
const direct = "https://cdn.example/uploads/a.png?imageView2/2/w/360/format/webp/q/88";
|
||||
expect(module.buildImageProxyUrl(source, options)).toBe(direct);
|
||||
expect(module.buildImageProxyUrl(`/api/image-proxy?url=${encodeURIComponent(source)}&w=331&q=86&f=webp`)).toBe(direct);
|
||||
expect(module.buildImageProxyUrl(source + "?token=signed", options)).toBe(source + "?token=signed");
|
||||
expect(module.buildImageProxyUrl(`/api/image-proxy?url=${encodeURIComponent(source)}&proxy=1&w=331`)).toContain("proxy=1");
|
||||
});
|
||||
|
||||
test("saved preview URLs redirect to an archived CDN thumbnail without reading the upstream image", async () => {
|
||||
const source = "https://images.provider.example/result.png";
|
||||
const route = serverLoader({
|
||||
"next/server": { NextResponse },
|
||||
"@/lib/storage/image-archive-db": { getArchivedImageUrlMappings: async () => ({ [source]: "https://cdn.example/uploads/a.png" }) },
|
||||
fetch: () => { throw Error("No image bytes should pass through the app"); },
|
||||
}, { NEXT_PUBLIC_CANVAS_CDN_ORIGIN: "https://cdn.example" })("app/api/image-proxy/route.ts");
|
||||
const response = await route.GET(new NextRequest(`https://app.example/api/image-proxy?url=${encodeURIComponent(source)}&w=331&q=86&f=webp`, { headers: { "sec-fetch-dest": "image" } }));
|
||||
expect(response.status).toBe(307);
|
||||
expect(response.headers.get("location")).toBe("https://cdn.example/uploads/a.png?imageView2/2/w/360/format/webp/q/88");
|
||||
});
|
||||
@ -54,6 +54,20 @@ const textToImageInvocation: Vip98kTaskInvocation = {
|
||||
"/api/external/tools/result?tool_id=google%2Fnano-banana-2%2Ftext-to-image&taskId={task_id}",
|
||||
};
|
||||
|
||||
test("native expansion maps final canvas scales to per-side ratios and requests PNG", () => {
|
||||
const invocation: Vip98kTaskInvocation = {
|
||||
kind: "task", method: "POST", path: "/extensions/v1/tasks", contentType: "application/json",
|
||||
resultMethod: "GET", resultPath: "/extensions/v1/tasks/{task_id}/result",
|
||||
requestExample: { model: "image-expansion", metadata: { top: 0.1, bottom: 0.1, left: 0.1, right: 0.1 }, output_format: "" },
|
||||
inputSchema: { properties: { model: { type: "string" }, file_urls: { type: "array" }, metadata: { type: "object" }, output_format: { type: "string" } } },
|
||||
};
|
||||
const { body } = buildTaskBody({ apiKey: "test", modelId: "image-expansion", kind: "image-tool", prompt: "", imageUrls: ["https://source.test/original.jpg"], videoUrls: [], modelConfig: {}, imageParams: { xScale: 1.5, yScale: 2 } }, invocation);
|
||||
expect(body).toMatchObject({ model: "image-expansion", file_urls: ["https://source.test/original.jpg"], metadata: { left: 0.25, right: 0.25, top: 0.5, bottom: 0.5 }, output_format: "png" });
|
||||
expect(body).not.toHaveProperty("mask");
|
||||
expect(body).not.toHaveProperty("prompt");
|
||||
expect(body).not.toHaveProperty("x_scale");
|
||||
});
|
||||
|
||||
test("text-to-image uses the advertised task contract with native size and ratio, without example reference images", () => {
|
||||
const configured: Vip98kTaskInvocation = { ...textToImageInvocation, path: "/v1/images/generations", requestExample: { model: "nano-banana-pro", prompt: "sample" } };
|
||||
const task: Vip98kTaskInvocation = { ...configured, kind: "task", path: "/extensions/v1/tasks",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user