Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 163 additions & 0 deletions video/videos/accent-picker/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
# accent-picker — the 0.67 Appearance clip (~24s)

The rendered `accent-picker.mp4` + `accent-picker-poster.jpg` are **not in this
PR**. They ship with the `<DemoVideo>` markup that points at them, on the What's
New branch, because both PRs would otherwise add the same two paths under
`website/public/demo/` and whichever merged second would hit an add/add conflict
on a 1.3 MB binary.

Ordering, for whoever lands the pair: `public/demo/` is served as-is with no
build-time check, so markup that merges *ahead* of its asset 404s on the live
site while the build stays green. An asset ahead of its markup is merely an
unreferenced file. **Asset first, or both together — never markup first.**

> ## ⚠️ This directory is inert on `main` today
>
> Every import here resolves into `video/lib/`, which is **on PR #584
> (`video/demo-harness`) and is not on `main`**:
>
> - `scenes/scene1.mjs` → `../../../lib/record.mjs`, `../../../lib/cinematics.mjs`
> - `manifest.mjs` → `../../lib/paths.mjs`
>
> So running any of it on `main` fails with `ERR_MODULE_NOT_FOUND`, **and that is
> expected, not a defect.** #584 was deliberately not merged to land this. Until
> it does, run this from a worktree of that branch with this directory copied in;
> when it lands, this works as-is.
>
> It is committed now because a rendered MP4 whose recipe lives on one box is a
> clip nobody can ever re-cut.

## Why a video and not a still

A still of the accent picker is a screenshot of a slider. The subject is a
*continuous* change — one click and the wordmark, the nav row, the chip
borders, the preview row's button, link and status dot all move together. That
is the thing a reader cannot infer from a static frame, and it is the headline
of the release the 0.67 What's New entry has to carry.

## ⛔ What this clip must never claim

**Two opposite captions are both wrong here, and the true position is narrower
than either.** `solve()` does treat the contrast floor as a guarantee, and
`repairFill` does repair derived tokens — so "nothing is enforced" is false.
But when the solve *fails*, it is applied silently: `hit` is discarded and
`report.ok` is never surfaced (#813; #816 is the tint equivalent) — so "every
combination passes AA" is false too.

So no caption in this cut goes near readability in either direction. The claim
is *the whole UI follows*, which is exactly what the frames show and is
independent of the open issues.

If you re-cut this and are tempted to add a beat showing a colour being rejected
or clamped: that beat cannot be shot. A failing solve produces no visible
refusal — that silence is precisely what #813 is about.

## Shots

Four takes, one clip each (`scenes/scene1.mjs`), cut into six segments — two
clips are each split in two so a beat can carry its own caption.

| clip | what happens | measured |
|---|---|---|
| `accent-open` | land on `/config`, hold still | lead-in 2.4s |
| `accent-hues` | Teal → Ember → Violet | 4.6s · 8.5s · 11.4s |
| `accent-persist` | pick Violet, navigate to a project | 4.7s · 7.3s |
| `accent-themes` | Parchment → Terminal → Sci-Fi | 4.8s · 8.8s · 12.0s |

**Those trim points are measured off 2 fps contact sheets of the actual clips,
not assumed from the nominal lead-in.** Re-record a shot and re-derive its
trim point — the clicks land wherever the cursor animation got to.

Getting this wrong is quiet rather than loud: the first cut put Sci-Fi 1.0s
before the end, so the payoff beat read as the film running out rather than as
an arrival.

## A caption can be true and still be contradicted by its own frame

Segment 2's caption was originally **"No save, no restart"** — a fair paraphrase
of the Appearance panel's own subtitle. But that beat is on `/config`, and the
amber banner at the foot of that page reads *"Changes here are written to
`paddock.config.yaml` and take effect only after the server restarts"*, with a
**Save changes** button beside it. The caption landed directly above both.

Both statements are true and they are about **different scopes** — appearance is
per-browser and applies immediately; the banner is about file-backed instance
config. But a viewer watching a 24-second loop cannot stop to reason about
scope, and what they see is a caption being flatly denied by the words it is
touching.

**Dropping the colliding word was not enough.** "No save step" still repeats
what the subtitle already says overhead, so it spends three seconds telling the
viewer something they have read. The shipped caption is **"Ten named hues"** —
it abandons the immediacy claim entirely (the subtitle makes it perfectly well)
and says something the frame does not: how many there are. Ten chips, Ember
through Rose, all on screen.

The same defect was then found in **segment 1**, which read **"Pick any
colour"** — verbatim the ACCENT COLOUR section's own subtitle. It ships as
**"Chrome, buttons and links"**.

**And the tempting generalisation is wrong too.** "Every surface follows" reads
beautifully and is contradicted by a label in the very frame it would sit on:
the PREVIEW row ends *"status hues (theme's, not yours)"*, and those four dots
deliberately do **not** track the accent. So the caption enumerates the three
things that visibly do, rather than generalising over one that does not.

**The general rule: a caption must add something the frame does not already say,
and must survive everything else in the frame.** Redundancy is the common
failure; contradiction is the expensive one. Neither was findable by re-reading
the manifest, and no automated check would catch either — caption text and page
text are both correct in isolation. They only appear when you look at a rendered
frame with the caption burned in.

## Staging

Shot against a `docs-media`-style rig — synthetic projects only, `driveMode:
batch`, a fake `claude`, isolated `HOME` **and** `CLAUDE_CONFIG_DIR`. See
`tools/docs-media/` (PR #830) for the launcher and seed. Beyond the standard
seed, three of the four adoptable native sessions were adopted into Tidepool so
the destination of the route-change beat has real chats with real relative dates
rather than an empty state.

`PADDOCK_RIG_BASE` points the scene at the rig; there is no baked-in port.

## Appearance is pinned in `addInitScript`, not `page.evaluate`

Three localStorage keys, read by a **pre-paint** inline script. Setting them
after `goto` is too late — you get a flash, or a frame captured mid-swap.
`paddock:appearance-cache` is removed rather than left, because it is keyed
`<theme>:<dark|light>` and a stale entry paints the previous accent for one
frame.

The pin is **idempotent**, guarded by a sentinel key. An unconditional init
script re-runs on every navigation and would reset the accent during the
route-change shot — which would still look fine, and would be showing the
opposite of what the caption says.

## Leak check

The film never scrolls, and every frame sits at `scrollY: 0`. That matters:
`/config`, `/projects/<slug>` and Home all carry host paths **below the fold**
(the Advanced read-only section, the project working directory). A whole-document
`innerText` scan reports those and is correct to; what decides whether a clip is
publishable is whether a leaking element was ever *inside the viewport*. Both
scans were run — document-wide and viewport-restricted — with a control string
proving the matcher discriminates. Nothing was on camera.

**If you add a scrolling beat, re-run the viewport-restricted scan at every
scroll position, not just at the top.** And `strings clip.mp4` is not a leak
check: rendered text is pixels.

## Encoding

`segmentCrf: 32`, not the harness default of 16. The join is `-c copy`, so the
shipped bitrate is set by `segmentCrf` — `finalCrf` only bites under
`--crossfade`. At 16 this came out at 4.4 MB, faithfully preserving VP8's own
1 Mbit/s compression noise; 32 lands at ~1.3 MB with no visible difference on UI
chrome, checked by comparing crops at 26 / 30 / 34.

Durations are exact frame multiples (0.04s steps). A 2.1s segment asks for 52.5
frames and gets 53, and four such segments put the film two frames past its own
expected total — `assemble.mjs` reported `*** MISMATCH ***`, correctly. Quantise
rather than ignoring the warning; the exact-frame arithmetic is only worth
having if the check stays meaningful.
143 changes: 143 additions & 0 deletions video/videos/accent-picker/manifest.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/**
* manifest.mjs — THE CUT for the accent-picker docs clip (~24s).
*
* env -u NODE_ENV node video/lib/assemble.mjs \
* video/videos/accent-picker/manifest.mjs --name accent-picker --target 24 --no-gif
*
* 1280x800, not the harness default 1920x1080: this ships into
* website/public/demo/ alongside config-filter.mp4 (1280x800) and
* subagent-bar.mp4 (1280x720), and it is displayed in a docs column. Recording
* at 1920 and downscaling would render the UI text 1.5x smaller than native at
* the size it is actually watched, and small UI text is the entire payload.
*
* --no-gif is deliberate and settled: DemoVideo.astro's own header carries the
* measurement (MP4 1.1 MB vs GIF 6.1 MB *and already too soft to read* on a
* comparable clip). GIF's 256-colour dithering destroys small UI text, and on
* THIS clip it would also band the very colour gradients that are the subject.
*
* TRIM POINTS ARE MEASURED, NOT NOMINAL. Each was read off a 2 fps contact
* sheet of the actual clip, not assumed from the 2.2s lead-in. Re-record any
* shot and you must re-derive its trim point — the clicks land wherever the
* cursor animation got to, which moves between takes.
*
* accent-hues.webm teal 4.6s · ember 8.5s · violet 11.4s (len 15.48)
* accent-persist.webm violet 4.7s · navigation 7.3s (len 13.40)
* accent-themes.webm parchment 4.8s · terminal 8.8s · sci-fi 11.8s (len 17.32)
*
* DURATIONS ARE EXACT FRAME MULTIPLES (n/25 = 0.04s steps). Segments are cut
* with `-frames:v round(duration x 25)`, so a duration of 2.1s asks for 52.5
* frames, gets 53, and runs 0.02s long. Four such segments put the film two
* frames past its own expected total and assemble.mjs reported
* `*** MISMATCH ***` — correctly. It is a rounding artefact rather than a
* defect, but the whole point of the exact-frame arithmetic is that the check
* stays meaningful, so the durations below are quantised instead of the
* warning being ignored.
*
* Two clips are each cut into TWO segments so a beat can carry its own caption
* — same source file, different trims, hard cut between. That is cheaper and
* steadier than re-recording the shot twice.
*
* ⛔ CAPTIONS: no beat here makes a readability claim, in EITHER direction.
* solve() does treat the contrast floor as a guarantee and repairFill does
* repair derived tokens, so "nothing is enforced" is false; but a FAILING solve
* is applied silently — `hit` discarded, report.ok never surfaced (#813; #816
* for the tint equivalent) — so "every combination passes AA" is false too. The
* claim is "the whole UI follows", which the frames show and which is
* independent of both issues.
*/
import { OUT_DIR } from "../../lib/paths.mjs";

const OUT = OUT_DIR;

export default {
width: 1280,
height: 800,
gifEnabled: false,
target: 24,
// The join is `-c copy`, so the SHIPPED bitrate is set by segmentCrf, not
// finalCrf — finalCrf only bites when --crossfade forces a second encode.
// The harness default (16) is tuned for a 1920x1080 hero film and produced a
// 4.4 MB file here: it was faithfully preserving VP8's own 1 Mbit/s
// compression noise. 32 lands at ~1.2 MB — in line with subagent-bar.mp4 (1.07 MB, 35s)
// in website/public/demo/, with no visible difference on UI chrome.
segmentCrf: 32,
segments: [
// Establish. Still frame, no cursor movement — the cut itself is the beat.
{
clip: `${OUT}/accent-open.webm`,
trimStart: 2.4,
duration: 2.12,
caption: "Appearance lives in Config",
captionDelay: 0.25,
captionDuration: 1.7,
},

// Teal. One click, five accented surfaces move at once: the wordmark, the
// Config row, the chip borders, and the PREVIEW row's Send / link / dot.
{
clip: `${OUT}/accent-hues.webm`,
trimStart: 4.1,
duration: 3.92,
caption: "Chrome, buttons and links",
captionDelay: 0.9,
captionDuration: 2.6,
},

// Ember, then Violet. Two more recolours with no save step in between.
//
// The caption deliberately does NOT say "no restart", though the panel's own
// subtitle does. This beat is on /config, and the amber banner at the foot
// of that page reads "...take effect only after the server restarts". Both
// statements are true and they are about different scopes — appearance is
// per-browser and immediate; the banner is about file-backed instance config
// — but a viewer seeing both words in one frame reads a contradiction, and a
// 24-second clip is exactly where nobody can stop to reason about scope.
// Dropping the colliding word costs nothing: "no save step" already carries
// the immediacy. Caught by looking at the frame, not by reading the manifest.
{
clip: `${OUT}/accent-hues.webm`,
trimStart: 8.0,
duration: 4.72,
caption: "Ten named hues",
captionDelay: 0.4,
captionDuration: 3.2,
},

// Route change. Trimmed in AFTER the pick so the shot is the navigation;
// the violet on the destination was genuinely produced on camera.
{
clip: `${OUT}/accent-persist.webm`,
trimStart: 6.8,
duration: 3.52,
caption: "Everywhere, not just here",
captionDelay: 1.0,
captionDuration: 2.3,
},

// Parchment. Ground, chrome and typeface all change, so the whole frame
// moves — the longest hold in the film, for the bitrate's sake.
{
clip: `${OUT}/accent-themes.webm`,
trimStart: 4.2,
duration: 4.4,
caption: "Four themes",
captionDelay: 0.9,
captionDuration: 2.8,
},

// Terminal, then Sci-Fi. The longest segment, and deliberately so: Sci-Fi
// does not land until source 12.0s, so the first cut of this gave the
// payoff beat a 1.0s hold and it read as an accident of the ending. Note
// segments 4 and 5 are CONTIGUOUS in the source (4.2→8.6→14.2) — shortening
// either one without moving the other opens a gap and the cursor jumps.
// Lands on Sci-Fi; the loop back to Foundation reads as a reset.
{
clip: `${OUT}/accent-themes.webm`,
trimStart: 8.6,
duration: 5.6,
caption: "Ground, type and chrome, together",
captionDelay: 0.5,
captionDuration: 3.4,
},
],
};
Loading