Phases 28-31 archived to milestones/v2.2-phases/ Requirements and roadmap snapshots archived to milestones/ Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
252 lines
9.4 KiB
Markdown
252 lines
9.4 KiB
Markdown
# Phase 29: Image Presentation - Research
|
|
|
|
**Researched:** 2026-04-12
|
|
**Status:** Complete
|
|
|
|
## 1. Current Image Architecture
|
|
|
|
### Display Pattern
|
|
Every image surface uses the same CSS pattern:
|
|
```
|
|
<div class="aspect-[4/3] overflow-hidden">
|
|
<img class="w-full h-full object-cover" />
|
|
</div>
|
|
```
|
|
|
|
15 total `object-cover` usages across the client (excluding the user avatar which uses `rounded-full`):
|
|
- **Cards:** ItemCard, GlobalItemCard, CandidateCard, CandidateListItem
|
|
- **Detail pages:** items/$itemId, global-items/$globalItemId, threads/$threadId/candidates/$candidateId
|
|
- **Overlays/search:** CatalogSearchOverlay (2 instances), ComparisonTable, LinkToGlobalItem
|
|
- **Upload preview:** ImageUpload
|
|
- **Global items index:** global-items/index.tsx
|
|
|
|
### Upload Pipeline
|
|
1. Client: `ImageUpload.tsx` → file validation → `apiUpload("/api/images", file)`
|
|
2. Server: `routes/images.ts` → generates UUID filename → `uploadImage(buffer, filename, contentType)` via `storage.service.ts`
|
|
3. Storage: S3-compatible (Garage/R2/MinIO) via `@aws-sdk/client-s3`
|
|
4. Retrieval: `getImageUrl()` returns presigned URLs; `withImageUrl()`/`withImageUrls()` enriches records
|
|
|
|
**No image processing exists today** — images are uploaded raw and served as-is. No Sharp, no node-vibrant, no server-side manipulation.
|
|
|
|
### Database Schema (PostgreSQL via Drizzle)
|
|
- `items.imageFilename` — text, nullable
|
|
- `items.imageSourceUrl` — text, nullable
|
|
- `globalItems.imageUrl` — text, nullable (external URL)
|
|
- `globalItems.imageSourceUrl` — text, nullable
|
|
- `threadCandidates.imageFilename` — text, nullable
|
|
- `threadCandidates.imageSourceUrl` — text, nullable
|
|
|
|
No fields for dominant color or crop positioning exist today.
|
|
|
|
## 2. Dominant Color Extraction
|
|
|
|
### Recommended: Sharp
|
|
- Already the de facto standard for Bun/Node image processing
|
|
- `sharp(buffer).stats()` returns per-channel mean/dominant values
|
|
- Can extract dominant color via `sharp(buffer).resize(1,1).raw().toBuffer()` (resize to 1x1 pixel = weighted average)
|
|
- Alternative: use `sharp(buffer).stats()` to get channel means, convert to hex
|
|
- Lightweight — no additional binary deps beyond what Sharp bundles
|
|
- Bun compatibility: Sharp works via Node-API
|
|
|
|
### Alternative: node-vibrant / color-thief-node
|
|
- Heavier, purpose-built for palette extraction
|
|
- Returns multiple palette swatches (Vibrant, Muted, DarkVibrant, etc.)
|
|
- Overkill for a single dominant color fill background
|
|
|
|
### Recommendation
|
|
Use **Sharp** — single dependency handles both dominant color extraction and any future image processing needs. Resize to 1x1 pixel for a perceptually weighted average color.
|
|
|
|
### Implementation Notes
|
|
- Extract dominant color in the upload handler (both `/api/images` POST and `/api/images/from-url`)
|
|
- Return `dominantColor` in the response alongside `filename`
|
|
- For globalItems with external `imageUrl`: extract on first access or via backfill script (fetch + process)
|
|
|
|
## 3. Schema Changes Required
|
|
|
|
### New Fields
|
|
|
|
**items table:**
|
|
```sql
|
|
ALTER TABLE items ADD COLUMN dominant_color text;
|
|
ALTER TABLE items ADD COLUMN crop_zoom double precision;
|
|
ALTER TABLE items ADD COLUMN crop_x double precision;
|
|
ALTER TABLE items ADD COLUMN crop_y double precision;
|
|
```
|
|
|
|
**global_items table:**
|
|
```sql
|
|
ALTER TABLE global_items ADD COLUMN dominant_color text;
|
|
ALTER TABLE global_items ADD COLUMN crop_zoom double precision;
|
|
ALTER TABLE global_items ADD COLUMN crop_x double precision;
|
|
ALTER TABLE global_items ADD COLUMN crop_y double precision;
|
|
```
|
|
|
|
**thread_candidates table:**
|
|
```sql
|
|
ALTER TABLE thread_candidates ADD COLUMN dominant_color text;
|
|
ALTER TABLE thread_candidates ADD COLUMN crop_zoom double precision;
|
|
ALTER TABLE thread_candidates ADD COLUMN crop_x double precision;
|
|
ALTER TABLE thread_candidates ADD COLUMN crop_y double precision;
|
|
```
|
|
|
|
### Drizzle Schema
|
|
Add to each table in `src/db/schema.ts`:
|
|
```ts
|
|
dominantColor: text("dominant_color"),
|
|
cropZoom: doublePrecision("crop_zoom"),
|
|
cropX: doublePrecision("crop_x"),
|
|
cropY: doublePrecision("crop_y"),
|
|
```
|
|
|
|
Apply via: `bun run db:generate` then `bun run db:push`
|
|
|
|
## 4. Zoom+Pan Editor
|
|
|
|
### Library Options
|
|
|
|
| Library | Size | Touch | Maintained | Notes |
|
|
|---------|------|-------|------------|-------|
|
|
| react-easy-crop | ~15KB | Yes | Active | Battle-tested, used by many production apps. Returns crop area coordinates. MIT. |
|
|
| react-zoom-pan-pinch | ~25KB | Yes | Active | More general-purpose (maps, images, diagrams). Heavier. |
|
|
| Custom (pointer events + CSS transform) | 0KB | Manual | N/A | Full control but significant effort for touch/gesture handling |
|
|
|
|
### Recommendation: react-easy-crop
|
|
- Provides crop area with zoom, rotation, position
|
|
- Returns `croppedAreaPixels` and `croppedArea` (percentage-based)
|
|
- We need percentage-based output for CSS rendering (so images display correctly at any container size)
|
|
- Output: `{ x, y, zoom }` where x/y are percentage offsets
|
|
|
|
### Storage Model
|
|
Store 3 values per image:
|
|
- `cropZoom: number` — zoom level (1.0 = fit, >1 = zoomed in)
|
|
- `cropX: number` — horizontal offset as percentage (-50 to 50)
|
|
- `cropY: number` — vertical offset as percentage (-50 to 50)
|
|
|
|
When crop settings are `null`, default to `object-contain` with dominant color fill.
|
|
When crop settings are present, use CSS `transform: scale(cropZoom) translate(cropX%, cropY%)` with `overflow: hidden`.
|
|
|
|
## 5. CSS Rendering Strategy
|
|
|
|
### Default (no crop): Contain + Dominant Color
|
|
```tsx
|
|
<div
|
|
className="aspect-[4/3] overflow-hidden rounded-xl"
|
|
style={{ backgroundColor: dominantColor || '#f3f4f6' }}
|
|
>
|
|
<img
|
|
src={url}
|
|
className="w-full h-full object-contain"
|
|
/>
|
|
</div>
|
|
```
|
|
|
|
### With Crop: Transform
|
|
```tsx
|
|
<div className="aspect-[4/3] overflow-hidden rounded-xl"
|
|
style={{ backgroundColor: dominantColor || '#f3f4f6' }}>
|
|
<img
|
|
src={url}
|
|
className="w-full h-full object-cover"
|
|
style={{
|
|
transform: `scale(${cropZoom}) translate(${cropX}%, ${cropY}%)`,
|
|
transformOrigin: 'center center',
|
|
}}
|
|
/>
|
|
</div>
|
|
```
|
|
|
|
### Shared Component
|
|
Extract a reusable `<GearImage>` component that encapsulates this logic:
|
|
```tsx
|
|
interface GearImageProps {
|
|
src: string;
|
|
alt: string;
|
|
dominantColor?: string | null;
|
|
cropZoom?: number | null;
|
|
cropX?: number | null;
|
|
cropY?: number | null;
|
|
aspectRatio?: string; // default "4/3"
|
|
className?: string;
|
|
}
|
|
```
|
|
|
|
All 15 image surfaces replace their inline `<img>` with `<GearImage>`.
|
|
|
|
## 6. Backfill Migration
|
|
|
|
### Strategy: One-time Script
|
|
- Script reads all images from S3 (items + globalItems + candidates with imageFilename)
|
|
- Downloads each, runs Sharp 1x1 resize, extracts dominant color
|
|
- Updates the DB record with `dominantColor`
|
|
- For globalItems with external `imageUrl`: fetch from URL, extract, update
|
|
- Run as: `bun run scripts/backfill-dominant-colors.ts`
|
|
|
|
### Considerations
|
|
- Rate limit S3 reads (batch of 10 concurrent)
|
|
- Skip records that already have `dominantColor` set (idempotent)
|
|
- Log progress: `Processing 45/123 images...`
|
|
- Handle errors gracefully (skip failed images, log them)
|
|
|
|
## 7. API Changes
|
|
|
|
### Upload Response Changes
|
|
Current: `{ filename }` or `{ filename, sourceUrl }`
|
|
New: `{ filename, dominantColor }` or `{ filename, sourceUrl, dominantColor }`
|
|
|
|
### Item/Candidate CRUD
|
|
- `POST /api/items` and `PUT /api/items/:id` — accept `dominantColor`, `cropZoom`, `cropX`, `cropY`
|
|
- Same for `POST /api/threads/:id/candidates` and `PUT /api/threads/:id/candidates/:id`
|
|
- GlobalItems: similar updates
|
|
|
|
### Zod Schema Updates
|
|
Add to item/candidate schemas in `src/shared/schemas.ts`:
|
|
```ts
|
|
dominantColor: z.string().nullable().optional(),
|
|
cropZoom: z.number().nullable().optional(),
|
|
cropX: z.number().nullable().optional(),
|
|
cropY: z.number().nullable().optional(),
|
|
```
|
|
|
|
## 8. Scope of Component Changes
|
|
|
|
### Full List (15 surfaces)
|
|
1. `src/client/components/ItemCard.tsx`
|
|
2. `src/client/components/GlobalItemCard.tsx`
|
|
3. `src/client/components/CandidateCard.tsx`
|
|
4. `src/client/components/CandidateListItem.tsx`
|
|
5. `src/client/components/ImageUpload.tsx`
|
|
6. `src/client/components/ComparisonTable.tsx`
|
|
7. `src/client/components/LinkToGlobalItem.tsx`
|
|
8. `src/client/components/CatalogSearchOverlay.tsx` (2 instances)
|
|
9. `src/client/routes/items/$itemId.tsx`
|
|
10. `src/client/routes/global-items/$globalItemId.tsx`
|
|
11. `src/client/routes/global-items/index.tsx`
|
|
12. `src/client/routes/threads/$threadId/candidates/$candidateId.tsx`
|
|
|
|
### ProfileSection.tsx excluded
|
|
The `object-cover` in `ProfileSection.tsx` is for user avatars (circular), not gear images. Out of scope.
|
|
|
|
## 9. Risks & Mitigations
|
|
|
|
| Risk | Impact | Mitigation |
|
|
|------|--------|------------|
|
|
| Sharp installation issues on Bun | Build failure | Sharp has Bun-compatible prebuilt binaries; test early |
|
|
| Backfill takes long for large catalogs | Blocks deployment | Make it idempotent, run post-deploy as async script |
|
|
| Zoom+pan UX complexity | Scope creep | Use react-easy-crop as-is, minimal customization |
|
|
| Dominant color looks wrong for some images | Visual jank | Fallback to neutral gray when extraction fails |
|
|
| Performance: CSS transforms on many cards | Scroll jank | Transform is GPU-accelerated; no perf concern for static transforms |
|
|
|
|
## Validation Architecture
|
|
|
|
### Testable Claims
|
|
1. All 15 image surfaces use `GearImage` component (grep for component name)
|
|
2. No remaining `object-cover` on gear images (grep, excluding avatar)
|
|
3. `dominantColor` field exists on items, globalItems, threadCandidates tables
|
|
4. Upload endpoints return `dominantColor` in response
|
|
5. Backfill script processes existing images without errors
|
|
6. Zoom+pan editor appears in ImageUpload and item detail edit mode
|
|
|
|
---
|
|
|
|
## RESEARCH COMPLETE
|