Merge pull request '图片预览即时显示并按屏幕加载高清' (#38) from codex/image-preview-latency-20261105 into main
This commit is contained in:
commit
dfaeb9f09d
@ -82,6 +82,7 @@ import {
|
|||||||
inferOriginalImageUrl,
|
inferOriginalImageUrl,
|
||||||
} from "@/lib/image-url";
|
} from "@/lib/image-url";
|
||||||
import { buildImageFallbackUrl, buildImageProxyUrl } from "@/lib/image-delivery";
|
import { buildImageFallbackUrl, buildImageProxyUrl } from "@/lib/image-delivery";
|
||||||
|
import { ProgressiveImagePreview, type LoadedImagePreview } from "@/components/progressive-image-preview";
|
||||||
import {
|
import {
|
||||||
isImageGenerationModelDescriptor,
|
isImageGenerationModelDescriptor,
|
||||||
isVideoModelDescriptor,
|
isVideoModelDescriptor,
|
||||||
@ -3434,6 +3435,7 @@ export function CanvasChatPanel({
|
|||||||
const [fullscreenPreview, setFullscreenPreview] = useState<{
|
const [fullscreenPreview, setFullscreenPreview] = useState<{
|
||||||
url: string;
|
url: string;
|
||||||
alt?: string;
|
alt?: string;
|
||||||
|
preview: LoadedImagePreview;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [downloadProgress, setDownloadProgress] =
|
const [downloadProgress, setDownloadProgress] =
|
||||||
useState<CanvasImageDownloadProgress | null>(null);
|
useState<CanvasImageDownloadProgress | null>(null);
|
||||||
@ -3471,16 +3473,6 @@ export function CanvasChatPanel({
|
|||||||
const [downloadingOriginalUrl, setDownloadingOriginalUrl] = useState("");
|
const [downloadingOriginalUrl, setDownloadingOriginalUrl] = useState("");
|
||||||
const [addingClawdImageUrl, setAddingClawdImageUrl] = useState("");
|
const [addingClawdImageUrl, setAddingClawdImageUrl] = useState("");
|
||||||
const downloadAbortControllerRef = useRef<AbortController | null>(null);
|
const downloadAbortControllerRef = useRef<AbortController | null>(null);
|
||||||
const fullscreenPreviewSrc = useMemo(() => {
|
|
||||||
if (!fullscreenPreview?.url) return "";
|
|
||||||
const originalUrl = inferOriginalImageUrl(fullscreenPreview.url);
|
|
||||||
return buildImageProxyUrl(originalUrl || fullscreenPreview.url, {
|
|
||||||
delivery: "proxy",
|
|
||||||
width: 2400,
|
|
||||||
quality: 90,
|
|
||||||
format: "webp",
|
|
||||||
});
|
|
||||||
}, [fullscreenPreview]);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (composerUploadClearTimeoutRef.current) {
|
if (composerUploadClearTimeoutRef.current) {
|
||||||
@ -17036,11 +17028,19 @@ export function CanvasChatPanel({
|
|||||||
event.currentTarget.src = buildImageFallbackUrl(url);
|
event.currentTarget.src = buildImageFallbackUrl(url);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onClick={() => setFullscreenPreview({ url })}
|
onClick={(event) => setFullscreenPreview({ url, preview: {
|
||||||
|
src: event.currentTarget.currentSrc || event.currentTarget.src,
|
||||||
|
width: event.currentTarget.naturalWidth,
|
||||||
|
height: event.currentTarget.naturalHeight,
|
||||||
|
} })}
|
||||||
onDoubleClick={(e) => {
|
onDoubleClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setFullscreenPreview({ url });
|
setFullscreenPreview({ url, preview: {
|
||||||
|
src: e.currentTarget.currentSrc || e.currentTarget.src,
|
||||||
|
width: e.currentTarget.naturalWidth,
|
||||||
|
height: e.currentTarget.naturalHeight,
|
||||||
|
} });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="absolute bottom-2 right-2 flex items-center gap-1.5">
|
<div className="absolute bottom-2 right-2 flex items-center gap-1.5">
|
||||||
@ -17286,7 +17286,7 @@ export function CanvasChatPanel({
|
|||||||
onClick={() => setFullscreenPreview(null)}
|
onClick={() => setFullscreenPreview(null)}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
className="absolute top-4 right-4 p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors"
|
className="absolute top-4 right-4 z-10 p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors"
|
||||||
onClick={() => setFullscreenPreview(null)}
|
onClick={() => setFullscreenPreview(null)}
|
||||||
aria-label="关闭预览"
|
aria-label="关闭预览"
|
||||||
>
|
>
|
||||||
@ -17295,7 +17295,7 @@ export function CanvasChatPanel({
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="absolute top-4 right-16 inline-flex items-center gap-1 rounded-full bg-white/10 px-3 py-2 text-sm text-white transition-colors hover:bg-white/20 disabled:cursor-not-allowed disabled:opacity-65"
|
className="absolute top-4 right-16 z-10 inline-flex items-center gap-1 rounded-full bg-white/10 px-3 py-2 text-sm text-white transition-colors hover:bg-white/20 disabled:cursor-not-allowed disabled:opacity-65"
|
||||||
disabled={isDownloadingOriginalImage}
|
disabled={isDownloadingOriginalImage}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@ -17312,13 +17312,11 @@ export function CanvasChatPanel({
|
|||||||
{isDownloadingOriginalImage ? "下载中" : "下载原图"}
|
{isDownloadingOriginalImage ? "下载中" : "下载原图"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<img
|
<ProgressiveImagePreview
|
||||||
src={fullscreenPreviewSrc}
|
src={inferOriginalImageUrl(fullscreenPreview.url) || fullscreenPreview.url}
|
||||||
|
preview={fullscreenPreview.preview}
|
||||||
alt={fullscreenPreview.alt || "图片预览"}
|
alt={fullscreenPreview.alt || "图片预览"}
|
||||||
className="max-w-[95vw] max-h-[95vh] object-contain"
|
className="max-w-[95vw] max-h-[95vh] object-contain"
|
||||||
decoding="async"
|
|
||||||
draggable={false}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 text-white/60 text-sm">
|
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 text-white/60 text-sm">
|
||||||
|
|||||||
@ -60,6 +60,7 @@ import { DetailUiDomRenderer } from "@/components/canvas/DetailUiDomRenderer";
|
|||||||
import { ReferenceImageToolbar } from "@/components/canvas/ReferenceImageToolbar";
|
import { ReferenceImageToolbar } from "@/components/canvas/ReferenceImageToolbar";
|
||||||
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
|
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
|
||||||
import { CanvasImageDownloadProgressDialog } from "@/components/canvas/CanvasImageDownloadProgressDialog";
|
import { CanvasImageDownloadProgressDialog } from "@/components/canvas/CanvasImageDownloadProgressDialog";
|
||||||
|
import { ProgressiveImagePreview, type LoadedImagePreview } from "@/components/progressive-image-preview";
|
||||||
import {
|
import {
|
||||||
areImageAdjustmentsEqual,
|
areImageAdjustmentsEqual,
|
||||||
buildImageAdjustmentFilterStyle,
|
buildImageAdjustmentFilterStyle,
|
||||||
@ -1711,6 +1712,7 @@ export function ImageNode({
|
|||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [annotatorOpen, setAnnotatorOpen] = useState(false);
|
const [annotatorOpen, setAnnotatorOpen] = useState(false);
|
||||||
const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false);
|
const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false);
|
||||||
|
const [fullscreenPreviewImage, setFullscreenPreviewImage] = useState<LoadedImagePreview | null>(null);
|
||||||
const [downloadProgress, setDownloadProgress] =
|
const [downloadProgress, setDownloadProgress] =
|
||||||
useState<CanvasImageDownloadProgress | null>(null);
|
useState<CanvasImageDownloadProgress | null>(null);
|
||||||
const downloadAbortControllerRef = useRef<AbortController | null>(null);
|
const downloadAbortControllerRef = useRef<AbortController | null>(null);
|
||||||
@ -8559,6 +8561,11 @@ export function ImageNode({
|
|||||||
onDoubleClick={(e) => {
|
onDoubleClick={(e) => {
|
||||||
if (!isThisNodePicking && !boxCutoutMode && !localEditMode) {
|
if (!isThisNodePicking && !boxCutoutMode && !localEditMode) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
setFullscreenPreviewImage({
|
||||||
|
src: e.currentTarget.currentSrc || e.currentTarget.src,
|
||||||
|
width: e.currentTarget.naturalWidth,
|
||||||
|
height: e.currentTarget.naturalHeight,
|
||||||
|
});
|
||||||
setFullscreenPreviewOpen(true);
|
setFullscreenPreviewOpen(true);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@ -11964,6 +11971,7 @@ export function ImageNode({
|
|||||||
{/* 双击放大预览弹窗 */}
|
{/* 双击放大预览弹窗 */}
|
||||||
{fullscreenPreviewOpen &&
|
{fullscreenPreviewOpen &&
|
||||||
data.src &&
|
data.src &&
|
||||||
|
fullscreenPreviewImage &&
|
||||||
createPortal(
|
createPortal(
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[9999] flex cursor-zoom-out items-center justify-center bg-black/90"
|
className="fixed inset-0 z-[9999] flex cursor-zoom-out items-center justify-center bg-black/90"
|
||||||
@ -11971,7 +11979,7 @@ export function ImageNode({
|
|||||||
>
|
>
|
||||||
{/* 关闭按钮 */}
|
{/* 关闭按钮 */}
|
||||||
<button
|
<button
|
||||||
className="absolute top-4 right-4 rounded-full bg-slate-950 p-2 transition-colors hover:bg-black"
|
className="absolute top-4 right-4 z-10 rounded-full bg-slate-950 p-2 transition-colors hover:bg-black"
|
||||||
onClick={() => setFullscreenPreviewOpen(false)}
|
onClick={() => setFullscreenPreviewOpen(false)}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@ -11989,7 +11997,7 @@ export function ImageNode({
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="absolute top-4 right-16 inline-flex items-center gap-1.5 rounded-full bg-slate-950 px-3 py-2 text-sm text-white transition-colors hover:bg-black disabled:cursor-not-allowed disabled:opacity-60"
|
className="absolute top-4 right-16 z-10 inline-flex items-center gap-1.5 rounded-full bg-slate-950 px-3 py-2 text-sm text-white transition-colors hover:bg-black disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
disabled={isDownloadingOriginalImage}
|
disabled={isDownloadingOriginalImage}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@ -12008,13 +12016,11 @@ export function ImageNode({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 图片 */}
|
{/* 图片 */}
|
||||||
<img
|
<ProgressiveImagePreview
|
||||||
alt={data.prompt || "图片预览"}
|
alt={data.prompt || "图片预览"}
|
||||||
className="max-h-[95vh] max-w-[95vw] object-contain"
|
className="max-h-[95vh] max-w-[95vw] object-contain"
|
||||||
decoding="async"
|
src={resolveOriginalImageUrl(data.src) || data.src}
|
||||||
draggable={false}
|
preview={fullscreenPreviewImage}
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
src={fullscreenPreviewSrc}
|
|
||||||
style={activeImageAdjustmentsStyle}
|
style={activeImageAdjustmentsStyle}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
74
components/progressive-image-preview.tsx
Normal file
74
components/progressive-image-preview.tsx
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, type CSSProperties, type MouseEventHandler } from "react";
|
||||||
|
import { buildImageProxyUrl, getFullscreenPreviewSize } from "@/lib/image-delivery";
|
||||||
|
|
||||||
|
export type LoadedImagePreview = { src: string; width: number; height: number };
|
||||||
|
|
||||||
|
/** Keep the already displayed image visible while its larger preview loads. */
|
||||||
|
export function ProgressiveImagePreview({
|
||||||
|
src,
|
||||||
|
preview,
|
||||||
|
alt,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
src: string;
|
||||||
|
preview: LoadedImagePreview;
|
||||||
|
alt: string;
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
onClick?: MouseEventHandler<HTMLImageElement>;
|
||||||
|
}) {
|
||||||
|
const size = getFullscreenPreviewSize({
|
||||||
|
imageWidth: preview.width,
|
||||||
|
imageHeight: preview.height,
|
||||||
|
viewportWidth: typeof window === "undefined" ? 1280 : window.innerWidth,
|
||||||
|
viewportHeight: typeof window === "undefined" ? 900 : window.innerHeight,
|
||||||
|
pixelRatio: typeof window === "undefined" ? 1 : window.devicePixelRatio,
|
||||||
|
});
|
||||||
|
const fullSrc = buildImageProxyUrl(src, {
|
||||||
|
delivery: "proxy",
|
||||||
|
width: size.requestWidth,
|
||||||
|
quality: 86,
|
||||||
|
format: "webp",
|
||||||
|
});
|
||||||
|
const [loadedSrc, setLoadedSrc] = useState("");
|
||||||
|
const [failedSrc, setFailedSrc] = useState("");
|
||||||
|
const ready = fullSrc === preview.src || loadedSrc === fullSrc;
|
||||||
|
const failed = failedSrc === fullSrc;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (fullSrc === preview.src) return;
|
||||||
|
const image = new Image();
|
||||||
|
image.decoding = "async";
|
||||||
|
image.onload = () => setLoadedSrc(fullSrc);
|
||||||
|
image.onerror = () => setFailedSrc(fullSrc);
|
||||||
|
image.src = fullSrc;
|
||||||
|
return () => {
|
||||||
|
image.onload = null;
|
||||||
|
image.onerror = null;
|
||||||
|
image.removeAttribute("src");
|
||||||
|
};
|
||||||
|
}, [fullSrc, preview.src]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex items-center justify-center" onClick={(event) => event.stopPropagation()}>
|
||||||
|
<img
|
||||||
|
alt={alt}
|
||||||
|
className={className}
|
||||||
|
decoding="async"
|
||||||
|
draggable={false}
|
||||||
|
onClick={onClick}
|
||||||
|
src={ready ? fullSrc : preview.src}
|
||||||
|
style={{ width: size.displayWidth, ...style }}
|
||||||
|
/>
|
||||||
|
{!ready && (
|
||||||
|
<div role="status" className="pointer-events-none absolute bottom-4 max-w-full rounded-lg bg-black/80 px-3 py-2 text-center text-sm text-white">
|
||||||
|
{failed ? "高清预览加载失败,已保留当前预览;可关闭后重试或下载原图" : "正在加载高清预览…"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -20,6 +20,26 @@ export type ImageProxyOptions = {
|
|||||||
delivery?: "display" | "proxy" | "original";
|
delivery?: "display" | "proxy" | "original";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function getFullscreenPreviewSize({
|
||||||
|
imageWidth,
|
||||||
|
imageHeight,
|
||||||
|
viewportWidth,
|
||||||
|
viewportHeight,
|
||||||
|
pixelRatio = 1,
|
||||||
|
}: {
|
||||||
|
imageWidth: number;
|
||||||
|
imageHeight: number;
|
||||||
|
viewportWidth: number;
|
||||||
|
viewportHeight: number;
|
||||||
|
pixelRatio?: number;
|
||||||
|
}) {
|
||||||
|
const ratio = imageWidth > 0 && imageHeight > 0 ? imageWidth / imageHeight : 1;
|
||||||
|
const displayWidth = Math.min(viewportWidth * 0.95, viewportHeight * 0.95 * ratio);
|
||||||
|
const requiredWidth = displayWidth * Math.max(1, pixelRatio || 1);
|
||||||
|
const requestWidth = [640, 960, 1280, 1600, 2048, 2560, 3200, 4096].find((width) => width >= requiredWidth) || 4096;
|
||||||
|
return { displayWidth, requestWidth };
|
||||||
|
}
|
||||||
|
|
||||||
/** Preserve the actual response format when naming an original download. */
|
/** Preserve the actual response format when naming an original download. */
|
||||||
export function getImageDownloadExtension(contentType: string) {
|
export function getImageDownloadExtension(contentType: string) {
|
||||||
const mime = contentType.split(";")[0]?.trim().toLowerCase();
|
const mime = contentType.split(";")[0]?.trim().toLowerCase();
|
||||||
|
|||||||
@ -2,9 +2,24 @@ import { expect, test } from "@playwright/test";
|
|||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { GET } from "@/app/api/image-proxy/route";
|
import { GET } from "@/app/api/image-proxy/route";
|
||||||
import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery";
|
import { buildImageProxyUrl, getFullscreenPreviewSize, getImageDownloadExtension } from "@/lib/image-delivery";
|
||||||
|
|
||||||
test.describe("Qiniu image delivery", () => {
|
test.describe("Qiniu image delivery", () => {
|
||||||
|
test("sizes a fullscreen square to its visible height and reuses the chat preview on standard density screens", () => {
|
||||||
|
const size = getFullscreenPreviewSize({ imageWidth: 2048, imageHeight: 2048, viewportWidth: 1657, viewportHeight: 884 });
|
||||||
|
expect(size.displayWidth).toBeCloseTo(839.8);
|
||||||
|
expect(size.requestWidth).toBe(960);
|
||||||
|
expect(buildImageProxyUrl("https://yybb.top/a.png", { width: size.requestWidth, quality: 86, format: "webp" }))
|
||||||
|
.toBe(buildImageProxyUrl("https://yybb.top/a.png", { width: 960, quality: 86, format: "webp" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves screen density and image aspect ratio without requesting oversized fixed previews", () => {
|
||||||
|
expect(getFullscreenPreviewSize({ imageWidth: 2048, imageHeight: 2048, viewportWidth: 1657, viewportHeight: 884, pixelRatio: 2 }).requestWidth).toBe(2048);
|
||||||
|
const portrait = getFullscreenPreviewSize({ imageWidth: 1000, imageHeight: 2000, viewportWidth: 1440, viewportHeight: 900, pixelRatio: 2 });
|
||||||
|
expect(portrait.displayWidth).toBeCloseTo(427.5);
|
||||||
|
expect(portrait.requestWidth).toBe(960);
|
||||||
|
expect(getFullscreenPreviewSize({ imageWidth: 4000, imageHeight: 1000, viewportWidth: 5000, viewportHeight: 2000, pixelRatio: 2 }).requestWidth).toBe(4096);
|
||||||
|
});
|
||||||
test("reports a timeout while reading image bytes as a retryable response", async () => {
|
test("reports a timeout while reading image bytes as a retryable response", async () => {
|
||||||
const previousFetch = globalThis.fetch;
|
const previousFetch = globalThis.fetch;
|
||||||
globalThis.fetch = async () => new Response(new ReadableStream({
|
globalThis.fetch = async () => new Response(new ReadableStream({
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user