chore: remove local-only files from git tracking

Untrack CLAUDE.md, QA reports, security audit report, and superpowers
docs. These are internal working documents that should never be pushed
to GitHub. Updated .gitignore to ensure they stay local.
This commit is contained in:
SnapOtter
2026-05-15 22:56:42 +08:00
parent 77d9a67500
commit 74d7f34d34
8 changed files with 2 additions and 5899 deletions
-168
View File
@@ -1,168 +0,0 @@
# SnapOtter QA Release Report
**Date:** 2026-05-15
**Version:** 1.16.0
**Commit:** 51bc2d573263532e2bd799ff7494e79185fcd6f5
**Parallel Agents Used:** 14 (Agent 16/Windows skipped -- Docker Desktop not available)
## Executive Summary
- **Total API/functional tests:** 376
- **Passed:** 368
- **Failed:** 4 (3 unique root causes)
- **N/A:** 4 (branding endpoints removed)
- **Playwright GUI tests:** 552
- **Passed:** 508
- **Failed:** 44 (mostly test infrastructure, fixed post-QA)
- **Fixed during testing:** 6 product issues + 44 test issues
- **GPU verified:** RTX 4070 hitting 96-100% SM utilization during AI inference
## Bugs Found & Fixed
| # | Severity | Category | Description | Fix | Commit |
|---|----------|----------|-------------|-----|--------|
| 1 | Critical | DB Migration | Migration 0012 `SELECT *` column order mismatch causing NOT NULL constraint on existing databases | Explicit column mapping in INSERT | 51bc2d5 |
| 2 | Critical | DB Migration | FK constraint on `DROP TABLE users` during migration | Disable FK checks in migration runner | 51bc2d5 |
| 3 | Medium | Security | `cookie_secret` exposed to non-admin users via `/api/v1/settings` | Filter sensitive keys for non-admin | 51bc2d5 |
| 4 | Low | Lint | 7 API lint warnings (noParameterAssign, noImplicitAnyLet) | Use local variables | 51bc2d5 |
| 5 | Doc | CLAUDE.md | Permission count stated as 16, actual is 14 | Updated to 14 | 51bc2d5 |
| 6 | Test | E2E | 44 Playwright failures (locator collisions, UI drift, fixtures) | Fixed test selectors and expectations | 51bc2d5 |
## Test Matrix
### API Contract Tests
| Category | Total | Pass | Fail | Node | Notes |
|----------|-------|------|------|------|-------|
| Essential Tools (resize, crop, rotate, convert, compress) | 14 | 14 | 0 | GPU+CPU | |
| Metadata Tools (strip, edit, inspect) | 4 | 4 | 0 | GPU+CPU | |
| Color/Sharpening Tools | 9 | 9 | 0 | GPU+CPU | |
| Watermark/Overlay (text, image, compose) | 8 | 8 | 0 | GPU+CPU | |
| Utility Tools (info, compare, duplicates, palette, QR, barcode, base64) | 11 | 10 | 1 | GPU+CPU | info HEIC decode plugin |
| Layout Tools (collage, stitch, split, border) | 8 | 8 | 0 | GPU+CPU | |
| Format/Conversion (SVG, vectorize, GIF, PDF) | 18 | 18 | 0 | GPU+CPU | |
| Optimization (web, rename, favicon, PDF, replace-color) | 7 | 7 | 0 | GPU+CPU | |
| AI Tools - GPU (35) | 35 | 35 | 0 | GPU | All CUDA, no fallbacks |
| AI Tools - CPU Fallback (35) | 35 | -- | -- | CPU | Agent 9 still running |
| Auth System (20) | 20 | 20 | 0 | Both | |
| API Keys (10) | 10 | 10 | 0 | Both | |
| Custom Roles/RBAC (10) | 10 | 9 | 1 | Both | 14 permissions (not 16) |
| Teams (6) | 6 | 6 | 0 | Both | |
| Audit Log (4) | 4 | 4 | 0 | Both | |
| Settings (5) | 5 | 5 | 0 | Both | |
| Branding (4) | 0 | 0 | 0 | N/A | Endpoints removed (migration 0010) |
| AI Features (5) | 5 | 5 | 0 | Both | |
| Analytics/Docs (5) | 5 | 5 | 0 | Both | |
| Health/Upload/Download (5) | 5 | 5 | 0 | Both | |
| File Library (10) | 10 | 10 | 0 | Both | |
| Pipeline/Automate - GPU (12) | 12 | 11 | 1 | GPU | MAX_PIPELINE_STEPS=0 unlimited |
| Pipeline/Automate - CPU (12) | 12 | 11 | 1 | CPU | Same root cause |
| Batch Processing - GPU (8) | 8 | 8 | 0 | GPU | |
| Batch Processing - CPU (8) | 8 | 8 | 0 | CPU | |
| Security (16) | 16 | 16 | 0 | Both | |
| Performance/Stress (7) | 7 | 7 | 0 | Both | |
| CPU Tool Parity (79) | 79 | 79 | 0 | CPU | Full parity confirmed |
### Input Format Support
| Format | CPU (Ubuntu) | GPU (WSL) |
|--------|-------------|-----------|
| JPEG | PASS | PASS |
| PNG | PASS | PASS |
| WebP | PASS | PASS |
| GIF | PASS | PASS |
| BMP | PASS | PASS |
| TIFF | PASS | PASS |
| Multi-page TIFF | PASS | PASS |
| AVIF | PASS | PASS |
| SVG | PASS | PASS |
| HEIC | PASS | PASS |
| HEIF | PASS | PASS |
| DNG (RAW) | PASS | PASS |
| PSD | PASS | PASS |
| TGA | PASS | PASS |
| EXR | PASS | PASS |
| HDR | PASS | PASS |
| ICO | PASS | PASS |
| PDF | PASS | PASS |
### Playwright E2E GUI Tests
| Category | Total | Pass | Fail | Notes |
|----------|-------|------|------|-------|
| Essential Tools | 73 | 58 | 15 | Compress defaults, CSS preview, aspect ratio |
| Watermark/Overlay | 68 | 67 | 1 | Compose undo state |
| Utility Tools | 46 | 43 | 3 | Strict mode, barcode fixture |
| Metadata Tools | 26 | 26 | 0 | |
| Color/Adjustment | 59 | 55 | 4 | CSS filter preview, color blindness timeout |
| Batch Upload | 29 | 28 | 1 | Thumbnail viewer selector |
| Layout Tools | 36 | 35 | 1 | Stitch image count |
| Format Tools | 77 | 71 | 6 | SVG scale, GIF modes, processing timeout |
| AI Tools | 113 | 103 | 10 | Strict mode collisions |
| Optimization | 25 | 22 | 3 | Submit timing, strict mode |
### Security Tests
| Test | Result | Notes |
|------|--------|-------|
| Path traversal (5 variants) | PASS | All blocked |
| SVG XXE | PASS | Entity rejected by libxml2 |
| SVG SSRF | PASS | External refs ignored by librsvg |
| SVG script injection | PASS | Stripped during parse |
| SQL injection (5 variants) | PASS | Drizzle parameterized queries |
| XSS in settings | PASS | Zod validation rejects HTML |
| Rate limiting | PASS | 429 on rapid login attempts |
| Token reuse after logout | PASS | Immediate invalidation |
| Unauthenticated access | PASS | 401 on all variants |
| RBAC enforcement | PASS | Correct per-role blocking |
| Admin escalation (register) | PASS | 403 for non-admin |
| Admin escalation (update) | PASS | 403 for non-admin |
| File upload validation | PASS | Magic bytes check rejects non-images |
| Header injection | PASS | CRLF not reflected |
| cookie_secret exposure | FIXED | Was exposed to non-admin, now filtered |
### Performance
| Test | CPU Time | GPU Time | Memory | Notes |
|------|----------|----------|--------|-------|
| Large file resize | 6,698ms | 5,004ms | Stable | |
| 20-image batch | 511ms | 148ms | Stable | GPU 3.5x faster |
| 10 concurrent | 114ms | 71ms | Stable | All 200, no 500s |
| 10-step pipeline x5 | 1,534ms | 926ms | Stable | |
| 50 sequential (leak check) | 2,590ms | 1,118ms | +1MB | No leak |
| 5 AI sequential | N/A | see below | +290MB | Model loading |
| 20-image ZIP | 6,349ms | 2,045ms | Stable | |
### Cross-Platform Parity
| Check | CPU (Ubuntu) | GPU (WSL) | Match? |
|-------|-------------|-----------|--------|
| Non-AI tools (79) | 79/79 PASS | 79/79 PASS | YES |
| Format support (18) | 18/18 PASS | 18/18 PASS | YES |
| Batch processing | PASS | PASS | YES |
| Pipelines | PASS | PASS | YES |
| Auth/RBAC | PASS | PASS | YES |
### Model Quality Verification (GPU)
- [x] Real-ESRGAN loaded to GPU (96-100% SM utilization during upscale)
- [x] GFPGAN loaded to GPU
- [x] CodeFormer loaded to GPU
- [x] DDColor loaded to GPU (colorize works)
- [x] rembg/BiRefNet loaded to GPU
- [x] PaddleOCR works on GPU
- [x] No silent CPU fallbacks detected on GPU node
### Batch Processing Integrity
- [x] Multi-image uploads process 100% of files
- [x] ZIP downloads contain all processed files
- [x] SSE progress events fire for every file
- [x] No "first-image-only" bug
### Pipeline (Automate) Health
- [x] Single-image pipelines complete all steps
- [x] Batch pipelines produce correct ZIP
- [x] AI tools work in pipeline chains
- [x] No state corruption between pipeline steps
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,736 +0,0 @@
# Remotion Promotional Videos -- Design Spec
## Overview
Three standalone promotional videos for SnapOtter built with Remotion. Each video serves a different purpose and platform, with a shared design system for brand consistency.
| # | Video | Purpose | Theme | Resolution | Duration |
|---|-------|---------|-------|------------|----------|
| 1 | X Launch Video | Twitter/X announcement | Dark | 1080x1080 | ~35s |
| 2 | Product Demo | Website/sales walkthrough | Light | 1920x1080 | ~75s |
| 3 | Promo Teaser | Social media awareness | Dark | 1080x1080 + 1080x1920 | ~20s |
All videos use music + text captions (no voiceover). Music is placeholder-ready (Remotion audio infrastructure wired up, user swaps in a real track later).
---
## Technical Architecture
### Workspace Setup
New monorepo workspace: `apps/videos/`
```
apps/videos/
package.json (@snapotter/videos)
tsconfig.json
remotion.config.ts
tailwind.config.js (v3 format for Remotion Webpack compat)
src/
index.ts (entry point)
Root.tsx (Composition registry -- all 3 + aspect ratio variants)
design-system/
colors.ts (dark + light palettes from SnapOtter brand)
fonts.ts (Inter, Nunito, JetBrains Mono registration)
animations.ts (spring presets, easing curves, timing constants)
components/
AppWindow.tsx (macOS-style window chrome with traffic lights)
Terminal.tsx (terminal with typing animation + syntax highlighting)
AnimatedText.tsx (word-by-word / character reveal with clip-mask)
ToolGrid.tsx (animated grid of tool cards by category)
BeforeAfter.tsx (split comparison with scan-line wipe)
LogoReveal.tsx (SnapOtter logo with particle convergence)
GitHubCTA.tsx (star on GitHub end card)
RotatingTaglines.tsx (cycling phrases from landing page)
FeaturePill.tsx (animated feature badge)
NumberPunch.tsx (large number with impact animation)
GrainOverlay.tsx (animated Perlin noise film grain)
ProgressBar.tsx (animated processing progress)
GradientMesh.tsx (ambient amber/orange blob background)
compositions/
x-launch/
XLaunchVideo.tsx
scenes/
HookScene.tsx
TerminalInstallScene.tsx
ToolGridRevealScene.tsx
AiShowcaseScene.tsx
PrivacyBeatScene.tsx
FeatureBurstScene.tsx
GitHubCTAScene.tsx
product-demo/
ProductDemo.tsx
scenes/
DashboardScene.tsx
SingleToolScene.tsx
BatchProcessingScene.tsx
PipelineBuilderScene.tsx
AiToolsScene.tsx
ImageEditorScene.tsx
ApiDocsScene.tsx
EndCardScene.tsx
promo-teaser/
PromoTeaser.tsx
PromoTeaserVertical.tsx (1080x1920 variant)
scenes/
AmbientOpenScene.tsx
NumberPunchScene.tsx
TaglineCascadeScene.tsx
LogoRevealScene.tsx
CTAScene.tsx
lib/
tools.ts (all 49 tool names + categories, mirrored from shared)
audio.ts (audio placeholder config with fade-in/fade-out)
public/
otter-logo.svg
audio/
placeholder.mp3 (silent placeholder, same duration as longest video)
scripts/
render-all.mjs (batch render all compositions to MP4)
```
### Dependencies
```json
{
"dependencies": {
"remotion": "^4.0.0",
"@remotion/cli": "^4.0.0",
"@remotion/renderer": "^4.0.0",
"@remotion/bundler": "^4.0.0",
"@remotion/tailwind": "^4.0.0",
"@remotion/noise": "^4.0.0",
"@remotion/motion-blur": "^4.0.0",
"@remotion/paths": "^4.0.0",
"@remotion/google-fonts": "^4.0.0",
"@remotion/shapes": "^4.0.0",
"react": "^18.3.0",
"react-dom": "^18.3.0"
}
}
```
Note: Remotion v4 requires React 18, not React 19. This workspace pins React 18 independently (pnpm handles this with workspace overrides). `@remotion/tailwind` uses Webpack and requires Tailwind CSS v3 config format.
### Config
**`remotion.config.ts`:**
```ts
import { Config } from "@remotion/cli/config";
import { enableTailwind } from "@remotion/tailwind";
Config.setVideoImageFormat("jpeg");
Config.setOverwriteOutput(true);
Config.overrideWebpackConfig((config) => enableTailwind(config));
```
**`tailwind.config.js`** (v3 format):
```js
module.exports = {
content: ["./src/**/*.{ts,tsx}"],
theme: {
extend: {
fontFamily: {
heading: ["Nunito", "sans-serif"],
body: ["Inter", "sans-serif"],
mono: ["JetBrains Mono", "monospace"],
},
colors: {
accent: "#f59e0b",
safe: "#22c55e",
danger: "#ef4444",
},
},
},
};
```
### Root Composition Registry
```tsx
// Root.tsx
import { Composition } from "remotion";
import { XLaunchVideo } from "./compositions/x-launch/XLaunchVideo";
import { ProductDemo } from "./compositions/product-demo/ProductDemo";
import { PromoTeaser } from "./compositions/promo-teaser/PromoTeaser";
import { PromoTeaserVertical } from "./compositions/promo-teaser/PromoTeaserVertical";
export const RemotionRoot = () => (
<>
<Composition id="XLaunchVideo" component={XLaunchVideo}
durationInFrames={1050} fps={30} width={1080} height={1080} />
<Composition id="ProductDemo" component={ProductDemo}
durationInFrames={2250} fps={30} width={1920} height={1080} />
<Composition id="PromoTeaser" component={PromoTeaser}
durationInFrames={600} fps={30} width={1080} height={1080} />
<Composition id="PromoTeaserVertical" component={PromoTeaserVertical}
durationInFrames={600} fps={30} width={1080} height={1920} />
</>
);
```
### Audio Setup
All videos use a placeholder audio track. The infrastructure is wired so the user drops in a real track later.
```ts
// lib/audio.ts
import { Audio, interpolate, useCurrentFrame } from "remotion";
import { staticFile } from "remotion";
export const BackgroundMusic: React.FC<{
src?: string;
volume?: number;
fadeInFrames?: number;
fadeOutFrames?: number;
totalFrames: number;
}> = ({
src = staticFile("audio/placeholder.mp3"),
volume = 0.4,
fadeInFrames = 30,
fadeOutFrames = 60,
totalFrames,
}) => {
const frame = useCurrentFrame();
const vol = interpolate(
frame,
[0, fadeInFrames, totalFrames - fadeOutFrames, totalFrames],
[0, volume, volume, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
return <Audio src={src} volume={vol} />;
};
```
---
## Shared Design System
### Color Palettes
```ts
// design-system/colors.ts
export const DARK = {
bg: "#0c0a09",
surface: "#1c1917",
border: "#292524",
text: "#fafaf9",
textMuted: "#a8a29e",
textDim: "#78716c",
accent: "#f59e0b",
accentHover: "#d97706",
};
export const LIGHT = {
bg: "#ffffff",
surface: "#fafaf9",
border: "#e7e5e4",
text: "#0a0a0a",
textMuted: "#737373",
accent: "#f59e0b",
accentHover: "#d97706",
primary: "#3b82f6",
};
export const CATEGORY = {
essentials: "#3B82F6",
optimization: "#10B981",
adjustments: "#8B5CF6",
ai: "#F59E0B",
watermark: "#EF4444",
utilities: "#6366F1",
layout: "#EC4899",
format: "#14B8A6",
};
```
### Typography
```ts
// design-system/fonts.ts
import { loadFont as loadNunito } from "@remotion/google-fonts/Nunito";
import { loadFont as loadInter } from "@remotion/google-fonts/Inter";
import { loadFont as loadJetBrainsMono } from "@remotion/google-fonts/JetBrainsMono";
const { fontFamily: nunito } = loadNunito();
const { fontFamily: inter } = loadInter();
const { fontFamily: mono } = loadJetBrainsMono();
export const FONT = { heading: nunito, body: inter, mono };
export const TEXT = {
heroHeadline: { fontFamily: nunito, fontWeight: 800, fontSize: 72, letterSpacing: "-0.02em", lineHeight: 1.1 },
heroSub: { fontFamily: inter, fontWeight: 500, fontSize: 28, lineHeight: 1.4 },
sectionTitle: { fontFamily: nunito, fontWeight: 700, fontSize: 48, letterSpacing: "-0.01em" },
label: { fontFamily: inter, fontWeight: 600, fontSize: 18, letterSpacing: "0.04em", textTransform: "uppercase" as const },
body: { fontFamily: inter, fontWeight: 400, fontSize: 20, lineHeight: 1.5 },
mono: { fontFamily: mono, fontWeight: 400, fontSize: 16, lineHeight: 1.6 },
counter: { fontFamily: nunito, fontWeight: 800, fontSize: 96, letterSpacing: "-0.03em" },
toolPill: { fontFamily: inter, fontWeight: 600, fontSize: 14 },
};
```
### Animation Presets
```ts
// design-system/animations.ts
import { Easing } from "remotion";
export const EASE = {
enter: Easing.bezier(0.16, 1, 0.3, 1),
exit: Easing.bezier(0.55, 0, 1, 0.45),
emphasis: Easing.bezier(0.34, 1.56, 0.64, 1),
smooth: Easing.bezier(0.37, 0, 0.63, 1),
snap: Easing.bezier(0.22, 1, 0.36, 1),
};
export const SPRING = {
snappy: { damping: 200, stiffness: 100, mass: 0.5 },
natural: { damping: 15, stiffness: 80, mass: 1 },
popIn: { damping: 12, stiffness: 200, mass: 0.6 },
heavy: { damping: 20, stiffness: 60, mass: 2 },
settle: { damping: 18, stiffness: 150, mass: 0.8 },
};
export const TIMING = {
fps: 30,
staggerFrames: 2,
holdShort: 30, // 1s
holdMedium: 60, // 2s
holdLong: 90, // 3s
fadeIn: 12, // 400ms
fadeOut: 8, // 267ms
wipe: 18, // 600ms
sectionGap: 12, // 400ms
};
```
### Grain Overlay
Every composition gets subtle animated film grain via SVG feTurbulence with per-frame seed:
```tsx
export const GrainOverlay: React.FC<{ opacity?: number }> = ({ opacity = 0.03 }) => {
const frame = useCurrentFrame();
return (
<AbsoluteFill style={{ opacity, mixBlendMode: "overlay", pointerEvents: "none" }}>
<svg width="100%" height="100%">
<filter id={`grain-${frame}`}>
<feTurbulence type="fractalNoise" baseFrequency={0.65} numOctaves={3} seed={frame} stitchTiles="stitch" />
<feColorMatrix type="saturate" values="0" />
</filter>
<rect width="100%" height="100%" filter={`url(#grain-${frame})`} />
</svg>
</AbsoluteFill>
);
};
```
---
## Video 1: X Launch Video
**ID:** `XLaunchVideo`
**Resolution:** 1080x1080 (square, optimal for X feed)
**Duration:** 1050 frames (35s @ 30fps)
**Theme:** Dark (`#0c0a09` background, `#f59e0b` amber accents)
**Audio:** Background music at 40% volume, 1s fade-in, 2s fade-out
### Scene 1 -- Hook (frame 0-120, 4s)
Full screen animated text. "Your images." appears first via clip-mask reveal (word rises up from behind invisible mask, 15 frames per word). Spring animation with slight overshoot. Then "Stay yours." appears 20 frames later, same treatment. Both in Nunito 800 weight, 72px, white on dark.
Behind the text: subtle amber gradient glow pulses. Radial gradient centered on text, amber at 8% opacity, breathing via `Math.sin(frame * 0.08) * 0.03 + 0.08`.
### Scene 2 -- Terminal Install (frame 120-270, 5s)
macOS terminal window slides up from bottom with `SPRING.snappy`. Terminal specs:
- 800x450px, centered, border-radius 12px
- Top bar: `#1e1e2e`, three traffic light dots (red/yellow/green, 12px)
- Body: `#0d1117`, JetBrains Mono font
Character-by-character typing (2 frames/char) of:
```
docker run -p 3000:3000 snapotter/snapotter
```
Syntax highlighting: `docker` in `#ff7b72`, flags in `#79c0ff`, image name in `#7ee787`.
After typing completes, output lines appear with staggered fade-in (3-frame stagger):
- `v2.x.x` (version banner, amber)
- `49 tools loaded` (green)
- `15 AI models ready` (green)
- `Server running on :3000` (bright green with checkmark)
### Scene 3 -- Tool Grid Reveal (frame 270-450, 6s)
Terminal shrinks (scale to 0.6) and slides to top-left corner (opacity 40%). Tool cards cascade in from the right with staggered spring animations (`SPRING.settle`, 2-frame stagger between cards).
Cards grouped by category with their accent colors. Each card: rounded rectangle, category color at 15% opacity background, white text, category color left border (3px). 8 columns, one per category.
Animated counter in bottom-right: 0 to 49, Nunito 800 weight, 64px, amber. Easing: fast start, slow finish.
### Scene 4 -- AI Showcase (frame 450-630, 6s)
Split screen before/after with scan-line wipe. Three quick transitions (60 frames each):
1. **Background removal** (frame 450-510): Left shows a photo placeholder (warm gradient with geometric background pattern). Amber scan line sweeps left to right. Behind the line, background becomes checkerboard transparency. Subject "floats forward" after completion.
2. **Upscale 4x** (frame 510-570): Small blocky image (pixel grid) on left. Scan line sweeps. Right side shows smooth, 4x larger version. Size labels: "200px" morphs to "800px".
3. **Object erase** (frame 570-630): Photo with a highlighted region (amber dashed outline). Scan line sweeps. Region fills in seamlessly. Checkmark pops in.
Text overlay throughout: "15 AI models. Your hardware. No cloud." in Inter 600, 24px, white at 80% opacity, bottom of frame.
### Scene 5 -- Privacy Beat (frame 630-750, 4s)
Rotating taglines from the landing page, one per 30 frames (1 second each), centered:
- "No uploads to the cloud. Ever."
- "100% local processing."
- "Works fully offline."
- "Air-gapped ready."
Each fades in via clip-mask reveal in amber (`#f59e0b`), holds briefly, then fades out before the next. Inter 500, 36px. Subtle pulse glow behind each line.
### Scene 6 -- Feature Burst (frame 750-900, 5s)
Feature pills fly in from screen edges. Each pill: rounded rectangle, amber background at 15%, white text, Inter 600, 16px. Features:
"Batch Processing", "Pipeline Automation", "REST API", "55+ Input Formats", "Image Editor", "One Container", "Multi-arch", "15 AI Models"
Pills enter with `SPRING.popIn` wrapped in motion blur Trail (4 layers). Staggered by 8 frames. They arrange into a 2x4 centered grid, each snapping into place with `SPRING.settle`.
After all placed: brief hold, then pills scale down to 60% and shift upward.
### Scene 7 -- GitHub CTA (frame 900-1050, 5s)
"100% OPEN SOURCE" label in amber, Inter 600, 18px, uppercase, letter-spacing 0.04em. Appears via clip-mask reveal.
SnapOtter logo scales up from center with a soft amber glow burst (radial gradient expanding from 0 to 200px radius, amber at 15% opacity, over 15 frames).
"Free forever." in Nunito 700, 48px, white. Below: GitHub icon (SVG) with "Star us on GitHub" in Inter 400, 20px. Repo URL `github.com/snapotter-hq/SnapOtter` in JetBrains Mono 400, 16px, amber.
Pulsing amber border around the entire frame (2px, opacity oscillates 0.3 to 0.6 via sin wave).
---
## Video 2: Product Demo
**ID:** `ProductDemo`
**Resolution:** 1920x1080 (landscape, website/presentation quality)
**Duration:** 2250 frames (75s @ 30fps)
**Theme:** Light (`#ffffff` background, `#3b82f6` blue UI accents, `#f59e0b` amber brand)
**Audio:** Background music at 30% volume (lower than launch video -- demo feel), 1s fade-in, 2s fade-out
This video replicates the actual SnapOtter UI in React components. Every scene shows a recognizable version of the real app interface.
### Scene 1 -- Dashboard Overview (frame 0-240, 8s)
AppWindow component fades in (spring animation from scale 0.95 to 1.0, opacity 0 to 1). Renders a simplified SnapOtter dashboard:
- Top bar: SnapOtter logo + wordmark (Nunito bold), search bar (rounded input with magnifying glass icon), user avatar circle
- Below: horizontal category pill filters (Essentials, AI, Optimization, etc.) in their category colors
- Main area: 4x3 grid of tool cards, each with icon, name, and category color dot
A cursor (custom CSS cursor, slight glow) moves to the search bar. Types "resize" character by character (2 frames/char). Cards filter in real-time -- non-matching cards fade out (opacity to 0.2, scale to 0.95), matching cards stay. Shows 3 results: "Resize", "Content-Aware Resize", "Smart Crop".
### Scene 2 -- Single Tool Flow (frame 240-600, 12s)
Cursor clicks "Resize" card. Dashboard slides left (exit animation), Resize tool page slides in from right.
Tool page layout:
- Left (60%): Large dropzone area with dashed border, upload icon, "Drop an image here" text
- Right (40%): Settings panel with controls
An image file icon drops onto the dropzone (animated fall from above with `SPRING.natural`, slight bounce). Dropzone border turns solid blue, image preview appears.
Settings panel animates:
- Width field: cursor clicks, types "1920"
- Height field: shows "auto" (grayed, aspect ratio locked)
- Toggle: "Maintain aspect ratio" is ON (blue)
- Format selector: "WebP" selected
"Process" button clicks (button depresses slightly, color intensifies). Progress bar fills left to right (blue, 2 seconds). Result appears: side-by-side comparison with file size badge. Original: "2.4 MB" (gray). Processed: "340 KB" (green). Reduction percentage: "-86%" in amber.
### Scene 3 -- Batch Processing (frame 600-960, 12s)
Navigate back to dashboard (slide transition). Cursor clicks "Compress" tool.
Compress tool page appears. 8 file icons cascade onto the dropzone (staggered by 4 frames, `SPRING.popIn`). Each file shows a tiny thumbnail and filename.
Settings: Quality slider moves to 80. Format: "WebP".
"Process All" button clicks. A progress card appears for each file, arranged in a 2x4 grid. Each card shows:
- Filename
- Progress bar filling
- Checkmark appearing when done
Cards complete in staggered order (not all at once -- simulates real parallel processing). As each finishes, file size updates: "1.8 MB -> 95 KB", "3.2 MB -> 210 KB", etc.
All complete. "Download ZIP" button pulses with amber glow.
Text overlay fades in at bottom: "Unlimited batch. No caps." in Nunito 700, 32px.
### Scene 4 -- Pipeline Builder (frame 960-1350, 13s)
Navigate to pipeline page (URL bar shows `/automate`). Empty canvas with a sidebar of available tools.
Three tool blocks drag from sidebar to canvas (animated drag with cursor):
1. "Resize" block (blue) drops at position 1
2. "Compress" block (green) drops at position 2
3. "Text Watermark" block (red) drops at position 3
Connection lines animate between blocks (SVG path with `evolvePath()`, dotted line becoming solid on connection). Each block briefly flashes its settings panel (expanding, showing 2-3 key settings, then collapsing).
"Run Pipeline" button clicks. A stream of 5 image thumbnails flows through the pipeline left to right. Each thumbnail pauses at each station (brief glow on the station), then continues. Stage indicator shows which step is active.
Output: 5 processed images appear in a results tray at bottom.
### Scene 5 -- AI Tools (frame 1350-1710, 12s)
Navigate to "Remove Background" tool. Upload a photo placeholder (portrait-style gradient).
Processing state: spinner with "Running rembg model locally..." text in Inter 400, blue. Progress bar advances.
Result: clean cutout on transparent checkerboard background. Before/after slider appears, user drags it.
Quick cuts (90 frames each) to three more AI tools:
1. **Upscale**: Side-by-side zoom comparison. "4x" badge in amber.
2. **OCR**: Document image with text regions highlighting in blue, extracted text appearing in a panel on the right.
3. **Face Blur**: Group photo with faces getting pixelated in real-time (one by one, quick).
Text overlay: "All on your hardware." in Nunito 700, 32px, centered at bottom.
### Scene 6 -- Image Editor (frame 1710-2010, 10s)
Navigate to `/editor`. Editor canvas opens with an image loaded.
Quick demo sequence:
- Brush tool selected from left toolbar (highlight animation). Draws a stroke on the canvas.
- Text tool: types "SnapOtter" as a caption overlay, positioned at bottom.
- Crop handles appear, drag inward to adjust framing.
- Filter dropdown opens, "Warm" filter applies (subtle color shift on the image).
Toolbar visible on left (30 tool icons stacked), layers panel on right (3 layers shown). Canvas zoom controls at bottom.
Shows this is a real editor, not just a batch processor.
### Scene 7 -- API Docs (frame 2010-2160, 5s)
AppWindow shows the Scalar API docs interface (simplified). Endpoint list on left, detail on right.
A curl command types out in a terminal overlay:
```
curl -X POST localhost:1349/api/v1/tools/resize \
-F "file=@photo.jpg" \
-F "settings={\"width\":800}"
```
JSON response fades in below:
```json
{
"downloadUrl": "/api/v1/download/abc123",
"originalSize": 2400000,
"processedSize": 340000
}
```
Text: "Every tool via REST API." in Nunito 700, 28px.
### Scene 8 -- End Card (frame 2160-2250, 3s)
Clean white background. SnapOtter logo centered (80x80px), fades in with `SPRING.natural`.
"Self-hosted image processing." in Nunito 700, 36px, dark text.
Below: `snapotter.com` in Inter 400, amber. GitHub icon + URL below that.
Fade to white.
---
## Video 3: Promo Teaser
**ID:** `PromoTeaser` (1080x1080) + `PromoTeaserVertical` (1080x1920)
**Duration:** 600 frames (20s @ 30fps)
**Theme:** Dark (`#0c0a09` background, amber gradient energy)
**Audio:** Background music at 50% volume (higher energy), 0.5s fade-in, 1.5s fade-out
Apple keynote energy. Minimal text, dramatic timing, ambient motion. The vertical variant rearranges elements for portrait orientation but uses the same scenes.
### Scene 1 -- Ambient Open (frame 0-90, 3s)
Black screen. Soft amber gradient blobs (`GradientMesh` component) fade in with slow drift. Three blobs:
- Amber (`#f59e0b`), 200px radius, center-left
- Orange (`#f97316`), 150px radius, top-right
- Deep amber (`#d97706`), 180px radius, bottom-center
Each blob: radial gradient, blur 80px, opacity 0.4, `mix-blend-mode: screen`. Lissajous motion paths (very slow, ~0.5 cycle over the full 20s).
Builds anticipation with pure visual atmosphere.
### Scene 2 -- Number Punch (frame 90-240, 5s)
Large numbers slam in one at a time with impact animations. Each number-descriptor pair gets 37 frames (~1.25s):
1. **"49"** (frame 90-127): Number scales from 150% to 100% with `SPRING.settle` (overshoot then snap). Nunito 800, 120px (square) / 96px (vertical), white. Brief hold, then "tools" appears to the right in Inter 500, 36px, amber. Subtle screen shake (translateX/Y oscillates 2px for 3 frames on impact).
2. **"15"** (frame 127-164): Same treatment. "AI models" descriptor.
3. **"55+"** (frame 164-201): Same treatment. "formats" descriptor.
4. **"1"** (frame 201-240): Same treatment. "container" descriptor. This one gets extra emphasis -- the "1" is larger (144px), and the impact shake is stronger (3px, 4 frames).
Between each: quick cut (2-frame black flash for rhythm).
### Scene 3 -- Tagline Cascade (frame 240-390, 5s)
The rotating taglines from the landing page flow vertically through the screen like a waterfall. Each line appears at center, holds briefly, then slides up and fades as the next appears below.
Lines (25 frames each):
1. "No signups."
2. "No uploads."
3. "No limits."
4. "Free forever."
5. "Open source."
6. "Fully offline."
Each line: Inter 500, 40px, white. Appears via clip-mask reveal from below. Exits by sliding up with opacity fade. Lines slightly overlap during transitions (outgoing at 30% opacity while incoming at 100%).
The amber glow from Scene 1 intensifies throughout this scene (blob opacity increases from 0.4 to 0.6).
### Scene 4 -- Logo Reveal (frame 390-510, 4s)
All ambient motion converges to center. Blobs drift inward (positions interpolate toward center of frame over 30 frames).
Amber particles (20-30 tiny circles, 2-4px, amber at varying opacities) swirl inward from edges in a spiral pattern. Uses polar coordinates with decreasing radius:
```
angle = baseAngle + frame * rotationSpeed
radius = maxRadius * (1 - progress)
```
At convergence point (frame 420): soft burst -- particles scatter outward briefly (10 frames), then SnapOtter logo materializes at center. Logo scales from 0 to 1.0 with `SPRING.popIn`.
"SnapOtter" in Nunito 800, 48px, white, appears below via clip-mask reveal at frame 440.
"Your images. Stay yours." in Inter 500, 24px, amber, appears at frame 460.
### Scene 5 -- CTA (frame 510-600, 3s)
"Get it free" in an amber pill button style (matching the landing page CTA). Rounded rectangle, amber gradient background (`#f59e0b` to `#d97706`), white text, Nunito 700, 20px. Appears with `SPRING.popIn`. Subtle pulsing glow (box-shadow oscillates).
`snapotter.com` in JetBrains Mono 400, 16px, white at 70%, below the pill.
GitHub star icon (small, 16px) with star count placeholder next to it.
Clean fade to black over final 30 frames.
### Vertical Variant (1080x1920)
Same 5 scenes, same timing. Layout adjustments:
- Number Punch: numbers stack vertically with more spacing
- Tagline Cascade: lines have more vertical travel distance
- Logo Reveal: logo and text spaced with more vertical padding
- CTA: button and URL stacked with generous spacing
- Gradient blobs: repositioned for portrait aspect ratio
---
## Rendering Pipeline
### `scripts/render-all.mjs`
```ts
import { bundle } from "@remotion/bundler";
import { renderMedia, selectComposition } from "@remotion/renderer";
import { enableTailwind } from "@remotion/tailwind";
import path from "node:path";
import fs from "node:fs";
const COMPOSITIONS = [
{ id: "XLaunchVideo", slug: "x-launch" },
{ id: "ProductDemo", slug: "product-demo" },
{ id: "PromoTeaser", slug: "promo-teaser-square" },
{ id: "PromoTeaserVertical", slug: "promo-teaser-vertical" },
];
const OUTPUT_DIR = path.resolve("./out");
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const bundleLocation = await bundle({
entryPoint: "./src/index.ts",
webpackOverride: (config) => enableTailwind(config),
});
for (const comp of COMPOSITIONS) {
console.log(`Rendering ${comp.id}...`);
const composition = await selectComposition({
serveUrl: bundleLocation,
id: comp.id,
});
await renderMedia({
composition,
serveUrl: bundleLocation,
codec: "h264",
crf: 20,
pixelFormat: "yuv420p",
imageFormat: "jpeg",
concurrency: 4,
outputLocation: path.join(OUTPUT_DIR, `${comp.slug}.mp4`),
onProgress: ({ progress }) => {
if (Math.round(progress * 100) % 25 === 0) {
process.stdout.write(` ${Math.round(progress * 100)}%`);
}
},
});
console.log(` Done.`);
}
console.log(`\nAll ${COMPOSITIONS.length} videos rendered to ${OUTPUT_DIR}`);
```
### Rendering Settings
| Setting | Value |
|---------|-------|
| Codec | H.264 |
| CRF | 20 (higher quality than landing page videos since these are standalone promotional content) |
| Pixel format | yuv420p |
| Image format | jpeg |
| Concurrency | 4 threads |
| FPS | 30 |
### Expected Output Sizes
| Video | Duration | Resolution | Est. Size |
|-------|----------|------------|-----------|
| X Launch | 35s | 1080x1080 | 3-5MB |
| Product Demo | 75s | 1920x1080 | 8-12MB |
| Promo Teaser (square) | 20s | 1080x1080 | 2-3MB |
| Promo Teaser (vertical) | 20s | 1080x1920 | 2-4MB |
### Dev Workflow
```bash
cd apps/videos
npx remotion studio # Real-time preview with timeline, composition picker
npx remotion render XLaunchVideo out/x-launch.mp4 # Render single video
node scripts/render-all.mjs # Render all videos
```
---
## Component Reuse Between Landing Page Videos and Promo Videos
The existing `2026-05-08-remotion-landing-videos-design.md` spec defines 10 landing page videos in the same `apps/videos/` workspace. Both specs share the same design system (`colors.ts`, `fonts.ts`, `animations.ts`) and reusable components (`GrainOverlay`, `Terminal`, `AppWindow`, etc.).
The promo videos add these new components not needed by the landing page videos:
- `BeforeAfter.tsx` (scan-line wipe comparison)
- `NumberPunch.tsx` (large impact number animation)
- `RotatingTaglines.tsx` (cycling phrase animation)
- `GitHubCTA.tsx` (star on GitHub end card)
- `FeaturePill.tsx` (feature badge animation)
- `ProgressBar.tsx` (processing progress animation)
All compositions (10 landing + 4 promo) coexist in `Root.tsx` and can be previewed/rendered independently via Remotion Studio.
@@ -1,182 +0,0 @@
# Restore Photo Quality Overhaul
**Date:** 2026-05-13
**Status:** Draft
**Scope:** Fix critical quality issues in the restore-photo pipeline, informed by diagnostic testing on real damaged photos
**Supersedes:** `docs/specs/2026-04-15-restore-photo-design.md` (approved but unimplemented; this spec is scoped to the subset of changes backed by diagnostic evidence)
---
## Diagnostic Evidence
Full pipeline was run on 4 sample images with intermediate outputs saved at each step. Key findings:
| Image | Pixels | Mask Coverage | Actual Damage | Result |
|---|---|---|---|---|
| images2.jpg | 290x174 | **68.7%** | ~3% | Faces erased, gray blobs |
| images.jpg | 188x267 | **30.6%** | ~1% | Face structure altered |
| woman-baby1.webp | 400x277 | **23.2%** | ~6% | Baby face plasticky, scratches remain |
| ai-old-photo...webp | 768x513 | **6.4%** | ~5% | Reasonable but scratches still visible |
Root causes identified (ranked by impact):
1. **Scratch detection massively over-detects** on small images (4-15x worse than reality)
2. **LaMa at 512x512** can't fully remove scratches even when correctly detected
3. **CodeFormer over-smooths small faces** (< 80px)
4. **NLMeans default strength too high** (40 maps to h=7.8, over-smooths compressed images)
Diagnostic outputs saved at `/tmp/restore-diagnostic/` with per-step intermediate images.
---
## Changes
### 1. Scratch Detection Rewrite (`restore.py :: detect_scratches`)
**Current:** 4 angles (0/45/90/135), 3 kernel sizes, fixed threshold per mode, no filtering.
**New:**
- **8 angles**: 0, 22.5, 45, 67.5, 90, 112.5, 135, 157.5 degrees
- **Rotation-matrix kernels**: `cv2.getRotationMatrix2D` + `cv2.warpAffine` for accurate arbitrary-angle line kernels (replaces hard-coded pixel patterns)
- **Image-size-adaptive kernel count**: for images with shortest side < 300px, use only 2 kernel sizes (max kernel capped at `base_dim // 15`). For >= 300px, use 3 sizes as before
- **Pre-filtering**: light bilateral filter (`d=5, sigmaColor=50, sigmaSpace=50`) before CLAHE to suppress JPEG/WebP compression artifacts
- **Adaptive threshold**: use `cv2.THRESH_BINARY + cv2.THRESH_OTSU` per combined morphological response map instead of a fixed threshold. This auto-adapts to each image's contrast characteristics. If Otsu selects a threshold < 60, skip detection entirely (image has no significant scratches; low Otsu = unimodal histogram = no damage signal)
- **Connected component filtering**: after initial detection, run `cv2.connectedComponentsWithStats`. Keep a component if:
- Area >= 20 pixels AND (elongation >= 2.5 OR area >= 200 pixels)
- Elongation = `max(bbox_w, bbox_h) / max(min(bbox_w, bbox_h), 1)`
- Reject components with area > 5% of total image area (too large to be a scratch)
- **Coverage cap**: after filtering, compute total mask coverage. If it exceeds 15%, compute the morphological response intensity at the 85th percentile of masked pixels and re-threshold at that intensity, keeping only the strongest 15% of detections. Log a warning. This prevents the catastrophic "erase everything" failure mode
- **Remove `sensitivity` parameter**: no more light/medium/heavy. The adaptive Otsu threshold + filtering handles all cases
**Removed:** `_make_line_kernel()` (replaced by `_make_line_kernel_rotated()`)
### 2. LaMa Inpainting Resolution Fix (`restore.py :: inpaint_damage`)
**Current:** Resize entire image to 512x512, process, resize back. Destroys detail and distorts aspect ratio.
**New two-path approach:**
**Small images (both dimensions <= 512px):**
- Pad image to 512x512 using `cv2.copyMakeBorder` with `BORDER_REFLECT_101` (no resize, no aspect distortion)
- Pad mask identically (padded area = mask 0, no damage)
- Run single-pass LaMa at 512x512
- Crop result back to original dimensions
- Feathered composite with original
**Large images (any dimension > 512px):**
- Process in overlapping 512x512 tiles (stride = 384, overlap = 128)
- For edge tiles that extend past the image boundary: pad with `BORDER_REFLECT_101`
- Only process tiles where mask has > 0 damaged pixels (skip clean tiles)
- Blend tiles using raised-cosine window to eliminate seams
- Feathered composite with original (only replace masked areas)
**Improved mask dilation:**
- Current: `MORPH_ELLIPSE(3,3)`, 1 iteration = 3px dilation
- New: `MORPH_ELLIPSE(5,5)`, 2 iterations = ~8px dilation
- This ensures scratch edges are fully covered for cleaner inpainting
**Improved feathering:**
- Current: `feather_r = max(3, min(w,h) // 200)` = 3px for most images
- New: `feather_r = max(5, min(w,h) // 100)` = at least 5px, scales better
### 3. Face Enhancement Guard (`restore.py :: enhance_faces`)
**Current:** Minimum face size 24x24px. CodeFormer runs at full strength on all faces.
**New:**
- Minimum face size raised to **48x48px** (skip smaller faces entirely)
- For faces 48-120px: clamp fidelity to `max(fidelity, 0.85)` to prevent over-smoothing
- For faces > 120px: use user's fidelity setting as-is
### 4. Lower Default Denoise Strength
**Current:** `denoiseStrength` default = 40, maps to NLMeans h = 7.8
**New:** `denoiseStrength` default = **25**, maps to NLMeans h = 6.0. Still removes grain but doesn't wash out small compressed images. Users can increase manually.
### 5. Remove Mode System
**API:** Remove `mode: "auto" | "light" | "heavy"` from Zod schema. Zod strips unknown fields by default, so old clients sending `mode` will work (it's silently ignored).
**Python:** Remove `mode` parameter from `main()`. The adaptive Otsu threshold replaces the fixed threshold that `mode` controlled.
**Frontend:** Remove 3-button mode selector from `RestorePhotoControls`.
### 6. Add Colorize Strength Control
**API:** Add `colorizeStrength: z.number().min(0).max(100).default(85)` to Zod schema.
**Python:** `colorize_bw()` already accepts `intensity` parameter. Pass `colorizeStrength / 100` from main.
**Frontend:** Add slider (0-100, step 5) below Auto-Colorize checkbox, visible when colorize is checked. Same nested styling as Face Fidelity slider.
**i18n:** Add `colorizeStrength: "Colorize Strength"` to `en.ts`.
### 7. TypeScript Bridge Update (`restoration.ts`)
- Remove `mode` from `RestorePhotoOptions`
- Add `colorizeStrength?: number`
---
## Files Changed
| File | Change |
|---|---|
| `packages/ai/python/restore.py` | Detection rewrite, tiled LaMa, face guard, remove mode, colorize strength passthrough |
| `packages/ai/src/restoration.ts` | Remove `mode`, add `colorizeStrength` to `RestorePhotoOptions` |
| `apps/api/src/routes/tools/restore-photo.ts` | Remove `mode` from both Zod schemas, add `colorizeStrength`, lower `denoiseStrength` default |
| `apps/web/src/components/tools/restore-photo-settings.tsx` | Remove mode selector, add colorize strength slider, update default denoise to 25 |
| `packages/shared/src/i18n/en.ts` | Add `colorizeStrength` key |
| `tests/unit/ai/restoration.test.ts` | Remove mode tests, add colorizeStrength tests |
| `tests/integration/restore-photo.test.ts` | Remove mode tests, add colorizeStrength validation |
| `tests/e2e/restore-photo.spec.ts` | Remove mode UI test, add colorize strength slider test |
---
## What This Spec Does NOT Include
The following items from the earlier approved spec (`2026-04-15`) are deferred. They add model downloads and complexity without diagnostic evidence that they're needed:
- **Stable Diffusion ONNX inpainting** (~1.7 GB): LaMa with tiled inference at native resolution should be sufficient. If quality is still lacking after this fix, SD-inpainting can be added as a follow-up
- **NAFNet neural denoising** (~68 MB): NLMeans at lower strength is adequate. NAFNet is a future upgrade
- **CT2 colorization** (~850 MB): DDColor with adjustable strength is good enough
- **GFPGAN fallback**: CodeFormer with the face size guard should handle all cases
- **Real-ESRGAN super-resolution step**: Separate from restoration quality; can be added independently
- **New `superResolution` setting**: Deferred with the SR step
These can each be added incrementally in future PRs if testing shows they're needed.
---
## Testing
### Unit (Vitest)
- API schema rejects `mode` field gracefully (stripped, not errored)
- API schema accepts `colorizeStrength` with default 85
- `denoiseStrength` default is 25
- `colorizeStrength` out of range (101, -1) rejected
### Integration (Vitest)
- POST with `colorizeStrength: 50` accepted
- POST with old `mode: "auto"` doesn't error (backward compat)
### E2E (Playwright)
- Mode selector buttons no longer present
- Colorize strength slider appears when Auto-Colorize is checked
- Default denoise strength shows 25
### Manual verification
- Run pipeline on all 4 diagnostic sample images
- Compare before/after at each step
- Verify: mask coverage < 15% on all samples, scratches removed, faces natural, no blurriness
---
## Success Criteria
The fix is successful when all 4 diagnostic images produce visibly better results than current:
1. `images2.jpg`: faces preserved (not erased), scratches reduced
2. `images.jpg`: face unchanged (minimal actual damage detected)
3. `woman-baby1.webp`: baby face natural (not plasticky), scratches removed
4. `ai-old-photo...webp`: diagonal scratch fully removed, face natural