From 2be20f84c3aadcc972728b1124efcc6a80c13880 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=9F=E6=9E=97=E9=93=96?= Date: Tue, 15 Sep 2026 13:05:30 +0800 Subject: [PATCH] fix: preprocess oversized knowledge previews in browser --- .../brand-knowledge/route.test.ts | 71 ++++++++++++++++- .../brand-knowledge/route.ts | 52 +++++++++++-- .../BrandKnowledgeDashboard.test.tsx | 44 ++++++++++- .../BrandKnowledgeUploadDialog.tsx | 78 ++++++++++++++++--- .../brand-knowledge-image-browser.test.ts | 72 +++++++++++++++++ lib/__tests__/brand-knowledge-preview.test.ts | 37 +++++++++ ...formance-survey-image-source-guard.test.ts | 23 ++++++ lib/brand-knowledge-file-types.ts | 2 + lib/brand-knowledge-image-browser.ts | 66 ++++++++++++++++ lib/brand-knowledge-preview.ts | 68 ++++++++++++++++ lib/performance-survey-image-browser.ts | 2 +- lib/performance-survey-image-source-guard.ts | 11 ++- lib/performance-survey-image.worker.ts | 24 ++++-- 13 files changed, 522 insertions(+), 28 deletions(-) create mode 100644 lib/__tests__/brand-knowledge-image-browser.test.ts create mode 100644 lib/brand-knowledge-image-browser.ts diff --git a/app/api/department-dashboard/brand-knowledge/route.test.ts b/app/api/department-dashboard/brand-knowledge/route.test.ts index 7b06e57..f9806c7 100644 --- a/app/api/department-dashboard/brand-knowledge/route.test.ts +++ b/app/api/department-dashboard/brand-knowledge/route.test.ts @@ -2,6 +2,7 @@ import { getServerSession } from 'next-auth' import { rename, unlink, writeFile } from 'fs/promises' import { prisma } from '@/lib/prisma' import { prepareBrandKnowledgePreview } from '@/lib/brand-knowledge-preview' +import { validateBrandKnowledgeUploadedPreviewSlices } from '@/lib/brand-knowledge-preview' const releaseRequestSlot = jest.fn() @@ -14,7 +15,10 @@ jest.mock('@/lib/brand-center-dashboard-access', () => ({ assertBrandCenterDashboardAccess: jest.fn(async () => undefined), })) jest.mock('@/lib/prisma', () => ({ prisma: { brandKnowledge: { create: jest.fn() } } })) -jest.mock('@/lib/brand-knowledge-preview', () => ({ prepareBrandKnowledgePreview: jest.fn() })) +jest.mock('@/lib/brand-knowledge-preview', () => ({ + prepareBrandKnowledgePreview: jest.fn(), + validateBrandKnowledgeUploadedPreviewSlices: jest.fn(), +})) jest.mock('@/lib/upload-paths', () => ({ getUploadRootDir: () => '/uploads' })) jest.mock('@/lib/path-security', () => ({ ensureSafeDirectoryWithinRoot: jest.fn(async () => '/uploads/brand-knowledge'), @@ -72,7 +76,47 @@ function uploadRequest() { ]) return { headers: new Headers(), - formData: async () => ({ get: (name: string) => values.get(name) ?? null }), + formData: async () => ({ + get: (name: string) => values.get(name) ?? null, + getAll: (name: string) => { + const value = values.get(name) + return value === undefined ? [] : Array.isArray(value) ? value : [value] + }, + }), + } as unknown as Request +} + +function slicedUploadRequest() { + const firstSlice = { + name: 'long-001.webp', size: 11, type: 'image/webp', + arrayBuffer: async () => Uint8Array.from(Buffer.from('first-slice')).buffer, + } + const secondSlice = { + name: 'long-002.webp', size: 12, type: 'image/webp', + arrayBuffer: async () => Uint8Array.from(Buffer.from('second-slice')).buffer, + } + const values = new Map([ + ['title', '长图规范'], + ['knowledgeType', '影像规范'], + ['brand', '索菲亚'], + ['productCategoryName', '衣柜'], + ['file', mainFile], + ['previewImageName', 'long.png'], + ['previewImageSliceCount', '2'], + ['previewImageSlices', [firstSlice, secondSlice]], + ]) + return { + headers: new Headers(), + formData: async () => ({ + get: (name: string) => { + const value = values.get(name) + return Array.isArray(value) ? value[0] : value ?? null + }, + getAll: (name: string) => { + const value = values.get(name) + return value === undefined ? [] : Array.isArray(value) ? value : [value] + }, + }), } as unknown as Request } @@ -109,6 +153,13 @@ describe('brand knowledge upload route', () => { source: { extension: 'png', contentType: 'image/png', width: 800, height: 4_100 }, slices: [Buffer.from('first-slice'), Buffer.from('second-slice')], }) + ;(validateBrandKnowledgeUploadedPreviewSlices as jest.Mock).mockResolvedValue({ + slices: [ + { buffer: Buffer.from('first-slice'), width: 1600, height: 1500 }, + { buffer: Buffer.from('second-slice'), width: 1600, height: 800 }, + ], + totalBytes: 23, + }) ;(prisma.brandKnowledge.create as jest.Mock).mockImplementation(async ({ data }: { data: Record }) => createdRecord(data)) }) @@ -143,4 +194,20 @@ describe('brand knowledge upload route', () => { expect(errorSpy).toHaveBeenCalled() expect(releaseRequestSlot).toHaveBeenCalledTimes(1) }) + + test('accepts ordered browser slices without decoding the original long image on the server', async () => { + const response = await POST(slicedUploadRequest()) + const call = (prisma.brandKnowledge.create as jest.Mock).mock.calls[0][0] + + expect(response.status).toBe(201) + expect(prepareBrandKnowledgePreview).not.toHaveBeenCalled() + expect(validateBrandKnowledgeUploadedPreviewSlices).toHaveBeenCalledWith([ + expect.objectContaining({ declaredContentType: 'image/webp' }), + expect.objectContaining({ declaredContentType: 'image/webp' }), + ]) + expect(call.data.previewImageName).toBe('long.png') + expect(call.data.previewImageSlices).toHaveLength(2) + expect(call.data.previewImageSlices[0]).toMatch(/-part-001\.webp$/) + expect(call.data.previewImageSlices[1]).toMatch(/-part-002\.webp$/) + }) }) diff --git a/app/api/department-dashboard/brand-knowledge/route.ts b/app/api/department-dashboard/brand-knowledge/route.ts index 71c2231..593ab2d 100644 --- a/app/api/department-dashboard/brand-knowledge/route.ts +++ b/app/api/department-dashboard/brand-knowledge/route.ts @@ -10,12 +10,14 @@ import { getUploadRootDir } from '@/lib/upload-paths' import { ensureSafeDirectoryWithinRoot, isPathFreeOfSymbolicLinks, normalizeSafeFileName, resolveStrictChildPath } from '@/lib/path-security' import { assertBoundedMultipartHeaders, assertMultipartFiles } from '@/lib/multipart-request-guard' import { boundedMultipartRequestLimiter, getBoundedMultipartFailure } from '@/lib/multipart-route-limits' -import { prepareBrandKnowledgePreview } from '@/lib/brand-knowledge-preview' +import { prepareBrandKnowledgePreview, validateBrandKnowledgeUploadedPreviewSlices } from '@/lib/brand-knowledge-preview' import { isWebFileLike } from '@/lib/web-file' import { BRAND_KNOWLEDGE_MAIN_FILE_EXTENSIONS, BRAND_KNOWLEDGE_MAX_MAIN_FILE_BYTES, BRAND_KNOWLEDGE_MAX_PREVIEW_IMAGE_BYTES, + BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_BYTES, + BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_COUNT, BRAND_KNOWLEDGE_MAX_REQUEST_BYTES, BRAND_KNOWLEDGE_TYPE_OPTIONS, } from '@/lib/brand-knowledge-file-types' @@ -160,18 +162,31 @@ export async function POST(request: Request) { maxFileBytes: MAX_PREVIEW_IMAGE_SIZE, allowedMimeTypes: ['image/jpeg', 'image/png', 'image/webp'], }, + previewImageSlices: { + maxCount: BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_COUNT, + maxFileBytes: BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_BYTES, + maxTotalBytes: MAX_PREVIEW_IMAGE_SIZE, + allowedMimeTypes: ['image/webp'], + }, }, - maxFiles: 2, + maxFiles: 1 + BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_COUNT, maxTotalBytes: MAX_FILE_SIZE + MAX_PREVIEW_IMAGE_SIZE, }) const file = formData.get('file') const previewImage = formData.get('previewImage') + const uploadedPreviewSlices = formData.getAll('previewImageSlices') if (!isWebFileLike(file)) { return NextResponse.json({ success: false, error: '请选择知识文件' }, { status: 400, headers: PRIVATE_HEADERS }) } if (previewImage !== null && !isWebFileLike(previewImage)) { return NextResponse.json({ success: false, error: '预览图文件无效' }, { status: 400, headers: PRIVATE_HEADERS }) } + if (isWebFileLike(previewImage) && uploadedPreviewSlices.length > 0) { + return NextResponse.json({ success: false, error: '预览图不能同时提交原图和切片' }, { status: 400, headers: PRIVATE_HEADERS }) + } + if (uploadedPreviewSlices.some((slice) => !isWebFileLike(slice))) { + return NextResponse.json({ success: false, error: '预览图切片文件无效' }, { status: 400, headers: PRIVATE_HEADERS }) + } const title = normalizeText(formData.get('title'), 200) const knowledgeType = normalizeText(formData.get('knowledgeType'), 30) const brand = normalizeText(formData.get('brand'), 40) @@ -181,6 +196,7 @@ export async function POST(request: Request) { if (file.size <= 0 || file.size > MAX_FILE_SIZE) return NextResponse.json({ success: false, error: '主文件大小需在 1B 至 1GB 之间' }, { status: 400, headers: PRIVATE_HEADERS }) let preparedPreview: Awaited> | null = null + let preparedUploadedSlices: Awaited> | null = null let previewImageName: string | null = null if (isWebFileLike(previewImage)) { if (previewImage.size <= 0 || previewImage.size > MAX_PREVIEW_IMAGE_SIZE) { @@ -195,6 +211,25 @@ export async function POST(request: Request) { ) } previewImageName = normalizeSafeFileName(previewImage.name) || `preview.${preparedPreview.source.extension}` + } else if (uploadedPreviewSlices.length > 0) { + const declaredSliceCount = Number(normalizeText(formData.get('previewImageSliceCount'), 4)) + if (!Number.isSafeInteger(declaredSliceCount) || declaredSliceCount !== uploadedPreviewSlices.length) { + return NextResponse.json({ success: false, error: '预览图切片数量不一致,请重新选择图片' }, { status: 400, headers: PRIVATE_HEADERS }) + } + try { + preparedUploadedSlices = await validateBrandKnowledgeUploadedPreviewSlices( + await Promise.all(uploadedPreviewSlices.map(async (slice) => { + const fileSlice = slice as File + return { buffer: Buffer.from(await fileSlice.arrayBuffer()), declaredContentType: fileSlice.type } + })), + ) + } catch (error) { + return NextResponse.json( + { success: false, error: error instanceof Error ? error.message : '预览图切片无效' }, + { status: 400, headers: PRIVATE_HEADERS }, + ) + } + previewImageName = normalizeSafeFileName(normalizeText(formData.get('previewImageName'), 255)) || 'preview.webp' } const safeOriginalName = normalizeSafeFileName(file.name) @@ -215,9 +250,12 @@ export async function POST(request: Request) { temporaryPath = null storedPath = `uploads/brand-knowledge/${storageName}` - if (isWebFileLike(previewImage) && preparedPreview) { + const previewSlices = preparedUploadedSlices + ? preparedUploadedSlices.slices.map((slice) => slice.buffer) + : preparedPreview?.slices || [] + if (previewSlices.length > 0) { const previewBatchId = randomUUID() - for (let index = 0; index < preparedPreview.slices.length; index += 1) { + for (let index = 0; index < previewSlices.length; index += 1) { const previewStorageName = `${previewBatchId}-part-${String(index + 1).padStart(3, '0')}.webp` const previewDestination = resolveStrictChildPath(knowledgeDirectory, previewStorageName) if (!previewDestination || !(await isPathFreeOfSymbolicLinks(uploadRoot, previewDestination, { allowMissingLeaf: true }))) { @@ -225,7 +263,7 @@ export async function POST(request: Request) { } const temporaryPreviewPath = `${previewDestination}.uploading` temporaryPreviewPaths.push(temporaryPreviewPath) - await writeFile(temporaryPreviewPath, preparedPreview.slices[index], { flag: 'wx' }) + await writeFile(temporaryPreviewPath, previewSlices[index], { flag: 'wx' }) await rename(temporaryPreviewPath, previewDestination) temporaryPreviewPaths.splice(temporaryPreviewPaths.indexOf(temporaryPreviewPath), 1) storedPreviewPaths.push(`uploads/brand-knowledge/${previewStorageName}`) @@ -245,8 +283,8 @@ export async function POST(request: Request) { previewImageName, previewImagePath: storedPreviewPaths[0] || null, previewImageSlices: [...storedPreviewPaths], - previewImageMimeType: preparedPreview ? 'image/webp' : null, - previewImageSize: preparedPreview ? BigInt(preparedPreview.slices[0].length) : null, + previewImageMimeType: previewSlices.length > 0 ? 'image/webp' : null, + previewImageSize: previewSlices.length > 0 ? BigInt(previewSlices[0].length) : null, uploadedBy: userId, }, select: { diff --git a/components/department-dashboard/BrandKnowledgeDashboard.test.tsx b/components/department-dashboard/BrandKnowledgeDashboard.test.tsx index 4b205b4..b4f7f02 100644 --- a/components/department-dashboard/BrandKnowledgeDashboard.test.tsx +++ b/components/department-dashboard/BrandKnowledgeDashboard.test.tsx @@ -2,6 +2,13 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/rea import BrandKnowledgeDashboard from './BrandKnowledgeDashboard' import { BRAND_KNOWLEDGE_TYPE_OPTIONS } from '@/lib/brand-knowledge-file-types' +const mockPrepareBrandKnowledgePreviewInBrowser = jest.fn() + +jest.mock('@/lib/brand-knowledge-image-browser', () => ({ + prepareBrandKnowledgePreviewInBrowser: (...args: unknown[]) => mockPrepareBrandKnowledgePreviewInBrowser(...args), + canUseBrandKnowledgeLegacyServerPreview: jest.fn(async () => false), +})) + jest.mock('next-auth/react', () => ({ useSession: () => ({ data: { user: { name: '本地超级管理员' } } }), signOut: jest.fn(), @@ -12,6 +19,30 @@ describe('BrandKnowledgeDashboard', () => { Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: jest.fn(() => 'blob:test-preview') }) Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: jest.fn() }) Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: jest.fn().mockResolvedValue(undefined) } }) + mockPrepareBrandKnowledgePreviewInBrowser.mockResolvedValue([ + { + file: new File(['slice one'], '预览图-001.webp', { type: 'image/webp' }), + mimeType: 'image/webp', + width: 1600, + height: 1500, + kind: 'slice', + sourceName: '预览图.png', + sourceIndex: 0, + sliceIndex: 0, + sliceCount: 2, + }, + { + file: new File(['slice two'], '预览图-002.webp', { type: 'image/webp' }), + mimeType: 'image/webp', + width: 1600, + height: 900, + kind: 'slice', + sourceName: '预览图.png', + sourceIndex: 0, + sliceIndex: 1, + sliceCount: 2, + }, + ]) global.fetch = jest.fn().mockImplementation((input: RequestInfo | URL) => { if (String(input) === '/api/product-categories') { return Promise.resolve({ ok: true, json: async () => [{ id: 'category-1', name: '床类' }] } as Response) @@ -186,8 +217,9 @@ describe('BrandKnowledgeDashboard', () => { fireEvent.change(screen.getByLabelText('选择知识文件'), { target: { files: [knowledgeFile] } }) fireEvent.change(screen.getByLabelText('选择预览图片'), { target: { files: [previewFile] } }) expect(screen.getByText('品牌手册.pdf')).toBeInTheDocument() - expect(screen.getByText('预览图.png')).toBeInTheDocument() - expect(screen.getByRole('img', { name: '预览图:预览图.png' })).toHaveClass('object-contain', 'object-center') + expect(await screen.findByText('预览图.png')).toBeInTheDocument() + expect(await screen.findByRole('img', { name: '预览图:预览图.png' })).toHaveClass('object-contain', 'object-center') + expect(screen.getByText(/已切为 2 片/)).toBeInTheDocument() expect(screen.getByRole('button', { name: '提交知识' })).toBeEnabled() fireEvent.click(screen.getByRole('button', { name: '提交知识' })) await waitFor(() => expect(fetchMock).toHaveBeenCalledWith('/api/department-dashboard/brand-knowledge', expect.objectContaining({ method: 'POST', body: expect.any(FormData) }))) @@ -196,7 +228,13 @@ describe('BrandKnowledgeDashboard', () => { const submittedFormData = postCall?.[1]?.body as FormData expect(submittedFormData.get('productCategoryName')).toBe('床类') expect(submittedFormData.get('file')).toBe(knowledgeFile) - expect(submittedFormData.get('previewImage')).toBe(previewFile) + expect(submittedFormData.get('previewImage')).toBeNull() + expect(submittedFormData.get('previewImageName')).toBe('预览图.png') + expect(submittedFormData.get('previewImageSliceCount')).toBe('2') + expect(submittedFormData.getAll('previewImageSlices').map((entry) => (entry as File).name)).toEqual([ + '预览图-001.webp', + '预览图-002.webp', + ]) fireEvent.click(screen.getByRole('button', { name: '关闭上传知识窗口' })) expect(screen.queryByRole('dialog', { name: '上传知识' })).not.toBeInTheDocument() diff --git a/components/department-dashboard/BrandKnowledgeUploadDialog.tsx b/components/department-dashboard/BrandKnowledgeUploadDialog.tsx index 2c56c9a..e98a920 100644 --- a/components/department-dashboard/BrandKnowledgeUploadDialog.tsx +++ b/components/department-dashboard/BrandKnowledgeUploadDialog.tsx @@ -11,6 +11,12 @@ import { BRAND_KNOWLEDGE_PREVIEW_IMAGE_ACCEPT, BRAND_KNOWLEDGE_TYPE_OPTIONS, } from '@/lib/brand-knowledge-file-types' +import { + prepareBrandKnowledgePreviewInBrowser, + canUseBrandKnowledgeLegacyServerPreview, + type BrandKnowledgeBrowserPreviewSlice, +} from '@/lib/brand-knowledge-image-browser' +import { isPerformanceSurveyBrowserCapabilityError } from '@/lib/performance-survey-image-error' const BRAND_OPTIONS = ['未关联品牌', '亿家达', '卓禾', '欧意朗', '顾家', '索菲亚', '朴美'] const MAX_MAIN_FILE_SIZE = BRAND_KNOWLEDGE_MAX_MAIN_FILE_BYTES @@ -43,7 +49,10 @@ export default function BrandKnowledgeUploadDialog({ onClose, onUploaded, return const [productCategories, setProductCategories] = useState([]) const [selectedFile, setSelectedFile] = useState(null) const [previewImage, setPreviewImage] = useState(null) + const [previewSlices, setPreviewSlices] = useState([]) const [previewUrl, setPreviewUrl] = useState('') + const [previewProgress, setPreviewProgress] = useState('') + const [previewUsesServerFallback, setPreviewUsesServerFallback] = useState(false) const [isDragging, setIsDragging] = useState(false) const [isPreviewDragging, setIsPreviewDragging] = useState(false) const [isUploading, setIsUploading] = useState(false) @@ -54,6 +63,8 @@ export default function BrandKnowledgeUploadDialog({ onClose, onUploaded, return const previewInputRef = useRef(null) const dragDepthRef = useRef(0) const previewDragDepthRef = useRef(0) + const previewProcessingAbortRef = useRef(null) + const previewProcessingVersionRef = useRef(0) useEffect(() => { const previousOverflow = document.body.style.overflow @@ -80,6 +91,7 @@ export default function BrandKnowledgeUploadDialog({ onClose, onUploaded, return } document.addEventListener('keydown', handleKeyDown) return () => { + previewProcessingAbortRef.current?.abort() document.body.style.overflow = previousOverflow document.removeEventListener('keydown', handleKeyDown) returnFocusElement?.focus() @@ -115,7 +127,7 @@ export default function BrandKnowledgeUploadDialog({ onClose, onUploaded, return setSelectedFile(file) } - const setPreview = (file: File | undefined) => { + const setPreview = async (file: File | undefined) => { if (!file) return const extension = extensionOf(file) if (!PREVIEW_EXTENSIONS.has(extension) || (file.type && !['image/jpeg', 'image/png', 'image/webp'].includes(file.type))) { @@ -126,16 +138,55 @@ export default function BrandKnowledgeUploadDialog({ onClose, onUploaded, return setErrorMessage('预览图需为 1B 至 50MB 之间的有效图片') return } - if (previewUrl) URL.revokeObjectURL(previewUrl) + previewProcessingAbortRef.current?.abort() + previewProcessingVersionRef.current += 1 + const processingVersion = previewProcessingVersionRef.current + const controller = new AbortController() + previewProcessingAbortRef.current = controller setPreviewImage(file) - setPreviewUrl(URL.createObjectURL(file)) + setPreviewSlices([]) + setPreviewUrl('') + setPreviewUsesServerFallback(false) + setPreviewProgress('正在浏览器后台处理长图并生成预览切片…') setErrorMessage('') + try { + const slices = await prepareBrandKnowledgePreviewInBrowser( + file, + (label) => { + if (previewProcessingVersionRef.current === processingVersion) setPreviewProgress(label) + }, + { signal: controller.signal }, + ) + if (previewProcessingVersionRef.current !== processingVersion) return + setPreviewSlices(slices) + setPreviewUrl(URL.createObjectURL(slices[0].file)) + setPreviewProgress(`已生成 ${slices.length} 个有序预览切片,上传后将连续展示`) + } catch (error) { + if (previewProcessingVersionRef.current !== processingVersion || (error instanceof Error && error.name === 'AbortError')) return + if (isPerformanceSurveyBrowserCapabilityError(error) + && await canUseBrandKnowledgeLegacyServerPreview(file)) { + if (previewProcessingVersionRef.current !== processingVersion) return + setPreviewUsesServerFallback(true) + setPreviewProgress('当前浏览器将由服务器生成普通图片预览') + return + } + setPreviewProgress('') + setErrorMessage(error instanceof Error ? error.message : '预览图处理失败,请重新选择图片') + } finally { + if (previewProcessingVersionRef.current === processingVersion) previewProcessingAbortRef.current = null + } } const removePreview = () => { - if (previewUrl) URL.revokeObjectURL(previewUrl) + previewProcessingVersionRef.current += 1 + previewProcessingAbortRef.current?.abort() + previewProcessingAbortRef.current = null setPreviewImage(null) + setPreviewSlices([]) setPreviewUrl('') + setPreviewProgress('') + setPreviewUsesServerFallback(false) + setErrorMessage('') } const handleDragEnter = (event: DragEvent) => { event.preventDefault(); dragDepthRef.current += 1; setIsDragging(true) } @@ -144,7 +195,8 @@ export default function BrandKnowledgeUploadDialog({ onClose, onUploaded, return const handlePreviewDragLeave = (event: DragEvent) => { event.preventDefault(); previewDragDepthRef.current -= 1; if (previewDragDepthRef.current <= 0) { previewDragDepthRef.current = 0; setIsPreviewDragging(false) } } const handleSubmit = async () => { - if (isUploading || !selectedFile || !title.trim()) return + if (isUploading || !selectedFile || !title.trim() + || (previewImage && previewSlices.length === 0 && !previewUsesServerFallback)) return setIsUploading(true) setErrorMessage('') try { @@ -154,7 +206,15 @@ export default function BrandKnowledgeUploadDialog({ onClose, onUploaded, return formData.set('brand', brand) formData.set('productCategoryName', productCategoryName) formData.set('file', selectedFile) - if (previewImage) formData.set('previewImage', previewImage) + if (previewImage) { + if (previewUsesServerFallback) { + formData.set('previewImage', previewImage) + } else { + formData.set('previewImageName', previewImage.name) + formData.set('previewImageSliceCount', String(previewSlices.length)) + previewSlices.forEach((slice) => formData.append('previewImageSlices', slice.file)) + } + } const response = await fetch('/api/department-dashboard/brand-knowledge', { method: 'POST', body: formData }) const payload = await response.json().catch(() => null) if (!response.ok || !payload?.success) throw new Error(payload?.error || '品牌知识上传失败,请稍后重试') @@ -173,17 +233,17 @@ export default function BrandKnowledgeUploadDialog({ onClose, onUploaded, return

上传知识

补充知识类型、品牌、品类和预览图,提交后独立保存到品牌知识库。

-
+
-

预览图片 (可选)

{previewImage && }
event.preventDefault()} onDragLeave={handlePreviewDragLeave} onDrop={(event) => { event.preventDefault(); previewDragDepthRef.current = 0; setIsPreviewDragging(false); setPreview(event.dataTransfer.files[0]) }} className={`rounded-xl border border-dashed p-4 transition-colors ${isPreviewDragging ? 'border-[#1677ff] bg-[#e6f4ff]' : 'border-[#cbd5e1] bg-[#f8fafc]'}`}> setPreview(event.currentTarget.files?.[0])} className="sr-only" />{previewImage && previewUrl ?
{`预览图:${previewImage.name}`}

{previewImage.name}

{formatFileSize(previewImage.size)} · 第一张图作为知识卡片预览

:

拖放预览图片到这里,或选择图片

支持 JPG、PNG、WebP,单张不超过 50MB

}
+

预览图片 (可选)

{previewImage && }
event.preventDefault()} onDragLeave={handlePreviewDragLeave} onDrop={(event) => { event.preventDefault(); previewDragDepthRef.current = 0; setIsPreviewDragging(false); void setPreview(event.dataTransfer.files[0]) }} className={`rounded-xl border border-dashed p-4 transition-colors ${isPreviewDragging ? 'border-[#1677ff] bg-[#e6f4ff]' : 'border-[#cbd5e1] bg-[#f8fafc]'}`}> { const file = event.currentTarget.files?.[0]; event.currentTarget.value = ''; void setPreview(file) }} className="sr-only" />{previewImage ?
{previewUrl ? {`预览图:${previewImage.name}`} :

{previewImage.name}

{formatFileSize(previewImage.size)} · {previewSlices.length > 0 ? `已切为 ${previewSlices.length} 片` : previewUsesServerFallback ? '将在服务器生成预览' : '正在生成安全预览'}

:

拖放预览图片到这里,或选择图片

支持 JPG、PNG、WebP,单张不超过 50MB

}

知识文件

{selectedFile && }
event.preventDefault()} onDragLeave={handleDragLeave} onDrop={(event) => { event.preventDefault(); dragDepthRef.current = 0; setIsDragging(false); setFile(event.dataTransfer.files[0]) }} className={`rounded-xl border border-dashed p-6 text-center transition-colors sm:p-8 ${isDragging ? 'border-[#1677ff] bg-[#e6f4ff]' : 'border-[#cbd5e1] bg-[#f8fafc]'}`}> setFile(event.currentTarget.files?.[0])} className="sr-only" />{selectedFile ?

{selectedFile.name}

{formatFileSize(selectedFile.size)} · 提交后保存到品牌知识库

:

拖放文件到这里,或选择文件

支持{BRAND_KNOWLEDGE_MAIN_FILE_LABEL}

}
-
+
) diff --git a/lib/__tests__/brand-knowledge-image-browser.test.ts b/lib/__tests__/brand-knowledge-image-browser.test.ts new file mode 100644 index 0000000..cdccd0f --- /dev/null +++ b/lib/__tests__/brand-knowledge-image-browser.test.ts @@ -0,0 +1,72 @@ +import { + canUseBrandKnowledgeLegacyServerPreview, + prepareBrandKnowledgePreviewInBrowser, +} from '@/lib/brand-knowledge-image-browser' + +const mockPreparePerformanceSurveyImagesInBrowser = jest.fn() + +jest.mock('@/lib/performance-survey-image-browser', () => ({ + preparePerformanceSurveyImagesInBrowser: (...args: unknown[]) => ( + mockPreparePerformanceSurveyImagesInBrowser(...args) + ), +})) + +describe('brand knowledge preview browser processing', () => { + beforeEach(() => { + jest.clearAllMocks() + mockPreparePerformanceSurveyImagesInBrowser.mockResolvedValue([ + { file: new File(['one'], 'long-001.webp', { type: 'image/webp' }), kind: 'slice' }, + { file: new File(['two'], 'long-002.webp', { type: 'image/webp' }), kind: 'slice' }, + ]) + }) + + test('uses the dedicated 120MP brand profile and returns ordered slices', async () => { + const controller = new AbortController() + const source = new File(['image'], 'long.jpg', { type: 'image/jpeg' }) + + const result = await prepareBrandKnowledgePreviewInBrowser(source, undefined, { + signal: controller.signal, + timeoutMs: 12_345, + }) + + expect(result.map((item) => item.file.name)).toEqual(['long-001.webp', 'long-002.webp']) + expect(mockPreparePerformanceSurveyImagesInBrowser).toHaveBeenCalledWith( + [source], + undefined, + { + profile: 'brand_knowledge_preview', + signal: controller.signal, + timeoutMs: 12_345, + }, + ) + }) + + test('allows server fallback only when the image header is within 60 million pixels', async () => { + function pngFile(width: number, height: number) { + const bytes = new Uint8Array(24) + bytes.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + const view = new DataView(bytes.buffer) + view.setUint32(16, width) + view.setUint32(20, height) + return { + name: 'preview.png', + size: bytes.byteLength, + slice: () => ({ arrayBuffer: async () => bytes.buffer }), + } as unknown as File + } + + await expect(canUseBrandKnowledgeLegacyServerPreview(pngFile(10_000, 6_000))).resolves.toBe(true) + await expect(canUseBrandKnowledgeLegacyServerPreview(pngFile(10_000, 6_001))).resolves.toBe(false) + }) + + test('rejects browser output that exceeds the derived preview byte limits', async () => { + mockPreparePerformanceSurveyImagesInBrowser.mockResolvedValue([{ + file: { name: 'huge.webp', size: 10 * 1024 * 1024 + 1 }, + kind: 'slice', + }]) + + await expect(prepareBrandKnowledgePreviewInBrowser( + new File(['image'], 'long.jpg', { type: 'image/jpeg' }), + )).rejects.toThrow('单个切片过大') + }) +}) diff --git a/lib/__tests__/brand-knowledge-preview.test.ts b/lib/__tests__/brand-knowledge-preview.test.ts index 5428449..cbee30b 100644 --- a/lib/__tests__/brand-knowledge-preview.test.ts +++ b/lib/__tests__/brand-knowledge-preview.test.ts @@ -4,6 +4,7 @@ import sharp from 'sharp' import { assertBrandKnowledgePreviewMetadata, prepareBrandKnowledgePreview, + validateBrandKnowledgeUploadedPreviewSlices, } from '@/lib/brand-knowledge-preview' describe('brand knowledge long preview processing', () => { @@ -45,4 +46,40 @@ describe('brand knowledge long preview processing', () => { format: 'webp', width: 800, height: 90_001, pages: 1, }, 'image/webp')).toThrow('尺寸过大') }) + + test('validates ordered browser WebP slices before storage', async () => { + const first = await sharp({ + create: { width: 1_600, height: 1_500, channels: 3, background: '#fff' }, + }).webp().toBuffer() + const last = await sharp({ + create: { width: 1_600, height: 800, channels: 3, background: '#fff' }, + }).webp().toBuffer() + + const result = await validateBrandKnowledgeUploadedPreviewSlices([ + { buffer: first, declaredContentType: 'image/webp' }, + { buffer: last, declaredContentType: 'image/webp' }, + ]) + + expect(result.slices.map((slice) => [slice.width, slice.height])).toEqual([ + [1_600, 1_500], + [1_600, 800], + ]) + }) + + test('rejects forged or geometrically inconsistent browser slices', async () => { + await expect(validateBrandKnowledgeUploadedPreviewSlices([ + { buffer: Buffer.from('not a webp'), declaredContentType: 'image/webp' }, + ])).rejects.toThrow('无法解码') + + const shortFirst = await sharp({ + create: { width: 1_600, height: 1_000, channels: 3, background: '#fff' }, + }).webp().toBuffer() + const last = await sharp({ + create: { width: 1_600, height: 800, channels: 3, background: '#fff' }, + }).webp().toBuffer() + await expect(validateBrandKnowledgeUploadedPreviewSlices([ + { buffer: shortFirst, declaredContentType: 'image/webp' }, + { buffer: last, declaredContentType: 'image/webp' }, + ])).rejects.toThrow('高度应为 1500px') + }) }) diff --git a/lib/__tests__/performance-survey-image-source-guard.test.ts b/lib/__tests__/performance-survey-image-source-guard.test.ts index 7977c13..1393302 100644 --- a/lib/__tests__/performance-survey-image-source-guard.test.ts +++ b/lib/__tests__/performance-survey-image-source-guard.test.ts @@ -1,5 +1,7 @@ import { assertPerformanceSurveyBrowserSourceImageDimensions, + BRAND_KNOWLEDGE_BROWSER_SOURCE_MAX_HEIGHT, + BRAND_KNOWLEDGE_BROWSER_SOURCE_MAX_PIXELS, PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS, readPerformanceSurveySourceImageDimensions, TASK_DETAIL_BROWSER_SOURCE_MAX_PIXELS, @@ -63,4 +65,25 @@ describe('performance survey browser source image guard', () => { { maxPixels: TASK_DETAIL_BROWSER_SOURCE_MAX_PIXELS }, )).toThrow('像素总量超过 60,000,000') }) + + test('品牌知识专用门禁允许 6000 万以上长图但保留 1.2 亿和高度上限', () => { + expect(() => assertPerformanceSurveyBrowserSourceImageDimensions( + { width: 1_600, height: 50_000 }, + 'brand-long.png', + { + maxPixels: BRAND_KNOWLEDGE_BROWSER_SOURCE_MAX_PIXELS, + maxHeight: BRAND_KNOWLEDGE_BROWSER_SOURCE_MAX_HEIGHT, + }, + )).not.toThrow() + expect(() => assertPerformanceSurveyBrowserSourceImageDimensions( + { width: 2_000, height: 60_001 }, + 'brand-too-many-pixels.png', + { maxPixels: BRAND_KNOWLEDGE_BROWSER_SOURCE_MAX_PIXELS }, + )).toThrow('像素总量超过 120,000,000') + expect(() => assertPerformanceSurveyBrowserSourceImageDimensions( + { width: 1, height: BRAND_KNOWLEDGE_BROWSER_SOURCE_MAX_HEIGHT + 1 }, + 'brand-too-tall.png', + { maxHeight: BRAND_KNOWLEDGE_BROWSER_SOURCE_MAX_HEIGHT }, + )).toThrow('高度超过 90000px') + }) }) diff --git a/lib/brand-knowledge-file-types.ts b/lib/brand-knowledge-file-types.ts index e120dda..c59e0ec 100644 --- a/lib/brand-knowledge-file-types.ts +++ b/lib/brand-knowledge-file-types.ts @@ -15,6 +15,8 @@ export const BRAND_KNOWLEDGE_MAIN_FILE_LABEL = export const BRAND_KNOWLEDGE_MAX_MAIN_FILE_BYTES = 1024 * 1024 * 1024 export const BRAND_KNOWLEDGE_MAX_PREVIEW_IMAGE_BYTES = 50 * 1024 * 1024 +export const BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_BYTES = 10 * 1024 * 1024 +export const BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_COUNT = 60 // Includes multipart framing headroom for a 1 GiB main file and 50 MiB preview. export const BRAND_KNOWLEDGE_MAX_REQUEST_BYTES = 1100 * 1024 * 1024 diff --git a/lib/brand-knowledge-image-browser.ts b/lib/brand-knowledge-image-browser.ts new file mode 100644 index 0000000..411f362 --- /dev/null +++ b/lib/brand-knowledge-image-browser.ts @@ -0,0 +1,66 @@ +import { + BRAND_KNOWLEDGE_MAX_PREVIEW_IMAGE_BYTES, + BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_BYTES, + BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_COUNT, +} from '@/lib/brand-knowledge-file-types' +import { + preparePerformanceSurveyImagesInBrowser, + type PerformanceSurveyBrowserProcessedImage, +} from '@/lib/performance-survey-image-browser' +import { + BRAND_KNOWLEDGE_LEGACY_SERVER_MAX_PIXELS, + PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS, + readPerformanceSurveySourceImageDimensions, +} from '@/lib/performance-survey-image-source-guard' + +export type BrandKnowledgeBrowserPreviewSlice = PerformanceSurveyBrowserProcessedImage & { + kind: 'slice' +} + +export async function prepareBrandKnowledgePreviewInBrowser( + file: File, + onProgress?: (label: string) => void, + options: { signal?: AbortSignal; timeoutMs?: number } = {}, +): Promise { + const processed = await preparePerformanceSurveyImagesInBrowser( + [file], + onProgress, + { + profile: 'brand_knowledge_preview', + signal: options.signal, + timeoutMs: options.timeoutMs, + }, + ) + const slices = processed.filter( + (item): item is BrandKnowledgeBrowserPreviewSlice => item.kind === 'slice', + ) + if (slices.length === 0) throw new Error('浏览器未生成品牌知识预览切片') + if (slices.length > BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_COUNT) { + throw new Error(`预览图处理后超过 ${BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_COUNT} 个切片`) + } + if (slices.some((slice) => slice.file.size <= 0 || slice.file.size > BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_BYTES)) { + throw new Error('预览图处理后的单个切片过大,请先压缩图片后重试') + } + const totalBytes = slices.reduce((sum, slice) => sum + slice.file.size, 0) + if (totalBytes > BRAND_KNOWLEDGE_MAX_PREVIEW_IMAGE_BYTES) { + throw new Error('预览图处理后的切片总大小超过 50MB,请先压缩图片后重试') + } + return slices +} + +export async function canUseBrandKnowledgeLegacyServerPreview(file: File) { + let bytes = new Uint8Array( + await file.slice(0, PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.headerReadBytes).arrayBuffer(), + ) + let dimensions = readPerformanceSurveySourceImageDimensions(bytes) + if (!dimensions && file.size > bytes.byteLength) { + bytes = new Uint8Array( + await file.slice(0, PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.extendedHeaderReadBytes).arrayBuffer(), + ) + dimensions = readPerformanceSurveySourceImageDimensions(bytes) + } + if (!dimensions) return false + return dimensions.width <= PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.maxWidth + && dimensions.height <= 90_000 + && dimensions.width * dimensions.height <= BRAND_KNOWLEDGE_LEGACY_SERVER_MAX_PIXELS +} diff --git a/lib/brand-knowledge-preview.ts b/lib/brand-knowledge-preview.ts index fcb330b..bcf7f96 100644 --- a/lib/brand-knowledge-preview.ts +++ b/lib/brand-knowledge-preview.ts @@ -1,6 +1,12 @@ import sharp from 'sharp' import { sliceImage } from '@/lib/image-processor' import { configureSharpRuntime } from '@/lib/sharp-runtime' +import { withImageProcessingSlot } from '@/lib/image-processing-limiter' +import { + BRAND_KNOWLEDGE_MAX_PREVIEW_IMAGE_BYTES, + BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_BYTES, + BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_COUNT, +} from '@/lib/brand-knowledge-file-types' configureSharpRuntime() @@ -85,3 +91,65 @@ export async function prepareBrandKnowledgePreview(buffer: Buffer, declaredConte if (slices.length === 0) throw new Error('预览图处理后没有可用片段') return { source, slices } } + +export type BrandKnowledgeUploadedPreviewSlice = { + buffer: Buffer + declaredContentType?: string | null +} + +export async function validateBrandKnowledgeUploadedPreviewSlices( + uploadedSlices: BrandKnowledgeUploadedPreviewSlice[], +) { + if (uploadedSlices.length < 1 || uploadedSlices.length > BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_COUNT) { + throw new Error(`预览图切片数量需在 1 至 ${BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_COUNT} 片之间`) + } + const totalBytes = uploadedSlices.reduce((sum, slice) => sum + slice.buffer.length, 0) + if (totalBytes > BRAND_KNOWLEDGE_MAX_PREVIEW_IMAGE_BYTES) { + throw new Error('预览图切片总大小不能超过 50MB') + } + + return withImageProcessingSlot(async () => { + const slices: Array<{ buffer: Buffer; width: number; height: number }> = [] + let expectedWidth = 0 + + for (let index = 0; index < uploadedSlices.length; index += 1) { + const uploaded = uploadedSlices[index] + if (uploaded.buffer.length < 1 || uploaded.buffer.length > BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_BYTES) { + throw new Error(`预览图第 ${index + 1} 片大小无效`) + } + if (normalizeContentType(uploaded.declaredContentType) !== 'image/webp') { + throw new Error(`预览图第 ${index + 1} 片只支持 WebP 格式`) + } + + let metadata: sharp.Metadata + try { + metadata = await sharp(uploaded.buffer, { + animated: true, + failOn: 'error', + limitInputPixels: BRAND_KNOWLEDGE_PREVIEW_OUTPUT_WIDTH * BRAND_KNOWLEDGE_PREVIEW_SLICE_HEIGHT, + sequentialRead: true, + }).metadata() + } catch { + throw new Error(`预览图第 ${index + 1} 片无法解码`) + } + + const width = metadata.width || 0 + const height = metadata.height || 0 + if (metadata.format !== 'webp' || Number(metadata.pages || 1) > 1) { + throw new Error(`预览图第 ${index + 1} 片不是有效的静态 WebP 图片`) + } + if (width < 1 || width > BRAND_KNOWLEDGE_PREVIEW_OUTPUT_WIDTH + || height < 1 || height > BRAND_KNOWLEDGE_PREVIEW_SLICE_HEIGHT) { + throw new Error(`预览图第 ${index + 1} 片尺寸无效`) + } + if (index === 0) expectedWidth = width + if (width !== expectedWidth) throw new Error('预览图切片宽度不一致') + if (index < uploadedSlices.length - 1 && height !== BRAND_KNOWLEDGE_PREVIEW_SLICE_HEIGHT) { + throw new Error(`预览图第 ${index + 1} 片高度应为 ${BRAND_KNOWLEDGE_PREVIEW_SLICE_HEIGHT}px`) + } + slices.push({ buffer: uploaded.buffer, width, height }) + } + + return { slices, totalBytes } + }) +} diff --git a/lib/performance-survey-image-browser.ts b/lib/performance-survey-image-browser.ts index ff09fe4..42b9354 100644 --- a/lib/performance-survey-image-browser.ts +++ b/lib/performance-survey-image-browser.ts @@ -43,7 +43,7 @@ const DESKTOP_BROWSER_REQUIRED_MESSAGE = '图片需要在桌面版 Chrome 或 Ed export const PERFORMANCE_SURVEY_IMAGE_WORKER_TIMEOUT_MS = 10 * 60 * 1000 export type PerformanceSurveyBrowserImageOptions = { - profile?: 'performance_survey' | 'work_review' | 'task_detail' | 'task_detail_preview' | 'task_detail_detail' | 'task_detail_thumbnail' + profile?: 'performance_survey' | 'work_review' | 'task_detail' | 'task_detail_preview' | 'task_detail_detail' | 'task_detail_thumbnail' | 'brand_knowledge_preview' signal?: AbortSignal timeoutMs?: number } diff --git a/lib/performance-survey-image-source-guard.ts b/lib/performance-survey-image-source-guard.ts index a8e34cd..78a2aa7 100644 --- a/lib/performance-survey-image-source-guard.ts +++ b/lib/performance-survey-image-source-guard.ts @@ -20,6 +20,9 @@ export const PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS = { // 任务详情在 Edge 预处理失败时会交给服务器 Sharp 兜底;两端必须使用同一 // 像素门禁,否则 60M~120M 的图片会在上传完成后才后台失败。 export const TASK_DETAIL_BROWSER_SOURCE_MAX_PIXELS = 60_000_000 +export const BRAND_KNOWLEDGE_BROWSER_SOURCE_MAX_PIXELS = 120_000_000 +export const BRAND_KNOWLEDGE_BROWSER_SOURCE_MAX_HEIGHT = 90_000 +export const BRAND_KNOWLEDGE_LEGACY_SERVER_MAX_PIXELS = 60_000_000 export type PerformanceSurveySourceImageDimensions = { width: number @@ -122,7 +125,7 @@ export function readPerformanceSurveySourceImageDimensions(bytes: Uint8Array) { export function assertPerformanceSurveyBrowserSourceImageDimensions( dimensions: PerformanceSurveySourceImageDimensions | null, sourceName: string, - options: { maxPixels?: number } = {}, + options: { maxPixels?: number; maxHeight?: number } = {}, ) { if (!dimensions || !Number.isInteger(dimensions.width) || !Number.isInteger(dimensions.height) || dimensions.width <= 0 || dimensions.height <= 0) { @@ -137,6 +140,12 @@ export function assertPerformanceSurveyBrowserSourceImageDimensions( `图片「${sourceName}」宽度超过 ${PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.maxWidth}px`, ) } + if (options.maxHeight && dimensions.height > options.maxHeight) { + throw new PerformanceSurveySourceImageError( + 'SOURCE_DIMENSIONS_INVALID', + `图片「${sourceName}」高度超过 ${options.maxHeight}px`, + ) + } const maxPixels = options.maxPixels || PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.maxPixels if (dimensions.width * dimensions.height > maxPixels) { throw new PerformanceSurveySourceImageError( diff --git a/lib/performance-survey-image.worker.ts b/lib/performance-survey-image.worker.ts index 7b16e0f..944a69a 100644 --- a/lib/performance-survey-image.worker.ts +++ b/lib/performance-survey-image.worker.ts @@ -5,6 +5,8 @@ import { PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS, readPerformanceSurveySourceImageDimensions, TASK_DETAIL_BROWSER_SOURCE_MAX_PIXELS, + BRAND_KNOWLEDGE_BROWSER_SOURCE_MAX_HEIGHT, + BRAND_KNOWLEDGE_BROWSER_SOURCE_MAX_PIXELS, } from './performance-survey-image-source-guard' import { PerformanceSurveyBrowserImageError } from './performance-survey-image-error' @@ -13,7 +15,7 @@ export {} type WorkerRequest = { id: string files: File[] - profile?: 'performance_survey' | 'work_review' | 'task_detail' | 'task_detail_preview' | 'task_detail_detail' | 'task_detail_thumbnail' + profile?: 'performance_survey' | 'work_review' | 'task_detail' | 'task_detail_preview' | 'task_detail_detail' | 'task_detail_thumbnail' | 'brand_knowledge_preview' } type WorkerProcessedImage = { @@ -43,6 +45,7 @@ let previewHeight = PREVIEW_HEIGHT let previewQuality = PREVIEW_QUALITY let previewFit: 'cover' | 'inside' = 'cover' let sourceMaxPixels: number = PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.maxPixels +let sourceMaxHeight: number | undefined const workerScope = self as unknown as DedicatedWorkerGlobalScope function removeFileExtension(fileName: string) { @@ -87,7 +90,10 @@ async function decodeImage(file: File) { // JPEG 的 EXIF/ICC 段可能超过 256KB;头部暂时读不到 SOF 时交给浏览器实际解码, // 再用解码后的尺寸做同一套限制,不能把合法图片误判成“不支持”。 if (headerDimensions) { - assertPerformanceSurveyBrowserSourceImageDimensions(headerDimensions, file.name, { maxPixels: sourceMaxPixels }) + assertPerformanceSurveyBrowserSourceImageDimensions(headerDimensions, file.name, { + maxPixels: sourceMaxPixels, + maxHeight: sourceMaxHeight, + }) } let bitmap: ImageBitmap try { @@ -108,7 +114,7 @@ async function decodeImage(file: File) { assertPerformanceSurveyBrowserSourceImageDimensions( { width: bitmap.width, height: bitmap.height }, file.name, - { maxPixels: sourceMaxPixels }, + { maxPixels: sourceMaxPixels, maxHeight: sourceMaxHeight }, ) return bitmap } catch (error) { @@ -376,7 +382,12 @@ workerScope.onmessage = async (event: MessageEvent) => { const isTaskDetailThumbnail = profile === 'task_detail_thumbnail' sourceMaxPixels = isTaskDetail ? TASK_DETAIL_BROWSER_SOURCE_MAX_PIXELS - : PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.maxPixels + : profile === 'brand_knowledge_preview' + ? BRAND_KNOWLEDGE_BROWSER_SOURCE_MAX_PIXELS + : PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.maxPixels + sourceMaxHeight = profile === 'brand_knowledge_preview' + ? BRAND_KNOWLEDGE_BROWSER_SOURCE_MAX_HEIGHT + : undefined previewWidth = isTaskDetailThumbnail ? TASK_DETAIL_THUMBNAIL_SIZE : isTaskDetail ? 800 : PREVIEW_WIDTH previewHeight = isTaskDetailThumbnail ? TASK_DETAIL_THUMBNAIL_SIZE : isTaskDetail ? 1_200 : PREVIEW_HEIGHT previewQuality = isTaskDetailThumbnail ? 0.68 : isTaskDetail ? 0.74 : PREVIEW_QUALITY @@ -390,7 +401,10 @@ workerScope.onmessage = async (event: MessageEvent) => { const images = files.length === 1 ? profile === 'task_detail_preview' || profile === 'task_detail_thumbnail' ? await processSinglePreview(files[0], profile === 'task_detail_thumbnail' ? 'thumbnail' : 'preview') - : await processSingleOriginal(files[0], profile !== 'task_detail_detail') + : await processSingleOriginal( + files[0], + profile !== 'task_detail_detail' && profile !== 'brand_knowledge_preview', + ) : await processOrderedSlices(files) workerScope.postMessage({ id, ok: true, images }) } catch (error) {