docs: add sandbox mode architecture and filter reference

Architecture diagrams, export pipeline, face detection pipeline,
filter preset reference table, CORS requirements, localStorage keys,
mobile behavior, and stacking with shop color filter.
This commit is contained in:
russell@unturf.com 2026-02-27 11:43:10 -05:00
parent ed108fe8f5
commit 9dda6ca36b

323
docs/sandbox-mode.md Normal file
View file

@ -0,0 +1,323 @@
# Sandbox Mode: Creative Filter System
Client-side creative toolkit for MPS shops. When a shop owner enables sandbox
mode, all visitors see a floating filter toolbar. Users can apply real-time
CSS/SVG/canvas filters to images and video, export filtered artifacts, and
activate face detection overlays. All processing runs in the browser. The
server stores only the boolean toggle.
## Architecture
```
┌──────────────────────────────────────────────────────────┐
│ Browser (client) │
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ CSS Filters │ │ Canvas Export │ │ MediaPipe │ │
│ │ (live view) │──>│ (artifacts) │ │ Face Mesh │ │
│ │ │ │ │ │ (on-demand) │ │
│ │ 32 presets │ │ ctx.filter │ │ 468 landmarks│ │
│ │ 7 sliders │ │ toBlob() │ │ eye glow │ │
│ │ SVG filters │ │ MediaRecorder│ │ face mask │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
│ │ ┌─────┴─────┐ │ │
│ │ │ Download │ │ │
│ │ │ or Upload │ │ │
│ │ │ (presign) │ │ │
│ │ └───────────┘ │ │
│ │ │ │
│ ┌──────┴──────────────────────────────────────┴───────┐ │
│ │ sandbox.js (IIFE, ~700 lines) │ │
│ │ localStorage persistence │ window.sandboxReapply │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└────────────────────────────┬──────────────────────────────┘
│
┌────────┴────────┐
│ MPS Server │
│ │
│ shop.sandbox │
│ _mode = True │
│ │
│ (that's it. │
│ server stores │
│ one boolean.) │
└─────────────────┘
```
## Filter Pipeline
```
User clicks preset User adjusts slider
│ │
▼ ▼
┌─────────────┐ ┌──────────────────┐
│ PRESETS[idx] │ │ sliderValues[id] │
│ .css string │ │ → fn(val+unit) │
└──────┬──────┘ └────────┬─────────┘
│ │
└────────────┬─────────────┘
│
▼
┌──────────────────┐
│ getCurrentFilter │ CSS filter string:
│ CSS() │──> "sepia(40%) contrast(90%)
└──────────────────┘ brightness(105%) saturate(80%)"
│
▼
┌──────────────────┐
│ element.style │ Applied to: img, video
│ .filter = css │ (NOT <html> — stacks with
└──────────────────┘ shop color_filter)
```
## Export Pipeline
### Image Export
```
img.product-main
│
▼
new Image()
crossOrigin = "anonymous" ← CORS required from CDN
│
▼
canvas.getContext("2d")
ctx.filter = getCurrentFilterCSS()
ctx.drawImage(img, 0, 0)
│
▼
canvas.toBlob("image/png")
│
├──> Download (a.download = "sandbox-export.png")
│
└──> Upload (presigned POST to S3) [future]
```
### Video Export
```
video element
│
▼
canvas.captureStream(30fps)
│
▼
MediaRecorder(stream, "video/webm")
│
▼
requestAnimationFrame loop:
ctx.filter = getCurrentFilterCSS()
ctx.drawImage(video, 0, 0)
│
▼
recorder.stop() → Blob
│
└──> Download ("sandbox-export.webm")
```
## Face Detection Pipeline (MediaPipe)
```
User clicks "Load Face Detection"
│
▼
Load face_mesh.js from CDN (~4MB WASM)
Load camera_utils.js from CDN
│
▼
FaceMesh({ maxNumFaces: 4, refineLandmarks: true })
│
│
┌────┴────────────────────┐
│ │
▼ ▼
Image path Video path
│ │
draw to canvas Camera utility
faceMesh.send() onFrame → send()
│ │
▼ ▼
onResults(landmarks) onResults(landmarks)
│ │
└────────┬────────────────┘
│
▼
468 face landmarks per face
│
┌──────┴──────┐
│ │
▼ ▼
Eye Glow Face Mask
│ │
│ Iris │ Face outline
│ landmarks │ (landmarks
│ 468-477 │ 10,338,297...)
│ │
│ Radial │ Fill face oval
│ gradient │ Draw eye shapes
│ overlay │ Draw eyebrows
│ │ Draw mustache
│ │ Draw goatee
▼ ▼
Canvas overlay composited
on top of media element
```
## Filter Preset Reference
### Basic (4)
| Name | CSS Filter Chain |
|------|-----------------|
| None | `none` |
| Grayscale | `grayscale(100%)` |
| Sepia | `sepia(100%)` |
| Invert | `invert(100%)` |
### Warm (4)
| Name | CSS Filter Chain |
|------|-----------------|
| Warm | `sepia(30%) saturate(140%) brightness(105%)` |
| Sunset | `sepia(50%) hue-rotate(-15deg) saturate(150%)` |
| Golden | `sepia(60%) brightness(110%) contrast(90%)` |
| Amber | `sepia(40%) saturate(160%) hue-rotate(-10deg) brightness(108%)` |
### Cool (4)
| Name | CSS Filter Chain |
|------|-----------------|
| Cool | `sepia(20%) hue-rotate(180deg) saturate(120%)` |
| Arctic | `sepia(30%) hue-rotate(200deg) brightness(110%)` |
| Moonlight | `sepia(40%) hue-rotate(220deg) saturate(80%) brightness(105%)` |
| Frost | `sepia(25%) hue-rotate(190deg) brightness(115%) contrast(95%)` |
### Dramatic (4)
| Name | CSS Filter Chain |
|------|-----------------|
| Hi-Contrast | `contrast(180%) saturate(120%)` |
| Noir | `grayscale(100%) contrast(150%) brightness(90%)` |
| Faded | `contrast(80%) saturate(60%) brightness(110%)` |
| Vintage | `sepia(40%) contrast(90%) brightness(105%) saturate(80%)` |
### Color (5)
| Name | CSS Filter Chain |
|------|-----------------|
| Red Shift | `sepia(100%) hue-rotate(-30deg) saturate(300%)` |
| Green Shift | `sepia(100%) hue-rotate(85deg) saturate(300%)` |
| Blue Shift | `sepia(100%) hue-rotate(200deg) saturate(300%)` |
| Purple Haze | `sepia(100%) hue-rotate(265deg) saturate(200%)` |
| Cyan | `sepia(100%) hue-rotate(145deg) saturate(300%)` |
### Instagram-Style (7)
| Name | CSS Filter Chain |
|------|-----------------|
| Clarendon | `contrast(120%) saturate(125%)` |
| Juno | `sepia(10%) contrast(110%) saturate(150%) brightness(105%)` |
| Lark | `contrast(90%) brightness(115%) saturate(85%)` |
| Gingham | `brightness(105%) hue-rotate(350deg) saturate(80%)` |
| Nashville | `sepia(25%) contrast(120%) brightness(105%) saturate(120%) hue-rotate(-15deg)` |
| Valencia | `sepia(15%) contrast(110%) brightness(108%) saturate(120%)` |
| Walden | `brightness(110%) saturate(160%) sepia(30%) hue-rotate(350deg)` |
### SVG Filters (4)
| Name | Applied As |
|------|-----------|
| Duo Blue | `url(#sandbox-duotone-blue)` — SVG feColorMatrix |
| Duo Green | `url(#sandbox-duotone-green)` — SVG feColorMatrix |
| Film Grain | `url(#sandbox-grain)` — SVG feTurbulence + feBlend |
| Vignette | `url(#sandbox-vignette)` — SVG feFlood + feGaussianBlur |
## Adjustment Sliders
| Slider | Range | Default | CSS Function |
|--------|-------|---------|-------------|
| Brightness | 0-200% | 100% | `brightness()` |
| Contrast | 0-200% | 100% | `contrast()` |
| Saturation | 0-300% | 100% | `saturate()` |
| Hue | 0-360deg | 0deg | `hue-rotate()` |
| Blur | 0-20px | 0px | `blur()` |
| Sepia | 0-100% | 0% | `sepia()` |
| Grayscale | 0-100% | 0% | `grayscale()` |
## Shop Owner Configuration
Enable in **Shop Settings > Announcement Ribbon Settings** (the form section
that contains display toggles):
```
Settings > ribbon-settings form section > Sandbox Mode > Enable/Disable
```
The toggle adds `sandbox_mode = True` to the shop model. The base template
conditionally renders the toolbar HTML, SVG filter definitions, and loads
`sandbox.js` only when enabled.
## Watch Mode SPA Integration
When watch mode is enabled, SPA navigation replaces media elements without a
full page reload. The `updatePageContent()` function in `watch.js` calls
`window.sandboxReapply()` after swapping DOM elements. This re-applies the
active sandbox filter to newly loaded images and video with a 50ms delay to
let the DOM settle.
## CORS Requirements for Canvas Export
CSS filter preview works without CORS — filters are applied as rendering hints
on the element. Canvas export (toBlob) requires pixel access, which requires
CORS headers from the CDN.
DigitalOcean Spaces CORS configuration needed:
```json
{
"CORSRules": [{
"AllowedOrigins": ["*"],
"AllowedMethods": ["GET"],
"AllowedHeaders": ["*"],
"MaxAgeSeconds": 3600
}]
}
```
Without CORS, the export button shows a user-friendly error message. Filter
preview continues to work normally.
## localStorage Keys
| Key | Value | Purpose |
|-----|-------|---------|
| `mps_sandbox_preset` | Integer (0-31) | Active preset index |
| `mps_sandbox_sliders` | JSON object | Slider values |
| `mps_sandbox_panel` | "0" or "1" | Panel open/closed |
| `mps_sandbox_mode` | "preset" or "custom" | Filter mode |
## Files
| File | Role |
|------|------|
| `models/shop.py` | `sandbox_mode` Boolean column |
| `views/shop.py` | Toggle handler in ribbon-settings |
| `templates/shop_settings.j2` | Radio buttons for enable/disable |
| `templates/base.j2` | Conditional toolbar HTML + SVG defs + script tag |
| `static/js/sandbox.js` | Client-side filter engine |
| `static/css/common.css` | Toolbar layout (CSS Grid) |
| `static/js/watch.js` | `sandboxReapply()` hook in SPA navigation |
| `scripts/alembic/versions/a2c13d3117f2_*.py` | Migration |
## Mobile Behavior
On screens `max-width: 800px`:
- Toggle button: bottom-right corner (10px offset)
- Panel: full-width (10px margin left + right)
- Max height: 50vh (scrollable)
- All controls always visible (no hover-only interactions)
- Touch targets adequate for finger tapping
## Stacking with Shop Color Filter
The shop-level `color_filter` (grayscale, red, green, blue, etc.) applies to
`<html>` via `data-color-filter` attribute. Sandbox filters apply to individual
`img` and `video` elements via `element.style.filter`. These stack: the shop
filter colors the entire page, and the sandbox filter adds per-element effects
on top. This is intentional — the two systems are independent and composable.