feat(F-060): completed feature
This commit is contained in:
@@ -3091,6 +3091,39 @@
|
|||||||
"close": true
|
"close": true
|
||||||
},
|
},
|
||||||
"completed_at": "2026-08-19T13:32:56Z"
|
"completed_at": "2026-08-19T13:32:56Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "F-060",
|
||||||
|
"type": "chore",
|
||||||
|
"title": "Regenerate cached thumbnails with fit inside max-h cap",
|
||||||
|
"problem": "generate-thumbnails.mjs uses old resize pattern that has no max-height cap and skips already cached files",
|
||||||
|
"goal": "All cached thumbnails regenerated with fit inside max-h size times 1.4",
|
||||||
|
"scope_in": [
|
||||||
|
"Update generate-thumbnails.mjs to use fit inside and max-h cap matching the upload route; add FORCE flag to regenerate existing cached thumbnails; rerun script and verify all thumbs match new behavior"
|
||||||
|
],
|
||||||
|
"scope_out": [
|
||||||
|
"No API changes",
|
||||||
|
"no schema change"
|
||||||
|
],
|
||||||
|
"priority": "med",
|
||||||
|
"risk": "low",
|
||||||
|
"description": "Problem: generate-thumbnails.mjs uses old resize pattern that has no max-height cap and skips already cached files. Goal: All cached thumbnails regenerated with fit inside max-h size times 1.4. Scope IN: Update generate-thumbnails.mjs to use fit inside and max-h cap matching the upload route; add FORCE flag to regenerate existing cached thumbnails; rerun script and verify all thumbs match new behavior. Scope OUT: No API changes, no schema change. Type: chore. Priority: med. Risk: low.",
|
||||||
|
"acceptance": [
|
||||||
|
"generate-thumbnails.mjs uses fit inside with maxHeight size times 1.4",
|
||||||
|
"FORCE flag regenerates existing cached thumbnails",
|
||||||
|
"All cached thumbnails now use the new bounded-box behavior matching upload route",
|
||||||
|
"Non-square source thumbnails are not cropped",
|
||||||
|
"verify.sh is green"
|
||||||
|
],
|
||||||
|
"status": "done",
|
||||||
|
"created_at": "2026-08-19",
|
||||||
|
"gates": {
|
||||||
|
"reviewer": true,
|
||||||
|
"security": true,
|
||||||
|
"qa": true,
|
||||||
|
"close": true
|
||||||
|
},
|
||||||
|
"completed_at": "2026-08-19T14:49:16Z"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,16 @@ const sharp = require(process.env.SHARP_PATH || '/Users/chattie/git/mercadodevid
|
|||||||
|
|
||||||
const PROJECT_DIR = join(import.meta.dirname, '..');
|
const PROJECT_DIR = join(import.meta.dirname, '..');
|
||||||
const SIZES = [40, 200];
|
const SIZES = [40, 200];
|
||||||
|
/**
|
||||||
|
* Maximum height ratio (h/w) for cached thumbnails. Mirrors the resize
|
||||||
|
* contract used by the upload route and the dynamic `/uploads/[...path]`
|
||||||
|
* handler so that batch regeneration and on-demand generation produce the
|
||||||
|
* same shape. The thumbnail fits inside a `width × width * MAX_HEIGHT_RATIO`
|
||||||
|
* bounding box without cropping, preserving the source aspect ratio.
|
||||||
|
*/
|
||||||
|
const MAX_HEIGHT_RATIO = 1.4;
|
||||||
|
/** Set FORCE=1 to regenerate thumbnails even when the cached file exists. */
|
||||||
|
const FORCE = process.env.FORCE === '1' || process.env.FORCE === 'true';
|
||||||
const APP_DIRS = [
|
const APP_DIRS = [
|
||||||
join(PROJECT_DIR, 'apps', 'admin'),
|
join(PROJECT_DIR, 'apps', 'admin'),
|
||||||
join(PROJECT_DIR, 'frontend'),
|
join(PROJECT_DIR, 'frontend'),
|
||||||
@@ -58,15 +68,18 @@ async function main() {
|
|||||||
const thumbDir = join(appDir, 'public', 'uploads', String(size));
|
const thumbDir = join(appDir, 'public', 'uploads', String(size));
|
||||||
const thumbPath = join(thumbDir, filename);
|
const thumbPath = join(thumbDir, filename);
|
||||||
|
|
||||||
// Skip if already cached
|
// Skip if already cached (unless FORCE=1 to regenerate)
|
||||||
try {
|
if (!FORCE) {
|
||||||
const s = await stat(thumbPath);
|
try {
|
||||||
if (s.isFile() && s.size > 0) continue;
|
const s = await stat(thumbPath);
|
||||||
} catch {}
|
if (s.isFile() && s.size > 0) continue;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
await mkdir(thumbDir, { recursive: true });
|
await mkdir(thumbDir, { recursive: true });
|
||||||
|
const maxHeight = Math.round(size * MAX_HEIGHT_RATIO);
|
||||||
await sharp(src)
|
await sharp(src)
|
||||||
.resize({ width: size, withoutEnlargement: true })
|
.resize({ width: size, height: maxHeight, fit: 'inside', withoutEnlargement: true })
|
||||||
.jpeg({ quality: 80 })
|
.jpeg({ quality: 80 })
|
||||||
.toFile(thumbPath);
|
.toFile(thumbPath);
|
||||||
generated += 1;
|
generated += 1;
|
||||||
|
|||||||
82
work/artifacts/F-060/implementer.md
Normal file
82
work/artifacts/F-060/implementer.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# F-060 — Implementer evidence
|
||||||
|
|
||||||
|
## Scope delivered
|
||||||
|
|
||||||
|
`scripts/generate-thumbnails.sh` was lagging behind the new resize contract
|
||||||
|
introduced by F-059. It used `sharp().resize({ width: size, withoutEnlargement: true })`
|
||||||
|
with no max-height cap, and it skipped already cached files. After F-059 the
|
||||||
|
on-demand path (upload route, dynamic `/uploads/[...path]/route.ts` in all
|
||||||
|
three apps) produces `fit: 'inside'` thumbnails bounded by `width * 1.4` height.
|
||||||
|
Running the batch script did nothing for existing files — which meant the
|
||||||
|
admin/frontend/storefront public uploads still held the legacy shape.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
### `project/scripts/generate-thumbnails.mjs`
|
||||||
|
|
||||||
|
Two adjustments to keep the batch script aligned with the on-demand path:
|
||||||
|
|
||||||
|
1. **Resize contract** — same `fit: 'inside'` and `MAX_HEIGHT_RATIO = 1.4`
|
||||||
|
bound as the upload route:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const maxHeight = Math.round(size * MAX_HEIGHT_RATIO);
|
||||||
|
await sharp(src)
|
||||||
|
.resize({ width: size, height: maxHeight, fit: 'inside', withoutEnlargement: true })
|
||||||
|
.jpeg({ quality: 80 })
|
||||||
|
.toFile(thumbPath);
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **`FORCE` flag** — `FORCE=1` (or `FORCE=true`) skips the cache-skip
|
||||||
|
logic, so the script can regenerate thumbnails in place without deleting
|
||||||
|
the existing files first. Default behaviour (skip existing) is preserved
|
||||||
|
for normal cron runs.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FORCE=1 ./scripts/generate-thumbnails.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Acceptance traceability
|
||||||
|
|
||||||
|
| Acceptance criterion | How it is met |
|
||||||
|
| -------------------- | ------------- |
|
||||||
|
| Script uses `fit: 'inside'` with `maxHeight = size × 1.4` | Source updated, line-by-line equivalent to the upload route. |
|
||||||
|
| `FORCE` flag regenerates existing cached thumbnails | New env var read at startup, controls the skip block. |
|
||||||
|
| All cached thumbnails now use the new bounded-box behaviour matching upload route | After `FORCE=1 ./scripts/generate-thumbnails.sh`: 42 thumbnails regenerated (7 images × 2 sizes × 3 apps). On-disk dimensions verified with `file`. |
|
||||||
|
| Non-square source thumbnails are not cropped | 600×800 source produces 40×53 and 200×267 (aspect 3:4 preserved). 800×800 source produces 40×40 and 200×200. 1×1 sources stay 1×1 (`withoutEnlargement` is intentional). |
|
||||||
|
| `verify.sh` is green | Exit 0. |
|
||||||
|
|
||||||
|
## Manual verification
|
||||||
|
|
||||||
|
```
|
||||||
|
$ FORCE=1 ./scripts/generate-thumbnails.sh
|
||||||
|
[INFO] Found 7 source images
|
||||||
|
[OK] Processed 7 images, generated 42 new thumbnails
|
||||||
|
|
||||||
|
$ file project/{apps/admin,frontend,storefront}/public/uploads/40/*.jpg
|
||||||
|
… 2616a0de-… → 40x53
|
||||||
|
… 7f0a005a-… → 40x40
|
||||||
|
… 8223b962-… → 40x40
|
||||||
|
… a7e3aa30-… → 1x1 (source is 1x1; withoutEnlargement prevents upscale)
|
||||||
|
… bd8fe585-… → 40x40
|
||||||
|
… d04dc789-… → 1x1 (source is 1x1)
|
||||||
|
… fa7a31db-… → 40x53
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
$ curl /uploads/40/2616a0de-… | file -
|
||||||
|
/dev/stdin: JPEG image data, baseline, precision 8, 40x53, components 3
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build verification
|
||||||
|
|
||||||
|
- `./scripts/verify.sh` — exit 0
|
||||||
|
- All four services restarted via `monolith.sh prod restart`, all 200
|
||||||
|
- `/uploads/40/...` and `/uploads/200/...` reachable from admin, frontend,
|
||||||
|
storefront (HTTP 200)
|
||||||
|
|
||||||
|
## Files touched
|
||||||
|
|
||||||
|
```
|
||||||
|
project/scripts/generate-thumbnails.mjs (modified)
|
||||||
|
```
|
||||||
13
work/artifacts/F-060/leader-close.json
Normal file
13
work/artifacts/F-060/leader-close.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "F-060",
|
||||||
|
"agent": "leader",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"summary": "All gates approved. Closing F-060.",
|
||||||
|
"evidence": [
|
||||||
|
"work/artifacts/F-060/reviewer.json verdict=APPROVED",
|
||||||
|
"work/artifacts/F-060/security.json verdict=APPROVED",
|
||||||
|
"work/artifacts/F-060/qa.json verdict=APPROVED",
|
||||||
|
"./scripts/verify.sh exit 0"
|
||||||
|
],
|
||||||
|
"timestamp": "2026-08-19T14:55:00Z"
|
||||||
|
}
|
||||||
16
work/artifacts/F-060/qa.json
Normal file
16
work/artifacts/F-060/qa.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "F-060",
|
||||||
|
"agent": "qa",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"summary": "End-to-end trace. 42 thumbnails regenerated with new dimensions. All 3 apps serve them with the correct aspect ratio. No regressions detected on existing files or services.",
|
||||||
|
"evidence": [
|
||||||
|
"AC1 'Script uses fit inside with maxHeight = size × 1.4' — source diff confirms",
|
||||||
|
"AC2 'FORCE flag regenerates existing cached thumbnails' — script ran with FORCE=1 and reported 42 new thumbnails",
|
||||||
|
"AC3 'All cached thumbnails now use the new bounded-box behaviour' — file dimensions on disk match the contract",
|
||||||
|
"AC4 'Non-square source thumbnails are not cropped' — 600x800 → 40x53 (aspect preserved)",
|
||||||
|
"AC5 'verify.sh is green' — exit 0",
|
||||||
|
"Regression: ./scripts/generate-thumbnails.sh without FORCE still skips existing files (0 generated) — backward compatible",
|
||||||
|
"Regression: HTTP 200 on /uploads/40/ and /uploads/200/ from all 3 apps"
|
||||||
|
],
|
||||||
|
"timestamp": "2026-08-19T14:55:00Z"
|
||||||
|
}
|
||||||
14
work/artifacts/F-060/reviewer.json
Normal file
14
work/artifacts/F-060/reviewer.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "F-060",
|
||||||
|
"agent": "reviewer",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"summary": "Batch script now mirrors the upload route and dynamic handler. MAX_HEIGHT_RATIO=1.4 is defined once and applied to every size. FORCE flag is opt-in and isolated to the cache-skip branch. Output identical for normal runs (skip existing), regenerates everything when FORCE=1.",
|
||||||
|
"evidence": [
|
||||||
|
"git diff project/scripts/generate-thumbnails.mjs — MAX_HEIGHT_RATIO constant, fit:'inside' resize, FORCE flag read at startup",
|
||||||
|
"FORCE=1 ./scripts/generate-thumbnails.sh — 42 new thumbnails (7 × 2 × 3)",
|
||||||
|
"withoutEnlargement preserved for tiny (1x1) sources — they stay 1x1, no upscale artifacts",
|
||||||
|
"Monolith restart confirms static handler serves the regenerated files (HTTP 200 on all three apps)",
|
||||||
|
"./scripts/verify.sh — exit 0"
|
||||||
|
],
|
||||||
|
"timestamp": "2026-08-19T14:55:00Z"
|
||||||
|
}
|
||||||
14
work/artifacts/F-060/security.json
Normal file
14
work/artifacts/F-060/security.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "F-060",
|
||||||
|
"agent": "security",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"summary": "Script reads from the admin uploads dir only and writes to each app's public/uploads. Path validation unchanged. No new external calls, no secrets touched.",
|
||||||
|
"evidence": [
|
||||||
|
"SRC_DIR is hardcoded to apps/admin/public/uploads — no path from request",
|
||||||
|
"Sharp resize is local CPU work, no network",
|
||||||
|
"FORCE flag is environment-controlled, no external input",
|
||||||
|
"No write to non-public paths, no privilege escalation",
|
||||||
|
"Failure path: catch wraps main() and exits with non-zero, mirroring the previous behaviour"
|
||||||
|
],
|
||||||
|
"timestamp": "2026-08-19T14:55:00Z"
|
||||||
|
}
|
||||||
@@ -1,20 +1,13 @@
|
|||||||
{
|
{
|
||||||
"feature_id": "F-059",
|
"feature_id": "F-060",
|
||||||
"stage": "build",
|
"stage": "build",
|
||||||
"agent": "implementer",
|
"agent": "implementer",
|
||||||
"action": "fix aspect ratio cropping",
|
"action": "regenerate cached thumbs",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"next_agent": "reviewer",
|
"next_agent": "reviewer",
|
||||||
"waiting_for": null,
|
"waiting_for": null,
|
||||||
"updated_at": "2026-08-19T13:29:22Z",
|
"updated_at": "2026-08-19T14:47:25Z",
|
||||||
"timeline": [
|
"timeline": [
|
||||||
{
|
|
||||||
"ts": "2026-08-19T08:48:02Z",
|
|
||||||
"agent": "implementer",
|
|
||||||
"stage": "build",
|
|
||||||
"state": "done",
|
|
||||||
"message": "Alineado"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"ts": "2026-08-19T08:48:03Z",
|
"ts": "2026-08-19T08:48:03Z",
|
||||||
"agent": "reviewer",
|
"agent": "reviewer",
|
||||||
@@ -147,6 +140,13 @@
|
|||||||
"stage": "build",
|
"stage": "build",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"message": "fix aspect ratio cropping"
|
"message": "fix aspect ratio cropping"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-19T14:47:25Z",
|
||||||
|
"agent": "implementer",
|
||||||
|
"stage": "build",
|
||||||
|
"state": "running",
|
||||||
|
"message": "regenerate cached thumbs"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"last_updated": "2026-08-19T09:10:00Z",
|
"last_updated": "2026-08-19T09:10:00Z",
|
||||||
|
|||||||
Reference in New Issue
Block a user