Compare commits
5 Commits
codex/auto
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d1f9590d05 | |||
| 74053490cc | |||
| 61b6eba4b4 | |||
| bbecafc826 | |||
| ed3b1b5e8a |
@ -4,6 +4,7 @@ import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { FaviconUpdater } from "@/components/favicon-updater";
|
||||
import { ImageDeliveryOptimizer } from "@/components/image-delivery-optimizer";
|
||||
import { AppUpdateNotice } from "@/components/app-update-notice";
|
||||
import { PAGE_DOWNLOAD_ACTIVE_ATTRIBUTE } from "@/lib/client/page-refresh";
|
||||
import { getSiteSettings } from "@/lib/db/queries";
|
||||
|
||||
import "./globals.css";
|
||||
@ -142,7 +143,7 @@ const CHUNK_ERROR_RECOVERY_SCRIPT = `\
|
||||
showManualRecoveryNotice();
|
||||
return;
|
||||
}
|
||||
if (lastReloadAt) return;
|
||||
if (lastReloadAt || document.documentElement.hasAttribute('${PAGE_DOWNLOAD_ACTIVE_ATTRIBUTE}')) return;
|
||||
var now = Date.now();
|
||||
// If storage is blocked, a reload cannot remember its attempt and must not run.
|
||||
if (!safeSetSessionStorage(RECOVERY_KEY, String(now))) return;
|
||||
@ -153,6 +154,10 @@ const CHUNK_ERROR_RECOVERY_SCRIPT = `\
|
||||
showManualRecoveryNotice();
|
||||
return;
|
||||
}
|
||||
if (document.documentElement.hasAttribute('${PAGE_DOWNLOAD_ACTIVE_ATTRIBUTE}')) {
|
||||
if (safeSetSessionStorage(RECOVERY_KEY, '0')) lastReloadAt = 0;
|
||||
return;
|
||||
}
|
||||
window.location.reload();
|
||||
}, 800);
|
||||
}
|
||||
|
||||
@ -1,14 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useSyncExternalStore } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter } from "@/components/ui/alert-dialog";
|
||||
import { preparePageRefresh } from "@/lib/client/page-refresh";
|
||||
import { isPageDownloadActive, preparePageRefresh, subscribeToPageDownloadActivity } from "@/lib/client/page-refresh";
|
||||
|
||||
export function AppUpdateNotice() {
|
||||
const [availableVersion, setAvailableVersion] = useState("");
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const downloading = useSyncExternalStore(subscribeToPageDownloadActivity, isPageDownloadActive, () => false);
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let controller: AbortController | undefined;
|
||||
@ -60,7 +61,7 @@ export function AppUpdateNotice() {
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
return <AlertDialog open={Boolean(availableVersion)}>
|
||||
return <AlertDialog open={Boolean(availableVersion) && !downloading}>
|
||||
<AlertDialogContent className="z-[10001]" overlayClassName="z-[10000]" onEscapeKeyDown={event => event.preventDefault()}>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>平台已更新</AlertDialogTitle>
|
||||
|
||||
@ -4467,7 +4467,6 @@ export function CanvasChatPanel({
|
||||
setDownloadProgress({
|
||||
phase: "preparing",
|
||||
message: "正在准备原图...",
|
||||
percent: 5,
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
@ -28,6 +28,7 @@ export function CanvasImageDownloadProgressDialog({
|
||||
: null;
|
||||
const loadedLabel = formatBytes(progress.loadedBytes);
|
||||
const totalLabel = formatBytes(progress.totalBytes);
|
||||
const converting = progress.phase === "converting";
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
@ -41,12 +42,12 @@ export function CanvasImageDownloadProgressDialog({
|
||||
{progress.phase === "saving" ? (
|
||||
<Download className="h-4 w-4" />
|
||||
) : (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<Loader2 className="h-4 w-4 motion-safe:animate-spin" />
|
||||
)}
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-[14px] font-semibold">下载原图</div>
|
||||
<div className="mt-0.5 text-[12px] text-white/58">{progress.message}</div>
|
||||
<div className="mt-0.5 text-[12px] text-white/58" role="status">{progress.message}</div>
|
||||
</div>
|
||||
</div>
|
||||
{onCancel ? (
|
||||
@ -63,14 +64,19 @@ export function CanvasImageDownloadProgressDialog({
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="mb-2 flex items-center justify-between text-[12px] text-white/55">
|
||||
<span>{loadedLabel && totalLabel ? `${loadedLabel} / ${totalLabel}` : "正在处理文件"}</span>
|
||||
<span>{loadedLabel && totalLabel ? `${loadedLabel} / ${totalLabel}` : converting ? "格式转换中" : "正在处理文件"}</span>
|
||||
<span>{percent !== null ? `${percent}%` : "..."}</span>
|
||||
</div>
|
||||
<div className="relative h-2 overflow-hidden rounded-full bg-white/10">
|
||||
<div
|
||||
className="relative h-2 overflow-hidden rounded-full bg-white/10"
|
||||
role="progressbar"
|
||||
aria-label={converting ? "图片格式转换" : "图片下载"}
|
||||
aria-valuenow={percent ?? undefined}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-y-0 left-0 rounded-full bg-gradient-to-r from-cyan-300 via-sky-400 to-emerald-300",
|
||||
percent === null && "w-1/3 animate-[download-indeterminate_1.15s_ease-in-out_infinite]"
|
||||
percent === null && "w-1/3 motion-safe:animate-[download-indeterminate_1.15s_ease-in-out_infinite]"
|
||||
)}
|
||||
style={percent !== null ? { width: `${percent}%` } : undefined}
|
||||
/>
|
||||
|
||||
@ -8236,7 +8236,6 @@ export function ImageNode({
|
||||
setDownloadProgress({
|
||||
phase: "preparing",
|
||||
message: "正在准备原图...",
|
||||
percent: 5,
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery";
|
||||
import { inferOriginalImageUrl } from "@/lib/image-url";
|
||||
import { formatDownloadFailure, getDownloadFailure, type DownloadFailure } from "@/lib/canvas/download-error";
|
||||
import { withActivePageDownload } from "@/lib/client/page-refresh";
|
||||
|
||||
export type CanvasImageDownloadPhase = "preparing" | "downloading" | "saving";
|
||||
export type CanvasImageDownloadPhase = "preparing" | "converting" | "downloading" | "saving";
|
||||
|
||||
export type CanvasImageDownloadProgress = {
|
||||
phase: CanvasImageDownloadPhase;
|
||||
@ -192,7 +193,11 @@ function triggerCdnDownload(result: { downloadUrl: string; filename: string; byt
|
||||
const PHOTOSHOP_DOWNLOAD_EXTENSIONS = new Set(["jpg", "png", "gif", "bmp", "tiff"]);
|
||||
|
||||
/** Preserve compatible originals and prepare a PNG for other stored images. */
|
||||
export async function downloadOriginalImage(params: DownloadCanvasImageParams): Promise<CanvasImageDownloadResult> {
|
||||
export function downloadOriginalImage(params: DownloadCanvasImageParams): Promise<CanvasImageDownloadResult> {
|
||||
return withActivePageDownload(() => downloadOriginalImageFile(params));
|
||||
}
|
||||
|
||||
async function downloadOriginalImageFile(params: DownloadCanvasImageParams): Promise<CanvasImageDownloadResult> {
|
||||
const { url, name, signal, onProgress } = params;
|
||||
const source = String(url || "").trim();
|
||||
if (!source) throw new Error("图片地址无效");
|
||||
@ -224,7 +229,7 @@ export async function downloadOriginalImage(params: DownloadCanvasImageParams):
|
||||
// The resolver names the attachment using the stored MIME type.
|
||||
const extension = delivery.filename.split(".").pop();
|
||||
if (!PHOTOSHOP_DOWNLOAD_EXTENSIONS.has(extension)) {
|
||||
return downloadCanvasImageAsPng(params);
|
||||
return downloadPngFile(params, "converting");
|
||||
}
|
||||
const result = triggerCdnDownload(delivery, signal);
|
||||
onProgress?.({ phase: "downloading", message: "已开始下载,可在浏览器下载列表查看进度" });
|
||||
@ -239,7 +244,7 @@ export async function downloadOriginalImage(params: DownloadCanvasImageParams):
|
||||
// A local blob can still be downloaded while its upload is in progress.
|
||||
if (/^https?:$/.test(target.protocol) && !PHOTOSHOP_DOWNLOAD_EXTENSIONS.has(extension)) {
|
||||
void response.body?.cancel().catch(() => {});
|
||||
return downloadCanvasImageAsPng(params);
|
||||
return downloadPngFile(params, "converting");
|
||||
}
|
||||
const blob = await readResponseBlobWithProgress(response, onProgress);
|
||||
signal?.throwIfAborted();
|
||||
@ -267,14 +272,15 @@ function toDownloadCandidates(url: string, candidates: string[] = []) {
|
||||
|
||||
async function readResponseBlobWithProgress(
|
||||
response: Response,
|
||||
onProgress?: (progress: CanvasImageDownloadProgress) => void
|
||||
onProgress?: (progress: CanvasImageDownloadProgress) => void,
|
||||
message = "正在下载原图..."
|
||||
) {
|
||||
const totalBytes = Number(response.headers.get("content-length") || 0);
|
||||
if (!response.body) {
|
||||
const blob = await response.blob();
|
||||
onProgress?.({
|
||||
phase: "downloading",
|
||||
message: "正在下载原图...",
|
||||
message,
|
||||
loadedBytes: blob.size,
|
||||
totalBytes: totalBytes || blob.size,
|
||||
percent: 100,
|
||||
@ -294,7 +300,7 @@ async function readResponseBlobWithProgress(
|
||||
loadedBytes += value.byteLength;
|
||||
onProgress?.({
|
||||
phase: "downloading",
|
||||
message: "正在下载原图...",
|
||||
message,
|
||||
loadedBytes,
|
||||
totalBytes: totalBytes || undefined,
|
||||
percent: totalBytes > 0 ? Math.min(99, Math.round((loadedBytes / totalBytes) * 100)) : undefined,
|
||||
@ -306,13 +312,17 @@ async function readResponseBlobWithProgress(
|
||||
});
|
||||
}
|
||||
|
||||
export async function downloadCanvasImageAsPng({
|
||||
export function downloadCanvasImageAsPng(params: DownloadCanvasImageParams): Promise<CanvasImageDownloadResult> {
|
||||
return withActivePageDownload(() => downloadPngFile(params, "preparing"));
|
||||
}
|
||||
|
||||
async function downloadPngFile({
|
||||
candidates,
|
||||
name,
|
||||
onProgress,
|
||||
signal,
|
||||
url,
|
||||
}: DownloadCanvasImageParams): Promise<CanvasImageDownloadResult> {
|
||||
}: DownloadCanvasImageParams, preparationPhase: "preparing" | "converting"): Promise<CanvasImageDownloadResult> {
|
||||
const raw = String(url || "").trim();
|
||||
if (!raw) {
|
||||
throw new Error("图片地址无效");
|
||||
@ -325,11 +335,9 @@ export async function downloadCanvasImageAsPng({
|
||||
"canvas-image"
|
||||
);
|
||||
|
||||
onProgress?.({
|
||||
phase: "preparing",
|
||||
message: "正在准备原图...",
|
||||
percent: 8,
|
||||
});
|
||||
const isConverting = preparationPhase === "converting";
|
||||
const conversionMessage = "正在转换为兼容 PNG,完成后自动下载";
|
||||
onProgress?.({ phase: preparationPhase, message: isConverting ? conversionMessage : "正在准备原图..." });
|
||||
|
||||
let response: Response | null = null;
|
||||
let errorPayload: DownloadErrorPayload | null = null;
|
||||
@ -368,7 +376,10 @@ export async function downloadCanvasImageAsPng({
|
||||
if (Date.now() >= preparationDeadline || preparationPolls >= 60) {
|
||||
throw new CanvasImageDownloadError("原图仍在准备中", { code: "preparation_timeout", status: 202, requestId: preparing.requestId });
|
||||
}
|
||||
onProgress?.({ phase: "preparing", message: preparing.message || "正在准备原图,完成后将自动下载" });
|
||||
onProgress?.({
|
||||
phase: preparationPhase,
|
||||
message: isConverting ? conversionMessage : preparing.message || "正在准备原图,完成后将自动下载",
|
||||
});
|
||||
const delaySeconds = Math.max(preparationPolls > 5 ? 5 : 1, Math.min(5, Number(preparing.retryAfter) || 3));
|
||||
await waitForRetry(delaySeconds * 1000, signal);
|
||||
continue;
|
||||
@ -399,7 +410,6 @@ export async function downloadCanvasImageAsPng({
|
||||
retryAfterSeconds > 0
|
||||
? `请求较多,${retryAfterSeconds} 秒后自动重试...`
|
||||
: "请求较多,正在自动重试...",
|
||||
percent: 8,
|
||||
});
|
||||
await waitForRetry(retryAfterSeconds * 1000, signal);
|
||||
}
|
||||
@ -428,7 +438,9 @@ export async function downloadCanvasImageAsPng({
|
||||
const filename =
|
||||
parseAttachmentFilename(response.headers.get("content-disposition")) ||
|
||||
`${filenameBase}.png`;
|
||||
const blob = await readResponseBlobWithProgress(response, onProgress);
|
||||
const downloadMessage = isConverting ? "正在下载 PNG..." : "正在下载原图...";
|
||||
onProgress?.({ phase: "downloading", message: downloadMessage });
|
||||
const blob = await readResponseBlobWithProgress(response, onProgress, downloadMessage);
|
||||
|
||||
onProgress?.({
|
||||
phase: "saving",
|
||||
|
||||
@ -1,10 +1,44 @@
|
||||
export const BEFORE_PAGE_REFRESH = "app:before-page-refresh";
|
||||
export const PAGE_DOWNLOAD_ACTIVE_ATTRIBUTE = "data-page-download-active";
|
||||
export type PageRefreshRequest = {
|
||||
save: (action: () => Promise<void>) => void;
|
||||
block: (message: string) => void;
|
||||
};
|
||||
|
||||
const downloadListeners = new Set<() => void>();
|
||||
let activePageDownloads = 0;
|
||||
|
||||
export function isPageDownloadActive() {
|
||||
return activePageDownloads > 0;
|
||||
}
|
||||
|
||||
export function subscribeToPageDownloadActivity(listener: () => void) {
|
||||
downloadListeners.add(listener);
|
||||
return () => { downloadListeners.delete(listener); };
|
||||
}
|
||||
|
||||
function publishDownloadActivity() {
|
||||
if (typeof document !== "undefined") {
|
||||
document.documentElement?.toggleAttribute(PAGE_DOWNLOAD_ACTIVE_ATTRIBUTE, isPageDownloadActive());
|
||||
}
|
||||
for (const listener of downloadListeners) listener();
|
||||
}
|
||||
|
||||
export async function withActivePageDownload<T>(download: () => Promise<T>): Promise<T> {
|
||||
if (++activePageDownloads === 1) publishDownloadActivity();
|
||||
try {
|
||||
return await download();
|
||||
} finally {
|
||||
if (--activePageDownloads === 0) publishDownloadActivity();
|
||||
}
|
||||
}
|
||||
|
||||
function assertPageDownloadFinished() {
|
||||
if (isPageDownloadActive()) throw new Error("文件正在下载,请等下载完成后再刷新。");
|
||||
}
|
||||
|
||||
export async function preparePageRefresh() {
|
||||
assertPageDownloadFinished();
|
||||
const actions: Array<() => Promise<void>> = [];
|
||||
let blocked = "";
|
||||
window.dispatchEvent(new CustomEvent<PageRefreshRequest>(BEFORE_PAGE_REFRESH, {
|
||||
@ -12,4 +46,5 @@ export async function preparePageRefresh() {
|
||||
}));
|
||||
if (blocked) throw new Error(blocked);
|
||||
for (const action of actions) await action();
|
||||
assertPageDownloadFinished();
|
||||
}
|
||||
|
||||
@ -26,14 +26,14 @@ function getBatchSize() {
|
||||
return Math.max(1, Math.min(50, Math.round(parsed)));
|
||||
}
|
||||
|
||||
export async function runImageArchiveJob() {
|
||||
export async function runImageArchiveJob({ preferredTaskId }: { preferredTaskId?: string } = {}) {
|
||||
return trackRuntimeWork(async () => {
|
||||
const batchSize = getBatchSize();
|
||||
console.log(`[ImageArchiveJob] Start image archive job (batchSize=${batchSize})`);
|
||||
|
||||
await recoverStaleImageArchiveTasks();
|
||||
scheduleImageDownloadPreparation();
|
||||
const storeTasks = await getImageArchiveTasksToStore({ limit: batchSize });
|
||||
const storeTasks = await getImageArchiveTasksToStore({ limit: batchSize, preferredTaskId });
|
||||
let storeSucceeded = 0;
|
||||
let storeFailed = 0;
|
||||
|
||||
|
||||
@ -98,8 +98,11 @@ export async function enqueueImageArchiveTasks(params: {
|
||||
return tasks;
|
||||
}
|
||||
|
||||
export async function getImageArchiveTasksToStore(params: { limit: number }) {
|
||||
export async function getImageArchiveTasksToStore(params: { limit: number; preferredTaskId?: string }) {
|
||||
const now = new Date();
|
||||
const priority = params.preferredTaskId
|
||||
? [asc(sql`case when ${imageArchiveTask.id} = ${params.preferredTaskId} then 0 else 1 end`)]
|
||||
: [];
|
||||
return await db
|
||||
.select()
|
||||
.from(imageArchiveTask)
|
||||
@ -110,7 +113,7 @@ export async function getImageArchiveTasksToStore(params: { limit: number }) {
|
||||
lt(imageArchiveTask.attemptCount, MAX_IMAGE_ARCHIVE_ATTEMPTS),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(imageArchiveTask.createdAt), asc(imageArchiveTask.id))
|
||||
.orderBy(...priority, asc(imageArchiveTask.createdAt), asc(imageArchiveTask.id))
|
||||
.limit(Math.max(1, Math.min(50, params.limit)));
|
||||
}
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ type Worker = {
|
||||
running: boolean;
|
||||
scheduled: boolean;
|
||||
requested: boolean;
|
||||
preferredTaskId?: string;
|
||||
retryTimer?: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
function getWorker(): Worker {
|
||||
@ -13,8 +14,9 @@ function getWorker(): Worker {
|
||||
return (root[workerKey] ??= { running: false, scheduled: false, requested: false });
|
||||
}
|
||||
|
||||
export function scheduleImageArchiveWorker(deliveryConfirmed = false) {
|
||||
export function scheduleImageArchiveWorker(deliveryConfirmed = false, preferredTaskId?: string) {
|
||||
const worker = getWorker();
|
||||
worker.preferredTaskId ??= preferredTaskId;
|
||||
worker.requested = true;
|
||||
if (worker.running || (worker.scheduled && !deliveryConfirmed)) return;
|
||||
worker.scheduled = true;
|
||||
@ -31,7 +33,9 @@ export function scheduleImageArchiveWorker(deliveryConfirmed = false) {
|
||||
const { runImageArchiveJob } = await import("@/lib/jobs/image-archive-job");
|
||||
do {
|
||||
worker.requested = false;
|
||||
const result = await runImageArchiveJob();
|
||||
const nextTaskId = worker.preferredTaskId;
|
||||
worker.preferredTaskId = undefined;
|
||||
const result = await runImageArchiveJob({ preferredTaskId: nextTaskId });
|
||||
retryNeeded = result.stats.pendingStore > 0 || result.stats.pendingReplace > 0 || result.pendingDownloads === true;
|
||||
// Stop when only backoff tasks remain; do not spin while storage is down.
|
||||
if (!worker.requested && result.store.total === 0 && result.replace.total === 0 && result.downloadTasks === 0) break;
|
||||
|
||||
@ -15,6 +15,10 @@ export async function resolveOriginalCdnDownload(source: string, name: string, s
|
||||
let target = new URL(source);
|
||||
if (target.origin !== base.origin && (!origin || target.origin !== new URL(origin).origin)) {
|
||||
const task = await findImageArchiveDownloadTask([...new Set([source, inferOriginalImageUrl(source)])]);
|
||||
if (task?.status === "pending" && !task.targetUrl) {
|
||||
const { scheduleImageArchiveWorker } = await import("./image-archive-worker");
|
||||
scheduleImageArchiveWorker(false, task.id);
|
||||
}
|
||||
target = new URL(task?.targetUrl || source);
|
||||
}
|
||||
if (origin && target.origin === new URL(origin).origin && !target.searchParams.has("token")) {
|
||||
|
||||
@ -242,7 +242,7 @@ test("inline outputs have short, stable preview identities and durable queue pay
|
||||
|
||||
test("background storage starts after delivery and coalesces repeated wakeups", async () => {
|
||||
const callbacks: Array<() => Promise<void>> = [];
|
||||
let runs = 0;
|
||||
const preferences: Array<string | undefined> = [];
|
||||
const module = serverLoader({
|
||||
"next/server": {
|
||||
after: (fn: () => Promise<void>) => {
|
||||
@ -250,10 +250,10 @@ test("background storage starts after delivery and coalesces repeated wakeups",
|
||||
},
|
||||
},
|
||||
"@/lib/jobs/image-archive-job": {
|
||||
runImageArchiveJob: async () => {
|
||||
runs += 1;
|
||||
runImageArchiveJob: async ({ preferredTaskId }: { preferredTaskId?: string }) => {
|
||||
preferences.push(preferredTaskId);
|
||||
return {
|
||||
store: { total: runs === 1 ? 1 : 0 },
|
||||
store: { total: preferences.length === 1 ? 1 : 0 },
|
||||
replace: { total: 0 },
|
||||
stats: { pendingStore: 0, pendingReplace: 0 },
|
||||
downloadTasks: 0,
|
||||
@ -262,11 +262,11 @@ test("background storage starts after delivery and coalesces repeated wakeups",
|
||||
},
|
||||
})("lib/storage/image-archive-worker.ts");
|
||||
module.scheduleImageArchiveWorker();
|
||||
module.scheduleImageArchiveWorker();
|
||||
module.scheduleImageArchiveWorker(false, "requested-image");
|
||||
expect(callbacks).toHaveLength(1);
|
||||
expect(runs).toBe(0);
|
||||
expect(preferences).toEqual([]);
|
||||
await callbacks[0]();
|
||||
expect(runs).toBe(2);
|
||||
expect(preferences).toEqual(["requested-image", undefined]);
|
||||
});
|
||||
|
||||
test("bucket failure retains the provider URL and retry task", async () => {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { serverLoader } from "../helpers/canvas-download-server-loader";
|
||||
import type { CanvasImageDownloadProgress } from "@/lib/client/canvas-image-download";
|
||||
|
||||
const config = { type: "qiniu-kodo", isEnabled: true, publicUrl: "https://cdn.example" };
|
||||
|
||||
@ -53,6 +54,18 @@ test("temporary third-party URLs are never checked with HEAD when no archive is
|
||||
expect(await module.resolveOriginalCdnDownload("https://upstream.example/image.png", "image", new AbortController().signal)).toBeNull();
|
||||
});
|
||||
|
||||
test("downloading a pending original prioritizes its existing archive task and keeps the proxy available", async () => {
|
||||
const preferred: string[] = [];
|
||||
const module = serverLoader({
|
||||
"@/lib/db/queries": { getDefaultStorageConfig: async () => config },
|
||||
"./image-archive-db": { findImageArchiveDownloadTask: async () => ({ id: "pending-image", status: "pending", targetUrl: null }) },
|
||||
"./image-archive-worker": { scheduleImageArchiveWorker: (_delivery: boolean, id: string) => preferred.push(id) },
|
||||
fetch() { throw new Error("The original must remain available through the proxy while it is queued"); },
|
||||
})("lib/storage/original-image-download.ts");
|
||||
expect(await module.resolveOriginalCdnDownload("https://upstream.example/queued.png", "image", new AbortController().signal)).toBeNull();
|
||||
expect(preferred).toEqual(["pending-image"]);
|
||||
});
|
||||
|
||||
test("a CDN metadata timeout falls back to the original Kodo object without reading its body", async () => {
|
||||
let closed = false;
|
||||
const module = serverLoader({
|
||||
@ -124,16 +137,18 @@ function browserMocks() {
|
||||
|
||||
test("chat original downloads use a native CDN download without fetching the image through CORS", async () => {
|
||||
const dom = browserMocks(); const calls: string[] = [];
|
||||
const progress: CanvasImageDownloadProgress[] = [];
|
||||
const client = serverLoader({ ...dom, fetch: async (url: string) => {
|
||||
calls.push(url);
|
||||
return Response.json({ delivery: "cdn", downloadUrl: "https://cdn.example/image.jpg?attname=original.jpg", filename: "original.jpg", bytes: 5000000 });
|
||||
} })("lib/client/canvas-image-download.ts");
|
||||
const result = await client.downloadOriginalImage({ url: "https://cdn.example/image.jpg" });
|
||||
const result = await client.downloadOriginalImage({ url: "https://cdn.example/image.jpg", onProgress: (event: CanvasImageDownloadProgress) => progress.push(event) });
|
||||
expect(result).toEqual({ filename: "original.jpg", bytes: 5000000 });
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(new URL(calls[0], "https://app.example").searchParams.get("download")).toBe("resolve");
|
||||
expect(dom.frames[0].src).toBe("https://cdn.example/image.jpg?attname=original.jpg");
|
||||
expect(dom.links).toHaveLength(0);
|
||||
expect(progress.map(event => event.phase)).toEqual(["preparing", "downloading"]);
|
||||
});
|
||||
|
||||
test("original fallback preserves JPEG bytes and the MIME-derived filename", async () => {
|
||||
@ -149,6 +164,7 @@ test("original fallback preserves JPEG bytes and the MIME-derived filename", asy
|
||||
test("one click prepares a compatible PNG when the CDN original is WebP", async () => {
|
||||
const dom = browserMocks();
|
||||
const calls: string[] = [];
|
||||
const progress: CanvasImageDownloadProgress[] = [];
|
||||
const url = "https://cdn.example/image.png";
|
||||
const client = serverLoader({ ...dom, fetch: async (path: string, init?: RequestInit) => {
|
||||
calls.push(path);
|
||||
@ -157,10 +173,19 @@ test("one click prepares a compatible PNG when the CDN original is WebP", async
|
||||
}
|
||||
expect(init?.method).toBe("POST");
|
||||
expect(JSON.parse(String(init?.body))).toMatchObject({ url, name: "商品", preparation: "async" });
|
||||
if (calls.length === 2) {
|
||||
return Response.json({ delivery: "preparing", retryAfter: 1, message: "正在准备原图" }, { status: 202 });
|
||||
}
|
||||
return Response.json({ delivery: "cdn", downloadUrl: "https://cdn.example/prepared.png", filename: "商品.png", bytes: 900 });
|
||||
} })("lib/client/canvas-image-download.ts");
|
||||
expect(await client.downloadOriginalImage({ url, name: "商品" })).toEqual({ filename: "商品.png", bytes: 900 });
|
||||
expect(calls[1]).toBe("/api/canvas/download-image");
|
||||
expect(await client.downloadOriginalImage({ url, name: "商品", onProgress: (event: CanvasImageDownloadProgress) => progress.push(event) }))
|
||||
.toEqual({ filename: "商品.png", bytes: 900 });
|
||||
expect(calls.slice(1)).toEqual(["/api/canvas/download-image", "/api/canvas/download-image"]);
|
||||
expect(progress.map(event => event.phase)).toEqual(["preparing", "converting", "converting", "downloading"]);
|
||||
for (const event of progress.filter(event => event.phase === "converting")) {
|
||||
expect(event.message).toContain("正在转换为兼容 PNG");
|
||||
expect(event.percent).toBeUndefined();
|
||||
}
|
||||
expect(dom.frames.map(frame => frame.src)).toEqual(["https://cdn.example/prepared.png"]);
|
||||
expect(dom.links).toHaveLength(0);
|
||||
});
|
||||
@ -168,6 +193,7 @@ test("one click prepares a compatible PNG when the CDN original is WebP", async
|
||||
test("one click uses the PNG preparation route without buffering an incompatible proxy response", async () => {
|
||||
const dom = browserMocks();
|
||||
const calls: string[] = [];
|
||||
const progress: CanvasImageDownloadProgress[] = [];
|
||||
let cancelled = false;
|
||||
const bytes = Uint8Array.of(137, 80, 78, 71, 13, 10, 26, 10);
|
||||
const client = serverLoader({ ...dom, fetch: async (path: string) => {
|
||||
@ -177,11 +203,16 @@ test("one click uses the PNG preparation route without buffering an incompatible
|
||||
return new Response(new ReadableStream({ cancel() { cancelled = true; } }),
|
||||
{ headers: { "content-type": "image/avif" } });
|
||||
}
|
||||
return new Response(bytes, { headers: { "content-type": "image/png" } });
|
||||
return new Response(bytes, { headers: { "content-type": "image/png", "content-length": String(bytes.length) } });
|
||||
} })("lib/client/canvas-image-download.ts");
|
||||
expect(await client.downloadOriginalImage({ url: "https://external.example/image", name: "商品" })).toEqual({ filename: "商品.png", bytes: bytes.length });
|
||||
expect(await client.downloadOriginalImage({
|
||||
url: "https://external.example/image", name: "商品", onProgress: (event: CanvasImageDownloadProgress) => progress.push(event),
|
||||
})).toEqual({ filename: "商品.png", bytes: bytes.length });
|
||||
expect(cancelled).toBe(true);
|
||||
expect(calls[2]).toBe("/api/canvas/download-image");
|
||||
expect(progress).toContainEqual({
|
||||
phase: "downloading", message: "正在下载 PNG...", loadedBytes: bytes.length, totalBytes: bytes.length, percent: 99,
|
||||
});
|
||||
expect(dom.links).toHaveLength(1);
|
||||
URL.revokeObjectURL(dom.links[0].href);
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user