Compare commits

..

No commits in common. "main" and "codex/native-image-expansion-20261112" have entirely different histories.

17 changed files with 33 additions and 352 deletions

View File

@ -331,18 +331,7 @@ export async function GET(request: NextRequest) {
); );
} }
if (!original && !forceProxy && isBrowserImageRequest(request)) { if (!original && isAlibabaModelImageUrl(target.toString())) {
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 { try {
const { getArchivedImageUrlMappings } = await import("@/lib/storage/image-archive-db"); const { getArchivedImageUrlMappings } = await import("@/lib/storage/image-archive-db");
const mappings = await getArchivedImageUrlMappings([target.toString()]); const mappings = await getArchivedImageUrlMappings([target.toString()]);

View File

@ -1,5 +1,4 @@
"use client"; "use client";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import { useCanvasGroupRuns } from "@/hooks/use-canvas-group-runs"; import { useCanvasGroupRuns } from "@/hooks/use-canvas-group-runs";
import { hasRecoverableCanvasTask } from "@/lib/video-canvas/task-recovery"; import { hasRecoverableCanvasTask } from "@/lib/video-canvas/task-recovery";
import { GroupStoryboardPreview } from "./GroupStoryboardPreview"; import { GroupStoryboardPreview } from "./GroupStoryboardPreview";
@ -15105,7 +15104,7 @@ function DirectorNodeComposer({
if(file.size>20*1024*1024){setError("参考图需小于 20 MiB");return;} if(file.size>20*1024*1024){setError("参考图需小于 20 MiB");return;}
setBusy(true);setError("");try{ setBusy(true);setError("");try{
const form=new FormData();form.append("file",file);form.append("purpose","director-studio"); const form=new FormData();form.append("file",file);form.append("purpose","director-studio");
const response=await fetchUploadWithRetry(form,{signal:AbortSignal.timeout(120000)}); const response=await fetch("/api/files/upload",{method:"POST",body:form,signal:AbortSignal.timeout(120000)});
const result=await response.json();const url=result.url||result.data?.url; const result=await response.json();const url=result.url||result.data?.url;
if(!response.ok||!url)throw new Error(result.error||"图片上传失败"); if(!response.ok||!url)throw new Error(result.error||"图片上传失败");
onDraft({directorReferenceUrl:String(url)}); onDraft({directorReferenceUrl:String(url)});

View File

@ -1,5 +1,4 @@
"use client"; "use client";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { readDirectorScene, type DirectorScene } from "@/lib/video-canvas/director-scene"; import { readDirectorScene, type DirectorScene } from "@/lib/video-canvas/director-scene";
@ -65,7 +64,9 @@ export function DirectorReconstructionPanel({
const form = new FormData(); const form = new FormData();
form.append("file", file); form.append("file", file);
form.append("purpose", "director-studio"); form.append("purpose", "director-studio");
const response = await fetchUploadWithRetry(form, { const response = await fetch("/api/files/upload", {
method: "POST",
body: form,
signal: AbortSignal.timeout(120000), signal: AbortSignal.timeout(120000),
}); });
const data = await response.json(); const data = await response.json();

View File

@ -1,5 +1,4 @@
"use client"; "use client";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import { import {
commitDirectorScene, commitDirectorScene,
resolveDirectorCamera, resolveDirectorCamera,
@ -44,13 +43,14 @@ async function upload(file: File) {
"purpose", "purpose",
file.type.startsWith("video/") ? "canvas-video-process" : "director-studio", file.type.startsWith("video/") ? "canvas-video-process" : "director-studio",
); );
const response = /\.glb$/i.test(file.name) const response = await fetch(
? await fetch("/api/canvas/director/upload-model", { /\.glb$/i.test(file.name) ? "/api/canvas/director/upload-model" : "/api/files/upload",
{
method: "POST", method: "POST",
body: form, body: form,
signal: AbortSignal.timeout(120000), signal: AbortSignal.timeout(120000),
}) },
: await fetchUploadWithRetry(form, { signal: AbortSignal.timeout(120000) }); );
const result = await response.json(); const result = await response.json();
const url = result.url || result.data?.url; const url = result.url || result.data?.url;
if (!response.ok || !url) throw new Error(result.error || "上传失败"); if (!response.ok || !url) throw new Error(result.error || "上传失败");

View File

@ -12,8 +12,6 @@ import { expandWithVip98kOutpaint, VIP98K_OUTPAINT_MODEL } from "@/lib/ai/extend
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { generatedImage } from "@/lib/db/schema"; import { generatedImage } from "@/lib/db/schema";
import { uploadFile } from "@/lib/storage/service"; import { uploadFile } from "@/lib/storage/service";
import { archiveTemporaryImage } from "@/lib/storage/image-archive";
import { randomUUID } from "node:crypto";
type ExtendConfig = { type ExtendConfig = {
provider?: ExtendProvider; provider?: ExtendProvider;
@ -436,10 +434,7 @@ export async function executeExtendJob(
} }
const startTime = Date.now(); 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; let modelId: string;
if (config.provider === "fal") { if (config.provider === "fal") {
buffer = await expandWithFalOutpaint({ imageUrl: inputImageUrl, apiKey: config.apiKey!, xScale, yScale }); buffer = await expandWithFalOutpaint({ imageUrl: inputImageUrl, apiKey: config.apiKey!, xScale, yScale });
@ -455,28 +450,21 @@ export async function executeExtendJob(
} else { } else {
const prepared = config.provider === "vip98k" ? inputImageUrl : await normalizeDashscopeExpansionInput(inputImageUrl); const prepared = config.provider === "vip98k" ? inputImageUrl : await normalizeDashscopeExpansionInput(inputImageUrl);
const url = await extendImage(prepared, config, xScale, yScale, requestId); const url = await extendImage(prepared, config, xScale, yScale, requestId);
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); const imageResponse = await fetch(url);
if (!imageResponse.ok) { if (!imageResponse.ok) {
throw new ExtendServiceError("下载拓展图片失败", 500, `${imageResponse.status} ${imageResponse.statusText}`); 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"; modelId = config.provider === "vip98k" ? "image-expansion" : "extend-1.0";
} }
const imageBase64Result = includeBase64 ? buffer.toString("base64") : undefined; const imageBase64Result = includeBase64 ? buffer.toString("base64") : undefined;
uploadResult ??= await uploadBufferPairToS3(buffer, config.provider === "fal" || config.provider === "vip98k" ? "png" : "jpg"); const uploadResult = await uploadBufferPairToS3(buffer, config.provider === "fal" || config.provider === "vip98k" ? "png" : "jpg");
console.log("[Extend Service] Result uploaded:", { console.log("[Extend Service] Result uploaded:", {
requestId, requestId,
includeBase64, includeBase64,
inputImageUrl: inputImageUrl.slice(0, 120), inputImageUrl: inputImageUrl.slice(0, 120),
modelId, modelId,
outputBytes: archivedBytes ?? buffer.length, outputBytes: buffer.length,
}); });
let mediaAssetId: string | null = null; let mediaAssetId: string | null = null;
let generatedImageId: string | null = null; let generatedImageId: string | null = null;
@ -525,7 +513,6 @@ export async function executeExtendJob(
xScale, xScale,
yScale, yScale,
imageSize: "2K", imageSize: "2K",
...(downloadAsset ? { downloadAsset } : {}),
}, },
}); });
mediaAssetId = media?.id || null; mediaAssetId = media?.id || null;

View File

@ -134,13 +134,6 @@ function resolveTrustedDirectStorageDisplayUrl(
try { try {
const target = new URL(url); const target = new URL(url);
const configuredCdn = getConfiguredCdnUrl(); 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; const isConfiguredCdn = target.origin === configuredCdn?.origin;
if ( if (
target.hostname !== configuredCdn?.hostname && target.hostname !== configuredCdn?.hostname &&
@ -222,23 +215,6 @@ function resolveAliyunOssDisplayUrl(
export function buildImageProxyUrl(url: string, options: ImageProxyOptions = {}): string { export function buildImageProxyUrl(url: string, options: ImageProxyOptions = {}): string {
let source = String(url || "").trim(); 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 // Some messages retain our preview URL instead of its source URL. Rebuild
// that local proxy request without the preview's resize/format parameters. // that local proxy request without the preview's resize/format parameters.
if (options.delivery === "original") { if (options.delivery === "original") {

View File

@ -1,6 +1,6 @@
import "server-only"; import "server-only";
import { getDefaultStorageConfig } from "@/lib/db/queries"; import { getDefaultStorageConfig } from "@/lib/db/queries";
import { ensureStoredImageDownloadTask, findImageArchiveDownloadTask } from "./image-archive-db"; import { findImageArchiveDownloadTask } from "./image-archive-db";
import { extractImageProxySourceUrl, inferOriginalImageUrl } from "@/lib/image-url"; import { extractImageProxySourceUrl, inferOriginalImageUrl } from "@/lib/image-url";
export async function resolveCanvasCdnDownload(urls: string[], filename: string) { export async function resolveCanvasCdnDownload(urls: string[], filename: string) {
@ -8,30 +8,17 @@ export async function resolveCanvasCdnDownload(urls: string[], filename: string)
const source = extractImageProxySourceUrl(url) || url; const source = extractImageProxySourceUrl(url) || url;
return [source, inferOriginalImageUrl(source) || source]; return [source, inferOriginalImageUrl(source) || source];
}))); })));
let task = await findImageArchiveDownloadTask(sources, true); 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 };
const config = await getDefaultStorageConfig(); const config = await getDefaultStorageConfig();
if (config?.type !== "qiniu-kodo" || !config.publicUrl || !config.isEnabled) return { task, direct: null, canPrepareDirect: false }; if (config?.type !== "qiniu-kodo" || !config.publicUrl || !config.isEnabled) return { task, direct: null, canPrepareDirect: false };
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 ready = asset?.status === "ready" && asset.url && asset.contentType === "image/png";
const source = ready ? asset.url : task?.targetUrl; const source = ready ? asset.url : task.targetUrl;
if (!source) return { task, direct: null, canPrepareDirect: false }; if (!source) return { task, direct: null, canPrepareDirect: false };
const url = new URL(source); const url = new URL(source);
const base = new URL(config.publicUrl);
const origin = process.env.CANVAS_QINIU_ORIGIN_URL;
if (origin && url.origin === new URL(origin).origin) url.host = base.host; if (origin && url.origin === new URL(origin).origin) url.host = base.host;
if (base.protocol !== "https:" || url.origin !== base.origin if (base.protocol !== "https:" || url.origin !== base.origin
|| !(url.pathname.startsWith(base.pathname.replace(/\/$/, "") + "/"))) return { task, direct: null, canPrepareDirect: false }; || !(url.pathname.startsWith(base.pathname.replace(/\/$/, "") + "/"))) return { task, direct: null, canPrepareDirect: false };

View File

@ -353,17 +353,6 @@ export async function findImageArchiveDownloadTask(urls: string[], prepareOnDema
} }
return task || null; 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() { export async function hasPendingImageDownloads() {
const [task] = await db.select({ id: imageArchiveTask.id }).from(imageArchiveTask).where( const [task] = await db.select({ id: imageArchiveTask.id }).from(imageArchiveTask).where(
sql`${imageArchiveTask.metadata}->'downloadAsset'->>'status' in ('pending', 'preparing', 'failed')`, sql`${imageArchiveTask.metadata}->'downloadAsset'->>'status' in ('pending', 'preparing', 'failed')`,

View File

@ -4,7 +4,7 @@ import { fetchImageBytes, cacheImageBytes } from "./image-byte-cache";
import { normalizeCanvasDownloadAsset } from "@/lib/canvas/photoshop-safe-download"; import { normalizeCanvasDownloadAsset } from "@/lib/canvas/photoshop-safe-download";
import { claimImageDownloadTask, saveImageDownloadAsset, getImageDownloadTasks, hasPendingImageDownloads } from "./image-archive-db"; import { claimImageDownloadTask, saveImageDownloadAsset, getImageDownloadTasks, hasPendingImageDownloads } from "./image-archive-db";
import { uploadFile } from "./service"; import { uploadFile } from "./service";
import { getReadyQiniuImageDownload, prepareQiniuWebpDownload } from "./qiniu-image-archive"; import { getReadyQiniuImageDownload } from "./qiniu-image-archive";
import { getDownloadFailure } from "@/lib/canvas/download-error"; import { getDownloadFailure } from "@/lib/canvas/download-error";
import { createLogger } from "@/lib/observability/logger"; import { createLogger } from "@/lib/observability/logger";
import { trackRuntimeWork } from "@/lib/observability/active-work"; import { trackRuntimeWork } from "@/lib/observability/active-work";
@ -21,11 +21,6 @@ export async function prepareImageArchiveDownload(id: string) {
await saveImageDownloadAsset(id, ready); await saveImageDownloadAsset(id, ready);
return; return;
} }
const converted = await prepareQiniuWebpDownload(task.targetUrl, task.id).catch(() => undefined);
if (converted) {
await saveImageDownloadAsset(id, converted);
return;
}
const source = await fetchImageBytes(task.targetUrl); const source = await fetchImageBytes(task.targetUrl);
phase = "convert"; phase = "convert";
const asset = await normalizeCanvasDownloadAsset({ const asset = await normalizeCanvasDownloadAsset({

View File

@ -48,7 +48,7 @@ export async function getReadyQiniuImageDownload(url: string, target?: Awaited<R
const base = buildS3PublicUrl(config, ""); const base = buildS3PublicUrl(config, "");
if (!url.startsWith(base)) return; if (!url.startsWith(base)) return;
const key = url.slice(base.length); const key = url.slice(base.length);
if (!/^uploads\/(?:[\w.-]+\/)*[\w.-]+$/.test(key)) return; if (!/^uploads\/[\w.-]+$/.test(key)) return;
const client = createS3Client(config); const client = createS3Client(config);
try { try {
@ -74,45 +74,17 @@ export async function getReadyQiniuImageDownload(url: string, target?: Awaited<R
} }
export async function archiveImageThroughQiniu(sourceUrl: string, archiveId: string) { export async function archiveImageThroughQiniu(sourceUrl: string, archiveId: string) {
let source: URL; if (!/^https:\/\/api\.vip98k\.com\/api\/external\/assets\/[A-Za-z0-9_-]+$/.test(sourceUrl)) {
try { source = new URL(sourceUrl); } catch { return null; } return null;
if (!/^https?:$/.test(source.protocol) || source.username || source.password) return null; }
const target = await resolveS3UploadTarget(); const target = await resolveS3UploadTarget();
const config = target?.config; const config = target?.config;
if (config?.provider !== "qiniu-kodo" || config.region !== "cn-east-1" || !config.publicUrl) { if (config?.provider !== "qiniu-kodo" || config.region !== "cn-east-1" || !config.publicUrl) {
return null; 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. // The archive task owns this object key, including retries after a timeout.
const pathname = `uploads/ai-generated-archive-${archiveId}`; const pathname = `uploads/ai-generated-archive-${archiveId}`;
return fetchImageIntoQiniu(sourceUrl, pathname, target!);
}
/** Prepare uploaded WebP images inside Kodo, retaining their embedded colour profile. */
export async function prepareQiniuWebpDownload(url: string, taskId: string) {
const target = await resolveS3UploadTarget();
const config = target?.config;
if (config?.provider !== "qiniu-kodo" || config.region !== "cn-east-1" || !config.publicUrl) return;
const base = buildS3PublicUrl(config, "");
if (!url.startsWith(base) || !/^uploads\/(?:[\w.-]+\/)*[\w.-]+\.webp$/i.test(url.slice(base.length))) return;
// No resizing or lossy re-encoding; apply orientation before removing EXIF.
const source = `${url}?imageMogr2/auto-orient/format/png/strip/meta-keep-list/${encodeQiniu("icc")}`;
const result = await fetchImageIntoQiniu(source, `uploads/canvas-download-${taskId}.png`, target!);
return result.downloadAsset;
}
async function fetchImageIntoQiniu(
sourceUrl: string,
pathname: string,
target: NonNullable<Awaited<ReturnType<typeof resolveS3UploadTarget>>>,
) {
const config = target.config;
const path = `/fetch/${encodeQiniu(sourceUrl)}/to/${encodeQiniu(`${config.bucket}:${pathname}`)}`; const path = `/fetch/${encodeQiniu(sourceUrl)}/to/${encodeQiniu(`${config.bucket}:${pathname}`)}`;
const contentType = "application/x-www-form-urlencoded"; const contentType = "application/x-www-form-urlencoded";
const signingData = `POST ${path}\nHost: ${QINIU_EAST_CHINA_IO}\nContent-Type: ${contentType}\n\n`; const signingData = `POST ${path}\nHost: ${QINIU_EAST_CHINA_IO}\nContent-Type: ${contentType}\n\n`;

View File

@ -49,10 +49,7 @@ function resolveDevMediaOrigins(isDev: boolean) {
} }
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
env: { env: { NEXT_PUBLIC_APP_RELEASE: appRelease },
NEXT_PUBLIC_APP_RELEASE: appRelease,
NEXT_PUBLIC_CANVAS_STORAGE_ORIGIN: process.env.CANVAS_QINIU_ORIGIN_URL || "",
},
distDir: process.env.NEXT_DIST_DIR?.trim() || ".next", distDir: process.env.NEXT_DIST_DIR?.trim() || ".next",
poweredByHeader: false, poweredByHeader: false,
allowedDevOrigins: ["127.0.0.1"], allowedDevOrigins: ["127.0.0.1"],

View File

@ -1,9 +0,0 @@
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|canvas-download-recovery)\.test\.ts/,
workers: 1,
reporter: "list",
timeout: 30_000,
});

View File

@ -124,7 +124,7 @@ test("requested downloads are prioritized with two workers before scanning backg
}, },
"./image-byte-cache": { fetchImageBytes: async () => { active++; maximum = Math.max(maximum, active); await new Promise<void>(resolve => releases.push(resolve)); active--; return { buffer: bytes, contentType: "image/png" }; }, cacheImageBytes: async () => {} }, "./image-byte-cache": { fetchImageBytes: async () => { active++; maximum = Math.max(maximum, active); await new Promise<void>(resolve => releases.push(resolve)); active--; return { buffer: bytes, contentType: "image/png" }; }, cacheImageBytes: async () => {} },
"@/lib/canvas/photoshop-safe-download": { normalizeCanvasDownloadAsset: async () => ({ ok: true, bytes, contentType: "image/png" }) }, "@/lib/canvas/photoshop-safe-download": { normalizeCanvasDownloadAsset: async () => ({ ok: true, bytes, contentType: "image/png" }) },
"./qiniu-image-archive": { getReadyQiniuImageDownload: async () => undefined, prepareQiniuWebpDownload: async () => undefined }, "./qiniu-image-archive": { getReadyQiniuImageDownload: async () => undefined },
"./service": { uploadFile() { throw new Error("Compatible PNG must retain its object"); } }, "./service": { uploadFile() { throw new Error("Compatible PNG must retain its object"); } },
})("lib/storage/image-download-preparation.ts"); })("lib/storage/image-download-preparation.ts");
worker.scheduleImageDownloadPreparation("one"); worker.scheduleImageDownloadPreparation("two"); worker.scheduleImageDownloadPreparation("three"); worker.scheduleImageDownloadPreparation("one"); worker.scheduleImageDownloadPreparation("two"); worker.scheduleImageDownloadPreparation("three");

View File

@ -137,7 +137,7 @@ test("PNG preparation failure leaves the original archived and schedules a retry
const module = serverLoader({ const module = serverLoader({
"./image-archive-db": { claimImageDownloadTask: async () => ({ id: "task", targetUrl: "https://cdn.example/original.png", metadata: { downloadAsset: { attempts: 2 } } }), saveImageDownloadAsset: async (_id: string, asset: any) => { saved = asset; } }, "./image-archive-db": { claimImageDownloadTask: async () => ({ id: "task", targetUrl: "https://cdn.example/original.png", metadata: { downloadAsset: { attempts: 2 } } }), saveImageDownloadAsset: async (_id: string, asset: any) => { saved = asset; } },
"./image-byte-cache": { fetchImageBytes: async () => { throw new Error("storage offline"); } }, "./image-byte-cache": { fetchImageBytes: async () => { throw new Error("storage offline"); } },
"./qiniu-image-archive": { getReadyQiniuImageDownload: async () => undefined, prepareQiniuWebpDownload: async () => undefined }, "./qiniu-image-archive": { getReadyQiniuImageDownload: async () => undefined },
"./service": {}, "./service": {},
})("lib/storage/image-download-preparation.ts"); })("lib/storage/image-download-preparation.ts");
await module.prepareImageArchiveDownload("task"); await module.prepareImageArchiveDownload("task");

View File

@ -13,7 +13,6 @@ test("native expansion sends the original image and never switches models after
"@/lib/db": {}, "@/lib/db": {},
"@/lib/db/schema": {}, "@/lib/db/schema": {},
"@/lib/storage/service": {}, "@/lib/storage/service": {},
"@/lib/storage/image-archive": {},
"@/lib/ai/vip98k-runtime": { "@/lib/ai/vip98k-runtime": {
executeConfiguredVip98kTask: async (params: unknown) => { calls.push(params); throw new Error(message); }, executeConfiguredVip98kTask: async (params: unknown) => { calls.push(params); throw new Error(message); },
}, },
@ -23,41 +22,6 @@ test("native expansion sends the original image and never switches models after
} }
}); });
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.describe("image extension provider configuration", () => {
test("uses the most recently updated Alibaba image provider key", () => { test("uses the most recently updated Alibaba image provider key", () => {
expect( expect(

View File

@ -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 }); expect((await load.archiveImageThroughQiniu(source, id)).downloadAsset).toMatchObject({ status: "ready", url: archiveUrl, bytes: rgb16.length });
stored = indexed; stored = indexed;
expect((await load.archiveImageThroughQiniu(source, id)).downloadAsset).toBeUndefined(); expect((await load.archiveImageThroughQiniu(source, id)).downloadAsset).toBeUndefined();
expect(await load.archiveImageThroughQiniu("data:image/png;base64,aW1hZ2U=", id)).toBeNull(); expect(await load.archiveImageThroughQiniu("https://another-provider.example/result.png", id)).toBeNull();
expect(cloudCalls).toHaveLength(3); expect(cloudCalls).toHaveLength(3);
expect(reads).toHaveLength(3); expect(reads).toHaveLength(3);
}); });

View File

@ -1,166 +0,0 @@
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");
});
test("uploaded WebP is converted inside Qiniu with orientation and ICC retention", async () => {
const png = await sharp({ create: { width: 20, height: 12, channels: 4, background: "#22887780" } }).withIccProfile("p3").png().toBuffer();
let calls = 0;
const module = serverLoader({
"@/lib/storage/service": { resolveS3UploadTarget: async () => ({ config }),
buildS3PublicUrl: (_: unknown, key: string) => `${config.publicUrl}/${key}`,
createS3Client: () => ({ destroy() {}, send: async (cmd: any) => {
expect(cmd.input.Key).toBe("uploads/canvas-download-task-1.png");
expect(cmd.input.Range).toBe("bytes=0-65535");
return { $metadata: { httpStatusCode: 206 }, ContentType: "image/png", ContentLength: png.length,
ContentRange: `bytes 0-${png.length - 1}/${png.length}`, Body: { transformToByteArray: async () => png } };
} }) },
fetch: async (url: string) => {
calls++;
const encoded = new URL(url).pathname.split("/fetch/")[1].split("/to/")[0];
const source = Buffer.from(encoded, "base64url").toString();
expect(source).toBe("https://cdn.example/uploads/direct/user/source.webp?imageMogr2/auto-orient/format/png/strip/meta-keep-list/aWNj");
return Response.json({ key: "uploads/canvas-download-task-1.png", fsize: png.length, mimeType: "image/png" });
},
})("lib/storage/qiniu-image-archive.ts");
const source = "https://cdn.example/uploads/direct/user/source.webp";
const ready = await module.prepareQiniuWebpDownload(source, "task-1");
expect(ready).toMatchObject({ status: "ready", url: "https://cdn.example/uploads/canvas-download-task-1.png", bytes: png.length });
for (const excluded of [source + "?token=signed", source.replace("cdn.example", "other.example"), source.replace(".webp", ".png")]) {
expect(await module.prepareQiniuWebpDownload(excluded, "task-1")).toBeUndefined();
}
expect(calls).toBe(1);
});
test("download worker uses a cloud-prepared PNG and retains local conversion on cloud failure", async () => {
const bytes = await sharp({ create: { width: 4, height: 4, channels: 4, background: "#bb442280" } }).png().toBuffer();
let cloudFails = false, reads = 0;
const saved: any[] = [];
const task = { id: "webp", targetUrl: "https://cdn.example/uploads/a.webp", metadata: {} };
const module = serverLoader({
"./image-archive-db": { claimImageDownloadTask: async () => task, saveImageDownloadAsset: async (_: string, asset: any) => saved.push(asset) },
"./qiniu-image-archive": { getReadyQiniuImageDownload: async () => undefined,
prepareQiniuWebpDownload: async () => { if (cloudFails) throw Error("cloud temporarily unavailable"); return { status: "ready", url: "https://cdn.example/uploads/converted.png", bytes: bytes.length, contentType: "image/png" }; } },
"./image-byte-cache": { fetchImageBytes: async () => { reads++; return { buffer: bytes, contentType: "image/png" }; }, cacheImageBytes: async () => undefined },
"@/lib/storage/image-byte-cache": { onceImageBytes: async (_: string, fn: any) => fn() },
"./service": { uploadFile: async () => ({ url: "https://cdn.example/uploads/fallback.png" }) },
})("lib/storage/image-download-preparation.ts");
await module.prepareImageArchiveDownload("webp");
expect(reads).toBe(0);
expect(saved[0]).toMatchObject({ status: "ready", url: "https://cdn.example/uploads/converted.png" });
cloudFails = true;
await module.prepareImageArchiveDownload("webp");
expect(reads).toBe(1);
expect(saved[1].status).toBe("ready");
});