Compare commits

...

7 Commits

15 changed files with 209 additions and 58 deletions

View File

@ -4,6 +4,7 @@ import { ThemeProvider } from "@/components/theme-provider";
import { FaviconUpdater } from "@/components/favicon-updater"; import { FaviconUpdater } from "@/components/favicon-updater";
import { ImageDeliveryOptimizer } from "@/components/image-delivery-optimizer"; import { ImageDeliveryOptimizer } from "@/components/image-delivery-optimizer";
import { AppUpdateNotice } from "@/components/app-update-notice"; import { AppUpdateNotice } from "@/components/app-update-notice";
import { PAGE_DOWNLOAD_ACTIVE_ATTRIBUTE } from "@/lib/client/page-refresh";
import { getSiteSettings } from "@/lib/db/queries"; import { getSiteSettings } from "@/lib/db/queries";
import "./globals.css"; import "./globals.css";
@ -142,7 +143,7 @@ const CHUNK_ERROR_RECOVERY_SCRIPT = `\
showManualRecoveryNotice(); showManualRecoveryNotice();
return; return;
} }
if (lastReloadAt) return; if (lastReloadAt || document.documentElement.hasAttribute('${PAGE_DOWNLOAD_ACTIVE_ATTRIBUTE}')) return;
var now = Date.now(); var now = Date.now();
// If storage is blocked, a reload cannot remember its attempt and must not run. // If storage is blocked, a reload cannot remember its attempt and must not run.
if (!safeSetSessionStorage(RECOVERY_KEY, String(now))) return; if (!safeSetSessionStorage(RECOVERY_KEY, String(now))) return;
@ -153,6 +154,10 @@ const CHUNK_ERROR_RECOVERY_SCRIPT = `\
showManualRecoveryNotice(); showManualRecoveryNotice();
return; return;
} }
if (document.documentElement.hasAttribute('${PAGE_DOWNLOAD_ACTIVE_ATTRIBUTE}')) {
if (safeSetSessionStorage(RECOVERY_KEY, '0')) lastReloadAt = 0;
return;
}
window.location.reload(); window.location.reload();
}, 800); }, 800);
} }

View File

@ -1,14 +1,15 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState, useSyncExternalStore } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter } from "@/components/ui/alert-dialog"; 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() { export function AppUpdateNotice() {
const [availableVersion, setAvailableVersion] = useState(""); const [availableVersion, setAvailableVersion] = useState("");
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const downloading = useSyncExternalStore(subscribeToPageDownloadActivity, isPageDownloadActive, () => false);
useEffect(() => { useEffect(() => {
let disposed = false; let disposed = false;
let controller: AbortController | undefined; let controller: AbortController | undefined;
@ -60,7 +61,7 @@ export function AppUpdateNotice() {
setRefreshing(false); setRefreshing(false);
} }
}; };
return <AlertDialog open={Boolean(availableVersion)}> return <AlertDialog open={Boolean(availableVersion) && !downloading}>
<AlertDialogContent className="z-[10001]" overlayClassName="z-[10000]" onEscapeKeyDown={event => event.preventDefault()}> <AlertDialogContent className="z-[10001]" overlayClassName="z-[10000]" onEscapeKeyDown={event => event.preventDefault()}>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>平台已更新</AlertDialogTitle> <AlertDialogTitle>平台已更新</AlertDialogTitle>

View File

@ -4467,7 +4467,6 @@ export function CanvasChatPanel({
setDownloadProgress({ setDownloadProgress({
phase: "preparing", phase: "preparing",
message: "正在准备原图...", message: "正在准备原图...",
percent: 5,
}); });
try { try {
@ -4476,7 +4475,7 @@ export function CanvasChatPanel({
signal: controller.signal, signal: controller.signal,
onProgress: setDownloadProgress, onProgress: setDownloadProgress,
}); });
toast.success("原图已开始下载"); toast.success("已开始下载");
} catch (error) { } catch (error) {
if (controller.signal.aborted) { if (controller.signal.aborted) {
toast.message("已取消下载"); toast.message("已取消下载");

View File

@ -28,6 +28,7 @@ export function CanvasImageDownloadProgressDialog({
: null; : null;
const loadedLabel = formatBytes(progress.loadedBytes); const loadedLabel = formatBytes(progress.loadedBytes);
const totalLabel = formatBytes(progress.totalBytes); const totalLabel = formatBytes(progress.totalBytes);
const converting = progress.phase === "converting";
return createPortal( return createPortal(
<div <div
@ -41,12 +42,12 @@ export function CanvasImageDownloadProgressDialog({
{progress.phase === "saving" ? ( {progress.phase === "saving" ? (
<Download className="h-4 w-4" /> <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> </span>
<div> <div>
<div className="text-[14px] font-semibold">下载原图</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>
</div> </div>
{onCancel ? ( {onCancel ? (
@ -63,14 +64,19 @@ export function CanvasImageDownloadProgressDialog({
<div className="mt-4"> <div className="mt-4">
<div className="mb-2 flex items-center justify-between text-[12px] text-white/55"> <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> <span>{percent !== null ? `${percent}%` : "..."}</span>
</div> </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 <div
className={cn( className={cn(
"absolute inset-y-0 left-0 rounded-full bg-gradient-to-r from-cyan-300 via-sky-400 to-emerald-300", "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} style={percent !== null ? { width: `${percent}%` } : undefined}
/> />

View File

@ -178,7 +178,6 @@ import {
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import { import {
downloadCanvasImageAsPng,
downloadOriginalImage, downloadOriginalImage,
getCanvasImageDownloadErrorMessage, getCanvasImageDownloadErrorMessage,
type CanvasImageDownloadProgress, type CanvasImageDownloadProgress,
@ -8225,7 +8224,7 @@ export function ImageNode({
}; };
const isDownloadingOriginalImage = Boolean(downloadProgress); const isDownloadingOriginalImage = Boolean(downloadProgress);
const handleDownload = async (format: "original" | "png" = "original") => { const handleDownload = async () => {
if (!data.src) return; if (!data.src) return;
if (isDownloadingOriginalImage) { if (isDownloadingOriginalImage) {
toast.info("原图正在下载中"); toast.info("原图正在下载中");
@ -8237,19 +8236,16 @@ export function ImageNode({
setDownloadProgress({ setDownloadProgress({
phase: "preparing", phase: "preparing",
message: "正在准备原图...", message: "正在准备原图...",
percent: 5,
}); });
try { try {
const request = { await downloadOriginalImage({
url: resolveOriginalImageUrl(data.src) || data.src, url: resolveOriginalImageUrl(data.src) || data.src,
name: displayTitle, name: displayTitle,
signal: controller.signal, signal: controller.signal,
onProgress: setDownloadProgress, onProgress: setDownloadProgress,
}; });
if (format === "png") await downloadCanvasImageAsPng({ ...request, url: data.src }); toast.success("已开始下载");
else await downloadOriginalImage(request);
toast.success(format === "png" ? "PNG 已开始下载" : "原图已开始下载");
} catch (error) { } catch (error) {
if (controller.signal.aborted) { if (controller.signal.aborted) {
toast.message("已取消下载"); toast.message("已取消下载");
@ -9642,7 +9638,6 @@ export function ImageNode({
<DropdownMenuSeparator /> <DropdownMenuSeparator />
) : null} ) : null}
{imageDownloadEnabled ? ( {imageDownloadEnabled ? (
<>
<DropdownMenuItem <DropdownMenuItem
disabled={isDownloadingOriginalImage} disabled={isDownloadingOriginalImage}
onClick={() => { onClick={() => {
@ -9660,11 +9655,6 @@ export function ImageNode({
)} )}
{imageDownloadItem.label} {imageDownloadItem.label}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem disabled={isDownloadingOriginalImage} onClick={() => void handleDownload("png")}>
<Download className="mr-2 h-4 w-4" />
下载为 PNG(兼容 Photoshop)
</DropdownMenuItem>
</>
) : null} ) : null}
{imageDeleteEnabled ? ( {imageDeleteEnabled ? (
<DropdownMenuItem <DropdownMenuItem

View File

@ -221,7 +221,9 @@ export async function convertImageToPhotoshopPng(bytes: Buffer): Promise<Buffer>
|| (metadata.space === "rgb16" && metadata.depth === "ushort"); || (metadata.space === "rgb16" && metadata.depth === "ushort");
if (metadata.format === "png" && rgb if (metadata.format === "png" && rgb
&& !metadata.isPalette && (metadata.pages || 1) === 1 && (!metadata.orientation || metadata.orientation === 1)) return bytes; && !metadata.isPalette && (metadata.pages || 1) === 1 && (!metadata.orientation || metadata.orientation === 1)) return bytes;
return image.rotate().toColorspace("srgb").png({ compressionLevel: 3, adaptiveFiltering: true }).toBuffer(); const output = image.rotate().toColorspace("srgb");
if (metadata.icc) output.withIccProfile("srgb");
return output.png({ compressionLevel: 3, adaptiveFiltering: true }).toBuffer();
} }
function buildCanvasAssetFilename(params: { function buildCanvasAssetFilename(params: {

View File

@ -1,8 +1,9 @@
import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery"; import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery";
import { inferOriginalImageUrl } from "@/lib/image-url"; import { inferOriginalImageUrl } from "@/lib/image-url";
import { formatDownloadFailure, getDownloadFailure, type DownloadFailure } from "@/lib/canvas/download-error"; 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 = { export type CanvasImageDownloadProgress = {
phase: CanvasImageDownloadPhase; phase: CanvasImageDownloadPhase;
@ -189,8 +190,15 @@ function triggerCdnDownload(result: { downloadUrl: string; filename: string; byt
return { filename: result.filename, bytes: result.bytes || 0 }; return { filename: result.filename, bytes: result.bytes || 0 };
} }
/** Keep the source format and bytes for chat, gallery and reference downloads. */ const PHOTOSHOP_DOWNLOAD_EXTENSIONS = new Set(["jpg", "png", "gif", "bmp", "tiff"]);
export async function downloadOriginalImage({ url, name, signal, onProgress }: DownloadCanvasImageParams): Promise<CanvasImageDownloadResult> {
/** Preserve compatible originals and prepare a PNG for other stored images. */
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(); const source = String(url || "").trim();
if (!source) throw new Error("图片地址无效"); if (!source) throw new Error("图片地址无效");
const proxy = buildImageProxyUrl(source, { delivery: "original" }); const proxy = buildImageProxyUrl(source, { delivery: "original" });
@ -218,6 +226,11 @@ export async function downloadOriginalImage({ url, name, signal, onProgress }: D
const metadata = await checkedFetch(target.pathname + target.search); const metadata = await checkedFetch(target.pathname + target.search);
const delivery = await metadata.json(); const delivery = await metadata.json();
if (delivery.delivery === "cdn") { if (delivery.delivery === "cdn") {
// The resolver names the attachment using the stored MIME type.
const extension = delivery.filename.split(".").pop();
if (!PHOTOSHOP_DOWNLOAD_EXTENSIONS.has(extension)) {
return downloadPngFile(params, "converting");
}
const result = triggerCdnDownload(delivery, signal); const result = triggerCdnDownload(delivery, signal);
onProgress?.({ phase: "downloading", message: "已开始下载,可在浏览器下载列表查看进度" }); onProgress?.({ phase: "downloading", message: "已开始下载,可在浏览器下载列表查看进度" });
return result; return result;
@ -225,9 +238,16 @@ export async function downloadOriginalImage({ url, name, signal, onProgress }: D
if (delivery.delivery !== "proxy") throw new Error("下载链接无效"); if (delivery.delivery !== "proxy") throw new Error("下载链接无效");
} }
const response = await checkedFetch(proxy); const response = await checkedFetch(proxy);
const contentType = response.headers.get("content-type") || "";
if (!contentType.toLowerCase().startsWith("image/")) throw new Error("Source is not an image");
const extension = getImageDownloadExtension(contentType);
// 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 downloadPngFile(params, "converting");
}
const blob = await readResponseBlobWithProgress(response, onProgress); const blob = await readResponseBlobWithProgress(response, onProgress);
if (!blob.type.toLowerCase().startsWith("image/")) throw new Error("Source is not an image"); signal?.throwIfAborted();
const extension = getImageDownloadExtension(blob.type);
const filename = extension ? `${filenameBase}.${extension}` : filenameBase; const filename = extension ? `${filenameBase}.${extension}` : filenameBase;
triggerBlobDownload(blob, filename); triggerBlobDownload(blob, filename);
return { filename, bytes: blob.size }; return { filename, bytes: blob.size };
@ -252,14 +272,15 @@ function toDownloadCandidates(url: string, candidates: string[] = []) {
async function readResponseBlobWithProgress( async function readResponseBlobWithProgress(
response: Response, response: Response,
onProgress?: (progress: CanvasImageDownloadProgress) => void onProgress?: (progress: CanvasImageDownloadProgress) => void,
message = "正在下载原图..."
) { ) {
const totalBytes = Number(response.headers.get("content-length") || 0); const totalBytes = Number(response.headers.get("content-length") || 0);
if (!response.body) { if (!response.body) {
const blob = await response.blob(); const blob = await response.blob();
onProgress?.({ onProgress?.({
phase: "downloading", phase: "downloading",
message: "正在下载原图...", message,
loadedBytes: blob.size, loadedBytes: blob.size,
totalBytes: totalBytes || blob.size, totalBytes: totalBytes || blob.size,
percent: 100, percent: 100,
@ -279,7 +300,7 @@ async function readResponseBlobWithProgress(
loadedBytes += value.byteLength; loadedBytes += value.byteLength;
onProgress?.({ onProgress?.({
phase: "downloading", phase: "downloading",
message: "正在下载原图...", message,
loadedBytes, loadedBytes,
totalBytes: totalBytes || undefined, totalBytes: totalBytes || undefined,
percent: totalBytes > 0 ? Math.min(99, Math.round((loadedBytes / totalBytes) * 100)) : undefined, percent: totalBytes > 0 ? Math.min(99, Math.round((loadedBytes / totalBytes) * 100)) : undefined,
@ -291,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, candidates,
name, name,
onProgress, onProgress,
signal, signal,
url, url,
}: DownloadCanvasImageParams): Promise<CanvasImageDownloadResult> { }: DownloadCanvasImageParams, preparationPhase: "preparing" | "converting"): Promise<CanvasImageDownloadResult> {
const raw = String(url || "").trim(); const raw = String(url || "").trim();
if (!raw) { if (!raw) {
throw new Error("图片地址无效"); throw new Error("图片地址无效");
@ -310,11 +335,9 @@ export async function downloadCanvasImageAsPng({
"canvas-image" "canvas-image"
); );
onProgress?.({ const isConverting = preparationPhase === "converting";
phase: "preparing", const conversionMessage = "正在转换为兼容 PNG,完成后自动下载";
message: "正在准备原图...", onProgress?.({ phase: preparationPhase, message: isConverting ? conversionMessage : "正在准备原图..." });
percent: 8,
});
let response: Response | null = null; let response: Response | null = null;
let errorPayload: DownloadErrorPayload | null = null; let errorPayload: DownloadErrorPayload | null = null;
@ -353,7 +376,10 @@ export async function downloadCanvasImageAsPng({
if (Date.now() >= preparationDeadline || preparationPolls >= 60) { if (Date.now() >= preparationDeadline || preparationPolls >= 60) {
throw new CanvasImageDownloadError("原图仍在准备中", { code: "preparation_timeout", status: 202, requestId: preparing.requestId }); 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)); const delaySeconds = Math.max(preparationPolls > 5 ? 5 : 1, Math.min(5, Number(preparing.retryAfter) || 3));
await waitForRetry(delaySeconds * 1000, signal); await waitForRetry(delaySeconds * 1000, signal);
continue; continue;
@ -384,7 +410,6 @@ export async function downloadCanvasImageAsPng({
retryAfterSeconds > 0 retryAfterSeconds > 0
? `请求较多,${retryAfterSeconds} 秒后自动重试...` ? `请求较多,${retryAfterSeconds} 秒后自动重试...`
: "请求较多,正在自动重试...", : "请求较多,正在自动重试...",
percent: 8,
}); });
await waitForRetry(retryAfterSeconds * 1000, signal); await waitForRetry(retryAfterSeconds * 1000, signal);
} }
@ -413,7 +438,9 @@ export async function downloadCanvasImageAsPng({
const filename = const filename =
parseAttachmentFilename(response.headers.get("content-disposition")) || parseAttachmentFilename(response.headers.get("content-disposition")) ||
`${filenameBase}.png`; `${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?.({ onProgress?.({
phase: "saving", phase: "saving",

View File

@ -1,10 +1,44 @@
export const BEFORE_PAGE_REFRESH = "app:before-page-refresh"; export const BEFORE_PAGE_REFRESH = "app:before-page-refresh";
export const PAGE_DOWNLOAD_ACTIVE_ATTRIBUTE = "data-page-download-active";
export type PageRefreshRequest = { export type PageRefreshRequest = {
save: (action: () => Promise<void>) => void; save: (action: () => Promise<void>) => void;
block: (message: string) => 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() { export async function preparePageRefresh() {
assertPageDownloadFinished();
const actions: Array<() => Promise<void>> = []; const actions: Array<() => Promise<void>> = [];
let blocked = ""; let blocked = "";
window.dispatchEvent(new CustomEvent<PageRefreshRequest>(BEFORE_PAGE_REFRESH, { window.dispatchEvent(new CustomEvent<PageRefreshRequest>(BEFORE_PAGE_REFRESH, {
@ -12,4 +46,5 @@ export async function preparePageRefresh() {
})); }));
if (blocked) throw new Error(blocked); if (blocked) throw new Error(blocked);
for (const action of actions) await action(); for (const action of actions) await action();
assertPageDownloadFinished();
} }

View File

@ -26,14 +26,14 @@ function getBatchSize() {
return Math.max(1, Math.min(50, Math.round(parsed))); return Math.max(1, Math.min(50, Math.round(parsed)));
} }
export async function runImageArchiveJob() { export async function runImageArchiveJob({ preferredTaskId }: { preferredTaskId?: string } = {}) {
return trackRuntimeWork(async () => { return trackRuntimeWork(async () => {
const batchSize = getBatchSize(); const batchSize = getBatchSize();
console.log(`[ImageArchiveJob] Start image archive job (batchSize=${batchSize})`); console.log(`[ImageArchiveJob] Start image archive job (batchSize=${batchSize})`);
await recoverStaleImageArchiveTasks(); await recoverStaleImageArchiveTasks();
scheduleImageDownloadPreparation(); scheduleImageDownloadPreparation();
const storeTasks = await getImageArchiveTasksToStore({ limit: batchSize }); const storeTasks = await getImageArchiveTasksToStore({ limit: batchSize, preferredTaskId });
let storeSucceeded = 0; let storeSucceeded = 0;
let storeFailed = 0; let storeFailed = 0;

View File

@ -98,8 +98,11 @@ export async function enqueueImageArchiveTasks(params: {
return tasks; return tasks;
} }
export async function getImageArchiveTasksToStore(params: { limit: number }) { export async function getImageArchiveTasksToStore(params: { limit: number; preferredTaskId?: string }) {
const now = new Date(); const now = new Date();
const priority = params.preferredTaskId
? [asc(sql`case when ${imageArchiveTask.id} = ${params.preferredTaskId} then 0 else 1 end`)]
: [];
return await db return await db
.select() .select()
.from(imageArchiveTask) .from(imageArchiveTask)
@ -110,7 +113,7 @@ export async function getImageArchiveTasksToStore(params: { limit: number }) {
lt(imageArchiveTask.attemptCount, MAX_IMAGE_ARCHIVE_ATTEMPTS), 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))); .limit(Math.max(1, Math.min(50, params.limit)));
} }

View File

@ -6,6 +6,7 @@ type Worker = {
running: boolean; running: boolean;
scheduled: boolean; scheduled: boolean;
requested: boolean; requested: boolean;
preferredTaskId?: string;
retryTimer?: ReturnType<typeof setTimeout>; retryTimer?: ReturnType<typeof setTimeout>;
}; };
function getWorker(): Worker { function getWorker(): Worker {
@ -13,8 +14,9 @@ function getWorker(): Worker {
return (root[workerKey] ??= { running: false, scheduled: false, requested: false }); return (root[workerKey] ??= { running: false, scheduled: false, requested: false });
} }
export function scheduleImageArchiveWorker(deliveryConfirmed = false) { export function scheduleImageArchiveWorker(deliveryConfirmed = false, preferredTaskId?: string) {
const worker = getWorker(); const worker = getWorker();
worker.preferredTaskId ??= preferredTaskId;
worker.requested = true; worker.requested = true;
if (worker.running || (worker.scheduled && !deliveryConfirmed)) return; if (worker.running || (worker.scheduled && !deliveryConfirmed)) return;
worker.scheduled = true; worker.scheduled = true;
@ -31,7 +33,9 @@ export function scheduleImageArchiveWorker(deliveryConfirmed = false) {
const { runImageArchiveJob } = await import("@/lib/jobs/image-archive-job"); const { runImageArchiveJob } = await import("@/lib/jobs/image-archive-job");
do { do {
worker.requested = false; 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; 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. // 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; if (!worker.requested && result.store.total === 0 && result.replace.total === 0 && result.downloadTasks === 0) break;

View File

@ -15,6 +15,10 @@ export async function resolveOriginalCdnDownload(source: string, name: string, s
let target = new URL(source); let target = new URL(source);
if (target.origin !== base.origin && (!origin || target.origin !== new URL(origin).origin)) { if (target.origin !== base.origin && (!origin || target.origin !== new URL(origin).origin)) {
const task = await findImageArchiveDownloadTask([...new Set([source, inferOriginalImageUrl(source)])]); 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); target = new URL(task?.targetUrl || source);
} }
if (origin && target.origin === new URL(origin).origin && !target.searchParams.has("token")) { if (origin && target.origin === new URL(origin).origin && !target.searchParams.has("token")) {

View File

@ -112,6 +112,10 @@ test("compatible PNG remains byte-identical; JPEG becomes RGB PNG without resizi
const converted = await module.convertImageToPhotoshopPng(jpeg); const converted = await module.convertImageToPhotoshopPng(jpeg);
const metadata = await sharp(converted).metadata(); const metadata = await sharp(converted).metadata();
expect(metadata).toMatchObject({ format: "png", space: "srgb", width: 32, height: 48, depth: "uchar" }); expect(metadata).toMatchObject({ format: "png", space: "srgb", width: 32, height: 48, depth: "uchar" });
const webp = await sharp(bytes).withIccProfile("p3").webp().toBuffer();
const webpPng = await module.convertImageToPhotoshopPng(webp);
const webpPngMetadata = await sharp(webpPng).metadata();
expect(webpPngMetadata).toMatchObject({ format: "png", width: 32, height: 48, hasAlpha: true, hasProfile: true });
}); });
test("CDN delivery requires a ready compatible asset and a configured HTTPS storage origin", async () => { test("CDN delivery requires a ready compatible asset and a configured HTTPS storage origin", async () => {
let url = "https://cdn.example/uploads/test.png"; let url = "https://cdn.example/uploads/test.png";

View File

@ -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 () => { test("background storage starts after delivery and coalesces repeated wakeups", async () => {
const callbacks: Array<() => Promise<void>> = []; const callbacks: Array<() => Promise<void>> = [];
let runs = 0; const preferences: Array<string | undefined> = [];
const module = serverLoader({ const module = serverLoader({
"next/server": { "next/server": {
after: (fn: () => Promise<void>) => { after: (fn: () => Promise<void>) => {
@ -250,10 +250,10 @@ test("background storage starts after delivery and coalesces repeated wakeups",
}, },
}, },
"@/lib/jobs/image-archive-job": { "@/lib/jobs/image-archive-job": {
runImageArchiveJob: async () => { runImageArchiveJob: async ({ preferredTaskId }: { preferredTaskId?: string }) => {
runs += 1; preferences.push(preferredTaskId);
return { return {
store: { total: runs === 1 ? 1 : 0 }, store: { total: preferences.length === 1 ? 1 : 0 },
replace: { total: 0 }, replace: { total: 0 },
stats: { pendingStore: 0, pendingReplace: 0 }, stats: { pendingStore: 0, pendingReplace: 0 },
downloadTasks: 0, downloadTasks: 0,
@ -262,11 +262,11 @@ test("background storage starts after delivery and coalesces repeated wakeups",
}, },
})("lib/storage/image-archive-worker.ts"); })("lib/storage/image-archive-worker.ts");
module.scheduleImageArchiveWorker(); module.scheduleImageArchiveWorker();
module.scheduleImageArchiveWorker(); module.scheduleImageArchiveWorker(false, "requested-image");
expect(callbacks).toHaveLength(1); expect(callbacks).toHaveLength(1);
expect(runs).toBe(0); expect(preferences).toEqual([]);
await callbacks[0](); await callbacks[0]();
expect(runs).toBe(2); expect(preferences).toEqual(["requested-image", undefined]);
}); });
test("bucket failure retains the provider URL and retry task", async () => { test("bucket failure retains the provider URL and retry task", async () => {

View File

@ -1,5 +1,6 @@
import { test, expect } from "@playwright/test"; import { test, expect } from "@playwright/test";
import { serverLoader } from "../helpers/canvas-download-server-loader"; 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" }; 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(); 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 () => { test("a CDN metadata timeout falls back to the original Kodo object without reading its body", async () => {
let closed = false; let closed = false;
const module = serverLoader({ 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 () => { test("chat original downloads use a native CDN download without fetching the image through CORS", async () => {
const dom = browserMocks(); const calls: string[] = []; const dom = browserMocks(); const calls: string[] = [];
const progress: CanvasImageDownloadProgress[] = [];
const client = serverLoader({ ...dom, fetch: async (url: string) => { const client = serverLoader({ ...dom, fetch: async (url: string) => {
calls.push(url); calls.push(url);
return Response.json({ delivery: "cdn", downloadUrl: "https://cdn.example/image.jpg?attname=original.jpg", filename: "original.jpg", bytes: 5000000 }); 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"); } })("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(result).toEqual({ filename: "original.jpg", bytes: 5000000 });
expect(calls).toHaveLength(1); expect(calls).toHaveLength(1);
expect(new URL(calls[0], "https://app.example").searchParams.get("download")).toBe("resolve"); 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.frames[0].src).toBe("https://cdn.example/image.jpg?attname=original.jpg");
expect(dom.links).toHaveLength(0); 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 () => { test("original fallback preserves JPEG bytes and the MIME-derived filename", async () => {
@ -146,6 +161,62 @@ test("original fallback preserves JPEG bytes and the MIME-derived filename", asy
URL.revokeObjectURL(dom.links[0].href); URL.revokeObjectURL(dom.links[0].href);
}); });
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);
if (calls.length === 1) {
return Response.json({ delivery: "cdn", downloadUrl: url, filename: "商品.webp", bytes: 500 });
}
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: "商品", 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);
});
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) => {
calls.push(path);
if (calls.length === 1) return Response.json({ delivery: "proxy" });
if (calls.length === 2) {
return new Response(new ReadableStream({ cancel() { cancelled = true; } }),
{ headers: { "content-type": "image/avif" } });
}
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: "商品", 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);
});
test("original downloads reject processed references before network access", async () => { test("original downloads reject processed references before network access", async () => {
const client = serverLoader({ ...browserMocks(), fetch() { throw new Error("Must not fetch a processed image as an original"); } })("lib/client/canvas-image-download.ts"); const client = serverLoader({ ...browserMocks(), fetch() { throw new Error("Must not fetch a processed image as an original"); } })("lib/client/canvas-image-download.ts");
await expect(client.downloadOriginalImage({ url: "https://cdn.example/image.jpg?imageView2/2/w/320&token=signed" })).rejects.toThrow("无法确认原图"); await expect(client.downloadOriginalImage({ url: "https://cdn.example/image.jpg?imageView2/2/w/320&token=signed" })).rejects.toThrow("无法确认原图");