Improve preview quality and create layout
This commit is contained in:
@@ -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';
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user