feat(desktop): auto-compress pasted/dropped images before upload

Images are now resized to max 1568px longest edge and re-encoded as
JPEG (quality 0.85) before being attached. This prevents oversized
base64 payloads hitting provider limits and fixes broken preview
thumbnails caused by large objectURLs exhausting renderer memory.

GIF images pass through unchanged (would lose animation).
Compression applies to both paste and file-drop/select paths.
This commit is contained in:
你的姓名 2026-06-19 21:23:45 +08:00
parent 06156b445f
commit 476ab5da2d
3 changed files with 29 additions and 16 deletions

View File

@ -42,6 +42,7 @@ import {
selectNativeFileAttachments,
type ComposerAttachment,
} from '../../lib/composerAttachments'
import { compressDataUrl } from '../../lib/imageCompress'
import { useComposerFileDrop } from './useComposerFileDrop'
import { shouldSubmitOnEnter } from './sendShortcut'
import {
@ -903,17 +904,20 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
const id = `att-${Date.now()}-${Math.random().toString(36).slice(2)}`
const reader = new FileReader()
reader.onload = () => {
setComposerAttachments((prev) => [
...prev,
{
id,
name: `pasted-image-${Date.now()}.png`,
type: 'image',
mimeType: file.type || 'image/png',
previewUrl: reader.result as string,
data: reader.result as string,
},
])
const rawDataUrl = reader.result as string
void compressDataUrl(rawDataUrl).then((compressed) => {
setComposerAttachments((prev) => [
...prev,
{
id,
name: `pasted-image-${Date.now()}.png`,
type: 'image',
mimeType: 'image/jpeg',
previewUrl: compressed,
data: compressed,
},
])
})
}
reader.readAsDataURL(file)
}

View File

@ -1,6 +1,7 @@
import { getApiUrl } from '../api/client'
import { isDesktopRuntime } from './desktopRuntime'
import { getDesktopHost } from './desktopHost'
import { compressDataUrl } from './imageCompress'
export type ComposerAttachment = {
id: string
@ -121,12 +122,13 @@ async function fileToComposerAttachment(file: File): Promise<ComposerAttachment
}
const isImage = file.type.startsWith('image/')
const data = await readFileAsDataUrl(file)
const rawData = await readFileAsDataUrl(file)
const data = isImage ? await compressDataUrl(rawData) : rawData
return {
id: nextAttachmentId(),
name: file.name,
type: isImage ? 'image' : 'file',
mimeType: file.type || undefined,
mimeType: isImage ? 'image/jpeg' : (file.type || undefined),
previewUrl: isImage ? data : undefined,
data,
}

View File

@ -5,13 +5,20 @@ export function planResize(w: number, h: number, maxEdge: number): { width: numb
return { width: Math.round(w * scale), height: Math.round(h * scale) }
}
/** 浏览器内:把 dataUrl 缩放到 maxEdge 内并以 quality 重新编码。 */
export async function compressDataUrl(dataUrl: string, maxEdge = 1600, quality = 0.85): Promise<string> {
/**
* Compress a data:URL image by resizing to maxEdge and re-encoding as JPEG.
* GIFs pass through unchanged (would lose animation).
* Small images ( maxEdge both sides) still re-encode to reduce payload.
*/
export async function compressDataUrl(dataUrl: string, maxEdge = 1568, quality = 0.85): Promise<string> {
if (!dataUrl.startsWith('data:')) return dataUrl
if (dataUrl.startsWith('data:image/gif')) return dataUrl
const img = new Image()
await new Promise<void>((res, rej) => { img.onload = () => res(); img.onerror = () => rej(new Error('load')); img.src = dataUrl })
const { width, height } = planResize(img.naturalWidth, img.naturalHeight, maxEdge)
const canvas = document.createElement('canvas')
canvas.width = width; canvas.height = height
canvas.getContext('2d')!.drawImage(img, 0, 0, width, height)
return canvas.toDataURL('image/png', quality)
return canvas.toDataURL('image/jpeg', quality)
}