diff --git a/CHANGELOG.md b/CHANGELOG.md index 61f1e7b..373f624 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to this project will be documented in this file. +## Unreleased + +### Added + +- Added client-side texture optimization so oversized valid texture uploads are downscaled and compressed before being embedded in saved material JSON. + ## [2.0.0-beta.1] - 2026-05-22 ### Added diff --git a/docs/backlog/ACTIVE_BACKLOG.md b/docs/backlog/ACTIVE_BACKLOG.md index 2bf9b76..5982c04 100644 --- a/docs/backlog/ACTIVE_BACKLOG.md +++ b/docs/backlog/ACTIVE_BACKLOG.md @@ -25,8 +25,9 @@ Rules: - Material Explorer is a local-first PBR material editor with optional HTTP sync, import/export, command palette workflows, draft history, guardrails, and a tested GitHub Pages deployment. -- Recent work hardened dependency security, texture storage budget feedback, JSON import limits, - mobile layout spacing, bundle budgets, Playwright coverage, and audit gates. +- Recent work hardened dependency security, texture storage budget feedback, texture upload + compression/downscaling, JSON import limits, mobile layout spacing, bundle budgets, Playwright + coverage, and audit gates. - Texture maps still live as embedded data URLs inside saved material JSON; this is reliable enough for small libraries, but not a complete asset-storage strategy. - Backend sync exists as a frontend adapter and mock API contract, not as a production persistence @@ -41,14 +42,13 @@ Rules: ## Active Workboard -| Priority | Area | Item | Status | Validation / Exit Criteria | -| -------- | ------------------- | ------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| P1 | Texture Reliability | Add client-side texture compression and downscaling before storage. | TODO | Oversized-but-valid images can be reduced before save/import; original rejection paths remain covered; e2e proves storage summary updates after compressed upload. | -| P1 | Preview Performance | Deepen 3D preview/runtime code-splitting. | TODO | `vendor-three-core` or total JS budget drops with no preview regressions; bundle budget script and Playwright preview smoke stay green. | -| P1 | Observability | Document telemetry ingestion contract and backend endpoint example. | TODO | `VITE_TELEMETRY_URL` payload shapes, event names, privacy expectations, and a mock/server example are documented and covered by focused tests where practical. | -| P1 | Backend Readiness | Turn optional API sync into a verified backend contract harness. | TODO | Mock API contract tests cover load/save failures, scope/auth headers, malformed payloads, and local fallback behavior from a consumer perspective. | -| P2 | Maintainability | Continue splitting `MaterialEditor` and `Sidebar` into modules. | TODO | A focused extraction reduces file complexity without changing behavior; associated unit/e2e coverage follows the extracted boundaries. | -| P2 | Accessibility & QA | Add focused manual QA notes for texture-heavy and mobile workflows. | TODO | Docs capture keyboard, screen-reader, reduced-motion, and mobile checks for texture upload, save warning, import rejection, and sidebar drawer flows. | +| Priority | Area | Item | Status | Validation / Exit Criteria | +| -------- | ------------------- | ------------------------------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| P1 | Preview Performance | Deepen 3D preview/runtime code-splitting. | TODO | `vendor-three-core` or total JS budget drops with no preview regressions; bundle budget script and Playwright preview smoke stay green. | +| P1 | Observability | Document telemetry ingestion contract and backend endpoint example. | TODO | `VITE_TELEMETRY_URL` payload shapes, event names, privacy expectations, and a mock/server example are documented and covered by focused tests where practical. | +| P1 | Backend Readiness | Turn optional API sync into a verified backend contract harness. | TODO | Mock API contract tests cover load/save failures, scope/auth headers, malformed payloads, and local fallback behavior from a consumer perspective. | +| P2 | Maintainability | Continue splitting `MaterialEditor` and `Sidebar` into modules. | TODO | A focused extraction reduces file complexity without changing behavior; associated unit/e2e coverage follows the extracted boundaries. | +| P2 | Accessibility & QA | Add focused manual QA notes for texture-heavy and mobile workflows. | TODO | Docs capture keyboard, screen-reader, reduced-motion, and mobile checks for texture upload, save warning, import rejection, and sidebar drawer flows. | ## Deferred diff --git a/src/components/MaterialEditor.tsx b/src/components/MaterialEditor.tsx index 48b1d84..1b5892a 100644 --- a/src/components/MaterialEditor.tsx +++ b/src/components/MaterialEditor.tsx @@ -38,7 +38,7 @@ import { resetOpticsSection as applyOpticsSectionReset, resetSurfaceSection as applySurfaceSectionReset, } from './editor/draftSections'; -import { validateTextureDataUrl, validateTextureUploadFile } from './editor/textureUpload'; +import { prepareTextureUpload, validateTextureUploadFile } from './editor/textureUpload'; import { getLocalStorageItem, removeLocalStorageItem, setLocalStorageItem } from '../utils/localStorage'; import { emitTelemetryEvent } from '../utils/telemetry'; import { @@ -445,14 +445,6 @@ const MaterialEditor: React.FC = () => { [setMaterialWithHistory] ); - const readFileAsDataUrl = (file: File) => - new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(String(reader.result)); - reader.onerror = () => reject(reader.error); - reader.readAsDataURL(file); - }); - const uploadMap = async (key: keyof MaterialDraft, file: File) => { const fileValidationMessage = validateTextureUploadFile(file); if (fileValidationMessage) { @@ -472,23 +464,21 @@ const MaterialEditor: React.FC = () => { } try { - const dataUrl = await readFileAsDataUrl(file); - const dataUrlValidationMessage = validateTextureDataUrl(dataUrl); - if (dataUrlValidationMessage) { - notify({ variant: 'warn', title: 'Texture upload blocked', message: dataUrlValidationMessage }); - emitTelemetryEvent( - 'texture.upload.rejected', - { - key, - reason: 'data-url-validation', - fileType: file.type || 'unknown', - fileSize: file.size, - dataUrlLength: dataUrl.length, - message: dataUrlValidationMessage, - }, - 'warn' - ); - return; + const preparedTexture = await prepareTextureUpload(file); + const dataUrl = preparedTexture.dataUrl; + if (preparedTexture.compressed) { + const dimensions = + preparedTexture.originalWidth && + preparedTexture.originalHeight && + preparedTexture.width && + preparedTexture.height + ? ` Downscaled from ${preparedTexture.originalWidth}x${preparedTexture.originalHeight} to ${preparedTexture.width}x${preparedTexture.height}.` + : ''; + notify({ + variant: 'info', + title: 'Texture optimized', + message: `Prepared embedded texture at ${formatEncodedSize(dataUrl.length)}.${dimensions}`, + }); } setMaterialWithHistory((prev) => { @@ -502,14 +492,40 @@ const MaterialEditor: React.FC = () => { fileType: file.type || 'unknown', fileSize: file.size, dataUrlLength: dataUrl.length, + compressed: preparedTexture.compressed, + outputType: preparedTexture.outputType, + outputBytes: preparedTexture.outputBytes, + originalWidth: preparedTexture.originalWidth, + originalHeight: preparedTexture.originalHeight, + width: preparedTexture.width, + height: preparedTexture.height, }, 'info' ); } catch (error) { + const message = + error instanceof Error && error.message ? error.message : 'Could not read this file. Try a different image.'; + + if (message.includes('too large') || message.includes('storage')) { + notify({ variant: 'warn', title: 'Texture upload blocked', message }); + emitTelemetryEvent( + 'texture.upload.rejected', + { + key, + reason: 'data-url-validation', + fileType: file.type || 'unknown', + fileSize: file.size, + message, + }, + 'warn' + ); + return; + } + notify({ variant: 'error', title: 'Texture upload failed', - message: 'Could not read this file. Try a different image.', + message, }); emitTelemetryEvent( 'texture.upload.failed', diff --git a/src/components/editor/TextureControls.tsx b/src/components/editor/TextureControls.tsx index 6fef753..1723601 100644 --- a/src/components/editor/TextureControls.tsx +++ b/src/components/editor/TextureControls.tsx @@ -57,7 +57,7 @@ export function TextureControls({
Textures
Maps, tiling, and advanced material detail.
- Upload images only. Recommended max size: 4 MB per map. + Upload images only. Large maps are optimized before embedding; max source size is 16 MB.
Draft storage: {textureSummaryText} Embedded textures count against browser storage. diff --git a/src/components/editor/textureUpload.test.ts b/src/components/editor/textureUpload.test.ts index a820419..255137f 100644 --- a/src/components/editor/textureUpload.test.ts +++ b/src/components/editor/textureUpload.test.ts @@ -1,5 +1,35 @@ -import { describe, expect, it } from 'vitest'; -import { validateTextureDataUrl, validateTextureUploadFile } from './textureUpload'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { prepareTextureUpload, validateTextureDataUrl, validateTextureUploadFile } from './textureUpload'; + +const fileReaderInputs: Blob[] = []; + +class MockFileReader { + result: string | ArrayBuffer | null = null; + error: Error | null = null; + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + + readAsDataURL(blob: Blob) { + fileReaderInputs.push(blob); + blob + .arrayBuffer() + .then((buffer) => { + this.result = `data:${blob.type || 'application/octet-stream'};base64,${Buffer.from(buffer).toString( + 'base64' + )}`; + this.onload?.(); + }) + .catch((error: Error) => { + this.error = error; + this.onerror?.(); + }); + } +} + +afterEach(() => { + vi.unstubAllGlobals(); + fileReaderInputs.length = 0; +}); describe('textureUpload', () => { it('accepts supported image files under size limits', () => { @@ -12,13 +42,57 @@ describe('textureUpload', () => { expect(validateTextureUploadFile(file)).toContain('Only image files'); }); - it('rejects oversized files', () => { - const file = { type: 'image/png', size: 4 * 1024 * 1024 + 1 } as File; - expect(validateTextureUploadFile(file)).toContain('Maximum supported size is 4 MB'); + it('allows oversized-but-compressible images under the hard source cap', () => { + const file = { type: 'image/jpeg', size: 4 * 1024 * 1024 + 1 } as File; + expect(validateTextureUploadFile(file)).toBeNull(); + }); + + it('rejects files beyond the hard source cap', () => { + const file = { type: 'image/png', size: 16 * 1024 * 1024 + 1 } as File; + expect(validateTextureUploadFile(file)).toContain('Maximum supported size is 16 MB'); }); it('rejects oversized encoded data URLs', () => { const dataUrl = `data:image/png;base64,${'a'.repeat(2_500_001)}`; expect(validateTextureDataUrl(dataUrl)).toContain('too large after encoding'); }); + + it('downscales and compresses large valid image files before embedding', async () => { + const close = vi.fn(); + vi.stubGlobal('FileReader', MockFileReader); + vi.stubGlobal('createImageBitmap', vi.fn().mockResolvedValue({ width: 4096, height: 2048, close })); + vi.stubGlobal('document', { + createElement: vi.fn(() => ({ + width: 0, + height: 0, + getContext: vi.fn(() => ({ drawImage: vi.fn() })), + toBlob: vi.fn((callback: BlobCallback, type: string) => { + callback(new Blob(['compressed-texture'], { type })); + }), + })), + }); + + const file = new File([new Uint8Array(5 * 1024 * 1024)], 'large-texture.jpg', { type: 'image/jpeg' }); + + const result = await prepareTextureUpload(file); + + expect(result.compressed).toBe(true); + expect(result.originalWidth).toBe(4096); + expect(result.originalHeight).toBe(2048); + expect(result.width).toBe(2048); + expect(result.height).toBe(1024); + expect(result.outputType).toBe('image/jpeg'); + expect(result.dataUrl).toContain('data:image/jpeg;base64,'); + expect(fileReaderInputs).toHaveLength(1); + expect(fileReaderInputs[0]?.size).toBe('compressed-texture'.length); + expect(close).toHaveBeenCalled(); + }); + + it('rejects images that still exceed the data URL budget after optimization fails', async () => { + vi.stubGlobal('FileReader', MockFileReader); + vi.stubGlobal('createImageBitmap', vi.fn().mockRejectedValue(new Error('decode failed'))); + const file = new File([new Uint8Array(4 * 1024 * 1024)], 'broken-texture.png', { type: 'image/png' }); + + await expect(prepareTextureUpload(file)).rejects.toThrow('too large after encoding'); + }); }); diff --git a/src/components/editor/textureUpload.ts b/src/components/editor/textureUpload.ts index 54d0de9..481e508 100644 --- a/src/components/editor/textureUpload.ts +++ b/src/components/editor/textureUpload.ts @@ -1,14 +1,39 @@ import { MAX_TEXTURE_DATA_URL_CHARS } from '../../utils/materialStorageBudget'; -const MAX_TEXTURE_FILE_BYTES = 4 * 1024 * 1024; // 4 MB +const MAX_TEXTURE_SOURCE_FILE_BYTES = 16 * 1024 * 1024; // 16 MB +const MAX_TEXTURE_DIMENSION = 2048; +const COMPRESSED_TEXTURE_QUALITY = 0.82; + +type ImageBitmapLike = CanvasImageSource & { + width: number; + height: number; + close?: () => void; +}; + +export type PreparedTextureUpload = { + dataUrl: string; + compressed: boolean; + originalBytes: number; + outputBytes: number; + originalType: string; + outputType: string; + originalWidth?: number; + originalHeight?: number; + width?: number; + height?: number; +}; + +type TextureCandidate = PreparedTextureUpload & { + priority: number; +}; export function validateTextureUploadFile(file: File): string | null { const type = file.type?.trim(); if (type && !type.startsWith('image/')) { return 'Only image files can be used as texture maps.'; } - if (file.size > MAX_TEXTURE_FILE_BYTES) { - return 'Texture file is too large. Maximum supported size is 4 MB.'; + if (file.size > MAX_TEXTURE_SOURCE_FILE_BYTES) { + return 'Texture source file is too large. Maximum supported size is 16 MB.'; } return null; } @@ -19,3 +44,138 @@ export function validateTextureDataUrl(dataUrl: string): string | null { } return null; } + +export async function prepareTextureUpload(file: File): Promise { + const optimizedCandidate = await optimizeTextureFile(file).catch(() => null); + if (optimizedCandidate && validateTextureDataUrl(optimizedCandidate.dataUrl) === null) { + const { priority: _priority, ...result } = optimizedCandidate; + return result; + } + + const originalCandidate = await createOriginalTextureCandidate(file); + const candidates = [optimizedCandidate, originalCandidate] + .filter((candidate): candidate is TextureCandidate => candidate !== null) + .sort((a, b) => a.priority - b.priority || a.dataUrl.length - b.dataUrl.length); + + const accepted = candidates.find((candidate) => validateTextureDataUrl(candidate.dataUrl) === null); + if (accepted) { + const { priority: _priority, ...result } = accepted; + return result; + } + + throw new Error( + validateTextureDataUrl(candidates[0]?.dataUrl ?? originalCandidate.dataUrl) ?? 'Texture could not be prepared.' + ); +} + +async function createOriginalTextureCandidate(file: File): Promise { + const dataUrl = await blobToDataUrl(file); + return { + dataUrl, + compressed: false, + originalBytes: file.size, + outputBytes: file.size, + originalType: file.type || 'application/octet-stream', + outputType: file.type || 'application/octet-stream', + priority: 2, + }; +} + +async function optimizeTextureFile(file: File): Promise { + if (typeof document === 'undefined') return null; + + const bitmap = await loadTextureBitmap(file); + try { + const target = getTargetDimensions(bitmap.width, bitmap.height); + const outputType = getCanvasOutputType(file.type); + const canvas = document.createElement('canvas'); + canvas.width = target.width; + canvas.height = target.height; + + const context = canvas.getContext('2d'); + if (!context) return null; + context.drawImage(bitmap, 0, 0, target.width, target.height); + + const blob = await canvasToBlob( + canvas, + outputType, + outputType === 'image/png' ? undefined : COMPRESSED_TEXTURE_QUALITY + ); + if (!blob) return null; + + const dataUrl = await blobToDataUrl(blob); + const dimensionsChanged = target.width !== bitmap.width || target.height !== bitmap.height; + const isSmaller = blob.size < file.size; + if (!dimensionsChanged && !isSmaller) return null; + + return { + dataUrl, + compressed: true, + originalBytes: file.size, + outputBytes: blob.size, + originalType: file.type || 'application/octet-stream', + outputType: blob.type || outputType, + originalWidth: bitmap.width, + originalHeight: bitmap.height, + width: target.width, + height: target.height, + priority: dimensionsChanged ? 0 : 1, + }; + } finally { + bitmap.close?.(); + } +} + +function getTargetDimensions(width: number, height: number): { width: number; height: number } { + const maxDimension = Math.max(width, height); + if (maxDimension <= MAX_TEXTURE_DIMENSION) return { width, height }; + const scale = MAX_TEXTURE_DIMENSION / maxDimension; + return { + width: Math.max(1, Math.round(width * scale)), + height: Math.max(1, Math.round(height * scale)), + }; +} + +function getCanvasOutputType(inputType: string): string { + if (inputType === 'image/jpeg' || inputType === 'image/webp') return inputType; + return 'image/png'; +} + +async function loadTextureBitmap(file: File): Promise { + if (typeof createImageBitmap === 'function') { + return createImageBitmap(file); + } + + return new Promise((resolve, reject) => { + const image = new Image(); + const objectUrl = URL.createObjectURL(file); + image.onload = () => { + URL.revokeObjectURL(objectUrl); + resolve(image); + }; + image.onerror = () => { + URL.revokeObjectURL(objectUrl); + reject(new Error('Unable to decode image')); + }; + image.src = objectUrl; + }); +} + +async function canvasToBlob(canvas: HTMLCanvasElement, type: string, quality?: number): Promise { + return new Promise((resolve) => { + canvas.toBlob((blob) => resolve(blob), type, quality); + }); +} + +async function blobToDataUrl(blob: Blob): Promise { + if (typeof FileReader === 'undefined') { + throw new Error('FileReader is unavailable'); + } + + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result)); + reader.onerror = () => reject(reader.error ?? new Error('Failed to read image')); + reader.readAsDataURL(blob); + }); +} diff --git a/tests/e2e/smoke.spec.ts b/tests/e2e/smoke.spec.ts index 6d7fecd..b9d8d41 100644 --- a/tests/e2e/smoke.spec.ts +++ b/tests/e2e/smoke.spec.ts @@ -1,6 +1,8 @@ import { test, expect, type Page } from '@playwright/test'; +import { deflateSync } from 'node:zlib'; type StoredMaterial = { name?: string; tags?: string[] }; +const PNG_CRC_TABLE = createCrcTable(); function makeSeedMaterial(id: string, name: string) { const now = Date.now(); @@ -22,6 +24,66 @@ function makeSeedMaterial(id: string, name: string) { }; } +function createCrcTable() { + const table = new Uint32Array(256); + for (let index = 0; index < table.length; index += 1) { + let crc = index; + for (let bit = 0; bit < 8; bit += 1) { + crc = crc & 1 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1; + } + table[index] = crc >>> 0; + } + return table; +} + +function crc32(buffer: Buffer): number { + let crc = 0xffffffff; + for (let index = 0; index < buffer.length; index += 1) { + const byte = buffer[index]; + crc = PNG_CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8); + } + return (crc ^ 0xffffffff) >>> 0; +} + +function pngChunk(type: string, data: Buffer): Buffer { + const typeBuffer = Buffer.from(type, 'ascii'); + const chunk = Buffer.alloc(12 + data.length); + chunk.writeUInt32BE(data.length, 0); + typeBuffer.copy(chunk, 4); + data.copy(chunk, 8); + chunk.writeUInt32BE(crc32(Buffer.concat([typeBuffer, data])), 8 + data.length); + return chunk; +} + +function createSolidPng(width: number, height: number): Buffer { + const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(width, 0); + ihdr.writeUInt32BE(height, 4); + ihdr[8] = 8; + ihdr[9] = 6; + + const row = Buffer.alloc(width * 4 + 1); + for (let offset = 1; offset < row.length; offset += 4) { + row[offset] = 70; + row[offset + 1] = 120; + row[offset + 2] = 180; + row[offset + 3] = 255; + } + + const raw = Buffer.alloc(row.length * height); + for (let y = 0; y < height; y += 1) { + row.copy(raw, y * row.length); + } + + return Buffer.concat([ + signature, + pngChunk('IHDR', ihdr), + pngChunk('IDAT', deflateSync(raw, { level: 9 })), + pngChunk('IEND', Buffer.alloc(0)), + ]); +} + async function clearMaterials(page: Page) { await page.goto('/'); await page.evaluate(() => { @@ -358,16 +420,16 @@ test('rejects non-image texture uploads with clear feedback', async ({ page }) = await expect(page.getByText('Only image files can be used as texture maps.')).toBeVisible(); }); -test('rejects oversized texture uploads with clear feedback', async ({ page }) => { +test('rejects texture uploads beyond the hard source cap with clear feedback', async ({ page }) => { const textureInput = page.locator('input[type="file"][accept="image/*"]').first(); await textureInput.setInputFiles({ name: 'oversized-texture.png', mimeType: 'image/png', - buffer: Buffer.alloc(4 * 1024 * 1024 + 1, 7), + buffer: Buffer.alloc(16 * 1024 * 1024 + 1, 7), }); await expect(page.getByText('Texture upload blocked', { exact: true })).toBeVisible(); - await expect(page.getByText('Texture file is too large. Maximum supported size is 4 MB.')).toBeVisible(); + await expect(page.getByText('Texture source file is too large. Maximum supported size is 16 MB.')).toBeVisible(); }); test('shows embedded texture storage after upload', async ({ page }) => { @@ -387,6 +449,21 @@ test('shows embedded texture storage after upload', async ({ page }) => { await expect(page.getByTestId('texture-storage-summary')).toContainText('embedded across 1 map'); }); +test('downscales oversized valid texture uploads before storage', async ({ page }) => { + const largePng = createSolidPng(3072, 1536); + const textureInput = page.locator('input[type="file"][accept="image/*"]').first(); + + await textureInput.setInputFiles({ + name: 'large-valid-texture.png', + mimeType: 'image/png', + buffer: largePng, + }); + + await expect(page.getByText('Texture optimized', { exact: true })).toBeVisible(); + await expect(page.getByText(/Downscaled from 3072x1536 to 2048x1024/)).toBeVisible(); + await expect(page.getByTestId('texture-storage-summary')).toContainText('embedded across 1 map'); +}); + test('can export materials as JSON', async ({ page }) => { await page.goto('/'); await page.evaluate(