Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 10 additions & 10 deletions docs/backlog/ACTIVE_BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
70 changes: 43 additions & 27 deletions src/components/MaterialEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -445,14 +445,6 @@ const MaterialEditor: React.FC = () => {
[setMaterialWithHistory]
);

const readFileAsDataUrl = (file: File) =>
new Promise<string>((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) {
Expand All @@ -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) => {
Expand All @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion src/components/editor/TextureControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export function TextureControls({
<div className="text-sm font-semibold text-slate-100">Textures</div>
<div className="text-xs ui-muted">Maps, tiling, and advanced material detail.</div>
<div className="mt-1 text-[11px] text-slate-300/75">
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.
</div>
<div className="mt-1 text-[11px] text-slate-300/75" data-testid="texture-storage-summary">
Draft storage: {textureSummaryText} Embedded textures count against browser storage.
Expand Down
84 changes: 79 additions & 5 deletions src/components/editor/textureUpload.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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');
});
});
Loading