Improve preview quality and create layout

This commit is contained in:
FengLee
2026-05-13 15:46:24 +08:00
parent 2fcf9c9773
commit b263c26ac0
9 changed files with 89 additions and 74 deletions

View File

@@ -21,7 +21,7 @@ interface CustomApiConfig {
}
const GENERATION_TIMEOUT = 180_000;
const MAX_UPSTREAM_REFERENCE_IMAGE_BYTES = Number(process.env.MAX_UPSTREAM_REFERENCE_IMAGE_BYTES || 700 * 1024);
const MAX_UPSTREAM_REFERENCE_IMAGE_BYTES = Number(process.env.MAX_UPSTREAM_REFERENCE_IMAGE_BYTES || 1536 * 1024);
export const runtime = 'nodejs';

View File

@@ -555,7 +555,7 @@ export function ImageToImagePanel() {
return (
<div className="grid min-h-[600px] grid-cols-1 gap-6 xl:grid-cols-[minmax(0,4fr)_minmax(0,6fr)]">
{/* Left: Settings */}
<div className="min-w-0 space-y-5 overflow-y-auto max-h-[calc(100vh-200px)] pr-2">
<div className="min-w-0 space-y-5 pb-8 pr-2">
{/* Reference Images Upload (Multi) */}
<div className="space-y-2">
<Label> <span className="text-destructive">*</span> <span className="text-muted-foreground text-xs">1</span></Label>

View File

@@ -347,7 +347,7 @@ export function ImageToVideoPanel() {
return (
<div className="grid min-h-[600px] grid-cols-1 gap-6 xl:grid-cols-[minmax(0,4fr)_minmax(0,6fr)]">
{/* Left: Settings */}
<div className="min-w-0 space-y-5 overflow-y-auto max-h-[calc(100vh-200px)] pr-2">
<div className="min-w-0 space-y-5 pb-8 pr-2">
{/* Reference Image */}
<div className="space-y-2">
<Label> <span className="text-destructive">*</span> <span className="text-muted-foreground text-xs"></span></Label>

View File

@@ -12,6 +12,7 @@ import { ChevronDown, ChevronUp, Copy, FileSearch, Grid3X3, History, Image as Im
import { GenerationLoadingPanel } from '@/components/create/generation-loading-panel';
import { CreationDetailDialog } from '@/components/creation-detail-dialog';
import { copyTextToClipboard } from '@/lib/utils';
import { compressImageFileForUpload } from '@/lib/browser-image-compression';
type ReversePromptResult = {
generalPrompt: string;
@@ -46,36 +47,6 @@ const sectionLabels: Array<[keyof NonNullable<ReversePromptResult['structuredSec
['character', '人物细节'],
];
function compressImage(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error('图片读取失败'));
reader.onload = () => {
const source = String(reader.result || '');
const image = new window.Image();
image.onerror = () => reject(new Error('图片读取失败'));
image.onload = () => {
const maxSide = 1600;
const scale = Math.min(1, maxSide / Math.max(image.width, image.height));
const width = Math.max(1, Math.round(image.width * scale));
const height = Math.max(1, Math.round(image.height * scale));
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('图片处理失败'));
return;
}
ctx.drawImage(image, 0, 0, width, height);
resolve(canvas.toDataURL('image/jpeg', 0.9));
};
image.src = source;
};
reader.readAsDataURL(file);
});
}
const TEXT_TO_IMAGE_DRAFT_KEY = 'miaojing:text-to-image-draft';
const IMAGE_TO_IMAGE_DRAFT_KEY = 'miaojing:image-to-image-draft';
@@ -147,8 +118,12 @@ export default function ReversePromptPanel({ onUseForTextToImage, onUseForImageT
return;
}
try {
setReverseImage(await compressImage(file));
const result = await compressImageFileForUpload(file);
setReverseImage(result.dataUrl);
setResult(null);
if (result.compressed) {
toast.info('已按高清预览质量自动压缩图片');
}
} catch (error) {
toast.error(error instanceof Error ? error.message : '图片处理失败');
}
@@ -301,7 +276,7 @@ export default function ReversePromptPanel({ onUseForTextToImage, onUseForImageT
return (
<div className="grid min-h-[600px] grid-cols-1 gap-6 xl:grid-cols-[minmax(0,4fr)_minmax(0,6fr)]">
<div className="min-w-0 space-y-5 overflow-y-auto max-h-[calc(100vh-200px)] py-1 pl-1 pr-2">
<div className="min-w-0 space-y-5 pb-8 pl-1 pr-2 pt-1">
<div className="space-y-2">
<Label> <span className="text-destructive">*</span></Label>
<div

View File

@@ -478,7 +478,7 @@ export function TextToImagePanel() {
return (
<div className="grid min-h-[600px] grid-cols-1 gap-6 xl:grid-cols-[minmax(0,4fr)_minmax(0,6fr)]">
{/* Left: Settings (scrollable) */}
<div className="min-w-0 space-y-5 overflow-y-auto max-h-[calc(100vh-200px)] pr-2">
<div className="min-w-0 space-y-5 pb-8 pr-2">
{/* Model Selection */}
<div className="space-y-2">
<Label></Label>

View File

@@ -253,7 +253,7 @@ export function TextToVideoPanel() {
return (
<div className="grid min-h-[600px] grid-cols-1 gap-6 xl:grid-cols-[minmax(0,4fr)_minmax(0,6fr)]">
{/* Left: Settings */}
<div className="min-w-0 space-y-5 overflow-y-auto max-h-[calc(100vh-200px)] pr-2">
<div className="min-w-0 space-y-5 pb-8 pr-2">
<div className="space-y-2">
<Label></Label>
{hasModels ? (

View File

@@ -251,7 +251,7 @@ export function CreationDetailDialog({ record, open, onClose, onPublishChange }:
<img
src={referenceImage}
alt="参考图片"
className="h-full w-full cursor-zoom-in object-cover"
className="h-full w-full cursor-zoom-in object-contain"
onDoubleClick={() => setFullscreenSrc(referenceImage)}
/>
) : (
@@ -329,7 +329,7 @@ export function CreationDetailDialog({ record, open, onClose, onPublishChange }:
const isPlaceholderUrl = isPlaceholder(record.url);
const configuredAspectRatioText = getConfiguredAspectRatio(record.params, record.prompt);
const configuredAspectRatio = parseAspectRatio(configuredAspectRatioText);
const previewAspectRatio = configuredAspectRatio || mediaAspectRatio || 1;
const previewAspectRatio = mediaAspectRatio || configuredAspectRatio || 1;
const isSquarePreview = previewAspectRatio >= 0.95 && previewAspectRatio <= 1.05;
const squarePreviewSize = Math.round(
Math.max(
@@ -345,8 +345,14 @@ export function CreationDetailDialog({ record, open, onClose, onPublishChange }:
const detailPanelHeight = isSquarePreview
? squarePanelSize
: Math.round(Math.max(560, Math.min(860, viewportSize.height * 0.82)));
const previewWidth = `${Math.max(36, Math.min(138, previewAspectRatio * 78)).toFixed(2)}vh`;
const constrainedPreviewWidth = `min(${previewWidth}, max(360px, calc(96vw - 560px)))`;
const nonSquareMaxPreviewWidth = Math.max(
1,
Math.min(1180, viewportSize.width * 0.96 - 500 - 20 - 104, viewportSize.width * 0.92),
);
const nonSquarePreviewWidth = Math.round(
Math.max(1, Math.min(nonSquareMaxPreviewWidth, detailPanelHeight * previewAspectRatio)),
);
const nonSquarePreviewHeight = Math.round(nonSquarePreviewWidth / previewAspectRatio);
const previewFrameStyle = isSquarePreview
? {
width: `${squarePreviewSize}px`,
@@ -355,17 +361,11 @@ export function CreationDetailDialog({ record, open, onClose, onPublishChange }:
maxHeight: `${squarePreviewSize}px`,
}
: {
aspectRatio: String(previewAspectRatio),
width: constrainedPreviewWidth,
height: `${detailPanelHeight}px`,
width: `${nonSquarePreviewWidth}px`,
height: `${nonSquarePreviewHeight}px`,
minHeight: `${nonSquarePreviewHeight}px`,
maxHeight: `${nonSquarePreviewHeight}px`,
};
const previewImageStyle = isSquarePreview
? {
objectPosition: 'center 34%',
transform: 'scale(1.16)',
transformOrigin: 'center 34%',
}
: undefined;
const previewShellStyle = isSquarePreview
? {
width: `${squarePanelSize}px`,
@@ -374,7 +374,7 @@ export function CreationDetailDialog({ record, open, onClose, onPublishChange }:
maxHeight: `${squarePanelSize}px`,
}
: {
width: constrainedPreviewWidth,
width: `${nonSquarePreviewWidth}px`,
height: `${detailPanelHeight}px`,
minHeight: `${detailPanelHeight}px`,
maxHeight: `${detailPanelHeight}px`,
@@ -438,7 +438,7 @@ export function CreationDetailDialog({ record, open, onClose, onPublishChange }:
>
{/* Media Preview */}
<div
className="relative flex h-full max-w-full items-center justify-center overflow-hidden rounded-lg border border-border bg-black group"
className="group relative flex max-w-full shrink-0 items-center justify-center overflow-hidden rounded-lg border border-border bg-black"
style={previewFrameStyle}
>
{isPlaceholderUrl ? (
@@ -450,9 +450,8 @@ export function CreationDetailDialog({ record, open, onClose, onPublishChange }:
<CachedPreviewImage
src={record.url}
alt={record.prompt}
className="h-full w-full object-cover cursor-zoom-in"
className="h-full w-full cursor-zoom-in object-contain"
badgeClassName="absolute right-3 top-3 z-20"
style={previewImageStyle}
onLoad={(event) => {
const img = event.currentTarget;
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
@@ -467,7 +466,7 @@ export function CreationDetailDialog({ record, open, onClose, onPublishChange }:
controls
playsInline
preload="metadata"
className="h-full w-full object-cover"
className="h-full w-full object-contain"
onLoadedMetadata={(event) => {
const video = event.currentTarget;
if (video.videoWidth > 0 && video.videoHeight > 0) {

View File

@@ -17,10 +17,10 @@ type CompressionOptions = {
minQuality?: number;
};
const DEFAULT_MAX_DIMENSION = 1280;
const DEFAULT_MAX_BYTES = 700 * 1024;
const DEFAULT_INITIAL_QUALITY = 0.82;
const DEFAULT_MIN_QUALITY = 0.42;
const DEFAULT_MAX_DIMENSION = 2048;
const DEFAULT_MAX_BYTES = 1536 * 1024;
const DEFAULT_INITIAL_QUALITY = 0.92;
const DEFAULT_MIN_QUALITY = 0.68;
function dataUrlByteLength(dataUrl: string): number {
const commaIndex = dataUrl.indexOf(',');
@@ -99,8 +99,13 @@ function jpegName(fileName: string): string {
export async function compressImageFileForUpload(
file: File,
_options: CompressionOptions = {},
options: CompressionOptions = {},
): Promise<BrowserCompressedImage> {
const maxDimension = options.maxDimension ?? DEFAULT_MAX_DIMENSION;
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
const initialQuality = options.initialQuality ?? DEFAULT_INITIAL_QUALITY;
const minQuality = options.minQuality ?? DEFAULT_MIN_QUALITY;
if (!file.type.startsWith('image/')) {
throw new Error('请上传图片文件');
}
@@ -108,15 +113,51 @@ export async function compressImageFileForUpload(
let sourceInfo: Awaited<ReturnType<typeof loadImageSource>> | null = null;
try {
sourceInfo = await loadImageSource(file);
const dataUrl = await fileToDataUrl(file);
const scale = Math.min(1, maxDimension / Math.max(sourceInfo.width, sourceInfo.height));
if (file.size <= maxBytes && scale >= 1 && /^image\/(jpeg|jpg|png|webp)$/i.test(file.type)) {
const dataUrl = await fileToDataUrl(file);
return {
dataUrl,
name: file.name,
width: sourceInfo.width,
height: sourceInfo.height,
originalBytes: file.size,
compressedBytes: dataUrlByteLength(dataUrl),
compressed: false,
};
}
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(sourceInfo.width * scale));
canvas.height = Math.max(1, Math.round(sourceInfo.height * scale));
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('浏览器不支持图片压缩');
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(sourceInfo.source, 0, 0, canvas.width, canvas.height);
let bestBlob: Blob | null = null;
for (let quality = initialQuality; quality >= minQuality; quality -= 0.04) {
const blob = await canvasToBlob(canvas, 'image/jpeg', Math.max(minQuality, quality));
bestBlob = blob;
if (blob.size <= maxBytes) break;
}
if (!bestBlob) throw new Error('图片压缩失败');
const dataUrl = await blobToDataUrl(bestBlob);
return {
dataUrl,
name: file.name,
width: sourceInfo.width,
height: sourceInfo.height,
name: jpegName(file.name),
width: canvas.width,
height: canvas.height,
originalBytes: file.size,
compressedBytes: dataUrlByteLength(dataUrl),
compressed: false,
compressed: bestBlob.size < file.size || scale < 1 || file.type !== 'image/jpeg',
};
} finally {
sourceInfo?.close();

View File

@@ -17,10 +17,10 @@ type CompressOptions = {
minQuality?: number;
};
const DEFAULT_MAX_DIMENSION = 1280;
const DEFAULT_MAX_BYTES = 700 * 1024;
const DEFAULT_QUALITY = 82;
const DEFAULT_MIN_QUALITY = 44;
const DEFAULT_MAX_DIMENSION = 2048;
const DEFAULT_MAX_BYTES = 1536 * 1024;
const DEFAULT_QUALITY = 92;
const DEFAULT_MIN_QUALITY = 68;
export function dataUrlToImageBuffer(dataUrl: string): ImageBufferInput | null {
const match = dataUrl.match(/^data:(image\/[^;]+);base64,(.+)$/);
@@ -63,15 +63,16 @@ export async function compressImageBufferForUpstream(
height: height > width ? maxDimension : undefined,
fit: 'inside',
withoutEnlargement: true,
kernel: sharp.kernel.lanczos3,
});
}
let output = await image.jpeg({ quality, mozjpeg: true }).toBuffer();
let output = await image.jpeg({ quality, mozjpeg: true, chromaSubsampling: '4:4:4' }).toBuffer();
let currentQuality = quality;
while (output.length > maxBytes && currentQuality > minQuality) {
currentQuality = Math.max(minQuality, currentQuality - 10);
currentQuality = Math.max(minQuality, currentQuality - 4);
output = await sharp(output, { failOn: 'none', limitInputPixels: 48_000_000 })
.jpeg({ quality: currentQuality, mozjpeg: true })
.jpeg({ quality: currentQuality, mozjpeg: true, chromaSubsampling: '4:4:4' })
.toBuffer();
}
@@ -90,4 +91,3 @@ export async function compressImageBufferForUpstream(
originalBytes: input.buffer.length,
};
}