# site-reels

Adopt this skill: https://proskills.md/skills/other/pedrojaques99-visant-skills-public-site-reels

Page: https://proskills.md/skills/other/pedrojaques99-visant-skills-public-site-reels
This document is the public skill bundle for that page. It contains SKILL.md and every helper file the catalog carries. No login is required.

## File: site-reels/SKILL.md
size: 15294
sha: 25db4dc27999b032c0c6f400e8d08407c50f82fb
upstream: https://github.com/pedrojaques99/visant-skills-public/blob/main/site-reels/SKILL.md

````text
---
name: site-reels
description: 'Records the best scenes of a website or web app (WebGL hero, 3D, scroll) as 1080p MP4 for decks, uploads them to Figma Slides, and builds brand or product showreels in Remotion. Use on "record the site scenes", "site video for the deck", "showreel", "launch video", "gerar vídeo do site", "vídeo pro deck", "reel do produto", /site-reels.'
metadata:
  family: video
---

# Site Reels: site → MP4 → deck

## Requirements

| Needed for | What | Check |
|---|---|---|
| everything | Windows, macOS or Linux with a GPU and a visible desktop (Chrome runs headful) | n/a |
| everything | Node.js 18+ | `node -v` |
| everything | ffmpeg + ffprobe on PATH (or `FFMPEG=<full path>`) | `ffmpeg -version` |
| everything | Chrome, Chromium or Edge (auto-detected, see below) | `node -e "import('./scripts/find-chrome.mjs').then(m=>console.log(m.findChrome()))"` |
| `record.mjs` | `puppeteer-core` + `puppeteer-screen-recorder` (npm, in the project) | `npm ls puppeteer-core puppeteer-screen-recorder` |
| `snap.mjs` | `puppeteer-core` + `timesnap` (brings `timeweb`) | `npm ls timesnap` |
| showreel | Remotion project | `npx remotion --version` |
| audits (optional) | ImageMagick | `magick -version` |
| Figma upload | Figma MCP (`use_figma`, `upload_assets`) + skills `figma-use`, `figma-use-slides` | MCP tool list |

**Chrome detection** (`scripts/find-chrome.mjs`, used by `record.mjs` and `snap.mjs`): `CHROME`
env var if set; else installed Chrome/Edge (Windows: Program Files and LocalAppData; macOS:
`/Applications` and `~/Applications`; Linux: `google-chrome`, `chromium`, `microsoft-edge` on PATH);
else the newest `ms-playwright/chromium-<N>` (or `PLAYWRIGHT_BROWSERS_PATH`); else it stops and
tells you how to set `CHROME` or run `npx playwright install chromium`. On Windows it adds
`--use-angle=d3d11`; on other OSes Chrome keeps its default GPU backend.

## What it does

Records scripted scenes of a site running locally and delivers MP4s ready for Figma (1920×1080,
30fps, x264, < 100MB). Validated engine: **puppeteer-core + puppeteer-screen-recorder** (CDP
screencast + ffmpeg). Never screen-record.

## Run

```
mkdir <proj>-reels && cd <proj>-reels && npm init -y && npm i puppeteer-core puppeteer-screen-recorder
mkdir -p scripts && cp <skill-dir>/scripts/{record,find-chrome}.mjs scripts/
cp <skill-dir>/scripts/scenes.example.mjs scenes.mjs   # edit the scenes
# in the site repo: npx next build && npx next start -p 3100   (production build: smoother WebGL)
OUT="<output folder>/Videos" node scripts/record.mjs scenes.mjs [scene ...]
```

Run the scripts **copied into the project**: ESM resolves `puppeteer-core` from the script's
folder, not from the cwd.

Env: `BASE` (default `http://localhost:3100`), `OUT`, `CHROME`, `FFMPEG`, `W`, `H`, `DPR`,
`MOBILE=1` (touch emulation), `KEEP_UI=1` (keeps the nav), `MIN_FPS` (24). Phone 4:5:
`W=360 H=450 DPR=2 MOBILE=1` gives 720x900.

Each scene = `{ name, path, theme?, trim?, crf?, css?, prepare(page, ctx), act(page, ctx) }`.
`prepare` runs before recording (scroll to the section, wait for 3D to load); `act` is what goes
into the video. `ctx` has `wait`, `scrollTo(page, y|selector, ms)` and
`scrollThrough(page, selector|null, ms, from, to)` with easing. Intro that runs on load:
`page.reload()` inside `act` and use `trim` to cut the loading.

Brand script that works: logo/hero intro (light and dark), hero media swap, 3D section with the
camera entering on scroll, immersive route (walk), footer wordmark and one full page pass.
5–7 scenes of 12–55s.

## Audit before delivering (do not trust the exit code)

- Contact sheet per video: `ffmpeg -i x.mp4 -vf "fps=6/<dur>,scale=480:-1,tile=3x2" -frames:v 1 sheet.png` and **look**.
- 1MB for 19s = blank scene. > 100MB = does not go into Figma (scene `crf` fixes it; 24 for a scene with grain + trails).
- Top strip (`crop=1920:120:0:0`) at several times to catch a leaking nav.
- `LOW-FPS` with a long pause at the start/end is a false alarm (the meter counts still frames); check the contact sheet.

## Known pitfalls

1. **Hidden window freezes rAF** → frozen frames. The launch already passes
   `--disable-backgrounding-occluded-windows --disable-renderer-backgrounding
   --disable-background-timer-throttling --disable-features=CalculateNativeWinOcclusion`.
   `headless: false` on purpose: headless falls back to SwiftShader and WebGL stutters.
2. **Hide-UI CSS disappears** on reload and when React hydration clears `<head>` → the script
   re-attaches a `<style id="reels-hide">` to the body every 100ms.
3. **One invalid selector kills the whole rule**: `\[` in a JS template literal becomes `[` and
   `.z-[60]` invalidates `header, nav, .z-[60]`. One rule per risky selector.
4. **Do not hide `[data-cursor]`**: sites tag the 3D canvas and carousels with it. Target the
   custom cursor element (fixed + mix-blend + pointer-events-none).
5. **Lazy-loaded 3D** (IntersectionObserver + dynamic import): wait 5–7s after reaching the
   section. Reading the WebGL canvas comes back empty without `preserveDrawingBuffer`: use a
   fixed wait or a screenshot.
6. Screencast delivers ~32 unique frames/s → **record at 30fps** (60 gives irregular repeated
   frames). Default `trim` 0.3s drops the first cached frame.
7. **Screencast stutters on a loaded machine** (Photoshop open, parallel render): the MP4 says
   "30fps" with 9–18 unique frames/s. `record.mjs` measures (`mpdecimate=hi=64:lo=24:frac=0.05`)
   and re-records up to 2x below `MIN_FPS` (24). For WebGL/3D plates prefer `snap.mjs`.
8. Cookie banner: accept it in `prepare` (generic click on "Accept/Aceitar/OK") before recording.
9. A tool page without scrolling (generator, effect editor) records still: the scene has to
   **interact** (upload a file to `input[type=file]`, click presets by button text). A custom
   slider does not respond to a synthetic `input`.

## 60fps locked plates: `snap.mjs` (deterministic)

`scripts/snap.mjs` injects **timeweb** (timesnap's virtual clock: rAF, performance.now,
setTimeout and video), advances 1/60s per frame and screenshots. Independent of machine load.
Scene: `{ name, path, theme, css, dur, wait (real ms to load 3D), pre (virtual ms before
scrolling, 2000; **0 for an intro that runs on load**), start (virtual s skipped),
scroll: { sel, from, to, at, len } | { bottom: [from, to] in screen heights },
frame(page, t) (e.g. mouse) }`. Scrolling is a function of virtual time.

```
npm i puppeteer-core timesnap && mkdir -p scripts
cp <skill-dir>/scripts/{snap,find-chrome,patch-timeweb}.mjs scripts/
npm pkg set scripts.postinstall="node scripts/patch-timeweb.mjs" && npm run postinstall
FPS=60 OUT=... node scripts/snap.mjs scenes-snap.mjs [scene]
```

- **Do not use `timesnap()` itself**: with Next + R3F + Lenis it recorded a blank page and left
  frozen stretches on the WebGL hero (rAF does not run). The own loop (goTo + scrollTo +
  screenshot) worked everywhere: 60/60 unique frames.
- **timeweb patch is mandatory**: it swaps `video.play()` for a version with no Promise and no
  `playing` event → `video.play().catch()` throws and the page goes blank.
- `preserveDrawingBuffer: true` forced on getContext (turn off with `preserve: false`).
- Frames go to the OS temp dir, never to a synced drive (thousands of jpgs syncing).
- **No GPU work in parallel** (Remotion render, Photoshop): WebGL loses its context and records
  seconds of background only. The script warns `FLASH` (frame flashing by luminance); a long blank
  stretch only shows on the contact sheet. Look.
- Real smoothness audit: `magick compare -metric MAE` between neighbor frames at full resolution.
  0 = repeated frame.
- Delivery: `scripts/deliver.example.sh` (copy and rename the plates; `V=` required) builds
  reveals, loops (`loop.sh`) and clean 3D, raising crf by 3 until < 95MB.

## Upload to Figma Slides (hidden, without disturbing the team)

The MP4 **does not go in through MCP**: `use_figma` has no network and the Plugin API does not
create video from bytes; `upload_assets` only accepts images (≤ 10MB). So:

1. Cover of each video: `ffmpeg -ss <t> -i x.mp4 -frames:v 1 -q:v 2 _capas/x.jpg`.
2. `use_figma` (skills `figma-use` + `figma-use-slides`): `createSlideRow(idx)` before the final
   slide, `row.name = 'WIP · Motion (hidden)'`, one `createSlide(idx, i)` per video with
   `isSkippedSlide = true`, black background, a 1920×1080 rectangle named `VIDEO · x.mp4` and the
   instruction in `speakerNotes`.
3. `upload_assets` with the rectangles' `nodeIds` → multipart POST of the covers.
4. The user drags the MP4 onto the rectangle (replaces the cover with the video). Say this
   clearly; never promise an inserted video.

`getSlideGrid()` omits skipped slides: check the structure via `SLIDE_GRID.children[i].children`.

## Brand showreel (Remotion): plates + designed scenes

For a branding showreel (logo, colors, type, layouts, manifesto, mockups), record only **clean
plates** from the site (no text: per-scene CSS hides title/nav) and compose the rest in
**Remotion** (frame-by-frame render, no jitter). Reusable building blocks worth having in
`src/fx.tsx`: `Mist`, `MistWords`, `Grain`, `KenBurns`, `Plate`, a `nevoa()` (mist) transition,
with brand data in `brand.ts`.

- **Visual identity comes from the deck, not the site**: check official type and palette in the
  slides (sites drift from the brand book).
- Scenes that work: opening phrase in mist, recorded reveal, logo construction (`evolvePath` +
  guides measured on the logo raster), manifesto phrase by phrase, pillars with variable `wght`
  axis, palette in bands, typography, layered graphics, 3D with title in
  `mix-blend-difference`, scrolling layout columns, 1/2/3-up mockup montage, product synced to
  the name, closing on the wordmark.
- Review by **stills** (`npx remotion still ... --frame=N` + montage) before rendering.
- Render with `--concurrency=3`: at 8, with Photoshop open, a 32GB machine ran out of memory.
- Loops: `scripts/loop.sh in out [start] [dur] [crossfade]`. Check the seam with
  `magick compare -metric RMSE` between first and last frame (< 0.5% = invisible).
- Bad plate is a discard: a solid of revolution spinning shows no motion; a camera framed for a
  portrait canvas looks small in 16:9.
- **Frantic reel** (approved rhythm): 25–35s, 60fps, `Series` with hard cuts on a 120 BPM grid
  (30 frames), image bursts every 3–6 frames, giant logo crop over a grid, flashing font weights,
  palette blocks rearranging, portrait in a circular mask, graphic burst, kinetic phrase, one-beat
  mockups, closing logo held ~2.5s. **Zero labels**: text only as graphics. A slow 1:40
  showreel was rejected.
- **Soft/smoky reel**: record the hero smoke **with no logo or photo**, black on pure white
  (scene with `storage` = params in localStorage and `block: ['assets/logo.png']`; the blank PNG
  must be transparent; **do not block the video**: a broken video freezes timeweb). Use the plate
  as a `mix-blend-mode: multiply` overlay: opens at the start of the scene, an inverted version
  (`ffmpeg -vf reverse`) closes it; the cut lands on black. Transition at ~1.1s (rate 2), or
  half the scene goes black.
- **Figma Slides layouts assembling**: clone the slide (skipped), duplicate each card 3x with only
  background / logo / text visible, download each node with `download_assets` (scale 2) and delete
  the clones. Slides **does not export transparency**: export logo/text with the slide black and
  then white and extract alpha by difference (ImageMagick). Never return base64 through
  `use_figma` (blows the context).

## Product showreel (real app filmed)

Screens are filmed for real (Playwright + CDP screencast at 2x, steps
`goto/move/click/type/scroll/wait`, pointer trail saved as `<id>.cursor.json`) and the cursor is
redrawn in Remotion on top.

**Rejected, do not repeat:**
- **Gluing a designed reel to the real screen** (illustrated opening + filmed middle + drawn CTA):
  two languages, the character vanishes and returns, the black card between acts stops the rhythm.
  One language per video.
- **Small, sped-up product screen** in a 16:9 window: feed and ranking become an unreadable wall.
- **Zoom that crops the screen** and **text stamped over the video** (giant verb, 01/07 counter,
  gradient): intrusive.
- Empty waiting on screen ("waiting…"): cut straight to the action.
- Vague copy: it has to be obvious and in the audience's plain spoken language.

**The form that works:** per action, **text alone first**, centered and short (2 beats), **then
the whole screen, uncropped**, in a centered window at the capture aspect, on a clean background.
Discreet text (weight 700, per-word entry with light blur), no counter, no gradient. Cuts on the
track grid (14 frames = 128.57 BPM). 40s max.

**Technique that paid off:**
- Speeding a capture: `<OffthreadVideo trimBefore={from} playbackRate={rate}>` and recompute click
  frames `/ rate` (sound and cursor ring).
- Starting another composition mid-way: `<Sequence from={X}><Sequence from={-start}><Comp/></Sequence></Sequence>`.
  Check the first frame of the stretch: a shared element "flying" in shows up loose. Skip a few
  frames and re-place the impact sound that fell before.
- Language per composition with a module-level timeline: a `globalThis` flag set by its own entry
  file imported before the Root.
- Continuous track across cuts: `trimBefore` on the bed so it continues from where the previous
  stretch stopped.
- Loudness: render, measure and normalize with 2-pass `loudnorm` (-14 LUFS / -1 dBTP), copying
  the video. Measure only at the end: Remotion's raw mix goes over 0 dBFS.
- Audit: timestamped contact sheet (`fps=2,drawtext=%{pts\:hms},tile=8x6`) of every render, and
  a dense sheet (fps=6) at every stretch junction.
- A project without git, edited by another session at the same time: new cut in a new file,
  `*.bak-*` backup before editing a shared file, and tell the user.

## Portfolio cards and studio reel

- `record.mjs` with `KEEP_UI=1` (the card shows the site as it is) and the phone preset above.
- Scenes generated from an inventory file (only public items with a live URL); no live URL =
  Ken Burns of the screenshot.
- Card package: crossfade loop from end to start, 2-pass bitrate to a KB ceiling, VP9 WebM
  alongside, WebP poster of frame 1.
- Logo with alpha for the web: `--codec=vp9 --pixel-format=yuva420p` (Safari does not play it) +
  ProRes 4444 for editing; `Config.setVideoImageFormat("png")` keeps the alpha.
- Cut-out/stop-motion look in Remotion: time quantized on twos (24 fps, pose every 2 frames), SVG
  filter per pose (feTurbulence + feDisplacementMap on the edge, feMorphology for a cream border,
  hard offset shadow) with 3 seeds cycling ("boil"), paper texture in multiply. Mascot parts read
  by id from the designer's SVG (DOMParser), so a new branding version drops in without code.
- CC0 music: Free Music Archive (CC0 albums). Tempo and fullest stretch with
  `librosa.beat.beat_track` + windowed RMS (Python, optional).

Origin stories, reference projects and machine-specific tips: `references/cases.md`.

## Neighbors

- **[[visant-video-editor]]**: footage from a camera
- **[[visant-premiere-editor]]**: a rough cut in Premiere
````

## File: site-reels/scripts/deliver.example.sh
size: 2938
sha: ecf8f7987e7373250506299b8ff08f8918b5405f
upstream: https://github.com/pedrojaques99/visant-skills-public/blob/main/site-reels/scripts/deliver.example.sh

```text
#!/bin/sh
# EXAMPLE delivery script: builds the deliverables from the 60fps plates (snap.mjs):
# reveals, seamless loops, clean 3D. Copy it into your project and rename the plates.
# Each file ends < 95MB (Figma video limit is 100MB): crf goes up by 3 until it fits.
#
# usage:  V=/path/to/client/Videos [SHOW=/path/to/showreel/public/plates] sh entrega.sh
#   V     folder with the client's videos (plates are read from $V/plates60)   REQUIRED
#   SHOW  Remotion showreel public/plates folder to copy plates into          optional
#   LOOP  path to loop.sh (default: next to this script, else ~/.claude/skills/site-reels/scripts)
# Works on Linux, macOS and Git Bash on Windows (file size via wc -c, no GNU stat).
V="${V:?set V=<client Videos folder>, e.g. V=\"\$HOME/clients/brand/videos\"}"
P="$V/plates60"
SHOW="${SHOW:-}"
HERE="$(cd "$(dirname "$0")" && pwd)"
LOOP="${LOOP:-$HERE/loop.sh}"
[ -f "$LOOP" ] || LOOP="$HOME/.claude/skills/site-reels/scripts/loop.sh"
MAX=95000000

size() { wc -c < "$1" | tr -d ' '; }

# enc <in> <out> [ss] [dur]: re-encode raising crf until it fits
enc() {
  crf=20
  while :; do
    ffmpeg -y -loglevel error ${3:+-ss "$3"} ${4:+-t "$4"} -i "$1" -an -c:v libx264 -preset slow -crf $crf -pix_fmt yuv420p -movflags +faststart "$2"
    [ "$(size "$2")" -lt $MAX ] && break
    crf=$((crf + 3))
  done
  echo "ok $(basename "$2") crf=$crf $(($(size "$2") / 1000000))MB"
}
# loop <in> <out> <ss> <dur> <crossfade>
loop() {
  crf=18
  while :; do
    sh "$LOOP" "$1" "$2" "$3" "$4" "$5" $crf >/dev/null
    [ "$(size "$2")" -lt $MAX ] && break
    crf=$((crf + 3))
  done
  echo "ok $(basename "$2") crf=$crf $(($(size "$2") / 1000000))MB"
}

# --- example plate names below: replace with your scenes ---
mkdir -p "$V/logo-reveal" "$V/loops" "$V/3d-limpo"
enc "$P/reveal-noir-dark.mp4" "$V/logo-reveal/logo-reveal-noir-dark.mp4"
enc "$P/reveal-seda-dark.mp4" "$V/logo-reveal/logo-reveal-seda-dark.mp4"
enc "$P/reveal-seda-light.mp4" "$V/logo-reveal/logo-reveal-papel-light.mp4"

loop "$P/loop-logo-seda-light.mp4" "$V/loops/logo-loop-seda-light.mp4" 1 12 2
loop "$P/loop-logo-noir-dark.mp4" "$V/loops/logo-loop-noir-dark.mp4" 1 12 2
loop "$P/3d-arvore-light.mp4" "$V/loops/3d-arvore-loop-light.mp4" 1 15 3
loop "$P/3d-arvore-dark.mp4" "$V/loops/3d-arvore-loop-dark.mp4" 1 15 3
loop "$P/wordmark-tinta-light.mp4" "$V/loops/wordmark-tinta-loop-light.mp4" 6.2 5.8 1
loop "$P/wordmark-tinta-dark.mp4" "$V/loops/wordmark-tinta-loop-dark.mp4" 6.2 5.8 1

enc "$P/3d-caminhada-light.mp4" "$V/3d-limpo/3d-caminhada-light.mp4" 0 23 # the end of the route is an empty field
enc "$P/3d-caminhada-dark.mp4" "$V/3d-limpo/3d-caminhada-dark.mp4" 0 23

# plates for the showreel (Remotion reads from public/plates)
if [ -n "$SHOW" ]; then
  mkdir -p "$SHOW"
  for f in reveal-noir-dark 3d-arvore-light 3d-caminhada-light wordmark-tinta-light; do cp "$P/$f.mp4" "$SHOW/$f.mp4"; done
  echo "plates copied to the showreel"
fi
```

## File: site-reels/scripts/find-chrome.mjs
size: 3184
sha: 7e8df227244cbbf76f0056a9314b308b1ff0872f
upstream: https://github.com/pedrojaques99/visant-skills-public/blob/main/site-reels/scripts/find-chrome.mjs

```text
// Finds the Chrome/Chromium executable for puppeteer-core on any machine.
// Order: CHROME env var > installed Chrome/Edge (per OS) > newest Playwright Chromium.
// Nothing found = error explaining how to set CHROME.
import { existsSync, readdirSync } from 'node:fs'
import { delimiter, join } from 'node:path'
import { homedir } from 'node:os'

const env = process.env
const OS = process.platform

function installed() {
  if (OS === 'win32') {
    const bases = [env.PROGRAMFILES, env['PROGRAMFILES(X86)'], env.LOCALAPPDATA].filter(Boolean)
    return [
      ...bases.map((b) => join(b, 'Google/Chrome/Application/chrome.exe')),
      ...bases.map((b) => join(b, 'Microsoft/Edge/Application/msedge.exe')),
    ]
  }
  if (OS === 'darwin') {
    const apps = ['/Applications', join(homedir(), 'Applications')]
    return apps.flatMap((a) => [
      join(a, 'Google Chrome.app/Contents/MacOS/Google Chrome'),
      join(a, 'Chromium.app/Contents/MacOS/Chromium'),
      join(a, 'Microsoft Edge.app/Contents/MacOS/Microsoft Edge'),
    ])
  }
  // Linux and friends: look in PATH
  const names = ['google-chrome-stable', 'google-chrome', 'chromium', 'chromium-browser', 'microsoft-edge']
  const dirs = (env.PATH ?? '').split(delimiter).filter(Boolean)
  return names.flatMap((n) => dirs.map((d) => join(d, n)))
}

// Chromium downloaded by Playwright (npx playwright install chromium): newest chromium-<N> first
function playwright() {
  const root =
    env.PLAYWRIGHT_BROWSERS_PATH ||
    (OS === 'win32'
      ? join(env.LOCALAPPDATA ?? join(homedir(), 'AppData/Local'), 'ms-playwright')
      : OS === 'darwin'
        ? join(homedir(), 'Library/Caches/ms-playwright')
        : join(homedir(), '.cache/ms-playwright'))
  if (!existsSync(root)) return []
  const dirs = readdirSync(root)
    .filter((d) => /^chromium-\d+$/.test(d))
    .sort((a, b) => +b.split('-')[1] - +a.split('-')[1])
  const inside =
    OS === 'win32'
      ? ['chrome-win64/chrome.exe', 'chrome-win/chrome.exe']
      : OS === 'darwin'
        ? ['chrome-mac-arm64/Chromium.app/Contents/MacOS/Chromium', 'chrome-mac/Chromium.app/Contents/MacOS/Chromium']
        : ['chrome-linux64/chrome', 'chrome-linux/chrome']
  return dirs.flatMap((d) => inside.map((p) => join(root, d, p)))
}

export function findChrome() {
  if (env.CHROME) {
    if (existsSync(env.CHROME)) return env.CHROME
    throw new Error(`CHROME points to a file that does not exist: ${env.CHROME}`)
  }
  const hit = [...installed(), ...playwright()].find((p) => existsSync(p))
  if (hit) {
    console.log('chrome:', hit)
    return hit
  }
  throw new Error(
    'Chrome/Chromium not found. Set CHROME to the executable path, e.g.:\n' +
      '  Windows: CHROME="C:/Program Files/Google/Chrome/Application/chrome.exe"\n' +
      '  macOS:   CHROME="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"\n' +
      '  Linux:   CHROME="$(command -v google-chrome || command -v chromium)"\n' +
      'or install a Chromium with: npx playwright install chromium',
  )
}

// ANGLE on D3D11 is what made WebGL smooth on Windows; other OSes keep Chrome's default.
export const angleArgs = OS === 'win32' ? ['--use-angle=d3d11'] : []
```

## File: site-reels/scripts/loop.sh
size: 720
sha: ee8f3456519d6026aa31eed5570b6aa76848b2db
upstream: https://github.com/pedrojaques99/visant-skills-public/blob/main/site-reels/scripts/loop.sh

```text
#!/bin/sh
# Seamless loop: the end dissolves into the start, no jump.
# usage: loop.sh in.mp4 out.mp4 [start_s=1] [dur_s=12] [crossfade_s=2] [crf=18]
# Output is dur-crossfade seconds long; the last frame joins the first.
IN="$1"; OUT="$2"; SS="${3:-1}"; D="${4:-12}"; C="${5:-2}"; CRF="${6:-18}"
FF="${FFMPEG:-ffmpeg}"
"$FF" -y -loglevel error -ss "$SS" -t "$D" -i "$IN" -filter_complex \
 "[0:v]split[a][b];[a]trim=start=$C:end=$D,setpts=PTS-STARTPTS[main];[b]trim=start=0:end=$C,setpts=PTS-STARTPTS[head];[main][head]xfade=transition=fade:duration=$C:offset=$(awk "BEGIN{print $D-2*$C}")[v]" \
 -map "[v]" -an -c:v libx264 -preset slow -crf "$CRF" -pix_fmt yuv420p -movflags +faststart "$OUT" && echo "loop ok: $OUT"
```

## File: site-reels/scripts/patch-timeweb.mjs
size: 1048
sha: a054f7a09ca56850d4cff23d314843a2aaf30665
upstream: https://github.com/pedrojaques99/visant-skills-public/blob/main/site-reels/scripts/patch-timeweb.mjs

```text
// timeweb (timesnap's virtual clock) replaces video.play() with a version that returns no
// Promise and never fires 'playing': `video.play().catch()` throws and whoever waits for 'playing' never starts.
import { readFileSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
const file = createRequire(import.meta.url).resolve('timeweb/dist/timeweb.js')
const src = readFileSync(file, 'utf8')
const from = `node.dispatchEvent(new CustomEvent('play', { detail: timewebEventDetail }));\n    };\n    node.pause`
const to = `node.dispatchEvent(new CustomEvent('play', { detail: timewebEventDetail }));\n      node.dispatchEvent(new CustomEvent('playing', { detail: timewebEventDetail }));\n      return Promise.resolve(); /* patch-timeweb */\n    };\n    node.pause`
if (src.includes('patch-timeweb')) console.log('timeweb already patched')
else if (!src.includes(from)) throw new Error('timeweb changed: review scripts/patch-timeweb.mjs')
else { writeFileSync(file, src.replace(from, to)); console.log('timeweb patched') }
```

## File: site-reels/scripts/record.mjs
size: 7034
sha: c4be7de89efa5221fd285336eff3a753a2a59f78
upstream: https://github.com/pedrojaques99/visant-skills-public/blob/main/site-reels/scripts/record.mjs

```text
// Records scenes of a site as MP4 (1920x1080) for a deck/presentation.
// Usage: node record.mjs <scenes.mjs> [scene-name ...]   (no name = all)
// Env: BASE (default http://localhost:3100), OUT (output folder), CHROME, FFMPEG.
// Requires the site running at BASE (preferably a production build: next build && next start -p 3100).
import puppeteer from 'puppeteer-core'
import { findChrome, angleArgs } from './find-chrome.mjs'
import { PuppeteerScreenRecorder } from 'puppeteer-screen-recorder'
import { mkdirSync, renameSync, rmSync } from 'node:fs'
import { execFileSync, spawnSync } from 'node:child_process'
import { pathToFileURL } from 'node:url'
import { resolve } from 'node:path'

const BASE = process.env.BASE ?? 'http://localhost:3100'
const OUT = process.env.OUT ?? './videos'
const CHROME = findChrome() // CHROME env, else installed Chrome/Edge, else Playwright Chromium (find-chrome.mjs)
const FFMPEG = process.env.FFMPEG ?? 'ffmpeg' // on PATH, or full path in FFMPEG
// W/H = viewport in CSS px; DPR = density (video comes out at W*DPR x H*DPR); MOBILE=1 emulates a phone (touch);
// KEEP_UI=1 keeps nav/header (portfolio cards show the site as it is).
const W = +(process.env.W ?? 1920), H = +(process.env.H ?? 1080)
const DPR = +(process.env.DPR ?? 1)
const MOBILE = process.env.MOBILE === '1'
const KEEP_UI = process.env.KEEP_UI === '1'
const MIN_FPS = +(process.env.MIN_FPS ?? 24) // below this it re-records (up to 2x)

// Hide navigation UI: the video shows the brand, not the site
const HIDE_UI = `
  /* site nav and the cursor dot (components/motion/Cursor.tsx). Do not use [data-cursor]: it tags the 3D canvas */
  header, nav { display: none !important; }
  .fixed.mix-blend-difference.pointer-events-none[class*='z-'] { display: none !important; }
  html, body, * { cursor: none !important; }
  ::-webkit-scrollbar { display: none; }
`

const wait = (ms) => new Promise((r) => setTimeout(r, ms))

// Smooth scroll driven by rAF inside the page (easing in-out)
async function scrollTo(page, y, ms) {
  await page.evaluate(
    ({ y, ms }) =>
      new Promise((res) => {
        const from = scrollY
        const max = document.documentElement.scrollHeight - innerHeight
        const to = Math.min(max, typeof y === 'string' ? document.querySelector(y).getBoundingClientRect().top + scrollY : y)
        const t0 = performance.now()
        const ease = (x) => (x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2)
        const step = (now) => {
          const k = Math.min(1, (now - t0) / ms)
          window.scrollTo(0, from + (to - from) * ease(k))
          k < 1 ? requestAnimationFrame(step) : res()
        }
        requestAnimationFrame(step)
      }),
    { y, ms },
  )
}

// Scrolls to the end of the element/page (fraction 0..1 of the selector's scrollable range)
async function scrollThrough(page, sel, ms, from = 0, to = 1) {
  const [a, b] = await page.evaluate(
    ({ sel, from, to }) => {
      const el = sel ? document.querySelector(sel) : document.documentElement
      const top = sel ? el.getBoundingClientRect().top + scrollY : 0
      const range = el.scrollHeight - innerHeight
      return [top + range * from, top + range * to]
    },
    { sel, from, to },
  )
  await page.evaluate((y) => window.scrollTo(0, y), a)
  await wait(300)
  await scrollTo(page, b, ms)
}

const ctx = { wait, scrollTo, scrollThrough }

async function run(scene, browser) {
  const page = await browser.newPage()
  await page.setViewport({ width: W, height: H, deviceScaleFactor: DPR, isMobile: MOBILE, hasTouch: MOBILE })
  await page.emulateMediaFeatures([{ name: 'prefers-color-scheme', value: scene.theme ?? 'light' }])
  await page.evaluateOnNewDocument((t) => localStorage.setItem('theme', t), scene.theme ?? 'light')
  // CSS injected into every document and re-attached if React hydration clears the <head>
  await page.evaluateOnNewDocument((css) => {
    const fix = () => {
      if (document.getElementById('reels-hide') || !document.body) return
      const st = document.createElement('style')
      st.id = 'reels-hide'
      st.textContent = css
      document.body.appendChild(st)
    }
    setInterval(fix, 100)
  }, (KEEP_UI ? 'html, body, * { cursor: none !important; } ::-webkit-scrollbar { display: none; }' : HIDE_UI) + (scene.css ?? ''))
  await page.goto(/^https?:/.test(scene.path) ? scene.path : BASE + scene.path, { waitUntil: 'networkidle2', timeout: 60000 })
  await page.mouse.move(-10, -10)
  if (scene.prepare) await scene.prepare(page, ctx)

  const rec = new PuppeteerScreenRecorder(page, {
    fps: 30,
    ffmpeg_Path: FFMPEG,
    videoFrame: { width: W * DPR, height: H * DPR },
    aspectRatio: `${W}:${H}`,
    videoCodec: 'libx264',
    videoPreset: 'slow',
    videoCrf: 14,
    videoPixelFormat: 'yuv420p',
    autopad: { color: 'black' },
  })
  const file = `${OUT}/${scene.name}.mp4`
  await rec.start(file)
  await scene.act(page, ctx)
  await rec.stop()
  await page.close()
  // Re-encode: cuts the loading (trim; 0.3s drops the screencast's cached 1st frame) and aims < 100MB, Figma's video limit
  const tmp = file.replace('.mp4', '.raw.mp4')
  renameSync(file, tmp)
  execFileSync(FFMPEG, ['-y', '-loglevel', 'error', '-ss', String(scene.trim ?? 0.3), '-i', tmp, '-c:v', 'libx264', '-preset', 'slow', '-crf', String(scene.crf ?? 20), '-pix_fmt', 'yuv420p', '-movflags', '+faststart', file])
  rmSync(tmp)
  const ufps = uniqueFps(file)
  console.log(ufps >= (scene.minFps ?? MIN_FPS) ? 'ok' : 'LOW-FPS', file, `${ufps.toFixed(1)} unique frames/s`)
  return ufps
}

// Unique frames per second (fine threshold: dark smoke changes little per frame).
// A choking screencast (machine out of memory, contended GPU) records 30fps with repeated frames.
function uniqueFps(file) {
  const r = spawnSync(FFMPEG, ['-hide_banner', '-i', file, '-vf', 'mpdecimate=hi=64:lo=24:frac=0.05', '-an', '-f', 'null', '-'], { encoding: 'utf8' })
  const frames = [...r.stderr.matchAll(/frame=\s*(\d+)/g)].pop()?.[1] ?? 0
  const dur = r.stderr.match(/Duration: (\d+):(\d+):([\d.]+)/)
  const secs = dur ? +dur[1] * 3600 + +dur[2] * 60 + +dur[3] : 1
  return frames / secs
}

mkdirSync(OUT, { recursive: true })
const { SCENES } = await import(pathToFileURL(resolve(process.argv[2])).href)
const pick = process.argv.slice(3)
const list = pick.length ? SCENES.filter((s) => pick.includes(s.name)) : SCENES
const browser = await puppeteer.launch({
  executablePath: CHROME,
  headless: false, // real GPU: smooth WebGL (headless falls back to SwiftShader)
  defaultViewport: null,
  args: [`--window-size=${Math.max(W, 500)},${H + 140}`, '--autoplay-policy=no-user-gesture-required', '--hide-scrollbars', ...angleArgs, '--ignore-gpu-blocklist', '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding', '--disable-background-timer-throttling', '--disable-features=CalculateNativeWinOcclusion'],
})
for (const s of list) {
  for (let k = 0; k < 3; k++) if ((await run(s, browser)) >= (s.minFps ?? MIN_FPS)) break
}
await browser.close()
```

## File: site-reels/scripts/scenes.example.mjs
size: 2064
sha: 6e279c8d6b564bf5821f3d340f9972b248cd3c9e
upstream: https://github.com/pedrojaques99/visant-skills-public/blob/main/site-reels/scripts/scenes.example.mjs

```text
// Recorded scenes. trim = seconds cut from the start (page loading).
export const SCENES = [
  {
    name: '01-logo-nevoa-light',
    path: '/',
    trim: 0.6,
    async act(page, { wait }) {
      await page.reload({ waitUntil: 'domcontentloaded' })
      await wait(12000)
    },
  },
  {
    name: '02-logo-nevoa-dark',
    path: '/',
    theme: 'dark',
    trim: 0.6,
    async act(page, { wait }) {
      await page.reload({ waitUntil: 'domcontentloaded' })
      await wait(12000)
    },
  },
  {
    name: '03-hero-playlist',
    path: '/',
    async prepare(page, { wait }) {
      await wait(5000)
    },
    async act(page, { wait }) {
      await wait(20000)
    },
  },
  {
    name: '04-jardim-arvore',
    path: '/',
    async prepare(page, { scrollTo, wait }) {
      await scrollTo(page, '#jardim', 800)
      await wait(7000) // 3D scene (ez-tree) loads near the viewport and takes a while
    },
    async act(page, { scrollThrough, wait }) {
      await wait(1500)
      await scrollThrough(page, '#jardim', 14000, 0, 1)
      await wait(2500)
    },
  },
  {
    name: '05-jardim-caminhada',
    path: '/jardim',
    crf: 24, // grain + trails are heavy: keeps it < 100MB
    async prepare(page, { wait }) {
      await wait(4000)
    },
    async act(page, { scrollThrough, wait }) {
      await wait(1500)
      await scrollThrough(page, 'main > section', 36000, 0, 1)
      await wait(2000)
    },
  },
  {
    name: '06-wordmark-tinta',
    path: '/',
    async prepare(page, { wait }) {
      await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight - innerHeight * 2.2))
      await wait(2500)
    },
    async act(page, { scrollTo, wait }) {
      await wait(800)
      await scrollTo(page, 1e7, 7000)
      await wait(4000)
    },
  },
  {
    name: '07-site-percurso',
    path: '/',
    async prepare(page, { wait }) {
      await wait(6000)
    },
    async act(page, { scrollThrough, wait }) {
      await wait(3000)
      await scrollThrough(page, null, 50000, 0, 1)
      await wait(2000)
    },
  },
]
```

## File: site-reels/scripts/snap.mjs
size: 7684
sha: 886d4f54d3b036ac1aa801cb08ee5a5b5324260a
upstream: https://github.com/pedrojaques99/visant-skills-public/blob/main/site-reels/scripts/snap.mjs

```text
// Deterministic frame-by-frame recording: timeweb (timesnap's virtual clock) freezes
// rAF, performance.now, setTimeout and video; each frame is rendered and captured without hurry.
// A locked 60fps even with heavy WebGL or a busy machine.
// Usage: node snap.mjs <scenes.mjs> [scene ...]   Env: OUT, FPS (60), CHROME, FFMPEG, BASE.
// Scene: { name, path, theme?, css?, dur (s), wait? (real ms to load), pre? (virtual ms of
//          settling before the scroll, 2000), start? (virtual s skipped before recording),
//          scroll?: { sel|null, from, to, at, len } (scroll as a function of virtual recording time),
//          scroll.bottom?: [from, to] in screen heights above the end of the page,
//          frame?(page, t) (every frame), preserve? (preserveDrawingBuffer, true), crf? }
// Requires scripts/patch-timeweb.mjs applied (postinstall).
import puppeteer from 'puppeteer-core'
import { findChrome, angleArgs } from './find-chrome.mjs'
import { mkdirSync, readFileSync, rmSync } from 'node:fs'
import { execFileSync, spawnSync } from 'node:child_process'
import { createRequire } from 'node:module'
import { pathToFileURL } from 'node:url'
import { join, resolve } from 'node:path'
import { tmpdir } from 'node:os'

const BASE = process.env.BASE ?? 'http://localhost:3100'
const OUT = process.env.OUT ?? './videos'
const FPS = +(process.env.FPS ?? 60)
const CHROME = findChrome() // CHROME env, else installed Chrome/Edge, else Playwright Chromium (find-chrome.mjs)
const FFMPEG = process.env.FFMPEG ?? 'ffmpeg' // on PATH, or full path in FFMPEG
const W = 1920, H = 1080
const TIMEWEB = readFileSync(createRequire(import.meta.url).resolve('timeweb/dist/timeweb.js'), 'utf8')
if (!TIMEWEB.includes('patch-timeweb')) throw new Error('run: node scripts/patch-timeweb.mjs')

const HIDE_UI = `
  header, nav { display: none !important; }
  .fixed.mix-blend-difference.pointer-events-none[class*='z-'] { display: none !important; }
  html, body, * { cursor: none !important; }
  ::-webkit-scrollbar { display: none; }
`
const wait = (ms) => new Promise((r) => setTimeout(r, ms))
const ease = (x) => (x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2)

async function snap(scene, browser) {
  const page = await browser.newPage()
  await page.setViewport({ width: W, height: H, deviceScaleFactor: 1 })
  await page.emulateMediaFeatures([{ name: 'prefers-color-scheme', value: scene.theme ?? 'light' }])
  await page.evaluateOnNewDocument(TIMEWEB)
  await page.evaluateOnNewDocument(
    (t, css, pdb) => {
      localStorage.setItem('theme', t)
      setInterval(() => {
        if (document.getElementById('reels-hide') || !document.body) return
        const st = document.createElement('style')
        st.id = 'reels-hide'
        st.textContent = css
        document.body.appendChild(st)
      }, 100)
      // WebGL without preserveDrawingBuffer: the capture sometimes gets an already cleared canvas
      const getContext = HTMLCanvasElement.prototype.getContext
      if (pdb)
        HTMLCanvasElement.prototype.getContext = function (type, attrs) {
          if (/webgl/.test(type)) attrs = { ...(attrs || {}), preserveDrawingBuffer: true }
          return getContext.call(this, type, attrs)
        }
    },
    scene.theme ?? 'light',
    HIDE_UI + (scene.css ?? ''),
    scene.preserve ?? true,
  )
  await page.goto(/^https?:/.test(scene.path) ? scene.path : BASE + scene.path, { waitUntil: 'networkidle2', timeout: 60000 })
  await page.mouse.move(-10, -10)

  let now = 0 // virtual clock (ms)
  const goTo = (x) => page.evaluate((x) => window.timeweb.goTo(x), x)
  // scroll as a function of recording time (s), with easing
  const scrollAt = async (t) => {
    if (!scene.scroll) return
    const { sel, from, to, at = 0, len = 1, bottom } = scene.scroll
    const k = ease(Math.min(1, Math.max(0, (t - at) / len)))
    await page.evaluate(
      ({ sel, f, bottom }) => {
        // bottom: [from, to] in screen heights above the end of the page
        if (bottom) return window.scrollTo(0, document.documentElement.scrollHeight - innerHeight * (1 + f))
        const el = sel ? document.querySelector(sel) : document.documentElement
        const top = sel ? el.getBoundingClientRect().top + scrollY : 0
        window.scrollTo(0, top + (el.scrollHeight - innerHeight) * f)
      },
      { sel, bottom: !!bottom, f: bottom ? bottom[0] + (bottom[1] - bottom[0]) * k : from + (to - from) * k },
    )
  }

  await wait(1500)
  for (const end = scene.pre ?? 2000; now < end; ) await goTo((now += 1000 / FPS)) // hydration, intro, timers
  if (scene.prepare) await scene.prepare(page)
  await scrollAt(0)
  await wait(scene.wait ?? 3000) // on-demand 3D loads in real time
  const skip = (scene.start ?? 0) * 1000
  for (const t0 = now; now < t0 + skip; ) {
    now += 1000 / FPS
    await scrollAt((now - t0 - skip) / 1000)
    await goTo(now)
  }

  const frames = join(tmpdir(), `snap-frames-${scene.name}`) // outside Drive: thousands of jpgs do not sync
  rmSync(frames, { recursive: true, force: true })
  mkdirSync(frames, { recursive: true })
  const n = Math.round(scene.dur * FPS)
  const rec0 = now
  for (let i = 0; i < n; i++) {
    now = rec0 + (i * 1000) / FPS
    await scrollAt((now - rec0) / 1000)
    if (scene.frame) await scene.frame(page, (now - rec0) / 1000) // e.g. mouse as a function of time
    await goTo(now)
    await page.screenshot({ path: join(frames, `f${String(i).padStart(5, '0')}.jpg`), type: 'jpeg', quality: 95 })
  }
  await page.close()

  const file = `${OUT}/${scene.name}.mp4`
  execFileSync(FFMPEG, ['-y', '-loglevel', 'error', '-framerate', String(FPS), '-i', join(frames, 'f%05d.jpg'), '-c:v', 'libx264', '-preset', 'slow', '-crf', String(scene.crf ?? 20), '-pix_fmt', 'yuv420p', '-movflags', '+faststart', file])
  rmSync(frames, { recursive: true, force: true })
  const r = spawnSync(FFMPEG, ['-hide_banner', '-i', file, '-vf', 'mpdecimate=hi=64:lo=24:frac=0.05', '-an', '-f', 'null', '-'], { encoding: 'utf8' })
  const uniq = +([...r.stderr.matchAll(/frame=\s*(\d+)/g)].pop()?.[1] ?? 0)
  const flash = flashes(file)
  console.log(flash.length ? 'FLASH' : 'ok', file, `${(uniq / scene.dur).toFixed(1)} unique frames/s of ${FPS}`, flash.length ? `flashing frames: ${flash.slice(0, 10).join(' ')}` : '')
}

// Flashing frame = mean luminance that departs from both neighbors in the same direction
function flashes(file, tol = 6) {
  const r = spawnSync(FFMPEG, ['-hide_banner', '-i', file, '-vf', 'scale=64:-1,signalstats,metadata=print:key=lavfi.signalstats.YAVG', '-an', '-f', 'null', '-'], { encoding: 'utf8' })
  const y = [...r.stderr.matchAll(/YAVG=([\d.]+)/g)].map((m) => +m[1])
  const out = []
  for (let i = 1; i < y.length - 1; i++) {
    const a = y[i] - y[i - 1], b = y[i] - y[i + 1]
    if (Math.sign(a) === Math.sign(b) && Math.min(Math.abs(a), Math.abs(b)) > tol) out.push(i)
  }
  return out
}

mkdirSync(OUT, { recursive: true })
const { SCENES } = await import(pathToFileURL(resolve(process.argv[2])).href)
const pick = process.argv.slice(3)
const list = pick.length ? SCENES.filter((s) => pick.includes(s.name)) : SCENES
const browser = await puppeteer.launch({
  executablePath: CHROME,
  headless: false, // real GPU (headless falls back to SwiftShader)
  defaultViewport: null,
  args: [`--window-size=${W},${H + 140}`, '--autoplay-policy=no-user-gesture-required', '--hide-scrollbars', ...angleArgs, '--ignore-gpu-blocklist', '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding', '--disable-background-timer-throttling', '--disable-features=CalculateNativeWinOcclusion'],
})
for (const s of list) await snap(s, browser)
await browser.close()
```

## File: site-reels/references/cases.md
size: 2656
sha: c7679b77ff64b459b400c8a70d923a9ff1bd1f73
upstream: https://github.com/pedrojaques99/visant-skills-public/blob/main/site-reels/references/cases.md

```text
# Cases behind the rules

The rules in `SKILL.md` came from these jobs. Paths below were on the original author's machine
and are not shipped; they only say where the reference implementation lived.

## Client Y: site plates for the deck (Oct 2026)

- 7 videos approved. Reference project kept privately (`marca-reels`), with an `entrega.sh` that
  became `scripts/deliver.example.sh`.
- Brand showreel reference project (`marca-showreel`): `src/fx.tsx` held `Mist`, `MistWords`,
  `Grain`, `KenBurns`, `Plate` and the `nevoa()` transition; `brand.ts` held the brand data.
- The site used Mozilla Text while the visual identity in the deck was Zalando Sans: that is why
  the rule says type and palette come from the deck.
- Frantic reel (`src/reel.tsx`) was approved with the Arko® Brand Reel as rhythm reference; the
  slow 1:40 version was rejected.
- Soft/smoky reel v2 (`src/reel2.tsx`) is where the multiply-overlay smoke technique came from.
- Figma layouts assembling: alpha extraction script was `layers-src/matte.mjs` (ImageMagick).

## Product B: real-app showreel (Oct 2026)

- Reference project `produto-showreel`: capture in `scripts/capture.mjs`, cut in
  `src/v6/Punch.tsx`, render + loudness by `scripts/render-fast.ps1 -Only <cut>` (PowerShell).
- The vague line "Ele chega sozinho." was rejected for "Ele mesmo cria o perfil.": copy has to be
  obvious and Brazilian-spoken. Accent was a cyan full stop.
- Language per composition: `src/pt.ts` imported `pt-flag.ts` before the Root, same as the teaser.
- Chat attachment in the OpenBot agent does not accept MP4: remux to `.mov` (`-c copy`) and attach
  from the agent folder.
- Windows-only tip used there: a Google Drive folder link without a logged-in browser. The folder
  id is in Drive for desktop's database
  (`%LOCALAPPDATA%\Google\DriveFS\<account>\mirror_metadata_sqlite.db`, table `items`, column
  `id`; open **read-only**). URL: `https://drive.google.com/drive/folders/<id>`.

## Client T: portfolio cards and studio reel (Oct 2026)

- Reference: a shared `cliente-t/motion/` folder (README + tools) plus `estudio-cliente/reels`
  (scenes) and `estudio-cliente/motion` (Remotion).
- `record.mjs` gained `W`, `H`, `DPR`, `MOBILE=1` and `KEEP_UI=1` in this job.
- Scenes were generated from `portfolio.json` (only `publico:true` with a URL).
- Card packaging script: `tools/pack-cards.ps1` (PowerShell).
- PowerShell gotchas paid there: variables ignore case (`$d` overwrites `$D`) and `R` is an alias
  of `Invoke-History`. Use unique names.
- CC0 track that worked: Free Music Archive, John Bartmann (CC0 album). The MP3 comes from the
  `fileUrl` in the track page HTML.
```

## File: README.md
upstream: https://github.com/pedrojaques99/visant-skills-public/blob/main/README.md

````text
# visant-skills-public

[Claude Code](https://docs.claude.com/en/docs/claude-code/skills) skills from
[Visant](https://visantlabs.com): how to decide interface, motion and copy, how
to audit a screen before the PR, and how to cut video, from a recorded website
to a Premiere rough cut. They work in any repo: each skill first discovers what
the target repo already has (gates, primitives, tokens) and only then acts. The
detectors are real scripts, and `npm test` proves every rule both ways against
fixtures.

## Install

```bash
npx skills add pedrojaques99/visant-skills-public                       # pick from the list
npx skills add pedrojaques99/visant-skills-public --skill visant-killer # just one
npx skills add pedrojaques99/visant-skills-public --skill visant-design visant-killer visant-frontend visant-motion visant-refine visant-copy # the interface set
```

Or copy a skill folder into `~/.claude/skills/`. Then just ask, in English or
Portuguese: "audit this screen", "tá com cara de IA", "make this smoother",
"edit this lesson".

## What works where

| | React, Next (`.tsx`, `.jsx`) | Vue, Svelte, Astro, HTML |
|---|---|---|
| Judgment: `visant-design`, `visant-frontend`, `visant-motion`, declutter, devil's advocate | yes | yes |
| Open-screen scans: `corta-url`, `corta-scan`, `ritmo-scan`, `tela-scan`, `regressao` | yes | yes |
| `copy-lint` | yes | yes |
| `killer-scan` and the code detectors | yes | refuses with exit 2; it never reports a zero it did not read |

Built on the open [Agent Skills](https://agentskills.io) format. Commands use `<skill-dir>`: the folder holding that `SKILL.md`.

**Tested on Claude Code only.** The skills follow the open format and validate with `skills-ref`, so other agents that read `SKILL.md` should work, but we have not run them there yet. Issues with Codex, Cursor or Gemini are welcome.

## What the killer catches

A typical AI pricing card, in a shadcn project with no design lint of its own:

```tsx
<section className="border rounded-2xl p-8">
  <span className="font-mono text-xs uppercase tracking-widest text-white/50">01 / Pricing</span>
  <h1 className="text-5xl font-serif tracking-tighter">Design that <em>converts</em></h1>
  <p className="text-white/70">Unlock your full potential — a revolutionary way to ship.</p>
  <div className="border rounded-xl p-4 text-purple-500">
    <span className="text-white/40">per month</span>
    <span className="text-white/90">$49</span>
  </div>
</section>
```

```text
$ node visant-killer/scripts/killer-scan.mjs demo/src

[OK]      impeccable   (AI tell)     zero
[FAIL]    audit:design (token)       1 finding(s)
            src/Pricing.tsx:7  tailwind-palette  <div className="border rounded-xl p-4 text-purple-500">
            via killer-scan built-in FALLBACK; token layer found (shadcn components.json): palette classes bypass it
[FAIL]    copy         (tic)         2 finding(s)
            src/Pricing.tsx:6  em-dash
            src/Pricing.tsx:6  en-hype
[FAIL]    noise        (chrome)      3 finding(s)
            src/Pricing.tsx:5  italic-headline-word  <em> italic inside <h1> headline
            src/Pricing.tsx:5  display-serif-tight  display serif + negative tracking
            src/Pricing.tsx:4  opacity-ladder  text-white: /50 /70 /40 /90

gates: 4/4 ran [impeccable, audit:design (fallback), copy, noise]; none skipped
GATE CLOSED: 3 detector(s) with findings. A confirmed finding is a defect, not a taste call: list it and ask before editing.
```

That is only the script half. The skill then judges what no detector sees
(does each element move a business variable?), opens the screen in a browser,
argues against its own verdict and scores the result.

## Tested on repos we don't own

Every detector was run, from outside, on three public Next.js projects with no
Visant code, then calibrated until what remained was defensible:

| Repo | Token gate | Noise gate | Distribution |
|---|---|---|---|
| [vercel/commerce](https://github.com/vercel/commerce) | passes: no token layer, Tailwind palette used as the design system (3 neutral, 3 accent hues) | 2: `hover-lift` on a product image and gallery buttons | 1 LOW: checkout hands off to Shopify, no `begin_checkout` event |
| [shadcn-ui/taxonomy](https://github.com/shadcn-ui/taxonomy) | 9: palette classes that bypass its CSS-variable tokens (`text-red-600`, `bg-slate-50`) | 2: a hand-drawn lucide `Shield` while lucide-react is installed; "Enter your email below…" | 1 HIGH (public routes without description), a route without title, no sitemap |
| [steven-tey/precedent](https://github.com/steven-tey/precedent) | 4 raw hex (Twitter blue as a one-off class, a gauge circle) + 1 palette-chaos finding (4 accent hues) | 2: the Vercel triangle, a CTA that grows on hover | clean |

What the calibration fixed, so it holds on any repo: palette classes only count
as a bypass when there is a token layer to bypass; SVGs in an `icons/` folder
and brand logos are not "hand-drawn icons"; components are never routes; a
hosted checkout is not an unmeasured form. Each fix is locked by a fixture.

## Interface

| Skill | What it does | Needs |
|---|---|---|
| [`visant-design`](visant-design/SKILL.md) | **Start here.** One door: reads the ask and loads the right playbook below | the skills it routes to |
| [`visant-killer`](visant-killer/SKILL.md) | Anti-slop UI audit: detectors as a hard gate, judgment through the skills below, a devil's advocate pass and a score. `tela-scan` and `regressao` measure what a screen lost, because removing is not automatically better | Node 20+; Playwright optional (screen checks); axe-core optional (target size, name) |
| [`visant-frontend`](visant-frontend/SKILL.md) | Interface decisions: every element names the business variable it moves, or it goes | nothing |
| [`visant-motion`](visant-motion/SKILL.md) | Easing/duration tokens for CSS and framer-motion, `motion-lint`, URL measurement | Node 20+ |
| [`visant-refine`](visant-refine/SKILL.md) | Charts, gauges, sparklines, sliders and metric tiles with one shared anatomy | Node 20+ |
| [`visant-copy`](visant-copy/SKILL.md) | Brazilian Portuguese copy in a spoken voice, with `copy-lint` against AI tells in PT and English copy | Node 20+ |

## Video

| Skill | What it does | Needs |
|---|---|---|
| [`site-reels`](site-reels/SKILL.md) | Records a website's best scenes (WebGL hero, scroll, 3D) as 1080p MP4 and builds brand or product showreels in Remotion | Chrome or Edge, ffmpeg, Node |
| [`visant-premiere-editor`](visant-premiere-editor/SKILL.md) | Builds the rough cut in Premiere Pro from a JSON cut plan: frame-exact cuts, markers, punch-ins, cards, b-roll, -14 LUFS delivery | Premiere Pro + [premiere-pro-mcp](https://github.com/leancoderkavy/premiere-pro-mcp), Python |
| [`visant-video-editor`](visant-video-editor/SKILL.md) | Vlogs, event videos and lessons from raw footage: breakdown, silence cuts that never clip a word, loudness, chapters | bash, ffmpeg, Python, faster-whisper |

## Process

| Skill | What it does | Needs |
|---|---|---|
| [`visant-methodology`](visant-methodology/SKILL.md) | Visant's 9-stage brand-building method and the dependency cascade between stages | nothing |
| [`visant-preflight`](visant-preflight/SKILL.md) | Preflight for a big project: one question round plus five premise checks (source of truth, fonts, photo resolution, brand files, sourced numbers) | Node 20+ |
| [`visant-mcp-connect`](visant-mcp-connect/SKILL.md) | Connects and runs the Visant Labs MCP | a Visant Labs account |

Each SKILL.md opens with its exact requirements and how to check them.

## Pair it with the library

The interface skills look for a finished piece before writing one:
[**@visant/ui**](https://ui.visantlabs.com), React components in a shadcn
registry, already through `visant-killer`. The code lands in your project; it
is not an npm package.

```bash
# components.json → "registries": { "@visant": "https://ui.visantlabs.com/r/{name}.json" }
npx shadcn@latest add 
````
