# 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 — 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 user's S3 bucket) ``` ### 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 | ## Artifact Storage (User S3 Bucket) Users can configure their own S3-compatible bucket to upload sandbox artifacts. ``` User Settings (/u/settings) │ │ Artifact Storage form: │ - S3 Endpoint URL │ - Region (optional) │ - Bucket Name │ - Access Key │ - Secret Key │ ▼ POST /u/settings/storage │ │ Saves credentials on User model │ (s3_endpoint, s3_region, s3_bucket, │ s3_access_key, s3_secret_key) │ ▼ Sandbox toolbar shows "Upload to Bucket" button │ (only when data-has-bucket="1") │ │ 1. User exports image/video (download) │ 2. User clicks "Upload to Bucket" │ 3. JS POSTs to /u/sandbox/upload │ 4. Server generates presigned POST with user's credentials │ 5. JS uploads blob directly to user's S3 bucket │ ▼ User's S3 Bucket sandbox/{user_id}/{timestamp}-{filename} ``` **Supported services:** DigitalOcean Spaces, AWS S3, MinIO, Backblaze B2, any S3-compatible endpoint. **Security:** Credentials stored as plain Unicode columns (same pattern as Stripe/PayPal/Adyen keys on Shop model). Secret key field uses `type="password"` in the form. ## Files | File | Role | |------|------| | `models/shop.py` | `sandbox_mode` Boolean column | | `models/user.py` | S3 credential columns + `has_s3_bucket` property | | `views/shop.py` | Toggle handler in ribbon-settings | | `views/user.py` | Storage settings POST handler + S3 fields in settings dict | | `views/user_sandbox.py` | Presigned upload endpoint (`/u/sandbox/upload`) | | `templates/shop_settings.j2` | Radio buttons for enable/disable | | `templates/user_settings.j2` | Artifact Storage form | | `templates/base.j2` | Conditional toolbar HTML + SVG defs + `data-has-bucket` | | `static/js/sandbox.js` | Client-side filter engine + upload-to-bucket | | `static/css/common.css` | Toolbar layout (CSS Grid) | | `static/js/watch.js` | `sandboxReapply()` hook in SPA navigation | | `routes.py` | `user_storage_settings`, `user_sandbox_upload` routes | | `scripts/alembic/versions/a2c13d3117f2_*.py` | Shop sandbox_mode migration | | `scripts/alembic/versions/f898ba460612_*.py` | User S3 credentials 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 `` 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.