diff --git a/app/api/department-dashboard/brand-knowledge/route.test.ts b/app/api/department-dashboard/brand-knowledge/route.test.ts index 84c4885..a9041b2 100644 --- a/app/api/department-dashboard/brand-knowledge/route.test.ts +++ b/app/api/department-dashboard/brand-knowledge/route.test.ts @@ -15,7 +15,7 @@ jest.mock('@/lib/brand-center-dashboard-access', () => ({ }, assertBrandCenterDashboardAccess: jest.fn(async () => undefined), })) -jest.mock('@/lib/prisma', () => ({ prisma: { brandKnowledge: { create: jest.fn(), findMany: jest.fn() } } })) +jest.mock('@/lib/prisma', () => ({ prisma: { brandKnowledge: { count: jest.fn(), create: jest.fn(), findMany: jest.fn() } } })) jest.mock('@/lib/department-permission', () => ({ isSuperAdmin: jest.fn(async () => false) })) jest.mock('@/lib/brand-knowledge-preview', () => ({ prepareBrandKnowledgePreview: jest.fn(), @@ -154,6 +154,7 @@ describe('brand knowledge upload route', () => { ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'user-1' } }) ;(isSuperAdmin as jest.Mock).mockResolvedValue(false) ;(prisma.brandKnowledge.findMany as jest.Mock).mockResolvedValue([]) + ;(prisma.brandKnowledge.count as jest.Mock).mockResolvedValue(0) ;(prepareBrandKnowledgePreview as jest.Mock).mockResolvedValue({ source: { extension: 'png', contentType: 'image/png', width: 800, height: 4_100 }, slices: [Buffer.from('first-slice'), Buffer.from('second-slice')], @@ -170,15 +171,20 @@ describe('brand knowledge upload route', () => { test('list excludes soft-deleted knowledge and returns database-backed management permission', async () => { ;(isSuperAdmin as jest.Mock).mockResolvedValue(true) + ;(prisma.brandKnowledge.count as jest.Mock).mockResolvedValue(23_081) const response = await GET(new Request('http://localhost/api/department-dashboard/brand-knowledge')) const body = await response.json() expect(response.status).toBe(200) + expect(body.total).toBe(23_081) expect(body.permissions).toEqual({ canManage: true }) expect(prisma.brandKnowledge.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ deletedAt: null }), })) + expect(prisma.brandKnowledge.count).toHaveBeenCalledWith({ + where: expect.objectContaining({ deletedAt: null }), + }) expect(isSuperAdmin).toHaveBeenCalledWith('user-1') }) diff --git a/app/api/department-dashboard/brand-knowledge/route.ts b/app/api/department-dashboard/brand-knowledge/route.ts index 91c417e..ca32738 100644 --- a/app/api/department-dashboard/brand-knowledge/route.ts +++ b/app/api/department-dashboard/brand-knowledge/route.ts @@ -96,7 +96,7 @@ export async function GET(request: Request) { const params = new URL(request.url).searchParams const type = params.get('type')?.trim() || '' const query = params.get('q')?.trim() || '' - const [items, canManage] = await Promise.all([ + const [items, total, canManage] = await Promise.all([ prisma.brandKnowledge.findMany({ where: { deletedAt: null, @@ -133,10 +133,24 @@ export async function GET(request: Request) { _count: { select: { likes: true } }, }, }), + prisma.brandKnowledge.count({ + where: { + deletedAt: null, + ...(KNOWLEDGE_TYPES.has(type) ? { knowledgeType: type } : {}), + ...(query ? { + OR: [ + { title: { contains: query, mode: 'insensitive' } }, + { fileName: { contains: query, mode: 'insensitive' } }, + { brand: { contains: query, mode: 'insensitive' } }, + { productCategoryName: { contains: query, mode: 'insensitive' } }, + ], + } : {}), + }, + }), isSuperAdmin(userId), ]) return NextResponse.json( - { success: true, data: items.map(serializeKnowledge), permissions: { canManage } }, + { success: true, data: items.map(serializeKnowledge), total, permissions: { canManage } }, { headers: PRIVATE_HEADERS }, ) } catch (error) { diff --git a/components/department-dashboard/BrandKnowledgeDashboard.test.tsx b/components/department-dashboard/BrandKnowledgeDashboard.test.tsx index 1dd2f1a..7e411ea 100644 --- a/components/department-dashboard/BrandKnowledgeDashboard.test.tsx +++ b/components/department-dashboard/BrandKnowledgeDashboard.test.tsx @@ -63,6 +63,9 @@ describe('BrandKnowledgeDashboard', () => { expect(screen.getAllByRole('button').map((button) => button.textContent)).toEqual(expect.arrayContaining([...BRAND_KNOWLEDGE_TYPE_OPTIONS])) expect(screen.getByRole('button', { name: '上传知识' })).toBeEnabled() await waitFor(() => expect(screen.getByText('品牌知识库暂未上传内容')).toBeInTheDocument()) + expect(screen.getByLabelText('共 0 条')).toHaveTextContent('共 0 条') + expect(screen.queryByRole('heading', { name: '知识内容' })).not.toBeInTheDocument() + expect(screen.queryByText('人员上传的知识文件将按类型保存,并可在此下载')).not.toBeInTheDocument() }) it('filters the empty state by type and search', async () => { @@ -81,6 +84,7 @@ describe('BrandKnowledgeDashboard', () => { ok: true, json: async () => ({ success: true, + total: 23_081, data: [{ id: 'knowledge-1', title: '索菲亚图像影像规范', @@ -106,6 +110,7 @@ describe('BrandKnowledgeDashboard', () => { render() const preview = await screen.findByRole('img', { name: '预览图:索菲亚图像影像规范' }) + expect(screen.getByLabelText('共 23081 条')).toHaveTextContent('共 23,081 条') expect(preview).toHaveClass('object-contain', 'object-center') expect(preview).not.toHaveClass('object-cover') expect(screen.getByRole('link', { name: '查看索菲亚图像影像规范详情' })).toHaveAttribute( @@ -292,4 +297,27 @@ describe('BrandKnowledgeDashboard', () => { expect(screen.queryByRole('dialog', { name: '上传知识' })).not.toBeInTheDocument() expect(uploadButton).toHaveFocus() }) + + it('labels a processed preview with one output as a single image', async () => { + mockPrepareBrandKnowledgePreviewInBrowser.mockResolvedValueOnce([{ + file: new File(['single preview'], '预览图.webp', { type: 'image/webp' }), + mimeType: 'image/webp', + width: 1_000, + height: 3_000, + kind: 'slice', + sourceName: '预览图.png', + sourceIndex: 0, + sliceIndex: 0, + sliceCount: 1, + }]) + render() + + fireEvent.click(screen.getByRole('button', { name: '上传知识' })) + const previewFile = new File(['fake image'], '预览图.png', { type: 'image/png' }) + fireEvent.change(screen.getByLabelText('选择预览图片'), { target: { files: [previewFile] } }) + + expect(await screen.findByText('10 B · 单张预览')).toBeInTheDocument() + expect(screen.getByRole('status')).toHaveTextContent('图片高度不超过 3000px,已保留为单张预览') + expect(screen.queryByText(/已切为 1 片/)).not.toBeInTheDocument() + }) }) diff --git a/components/department-dashboard/BrandKnowledgeDashboard.tsx b/components/department-dashboard/BrandKnowledgeDashboard.tsx index 6649ce2..c2c5295 100644 --- a/components/department-dashboard/BrandKnowledgeDashboard.tsx +++ b/components/department-dashboard/BrandKnowledgeDashboard.tsx @@ -379,6 +379,7 @@ export default function BrandKnowledgeDashboard() { const [searchKeyword, setSearchKeyword] = useState('') const [isUploadDialogOpen, setIsUploadDialogOpen] = useState(false) const [items, setItems] = useState([]) + const [totalCount, setTotalCount] = useState(0) const [isLoading, setIsLoading] = useState(true) const [loadError, setLoadError] = useState('') const [canManage, setCanManage] = useState(false) @@ -390,7 +391,7 @@ export default function BrandKnowledgeDashboard() { const loadKnowledge = useCallback(async () => { setIsLoading(true); setLoadError('') - try { const response = await fetch('/api/department-dashboard/brand-knowledge', { cache: 'no-store' }); const payload = await response.json().catch(() => null); if (!response.ok || !payload?.success) throw new Error(payload?.error || '品牌知识列表读取失败'); setItems(Array.isArray(payload.data) ? payload.data : []); setCanManage(payload.permissions?.canManage === true) } + try { const response = await fetch('/api/department-dashboard/brand-knowledge', { cache: 'no-store' }); const payload = await response.json().catch(() => null); if (!response.ok || !payload?.success) throw new Error(payload?.error || '品牌知识列表读取失败'); const nextItems = Array.isArray(payload.data) ? payload.data : []; setItems(nextItems); setTotalCount(Number.isSafeInteger(payload.total) && payload.total >= 0 ? payload.total : nextItems.length); setCanManage(payload.permissions?.canManage === true) } catch (error) { setLoadError(error instanceof Error ? error.message : '品牌知识列表读取失败,请稍后重试') } finally { setIsLoading(false) } }, []) @@ -431,6 +432,7 @@ export default function BrandKnowledgeDashboard() { const payload = await response.json().catch(() => null) if (!response.ok || !payload?.success) throw new Error(payload?.error || '品牌知识删除失败,请稍后重试') setItems((current) => current.filter((item) => item.id !== id)) + setTotalCount((current) => Math.max(0, current - 1)) showToast('品牌知识已删除,原文件已保留', { variant: 'success' }) }, [showToast]) @@ -491,12 +493,15 @@ export default function BrandKnowledgeDashboard() { -
-
-
-

知识内容

-

人员上传的知识文件将按类型保存,并可在此下载

-
+
+
+

+ 共 {isLoading ? '…' : totalCount.toLocaleString('zh-CN')} 条 +

-

预览图片 (可选)

{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

}
+

预览图片 (可选)

{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 === 1 ? '单张预览' : previewSlices.length > 1 ? `已切为 ${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 index cdccd0f..a893616 100644 --- a/lib/__tests__/brand-knowledge-image-browser.test.ts +++ b/lib/__tests__/brand-knowledge-image-browser.test.ts @@ -2,6 +2,11 @@ import { canUseBrandKnowledgeLegacyServerPreview, prepareBrandKnowledgePreviewInBrowser, } from '@/lib/brand-knowledge-image-browser' +import { + BRAND_KNOWLEDGE_PREVIEW_SINGLE_IMAGE_MAX_HEIGHT, + planBrandKnowledgePreviewSlices, + shouldSliceBrandKnowledgePreview, +} from '@/lib/performance-survey-image-source-guard' const mockPreparePerformanceSurveyImagesInBrowser = jest.fn() @@ -20,6 +25,21 @@ describe('brand knowledge preview browser processing', () => { ]) }) + test('uses 3000px as the inclusive single-image boundary', () => { + expect(BRAND_KNOWLEDGE_PREVIEW_SINGLE_IMAGE_MAX_HEIGHT).toBe(3_000) + expect(shouldSliceBrandKnowledgePreview(2_999)).toBe(false) + expect(shouldSliceBrandKnowledgePreview(3_000)).toBe(false) + expect(shouldSliceBrandKnowledgePreview(3_001)).toBe(true) + expect(planBrandKnowledgePreviewSlices(3_000, 1_200, 1_500)).toEqual({ + sliceHeightLimit: 1_200, + sliceCount: 1, + }) + expect(planBrandKnowledgePreviewSlices(3_001, 1_200, 1_500)).toEqual({ + sliceHeightLimit: 600, + sliceCount: 2, + }) + }) + 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' }) diff --git a/lib/__tests__/brand-knowledge-preview.test.ts b/lib/__tests__/brand-knowledge-preview.test.ts index cbee30b..b8db821 100644 --- a/lib/__tests__/brand-knowledge-preview.test.ts +++ b/lib/__tests__/brand-knowledge-preview.test.ts @@ -8,6 +8,54 @@ import { } from '@/lib/brand-knowledge-preview' describe('brand knowledge long preview processing', () => { + test('keeps previews at or below 3000px as one image and slices above the boundary', async () => { + const exactBoundary = await sharp({ + create: { + width: 800, + height: 3_000, + channels: 3, + background: { r: 238, g: 241, b: 244 }, + }, + }).png().toBuffer() + const overBoundary = await sharp({ + create: { + width: 800, + height: 3_001, + channels: 3, + background: { r: 238, g: 241, b: 244 }, + }, + }).png().toBuffer() + + const exactResult = await prepareBrandKnowledgePreview(exactBoundary, 'image/png') + const overResult = await prepareBrandKnowledgePreview(overBoundary, 'image/png') + + await expect(sharp(exactResult.slices[0]).metadata()).resolves.toMatchObject({ + format: 'webp', width: 800, height: 3_000, + }) + expect(exactResult.slices).toHaveLength(1) + expect(overResult.slices).toHaveLength(3) + }) + + test('still slices images above 3000px when width scaling makes the output shorter than 1500px', async () => { + const wideOverBoundary = await sharp({ + create: { + width: 4_000, + height: 3_001, + channels: 3, + background: { r: 238, g: 241, b: 244 }, + }, + }).png().toBuffer() + + const result = await prepareBrandKnowledgePreview(wideOverBoundary, 'image/png') + const metadata = await Promise.all(result.slices.map((slice) => sharp(slice).metadata())) + + expect(result.slices).toHaveLength(2) + expect(metadata.map((item) => [item.width, item.height])).toEqual([ + [1_600, 600], + [1_600, 600], + ]) + }) + test('slices a long image into ordered WebP segments suitable for seamless rendering', async () => { const source = await sharp({ create: { @@ -66,6 +114,37 @@ describe('brand knowledge long preview processing', () => { ]) }) + test('accepts one browser preview up to 3000px without treating it as a sliced image', async () => { + const single = await sharp({ + create: { width: 1_000, height: 3_000, channels: 3, background: '#fff' }, + }).webp().toBuffer() + + const result = await validateBrandKnowledgeUploadedPreviewSlices([ + { buffer: single, declaredContentType: 'image/webp' }, + ]) + + expect(result.slices.map((slice) => [slice.width, slice.height])).toEqual([[1_000, 3_000]]) + }) + + test('accepts a short equal-height pair created after scaling a wide source image', async () => { + const first = await sharp({ + create: { width: 1_600, height: 600, channels: 3, background: '#fff' }, + }).webp().toBuffer() + const last = await sharp({ + create: { width: 1_600, height: 600, 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, 600], + [1_600, 600], + ]) + }) + test('rejects forged or geometrically inconsistent browser slices', async () => { await expect(validateBrandKnowledgeUploadedPreviewSlices([ { buffer: Buffer.from('not a webp'), declaredContentType: 'image/webp' }, @@ -80,6 +159,11 @@ describe('brand knowledge long preview processing', () => { await expect(validateBrandKnowledgeUploadedPreviewSlices([ { buffer: shortFirst, declaredContentType: 'image/webp' }, { buffer: last, declaredContentType: 'image/webp' }, - ])).rejects.toThrow('高度应为 1500px') + ])).rejects.toThrow('切片高度不一致') + + await expect(validateBrandKnowledgeUploadedPreviewSlices([ + { buffer: shortFirst, declaredContentType: 'image/webp' }, + { buffer: shortFirst, declaredContentType: 'image/webp' }, + ])).rejects.toThrow('切片高度不一致') }) }) diff --git a/lib/brand-knowledge-preview.ts b/lib/brand-knowledge-preview.ts index bcf7f96..e689aaf 100644 --- a/lib/brand-knowledge-preview.ts +++ b/lib/brand-knowledge-preview.ts @@ -7,6 +7,10 @@ import { BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_BYTES, BRAND_KNOWLEDGE_MAX_PREVIEW_SLICE_COUNT, } from '@/lib/brand-knowledge-file-types' +import { + BRAND_KNOWLEDGE_PREVIEW_SINGLE_IMAGE_MAX_HEIGHT, + planBrandKnowledgePreviewSlices, +} from '@/lib/performance-survey-image-source-guard' configureSharpRuntime() @@ -71,11 +75,18 @@ export async function prepareBrandKnowledgePreview(buffer: Buffer, declaredConte } const source = assertBrandKnowledgePreviewMetadata(metadata, declaredContentType) + const outputScale = Math.min(1, BRAND_KNOWLEDGE_PREVIEW_OUTPUT_WIDTH / source.width) + const outputHeight = Math.max(1, Math.round(source.height * outputScale)) + const previewPlan = planBrandKnowledgePreviewSlices( + source.height, + outputHeight, + BRAND_KNOWLEDGE_PREVIEW_SLICE_HEIGHT, + ) let slices: Buffer[] try { slices = await sliceImage( buffer, - BRAND_KNOWLEDGE_PREVIEW_SLICE_HEIGHT, + previewPlan.sliceHeightLimit, BRAND_KNOWLEDGE_PREVIEW_SLICE_MAX_KB, BRAND_KNOWLEDGE_PREVIEW_OUTPUT_WIDTH, { @@ -111,6 +122,10 @@ export async function validateBrandKnowledgeUploadedPreviewSlices( return withImageProcessingSlot(async () => { const slices: Array<{ buffer: Buffer; width: number; height: number }> = [] let expectedWidth = 0 + let expectedFullSliceHeight = 0 + const maxUploadedSliceHeight = uploadedSlices.length === 1 + ? BRAND_KNOWLEDGE_PREVIEW_SINGLE_IMAGE_MAX_HEIGHT + : BRAND_KNOWLEDGE_PREVIEW_SLICE_HEIGHT for (let index = 0; index < uploadedSlices.length; index += 1) { const uploaded = uploadedSlices[index] @@ -126,7 +141,7 @@ export async function validateBrandKnowledgeUploadedPreviewSlices( metadata = await sharp(uploaded.buffer, { animated: true, failOn: 'error', - limitInputPixels: BRAND_KNOWLEDGE_PREVIEW_OUTPUT_WIDTH * BRAND_KNOWLEDGE_PREVIEW_SLICE_HEIGHT, + limitInputPixels: BRAND_KNOWLEDGE_PREVIEW_OUTPUT_WIDTH * maxUploadedSliceHeight, sequentialRead: true, }).metadata() } catch { @@ -139,13 +154,25 @@ export async function validateBrandKnowledgeUploadedPreviewSlices( throw new Error(`预览图第 ${index + 1} 片不是有效的静态 WebP 图片`) } if (width < 1 || width > BRAND_KNOWLEDGE_PREVIEW_OUTPUT_WIDTH - || height < 1 || height > BRAND_KNOWLEDGE_PREVIEW_SLICE_HEIGHT) { + || height < 1 || height > maxUploadedSliceHeight) { throw new Error(`预览图第 ${index + 1} 片尺寸无效`) } - if (index === 0) expectedWidth = width + if (index === 0) { + expectedWidth = width + expectedFullSliceHeight = height + } 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`) + if (uploadedSlices.length > 1) { + const isLast = index === uploadedSlices.length - 1 + const isForcedShortPair = expectedFullSliceHeight <= Math.ceil(BRAND_KNOWLEDGE_PREVIEW_SLICE_HEIGHT / 2) + const hasValidForcedPairGeometry = uploadedSlices.length === 2 + && (!isLast || height === expectedFullSliceHeight || height === expectedFullSliceHeight - 1) + const hasValidStandardGeometry = expectedFullSliceHeight === BRAND_KNOWLEDGE_PREVIEW_SLICE_HEIGHT + && (isLast ? height <= expectedFullSliceHeight : height === expectedFullSliceHeight) + if ((isForcedShortPair && !hasValidForcedPairGeometry) + || (!isForcedShortPair && !hasValidStandardGeometry)) { + throw new Error('预览图切片高度不一致') + } } slices.push({ buffer: uploaded.buffer, width, height }) } diff --git a/lib/performance-survey-image-source-guard.ts b/lib/performance-survey-image-source-guard.ts index 78a2aa7..03c9724 100644 --- a/lib/performance-survey-image-source-guard.ts +++ b/lib/performance-survey-image-source-guard.ts @@ -23,6 +23,31 @@ 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 const BRAND_KNOWLEDGE_PREVIEW_SINGLE_IMAGE_MAX_HEIGHT = 3_000 + +export function shouldSliceBrandKnowledgePreview(sourceHeight: number) { + return sourceHeight > BRAND_KNOWLEDGE_PREVIEW_SINGLE_IMAGE_MAX_HEIGHT +} + +export function planBrandKnowledgePreviewSlices( + sourceHeight: number, + outputHeight: number, + defaultSliceHeight: number, +) { + const safeOutputHeight = Math.max(1, Math.round(outputHeight)) + if (!shouldSliceBrandKnowledgePreview(sourceHeight)) { + return { sliceHeightLimit: safeOutputHeight, sliceCount: 1 } + } + + const naturalSliceCount = Math.ceil(safeOutputHeight / defaultSliceHeight) + const sliceHeightLimit = naturalSliceCount > 1 + ? defaultSliceHeight + : Math.max(1, Math.ceil(safeOutputHeight / 2)) + return { + sliceHeightLimit, + sliceCount: Math.ceil(safeOutputHeight / sliceHeightLimit), + } +} export type PerformanceSurveySourceImageDimensions = { width: number diff --git a/lib/performance-survey-image.worker.ts b/lib/performance-survey-image.worker.ts index 944a69a..28e28da 100644 --- a/lib/performance-survey-image.worker.ts +++ b/lib/performance-survey-image.worker.ts @@ -7,6 +7,8 @@ import { TASK_DETAIL_BROWSER_SOURCE_MAX_PIXELS, BRAND_KNOWLEDGE_BROWSER_SOURCE_MAX_HEIGHT, BRAND_KNOWLEDGE_BROWSER_SOURCE_MAX_PIXELS, + BRAND_KNOWLEDGE_PREVIEW_SINGLE_IMAGE_MAX_HEIGHT, + planBrandKnowledgePreviewSlices, } from './performance-survey-image-source-guard' import { PerformanceSurveyBrowserImageError } from './performance-survey-image-error' @@ -130,9 +132,10 @@ async function renderSlice( scale: number, sliceIndex: number, sliceCount: number, + sliceHeightLimit = SLICE_HEIGHT, ) { - const outputTop = sliceIndex * SLICE_HEIGHT - const sliceHeight = Math.min(SLICE_HEIGHT, outputHeight - outputTop) + const outputTop = sliceIndex * sliceHeightLimit + const sliceHeight = Math.min(sliceHeightLimit, outputHeight - outputTop) const sourceTop = Math.floor(outputTop / scale) const sourceBottom = sliceIndex === sliceCount - 1 ? bitmap.height @@ -194,7 +197,11 @@ async function renderPreview(firstSlice: Blob, sliceWidth: number, sliceHeight: } } -async function processSingleOriginal(file: File, includePreview = true): Promise { +async function processSingleOriginal( + file: File, + includePreview = true, + singleImageMaxSourceHeight?: number, +): Promise { const bitmap = await decodeImage(file) try { if (!bitmap.width || !bitmap.height) { @@ -203,7 +210,12 @@ async function processSingleOriginal(file: File, includePreview = true): Promise const scale = Math.min(1, outputWidthLimit / bitmap.width) const outputWidth = Math.max(1, Math.round(bitmap.width * scale)) const outputHeight = Math.max(1, Math.round(bitmap.height * scale)) - const sliceCount = Math.max(1, Math.ceil(outputHeight / SLICE_HEIGHT)) + const brandKnowledgePlan = singleImageMaxSourceHeight + ? planBrandKnowledgePreviewSlices(bitmap.height, outputHeight, SLICE_HEIGHT) + : null + const sliceHeightLimit = brandKnowledgePlan?.sliceHeightLimit || SLICE_HEIGHT + const sliceCount = brandKnowledgePlan?.sliceCount + || Math.max(1, Math.ceil(outputHeight / sliceHeightLimit)) if (sliceCount > MAX_SLICES_PER_SOURCE) { throw new PerformanceSurveyBrowserImageError( 'SOURCE_SLICE_LIMIT', @@ -214,7 +226,15 @@ async function processSingleOriginal(file: File, includePreview = true): Promise const slices: WorkerProcessedImage[] = [] for (let sliceIndex = 0; sliceIndex < sliceCount; sliceIndex += 1) { - const rendered = await renderSlice(bitmap, outputWidth, outputHeight, scale, sliceIndex, sliceCount) + const rendered = await renderSlice( + bitmap, + outputWidth, + outputHeight, + scale, + sliceIndex, + sliceCount, + sliceHeightLimit, + ) slices.push({ ...rendered, fileName: `${baseName}-${String(sliceIndex + 1).padStart(3, '0')}.webp`, @@ -404,6 +424,9 @@ workerScope.onmessage = async (event: MessageEvent) => { : await processSingleOriginal( files[0], profile !== 'task_detail_detail' && profile !== 'brand_knowledge_preview', + profile === 'brand_knowledge_preview' + ? BRAND_KNOWLEDGE_PREVIEW_SINGLE_IMAGE_MAX_HEIGHT + : undefined, ) : await processOrderedSlices(files) workerScope.postMessage({ id, ok: true, images })