Files
shop-server/.opencode/plans/2026-05-15-image-processing-refactor.md
T
2026-05-19 11:25:23 +05:00

544 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Image Processing Refactor Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Refactor image processing to use eager generation for admin product images and improve error messages for user uploads.
**Architecture:** Add eager processing functions to `image-resize.js`, integrate into `upload-images.js` via `eager` flag, update client-side validation and error handling.
**Tech Stack:** Node.js, Fastify, sharp, React, TypeScript, MUI
---
### Task 1: Add eager processing functions to image-resize.js
**Files:**
- Modify: `server/src/lib/image-resize.js`
- Test: `server/src/lib/__tests__/image-resize.test.js`
- [ ] **Step 1: Write failing tests for new functions**
Add to `server/src/lib/__tests__/image-resize.test.js`:
```javascript
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import fs from 'node:fs'
import path from 'node:path'
import { generateAllSizes, convertOriginalToWebp, findOriginalFile } from '../image-resize.js'
const TEST_UPLOADS_DIR = path.join(process.cwd(), 'uploads-test-eager')
const TEST_CACHE_DIR = path.join(TEST_UPLOADS_DIR, '.cache')
describe('eager image processing', () => {
beforeEach(async () => {
await fs.promises.mkdir(TEST_UPLOADS_DIR, { recursive: true })
})
afterEach(async () => {
await fs.promises.rm(TEST_UPLOADS_DIR, { recursive: true, force: true })
})
it('generateAllSizes creates all width+format combinations', async () => {
// Create a test PNG image using sharp
const sharp = (await import('sharp')).default
const testImagePath = path.join(TEST_UPLOADS_DIR, 'test-uuid.png')
await sharp({ create: { width: 2000, height: 1500, channels: 3, background: { r: 255, g: 0, b: 0 } } })
.png()
.toFile(testImagePath)
await generateAllSizes('test-uuid', '', testImagePath)
// Check all cache files exist
for (const width of [320, 640, 1024, 1600]) {
for (const format of ['avif', 'webp']) {
const cachePath = path.join(TEST_CACHE_DIR, `test-uuid_w${width}.${format}`)
const exists = await fs.promises.access(cachePath).then(() => true).catch(() => false)
expect(exists).toBe(true)
}
}
})
it('convertOriginalToWebp converts and deletes original', async () => {
const sharp = (await import('sharp')).default
const testImagePath = path.join(TEST_UPLOADS_DIR, 'test-uuid2.png')
await sharp({ create: { width: 800, height: 600, channels: 3, background: { r: 0, g: 255, b: 0 } } })
.png()
.toFile(testImagePath)
const result = await convertOriginalToWebp('test-uuid2', '')
expect(result).toBe('/uploads/test-uuid2.webp')
const pngExists = await fs.promises.access(testImagePath).then(() => true).catch(() => false)
expect(pngExists).toBe(false)
const webpPath = path.join(TEST_UPLOADS_DIR, 'test-uuid2.webp')
const webpExists = await fs.promises.access(webpPath).then(() => true).catch(() => false)
expect(webpExists).toBe(true)
})
})
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd server && npm test -- --run image-resize.test.js`
Expected: FAIL — `generateAllSizes` and `convertOriginalToWebp` are not defined
- [ ] **Step 3: Implement generateAllSizes and convertOriginalToWebp**
Add to `server/src/lib/image-resize.js` before the final `export` line:
```javascript
/**
* Generate all resize widths in AVIF + WebP for eager processing.
* @param {string} uuid - UUID without extension
* @param {string} subdir - Subdirectory (e.g., 'reviews') or empty
* @param {string} originalPath - Full path to the original file
*/
export async function generateAllSizes(uuid, subdir, originalPath) {
const cacheSubdir = subdir ? subdir : ''
const cacheDir = path.join(CACHE_DIR, cacheSubdir)
await fs.promises.mkdir(cacheDir, { recursive: true })
const sharp = (await import('sharp')).default
for (const width of VALID_WIDTHS) {
for (const format of SUPPORTED_FORMATS) {
const cacheFileName = `${uuid}_w${width}.${format}`
const cachePath = path.join(CACHE_DIR, cacheSubdir, cacheFileName)
const pipeline = sharp(originalPath).resize(width, null, { withoutEnlargement: true })
const options = format === 'avif' ? { quality: 75, effort: 4 } : { quality: 80 }
await pipeline[format](options).toFile(cachePath)
}
}
}
/**
* Convert original file to WebP and delete the source file.
* @param {string} uuid - UUID without extension
* @param {string} subdir - Subdirectory (e.g., 'reviews') or empty
* @returns {string} New URL path like `/uploads/<uuid>.webp`
*/
export async function convertOriginalToWebp(uuid, subdir) {
const uploadsDir = path.join(process.cwd(), 'uploads')
const targetDir = subdir ? path.join(uploadsDir, subdir) : uploadsDir
// Find original file
const originalPath = await findOriginalFile(uuid, subdir)
if (!originalPath) {
throw new Error(`Original file not found for UUID: ${uuid}`)
}
const originalExt = path.extname(originalPath).toLowerCase()
const webpPath = path.join(targetDir, `${uuid}.webp`)
// Convert to WebP
const sharp = (await import('sharp')).default
await sharp(originalPath).webp({ quality: 80 }).toFile(webpPath)
// Delete original if it's not already WebP
if (originalExt !== '.webp') {
await fs.promises.unlink(originalPath)
}
return subdir ? `/uploads/${subdir}/${uuid}.webp` : `/uploads/${uuid}.webp`
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd server && npm test -- --run image-resize.test.js`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add server/src/lib/image-resize.js server/src/lib/__tests__/image-resize.test.js
git commit -m "feat: add eager image processing functions (generateAllSizes, convertOriginalToWebp)"
```
---
### Task 2: Integrate eager processing into upload-images.js
**Files:**
- Modify: `server/src/lib/upload-images.js`
- Test: `server/src/lib/__tests__/upload-images.test.js` (create if not exists)
- [ ] **Step 1: Write failing test for eager mode**
Create `server/src/lib/__tests__/upload-images.test.js`:
```javascript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import fs from 'node:fs'
import path from 'node:path'
import { persistMultipartImages, uploadError } from '../upload-images.js'
const TEST_UPLOADS_DIR = path.join(process.cwd(), 'uploads-test-persist')
describe('persistMultipartImages with eager mode', () => {
beforeEach(async () => {
await fs.promises.mkdir(TEST_UPLOADS_DIR, { recursive: true })
})
afterEach(async () => {
await fs.promises.rm(TEST_UPLOADS_DIR, { recursive: true, force: true })
})
it('returns WebP URLs when eager=true', async () => {
// This test verifies the function signature accepts eager parameter
// Full integration test requires mocking multipart request
// For now, test that the function doesn't throw with eager option
const mockRequest = {
isMultipart: () => true,
parts: async function* () {
// Mock part with a small PNG buffer
const pngHeader = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature
...new Array(100).fill(0), // dummy data
])
yield {
file: true,
filename: 'test.png',
toBuffer: async () => pngHeader,
}
},
}
// Should not throw with eager option
try {
await persistMultipartImages(mockRequest, {
maxFiles: 1,
maxFileBytes: 20 * 1024 * 1024,
subdir: '',
eager: true,
})
} catch (err) {
// If sharp is not available or PNG is invalid, that's expected in unit test
// The key is that the function accepts the eager parameter
expect(err.message).not.toContain('eager')
}
})
})
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd server && npm test -- --run upload-images.test.js`
Expected: FAIL — `eager` parameter is not handled
- [ ] **Step 3: Modify persistMultipartImages to support eager mode**
Replace the `persistMultipartImages` function in `server/src/lib/upload-images.js`:
```javascript
export async function persistMultipartImages(request, { maxFiles = 10, maxFileBytes, subdir = '', eager = false }) {
if (!request.isMultipart()) {
throw uploadError('Ожидается multipart/form-data')
}
const uploadsDir = path.join(process.cwd(), 'uploads')
const targetDir = subdir ? path.join(uploadsDir, subdir) : uploadsDir
await fs.promises.mkdir(targetDir, { recursive: true })
const urls = []
const parts = request.parts({
limits: {
fileSize: maxFileBytes,
files: maxFiles,
},
})
for await (const part of parts) {
if (!part.file) continue
if (urls.length >= maxFiles) {
throw uploadError(`Можно загрузить не более ${maxFiles} файл(ов)`)
}
const ext = safeImageExt(part.filename)
if (!ext) {
throw uploadError('Разрешены только файлы: png, jpg, jpeg, webp')
}
const uuid = crypto.randomUUID()
const fileName = `${uuid}${ext}`
const fullPath = path.join(targetDir, fileName)
await fs.promises.writeFile(fullPath, await part.toBuffer())
let finalUrl = subdir ? `/uploads/${subdir}/${fileName}` : `/uploads/${fileName}`
if (eager) {
const { generateAllSizes, convertOriginalToWebp } = await import('./image-resize.js')
await generateAllSizes(uuid, subdir, fullPath)
finalUrl = await convertOriginalToWebp(uuid, subdir)
}
urls.push(finalUrl)
}
if (urls.length === 0) {
throw uploadError(
'Файлы не получены. Проверьте, что запрос multipart/form-data и поля — файлы изображений (png, jpg, webp).',
)
}
return urls
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cd server && npm test -- --run upload-images.test.js`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add server/src/lib/upload-images.js server/src/lib/__tests__/upload-images.test.js
git commit -m "feat: add eager mode to persistMultipartImages"
```
---
### Task 3: Enable eager mode in admin upload route
**Files:**
- Modify: `server/src/routes/api/admin-products.js`
- [ ] **Step 1: Update admin upload route to use eager mode**
Modify the `POST /api/admin/uploads` route in `server/src/routes/api/admin-products.js`:
```javascript
fastify.post(
'/api/admin/uploads',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
try {
const urls = await persistMultipartImages(request, {
maxFiles: 10,
maxFileBytes: getProductImageMaxFileBytes(),
eager: true,
})
await upsertGalleryImagesByUrls(urls)
return { urls }
} catch (error) {
let message = error instanceof Error ? error.message : 'Не удалось загрузить файлы'
let statusCode =
error && typeof error === 'object' && 'statusCode' in error && Number.isInteger(error.statusCode)
? Number(error.statusCode)
: 400
if (isMultipartFileTooLargeError(error)) {
message = formatFileTooLargeMessage(getProductImageMaxFileBytes())
statusCode = 413
}
return reply.code(statusCode).send({ error: message })
}
},
)
```
- [ ] **Step 2: Commit**
```bash
git add server/src/routes/api/admin-products.js
git commit -m "feat: enable eager image processing for admin uploads"
```
---
### Task 4: Improve user upload error messages
**Files:**
- Modify: `client/src/entities/product/api/reviews-api.ts`
- Modify: `client/src/shared/constants/upload-limits.ts`
- Modify: `client/src/features/product-review/ui/ReviewDialog.tsx`
- [ ] **Step 1: Add client-side size validation for review images**
Add to `client/src/shared/constants/upload-limits.ts`:
```typescript
export const OTHER_UPLOAD_MAX_FILE_BYTES = 2 * 1024 * 1024 // 2 MB
export function formatOtherUploadMaxSizeHint(): string {
return `${Math.round(OTHER_UPLOAD_MAX_FILE_BYTES / (1024 * 1024))} МБ`
}
```
- [ ] **Step 2: Add pre-upload size check in reviews-api.ts**
Modify `uploadReviewImage` in `client/src/entities/product/api/reviews-api.ts`:
```typescript
import { OTHER_UPLOAD_MAX_FILE_BYTES, formatOtherUploadMaxSizeHint } from '@/shared/constants/upload-limits'
export async function uploadReviewImage(file: File): Promise<{ url: string }> {
if (file.size > OTHER_UPLOAD_MAX_FILE_BYTES) {
throw new Error(
`Файл «${file.name}» слишком большой (максимум ${formatOtherUploadMaxSizeHint()}).`,
)
}
const fd = new FormData()
fd.append('file', file, file.name)
const { data } = await apiClient.post<{ url: string }>('reviews/upload-image', fd)
return data
}
```
- [ ] **Step 3: Update ReviewDialog to show user-friendly error message**
Modify the uploadError display in `client/src/features/product-review/ui/ReviewDialog.tsx`:
Replace:
```tsx
{uploadError ? (
<Alert severity="error" sx={{ mt: 2 }}>
Не удалось загрузить фото. Разрешены png, jpg, jpeg, webp.
</Alert>
) : null}
```
With:
```tsx
{uploadError ? (
<Alert severity="error" sx={{ mt: 2 }}>
{uploadError instanceof Error ? uploadError.message : 'Не удалось загрузить фото. Разрешены png, jpg, jpeg, webp.'}
</Alert>
) : null}
```
- [ ] **Step 4: Commit**
```bash
git add client/src/shared/constants/upload-limits.ts client/src/entities/product/api/reviews-api.ts client/src/features/product-review/ui/ReviewDialog.tsx
git commit -m "feat: improve error messages for user upload size validation"
```
---
### Task 5: Update OptimizedImage for WebP originals
**Files:**
- Modify: `client/src/shared/ui/OptimizedImage.tsx`
- Test: `client/src/shared/ui/__tests__/OptimizedImage.test.tsx`
- [ ] **Step 1: Update parseUploadUrl to handle .webp originals**
Modify `parseUploadUrl` in `client/src/shared/ui/OptimizedImage.tsx`:
```typescript
function parseUploadUrl(src: string): { uuid: string; ext: string; subdir: string } | null {
const match = src.match(/^\/uploads(?:\/(reviews))?\/([^.\\/]+)\.(png|jpe?g|webp)/i)
if (!match) return null
return { subdir: match[1] || '', uuid: match[2], ext: match[3].toLowerCase() }
}
```
- [ ] **Step 2: Update buildSrcSet to use cached AVIF/WebP directly**
Modify `buildSrcSet` and `buildFallbackSrc`:
```typescript
function buildSrcSet(src: string, widths: number[]): string | null {
const parsed = parseUploadUrl(src)
if (!parsed) return null
const pathPrefix = parsed.subdir ? `${parsed.subdir}/` : ''
return widths.map((w) => `/uploads-resized/${pathPrefix}${parsed.uuid}.avif?w=${w} ${w}w`).join(', ')
}
function buildFallbackSrc(src: string, width: number): string {
const parsed = parseUploadUrl(src)
if (!parsed) return src
const pathPrefix = parsed.subdir ? `${parsed.subdir}/` : ''
return `/uploads-resized/${pathPrefix}${parsed.uuid}.webp?w=${width}`
}
```
- [ ] **Step 3: Add original WebP URL getter for full-screen mode**
Add to `client/src/shared/ui/OptimizedImage.tsx`:
```typescript
/** Get the original WebP URL for full-screen display (no resize) */
export function getOriginalWebpUrl(src: string): string {
const parsed = parseUploadUrl(src)
if (!parsed) return src
const pathPrefix = parsed.subdir ? `${parsed.subdir}/` : ''
return `/uploads/${pathPrefix}${parsed.uuid}.webp`
}
```
- [ ] **Step 4: Commit**
```bash
git add client/src/shared/ui/OptimizedImage.tsx
git commit -m "feat: update OptimizedImage for WebP originals and add getOriginalWebpUrl"
```
---
### Task 6: Update ProductPage full-screen viewer
**Files:**
- Modify: `client/src/pages/product/ui/ProductPage.tsx`
- [ ] **Step 1: Find full-screen image viewer code**
Search for the full-screen image viewer in ProductPage.tsx. Look for where the original image URL is used.
- [ ] **Step 2: Use getOriginalWebpUrl for full-screen display**
Import and use `getOriginalWebpUrl`:
```typescript
import { getOriginalWebpUrl } from '@/shared/ui/OptimizedImage'
```
Replace the full-screen `<img>` src with:
```typescript
getOriginalWebpUrl(imageUrl)
```
- [ ] **Step 3: Commit**
```bash
git add client/src/pages/product/ui/ProductPage.tsx
git commit -m "feat: use WebP original for full-screen product image viewer"
```
---
### Task 7: Run full test suite and lint
- [ ] **Step 1: Run server tests**
```bash
cd server && npm test
```
- [ ] **Step 2: Run client lint and format check**
```bash
cd client && npm run lint && npm run format:check
```
- [ ] **Step 3: Run client tests**
```bash
cd client && npm test
```
- [ ] **Step 4: Run client build**
```bash
cd client && npm run build
```
- [ ] **Step 5: Commit any fixes**
```bash
git add .
git commit -m "fix: address lint and test issues"
```