retake-demos 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +10 -0
- package/AGENTS.md +213 -0
- package/README.md +58 -20
- package/demos/example.yaml +1 -1
- package/dist/captions.js +56 -0
- package/dist/cards.js +134 -0
- package/dist/cli.js +114 -6
- package/dist/describe.js +28 -9
- package/dist/dryrun.js +3 -0
- package/dist/env.js +76 -0
- package/dist/manifest.js +111 -6
- package/dist/operator/run.js +4 -0
- package/dist/operator/tools.js +62 -5
- package/dist/pace.js +99 -0
- package/dist/presets.js +12 -12
- package/dist/record.js +36 -4
- package/dist/render.js +179 -48
- package/dist/ui/chat.html +82 -3
- package/dist/ui/guide.html +2 -1
- package/dist/ui/index.html +2 -2
- package/dist/ui/server.js +109 -26
- package/dist/voice.js +36 -0
- package/llms.txt +11 -0
- package/package.json +6 -3
- package/site/index.html +141 -39
- package/site/logos/avexjets.png +0 -0
- package/skill/SKILL.md +124 -6
package/.env.example
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
# Your app's demo account, for demos that start behind a login. You rarely edit
|
|
2
|
+
# this by hand: the agent asks by name and the Retake window (or `retake secret
|
|
3
|
+
# APP_USER APP_PASSWORD`) writes it here. Stays on this machine; never sent to the
|
|
4
|
+
# agent. A manifest references ${APP_USER} with `secret: true` — never the values.
|
|
5
|
+
# APP_TOTP_SECRET is the authenticator enrolment secret; ${TOTP:APP_TOTP_SECRET}
|
|
6
|
+
# becomes the current code. Blank lines below count as unset.
|
|
7
|
+
APP_USER=
|
|
8
|
+
APP_PASSWORD=
|
|
9
|
+
APP_TOTP_SECRET=
|
|
10
|
+
|
|
1
11
|
# Retake — optional. Only `retake describe` and `retake ideas` use a model; validate/dry/run/render/check never do.
|
|
2
12
|
# With a coding agent driving Retake over MCP you can leave all of this empty.
|
|
3
13
|
# Pick explicitly with RETAKE_MODEL=claude-code|codex|groq|mistral|local; otherwise the first available is used.
|
package/AGENTS.md
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# Using Retake from Claude Code, Codex, or any coding agent
|
|
2
|
+
|
|
3
|
+
Retake records product demos of web apps from a small YAML manifest. Its CLI
|
|
4
|
+
is deliberately agent-shaped: every command prints plain text, exits non-zero
|
|
5
|
+
on failure, and the cheap checks come before the expensive recording. An agent
|
|
6
|
+
can drive the whole loop — write, check, record, inspect, fix — without ever
|
|
7
|
+
opening the UI.
|
|
8
|
+
|
|
9
|
+
This file is also what Claude Code and Codex read when you point them at this
|
|
10
|
+
folder, so it doubles as the tool's own instructions to them.
|
|
11
|
+
|
|
12
|
+
`retake` here is the installed command (`npm install -g retake-demos`). From a
|
|
13
|
+
clone of this repo the same commands run from source as `npm run retake -- …`.
|
|
14
|
+
The MCP route is better than the CLI when it exists: `retake install` registers
|
|
15
|
+
the tools and the skill with Claude Code, so the loop below becomes tool calls.
|
|
16
|
+
|
|
17
|
+
## Starting from nothing (the person pasted the repo link)
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
npm install -g retake-demos
|
|
21
|
+
mkdir -p ~/retake-demos && cd ~/retake-demos && retake install # workspace + tools + skill
|
|
22
|
+
retake ui # their window: http://localhost:4310
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`retake install` registers the MCP tools with Claude Code (user scope) and
|
|
26
|
+
prints the Codex / Cursor config. Tools load at the start of a session, so
|
|
27
|
+
in the session that ran the install, use the CLI loop below and tell the
|
|
28
|
+
person to restart you afterwards. `retake doctor` says what, if anything,
|
|
29
|
+
is missing. Always tell the person the window's address — they may not know
|
|
30
|
+
it exists.
|
|
31
|
+
|
|
32
|
+
## The loop an agent should run
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
write demos/<name>.yaml
|
|
36
|
+
→ retake validate demos/<name>.yaml schema + warnings (instant)
|
|
37
|
+
→ retake dry demos/<name>.yaml every selector & wait, no camera (~30–60s)
|
|
38
|
+
→ fix what dry reports, repeat until "all N steps resolved"
|
|
39
|
+
→ retake run demos/<name>.yaml --preset preview-fast first take (~2 min)
|
|
40
|
+
→ read outputs/<name>/proof-log.md what actually happened, per step
|
|
41
|
+
→ retake check outputs/<name> pass/fail on the result
|
|
42
|
+
→ adjust, then: retake run demos/<name>.yaml final quality
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Rule of thumb: **never go straight to `run`.** `dry` catches most failures in
|
|
46
|
+
seconds; a failed `run` costs two minutes and a confusing video. When `dry`
|
|
47
|
+
fails it prints the step, the error, and the text that was on screen — read
|
|
48
|
+
that before changing anything.
|
|
49
|
+
|
|
50
|
+
## How to tell Claude Code / Codex to use it
|
|
51
|
+
|
|
52
|
+
From the Retake folder:
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
claude "Record a demo of http://localhost:3000 that shows a user creating
|
|
56
|
+
their first project. Use Retake: draft demos/first-project.yaml,
|
|
57
|
+
dry-run it until it passes, record a preview, then check it.
|
|
58
|
+
Show me the proof log."
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
or with Codex:
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
codex "Using Retake (see AGENTS.md), make a 30-second demo of the sign-up
|
|
65
|
+
flow at http://localhost:3000. Dry-run before recording."
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Both CLIs read this file automatically when run in this directory, so the
|
|
69
|
+
loop above is already in their context. If the agent works from *another*
|
|
70
|
+
folder (your app's repo), add the workspace: `claude --add-dir <your retake workspace>`
|
|
71
|
+
— or skip all this with `retake install`, which gives the agent the tools directly.
|
|
72
|
+
|
|
73
|
+
## Letting the agent draft with Retake's own scouting
|
|
74
|
+
|
|
75
|
+
The agent does not have to write the manifest from nothing:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
retake describe <name> <url> "<one sentence of what to show>" -P <path-to-app-source>
|
|
79
|
+
retake ideas <url> -P <path-to-app-source> # 5–7 demo ideas, saved to ideas/
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`describe` scouts the live page for real, unique selectors, reads the app's
|
|
83
|
+
source for routes / sign-in fields / things that animate, and drafts the YAML.
|
|
84
|
+
`-P` is optional but makes drafts much better. The model is whichever is set in
|
|
85
|
+
`.env` (`RETAKE_MODEL=claude-code|codex|groq|mistral|local`) — so an agent can
|
|
86
|
+
use Retake's drafting, or draft itself; both are fine.
|
|
87
|
+
|
|
88
|
+
## What the agent must know about manifests
|
|
89
|
+
|
|
90
|
+
Full reference: `README.md` → "Demo-as-code". The short version:
|
|
91
|
+
|
|
92
|
+
- **Wait for results, not timers.** After any click that loads or computes
|
|
93
|
+
something, `{ action: waitFor, selector: <thing that appears> }`. A fixed
|
|
94
|
+
`wait` is for pacing only.
|
|
95
|
+
- **Selectors must be unique.** Playwright runs strict: a selector matching two
|
|
96
|
+
elements fails. Prefer ids, `data-testid`, `aria-label`, then
|
|
97
|
+
`button:has-text("…")`; append `>> nth=0` only when duplicates are intended.
|
|
98
|
+
- **The poster** is `thumbnail.png`. `outputs.thumbnail: { scene: <label> }` picks the scene; the person can override it in the window from any frame, a generated cover, or their own image (`POST /api/cover/<name>`). Launch mode also writes `cover.png` (the title card) and `cover-titled.png` (a real frame with the title on it) as candidates. Do not re-render to change a poster.
|
|
99
|
+
- **`mode:`** — `demo` (default) proves the interaction: nothing in the frame
|
|
100
|
+
but the product. A CLIENT WALKTHROUGH IS A DEMO. `launch` presents the
|
|
101
|
+
product in public (a launch, a post, an ad) and may carry a title card,
|
|
102
|
+
music, emphasis and a branded ending — ending with THEIR product, never
|
|
103
|
+
with Retake. Decide from the audience, not the subject; never upgrade a
|
|
104
|
+
demo yourself (`validate` warns).
|
|
105
|
+
- **voiceover** needs a continuous `script:` and `approved: true` from a
|
|
106
|
+
person who heard it. Off otherwise.
|
|
107
|
+
- **The launch cut**: `compressIdle: true` (waits shown short — render-time),
|
|
108
|
+
`voiceover: true` (captions read aloud), `typing: brisk` (re-record). Offer
|
|
109
|
+
as one package when the destination is a launch or post; default is plain.
|
|
110
|
+
- **`music: <file>`** mixes a bed under the video at render (looped, faded
|
|
111
|
+
out). The person supplies the file — never invent a path, never bundle.
|
|
112
|
+
- **Cards and callouts:** `intro: { title, subtitle }` / `outro:` splice a
|
|
113
|
+
title card (render-time; the intro frame becomes cover.png). A
|
|
114
|
+
`{ action: callout, selector, label }` step rings an element on camera.
|
|
115
|
+
At most one callout per scene; never callout what the caption already says.
|
|
116
|
+
- **Scenes carry the story.** `{ action: scene, label, caption }` at each beat;
|
|
117
|
+
captions burn into the video. The camera is still by default; `camera: auto`
|
|
118
|
+
eases toward the last thing touched, or per scene `camera: { focus:
|
|
119
|
+
<selector>, zoom: 1.3 }`. Add zoom only when the person asks.
|
|
120
|
+
- **Logins go in `auth.setup`**, never in `steps` — see "Logins" below.
|
|
121
|
+
- **Backend not available?** `stub:` answers API calls with canned JSON for the
|
|
122
|
+
take, and a `stub` step swaps the answer mid-demo. Every stub is named in the
|
|
123
|
+
proof log.
|
|
124
|
+
- **Long forms:** `{ action: scroll, to: <selector>, align: center }` before
|
|
125
|
+
filling them, so the viewer sees the whole thing.
|
|
126
|
+
|
|
127
|
+
## Logins
|
|
128
|
+
|
|
129
|
+
Most real demos start behind a sign-in. The agent never sees a password:
|
|
130
|
+
|
|
131
|
+
- **Ask by name, not for the value.** Over MCP, call `secrets` with the
|
|
132
|
+
variable names (`APP_USER`, `APP_PASSWORD`, `APP_TOTP_SECRET` for an
|
|
133
|
+
authenticator) and a one-line why. With the Retake window open, a form
|
|
134
|
+
appears there and the values go straight into the workspace `.env` — the
|
|
135
|
+
tool returns "set". Without a window, the tool returns the sentence to
|
|
136
|
+
relay: the person runs `retake secret APP_USER APP_PASSWORD` (typed into
|
|
137
|
+
their terminal, hidden, kept on their machine) and the agent calls
|
|
138
|
+
`secrets` again. From the CLI only, say the same sentence yourself.
|
|
139
|
+
- **Put the sign-in under `auth.setup`** with `${APP_USER}` / `${APP_PASSWORD}`
|
|
140
|
+
and `secret: true`, plus `auth.storageState: .auth/<name>.json`. It runs
|
|
141
|
+
before the camera, is trimmed off, and the session is reused by later takes.
|
|
142
|
+
A failed sign-in never saves a session.
|
|
143
|
+
- **Authenticator codes:** `${TOTP:APP_TOTP_SECRET}` is the current six-digit
|
|
144
|
+
code, computed when the field is filled.
|
|
145
|
+
- **SMS, SSO, captcha:** a script cannot; a person can, once.
|
|
146
|
+
`retake signin demos/<name>.yaml` opens a real window, they log in, press
|
|
147
|
+
Enter, and the session is saved. Leave `auth.setup` empty; a take with no
|
|
148
|
+
fresh session and no setup refuses to run rather than record logged out.
|
|
149
|
+
- **Firebase-style sessions** (IndexedDB) cannot be restored from a saved
|
|
150
|
+
session: put the login under plain `setup` so it runs every take.
|
|
151
|
+
- **Demo accounts only.** The output is a video.
|
|
152
|
+
|
|
153
|
+
## Reading the results
|
|
154
|
+
|
|
155
|
+
- `outputs/<name>/proof-log.md` — per-step ✓/✗ with timings, the shot list,
|
|
156
|
+
quality facts, and which endpoints were stubbed. This is the file to read
|
|
157
|
+
when something looks wrong.
|
|
158
|
+
- `outputs/<name>/take.json` — the same, machine-readable.
|
|
159
|
+
- `outputs/<name>/demo.mp4` — the video. `master.mp4` is the CRF-14 keeper
|
|
160
|
+
on post presets.
|
|
161
|
+
- `retake check outputs/<name>` — resolution, fps, duration, files,
|
|
162
|
+
pass/fail. Exit 3 on failure.
|
|
163
|
+
|
|
164
|
+
Exit codes: `run` → 0 all steps passed, 2 something failed (video still
|
|
165
|
+
produced, proof log says why). `dry` → 3 if any step would fail.
|
|
166
|
+
|
|
167
|
+
## Fixing a take without re-recording
|
|
168
|
+
|
|
169
|
+
Captions, camera zoom, hold length, trim, format and layout are **render-time**.
|
|
170
|
+
Change them in the YAML and `retake render outputs/<name>` — seconds,
|
|
171
|
+
no browser. Only changes to what the browser *does* (steps, selectors, waits,
|
|
172
|
+
viewport) need `run` again; `run --reuse` skips the browser when nothing
|
|
173
|
+
recording-relevant changed, and the UI's one button works this out for you.
|
|
174
|
+
|
|
175
|
+
## Things that trip agents up
|
|
176
|
+
|
|
177
|
+
- The app must be running and reachable at the manifest's `url` before `dry`
|
|
178
|
+
or `run`. Retake says "Nothing is running at …" if not.
|
|
179
|
+
- `describe` and `ideas` need a model configured (`.env` or the UI's Settings).
|
|
180
|
+
`validate`, `dry`, `run`, `render`, `check` never use a model.
|
|
181
|
+
- Two runs on the same demo at once are refused (a lock); wait for the first.
|
|
182
|
+
- Deleted demos go to `.trash/`; `POST /api/demos/<name>/restore` or copy the
|
|
183
|
+
file back.
|
|
184
|
+
- **Wait for durable state, not its announcement.** Toasts and banners clear
|
|
185
|
+
themselves (and often share a class with other notices); a wait on one can
|
|
186
|
+
time out while the thing it announced sits on the board. Wait for the row,
|
|
187
|
+
the count, the heading — `.act >> nth=1` beats `.landed`.
|
|
188
|
+
- `dry` runs file/command seeds first so it sees the state `run` will
|
|
189
|
+
(`--no-seed` to skip). `evaluate` seeds only run with a camera.
|
|
190
|
+
- A one-line `script:` with braces is a YAML mapping, not a string — write
|
|
191
|
+
`script: |` and put the JS on the next line.
|
|
192
|
+
- Iterating on one beat of a long demo: `run demos/x.yaml --until <scene>`
|
|
193
|
+
records up to the end of that scene and stops (MCP: `run` with `until`).
|
|
194
|
+
Better still, keep demos short — above ~40 steps, split the story.
|
|
195
|
+
- If Retake itself fails (render/ffmpeg error, crash), stop and report it —
|
|
196
|
+
do not patch around the tool.
|
|
197
|
+
- **Cursor cap: ~180 moves per take.** (Was ~45; Retake now flattens testreel's
|
|
198
|
+
cursor expressions at install.) `validate` warns past it, the recorder
|
|
199
|
+
detects a failed overlay and `check` FAILs. Split long stories anyway —
|
|
200
|
+
above ~40 steps nobody is watching.
|
|
201
|
+
- Stills: `NN-label.png` is mid-scene, `NN-label-end.png` the last moment.
|
|
202
|
+
- Scroll pacing defaults to constant pixels-per-second; `speed:` overrides.
|
|
203
|
+
- **New tabs are folded back into the recorded page** (`keepInTab`, default
|
|
204
|
+
true): `window.open` navigates in place and `target="_blank"` is stripped.
|
|
205
|
+
Retake records one page, so a popup login or a "preview" button used to lose
|
|
206
|
+
the demo's subject entirely. Set `keepInTab: false` if a flow genuinely needs
|
|
207
|
+
a separate window — and know it will not be on camera.
|
|
208
|
+
- **Iterate at `--preset draft`.** Same 960px layout as the post presets (so
|
|
209
|
+
selectors, wrapping and timing match) at a quarter of the pixels. Measured on
|
|
210
|
+
a 22s demo: capture 22.4s vs 30.1s, render 1.2s vs 3.6s — the 1080p encoder
|
|
211
|
+
adds ~8s to every take and competes with the app being recorded. Ladder:
|
|
212
|
+
`draft` while fixing, `preview-fast` to check final framing, a post preset to
|
|
213
|
+
ship. Never ship a draft.
|
package/README.md
CHANGED
|
@@ -19,6 +19,19 @@ It is built for the way people work now: **you ask your coding agent for a demo
|
|
|
19
19
|
|
|
20
20
|
<p align="center"><img src="assets/window.png" width="900" alt="The Retake window: your demos on the left, the video and its scenes in the middle"></p>
|
|
21
21
|
|
|
22
|
+
## If you are a coding agent reading this
|
|
23
|
+
|
|
24
|
+
Someone pasted this link and wants demos recorded. Do this, in order, and say what you are doing as you go — assume they have never seen Retake:
|
|
25
|
+
|
|
26
|
+
1. `npm install -g retake-demos` (Node 20+; ffmpeg is bundled, Chromium downloads once).
|
|
27
|
+
2. Make a workspace and set up from inside it: `mkdir -p ~/retake-demos && cd ~/retake-demos && retake install`. This creates `demos/` and `outputs/`, registers Retake's tools with Claude Code and installs its skill, and prints the config for Codex/Cursor — apply it if that is what you are.
|
|
28
|
+
3. **Your tools arrive at your next session.** For this one, drive Retake from the CLI exactly as [`AGENTS.md`](AGENTS.md) describes (`validate → dry → run → check`). Tell the person: "Restart me after this and I'll have Retake's tools directly."
|
|
29
|
+
4. Start the window for them — `retake ui` in the workspace — and give them the link: http://localhost:4310. That is where the video, stills and proof log appear.
|
|
30
|
+
5. If they did not say what to record, ask one question: which app (URL) and what to show. If the app needs a login, read the "Behind a login" section below — you never handle the password yourself.
|
|
31
|
+
6. Read [`skill/SKILL.md`](skill/SKILL.md) once: it is the method (order of operations, failures as text, when to stop) and it is what Claude Code loads automatically after step 2.
|
|
32
|
+
|
|
33
|
+
Everything below is for the person; it is accurate for you too.
|
|
34
|
+
|
|
22
35
|
## Install
|
|
23
36
|
|
|
24
37
|
Needs Node 20+. ffmpeg ships with the package; Chromium is downloaded once.
|
|
@@ -48,12 +61,14 @@ outputs/<name>/
|
|
|
48
61
|
demo.mp4 the shareable — captions burned in
|
|
49
62
|
master.mp4 the CRF-14 keeper (post presets only)
|
|
50
63
|
stills/ one PNG per scene, mid-scene and at its last moment
|
|
51
|
-
thumbnail.png the frame
|
|
64
|
+
thumbnail.png the poster — pick any frame, a cover, or your own image in the window
|
|
65
|
+
cover.png launch mode: the title card, settled
|
|
66
|
+
cover-titled.png launch mode: a real frame with the title over it
|
|
52
67
|
proof-log.md result, shot list, every step's timing, pass/fail, what was on screen when it failed
|
|
53
68
|
take.json the raw timeline the renderer reads
|
|
54
69
|
```
|
|
55
70
|
|
|
56
|
-
In the window: play it, switch speed (0.75× to 1.3×, re-rendered in seconds), download, or open its folder — stills, proof log, manifest. Demos group by the app they point at; archive what you're done with.
|
|
71
|
+
In the window: play it, switch speed (0.75× to 1.3×, re-rendered in seconds), download, or open its folder — stills, proof log, manifest. **The poster is chosen, not guessed**: scrub to a frame and press *Use this frame*, click any scene still, take one of the generated covers, or upload your own. It rewrites `thumbnail.png`, so it costs no re-render — and on a feed the poster decides whether anyone presses play. Demos group by the app they point at; archive what you're done with.
|
|
57
72
|
|
|
58
73
|
## The loop
|
|
59
74
|
|
|
@@ -82,7 +97,7 @@ preset: post-landscape # `retake presets` — 1920×1080 page + capti
|
|
|
82
97
|
viewport: { width: 1440, height: 1080 }
|
|
83
98
|
scale: 1.8 # page drawn at 1.8× so text stays crisp
|
|
84
99
|
reducedMotion: true
|
|
85
|
-
camera:
|
|
100
|
+
camera: static # still (the default); `auto` eases toward the last thing touched
|
|
86
101
|
|
|
87
102
|
seed: [] # put the app in a known state first
|
|
88
103
|
setup: [] # runs before the camera; trimmed off the video
|
|
@@ -107,6 +122,8 @@ The full template is [`demos/example.yaml`](demos/example.yaml); `retake init` c
|
|
|
107
122
|
|
|
108
123
|
**Scenes** are the spine: `{ action: scene, label, caption }` at each beat. They take their timestamps from the actual run, so captions land on the frames they describe even when the app took 3 seconds this time and 11 the last.
|
|
109
124
|
|
|
125
|
+
**Two kinds of take.** `mode: demo` (the default) proves how something works — a client walkthrough, a PR clip, a lesson — and keeps everything but the product out of the frame. `mode: launch` presents the product in public and may carry a title card, music, emphasis and a branded ending. `validate` warns if a demo is carrying launch furniture. **The launch cut.** `compressIdle: true` shows the app's long waits as ~1.5 s each (your own pacing `wait`s are never touched); `voiceover: true` reads the captions aloud (edge-tts, `pipx install edge-tts` once); `typing: brisk` makes typed text land fast with the pauses carrying the meaning. All render-time except typing. **Music.** `music: track.mp3` (or `{ file, gainDb, fadeOutMs }`) mixes a bed under the video at render time, looped or trimmed to fit and faded out at the end. Bring your own track — it ships inside your video, so it must be one you may use (CC0 or licensed). **Cards and callouts.** `intro:`/`outro:` add a title card in Retake's own look — rendered at render time, so changing a word is a re-render, and the intro's settled frame is written as `cover.png`, the poster. `{ action: callout, selector, label, ms }` draws an animated ring and label around an element while the take holds — recorded coordinates, drawn at render, moving with the camera. Sparingly: the captions carry the story.
|
|
126
|
+
|
|
110
127
|
**Seeds** put the app in a known state before the camera: write a JSON file, run JS in the page, or run a shell command. **Setup** is the stuff that runs before recording and is trimmed off the front — logins live there. **`tempo`** speeds the finished video up or down at render time; **`lock`** names a shared resource so two demos that touch the same backend don't record over each other.
|
|
111
128
|
|
|
112
129
|
## Quality
|
|
@@ -115,33 +132,54 @@ Think in the publishing format, not the browser window.
|
|
|
115
132
|
|
|
116
133
|
| Preset | Video | Page area | For |
|
|
117
134
|
|---|---:|---:|---|
|
|
118
|
-
| `draft` | 960×
|
|
119
|
-
| `preview-fast` | 1920×
|
|
120
|
-
| `post-landscape` *(default)* | 1920×
|
|
121
|
-
| `post-square` | 1080×1080 | 1080×
|
|
122
|
-
| `post-vertical` | 1080×1920 | 1080×
|
|
123
|
-
| `docs-gif` | 1440×
|
|
124
|
-
| `master` | 1920×
|
|
135
|
+
| `draft` | 960×592 | 960×540 | iterating — the finals' layout at a quarter of the pixels, fastest |
|
|
136
|
+
| `preview-fast` | 1920×1180 | 1920×1080 | checking timing and framing, hardware-encoded |
|
|
137
|
+
| `post-landscape` *(default)* | 1920×1180 | 1920×1080 | the general-purpose demo, plus a CRF-14 master |
|
|
138
|
+
| `post-square` | 1080×1080 | 1080×960 | feeds — a true square |
|
|
139
|
+
| `post-vertical` | 1080×1920 | 1080×1800 | shorts and reels — a true 9:16 |
|
|
140
|
+
| `docs-gif` | 1440×970 | 1440×900 | README and docs GIFs |
|
|
141
|
+
| `master` | 1920×1180 | 1920×1080 | archive, CRF 12 |
|
|
142
|
+
|
|
143
|
+
**The video is the page area plus the caption band, and the band fits the captions.** On landscape presets the page keeps its full 1920×1080 and the band under it is sized to the longest caption — about 100 px for one line, 160 for two, one height for the whole take (video sizes above are for one-line captions). The captions never cover the app. Square and vertical keep a fixed 120 px band inside the canvas, because a feed that wants 1:1 or 9:16 will letterbox anything else. `captions: false` → no band at all. A manifest's `viewport` replaces the page area and the video follows it, so a 1240×1080 viewport gives a 1240×1230 video. `retake presets` prints this table, and `retake validate` tells you the exact size of the video a manifest will produce before you record it.
|
|
144
|
+
|
|
145
|
+
**Page scale** is what makes it crisp: the page lays out as if the viewport were smaller and every glyph is drawn at 2×, so 1080p text reads on a phone. **Camera** is still by default — a calm demo reads as real. `camera: auto` eases toward whatever the demo just touched, at render time, clamped so it can never crop the thing it points at; per scene: `camera: { focus: ".result", zoom: 1.3 }`. Zoom is something you ask for after seeing a take, not something that happens to you. Captions, camera, speed, trim, format and layout are all render-time: change them and `retake render` takes seconds, never touches the browser.
|
|
146
|
+
|
|
147
|
+
## Behind a login
|
|
148
|
+
|
|
149
|
+
Most real demos start on the far side of a sign-in. You never put a password anywhere an agent can read it.
|
|
150
|
+
|
|
151
|
+
**With the window open** (`retake ui`): when the agent sees the app needs a login, a small form appears in the window asking for a demo account by name — `APP_USER`, `APP_PASSWORD`. You type them; they are written to one local file, the workspace `.env` (readable by you only), and never sent to the agent, to any server, or to anyone. The agent learns only that the names are set.
|
|
125
152
|
|
|
126
|
-
**
|
|
153
|
+
**Without the window:** the agent relays one line — run this in your workspace and type the values (hidden):
|
|
127
154
|
|
|
128
|
-
|
|
155
|
+
```bash
|
|
156
|
+
retake secret APP_USER APP_PASSWORD
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
**Then:** the login runs *before the camera* and is trimmed off the front; the video starts signed in. The session is saved to `.auth/`, so later takes skip the login. A wrong password stops the take instead of recording a logged-out one.
|
|
160
|
+
|
|
161
|
+
**Two-factor.** Authenticator apps: add the enrolment secret as `APP_TOTP_SECRET` and the manifest uses `${TOTP:APP_TOTP_SECRET}` — Retake computes the current code when it fills the field. SMS codes, SSO, captchas — anything only a person can do:
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
retake signin demos/<name>.yaml
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
opens a real browser; you log in by hand, press Enter, and Retake keeps the session (not the password) for every later take.
|
|
129
168
|
|
|
130
|
-
|
|
169
|
+
What the agent writes:
|
|
131
170
|
|
|
132
171
|
```yaml
|
|
133
172
|
auth:
|
|
134
173
|
storageState: .auth/myapp.json # session saved here, reused until stale
|
|
135
174
|
maxAgeHours: 8
|
|
136
|
-
setup:
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
175
|
+
setup:
|
|
176
|
+
- { action: fill, selector: "#user", text: "${APP_USER}" }
|
|
177
|
+
- { action: fill, selector: "#password", text: "${APP_PASSWORD}", secret: true }
|
|
178
|
+
- { action: fill, selector: "#code", text: "${TOTP:APP_TOTP_SECRET}", secret: true } # only if asked
|
|
179
|
+
- { action: click, selector: "button[type=submit]" }
|
|
140
180
|
```
|
|
141
181
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
Apps that keep their session in IndexedDB (Firebase) can't be restored from `storageState`; put the login in plain `setup` instead.
|
|
182
|
+
`secret: true` keeps a value out of the terminal, the window and the proof log — but not out of the video, which is why `validate` warns when a secret step is in `steps` rather than `auth.setup`. Use a demo account: the output is a video you may publish. Apps that keep their session in IndexedDB (Firebase) can't be restored from a saved session; put the login under plain `setup` instead.
|
|
145
183
|
|
|
146
184
|
## Limits worth knowing
|
|
147
185
|
|
package/demos/example.yaml
CHANGED
|
@@ -17,7 +17,7 @@ preset: post-landscape # 1080p @ 30fps · see `retake presets`
|
|
|
17
17
|
viewport: { width: 1440, height: 1080 } # match your app's natural shape
|
|
18
18
|
scale: 1.8 # page drawn at 1.8× so text stays crisp at 1080p
|
|
19
19
|
reducedMotion: true # calms infinite spinners and entrance animations
|
|
20
|
-
camera:
|
|
20
|
+
camera: static # still by default; `auto` eases toward the last thing touched
|
|
21
21
|
|
|
22
22
|
# Put the app in a known state before the camera rolls. Pick what fits:
|
|
23
23
|
# - { kind: file, path: /tmp/app/state.json, from: seeds/empty.json }
|
package/dist/captions.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** Word-wrap long captions; two-line captions break near the middle so no
|
|
2
|
+
word is left orphaned on the second line. */
|
|
3
|
+
export function wrap(text, max) {
|
|
4
|
+
if (text.length <= max)
|
|
5
|
+
return text;
|
|
6
|
+
const words = text.split(/\s+/);
|
|
7
|
+
if (text.length <= max * 2) {
|
|
8
|
+
let best = 1, bestDiff = Infinity;
|
|
9
|
+
for (let i = 1; i < words.length; i++) {
|
|
10
|
+
const a = words.slice(0, i).join(" ").length;
|
|
11
|
+
const b = words.slice(i).join(" ").length;
|
|
12
|
+
if (a <= max && b <= max && Math.abs(a - b) < bestDiff) {
|
|
13
|
+
best = i;
|
|
14
|
+
bestDiff = Math.abs(a - b);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return words.slice(0, best).join(" ") + "\n" + words.slice(best).join(" ");
|
|
18
|
+
}
|
|
19
|
+
const out = [];
|
|
20
|
+
let line = "";
|
|
21
|
+
for (const w of words) {
|
|
22
|
+
if (line && (line + " " + w).length > max) {
|
|
23
|
+
out.push(line);
|
|
24
|
+
line = w;
|
|
25
|
+
}
|
|
26
|
+
else
|
|
27
|
+
line = line ? line + " " + w : w;
|
|
28
|
+
}
|
|
29
|
+
if (line)
|
|
30
|
+
out.push(line);
|
|
31
|
+
return out.join("\n");
|
|
32
|
+
}
|
|
33
|
+
/** Characters per caption line at this width and font size. */
|
|
34
|
+
export const maxCharsFor = (width, fontSize) => Math.max(28, Math.round((width / fontSize) * 1.55));
|
|
35
|
+
/** How many lines the longest caption needs. */
|
|
36
|
+
export function captionLines(texts, width, fontSize) {
|
|
37
|
+
const max = maxCharsFor(width, fontSize);
|
|
38
|
+
return Math.max(1, ...texts.map((t) => wrap(t, max).split("\n").length));
|
|
39
|
+
}
|
|
40
|
+
/** The band's height for this take: fitted to the text, or the preset's
|
|
41
|
+
fixed band when the canvas is the constraint. 0 when captions are off. */
|
|
42
|
+
export function bandHeightFor(q, width, texts, pageHeight = q.height) {
|
|
43
|
+
if (q.layout !== "band" && q.layout !== "card")
|
|
44
|
+
return 0;
|
|
45
|
+
if (!q.captions)
|
|
46
|
+
return 0;
|
|
47
|
+
// Fixed canvas: whatever the page turned out to be (an older take may have
|
|
48
|
+
// a different page height), the band fills the rest so the canvas is exact.
|
|
49
|
+
if (q.bandFit === "fill")
|
|
50
|
+
return Math.max(0, q.height + q.bandHeight - pageHeight);
|
|
51
|
+
const size = q.captions.fontSize;
|
|
52
|
+
const lines = captionLines(texts, width, size);
|
|
53
|
+
// glyph height ≈ 1.05 × size; line spacing 0.25 × size; breathing room above and below.
|
|
54
|
+
const h = size * 1.05 * lines + size * 0.25 * (lines - 1) + size * 1.1;
|
|
55
|
+
return Math.ceil(h / 2) * 2;
|
|
56
|
+
}
|
package/dist/cards.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Motion pieces rendered from HTML — title cards and callout overlays.
|
|
3
|
+
*
|
|
4
|
+
* Chromium is the motion renderer: a template page defines `__seek(tMs)`
|
|
5
|
+
* which lays the frame out for that exact moment, and Node steps through
|
|
6
|
+
* the timeline one screenshot per video frame. Deterministic to the pixel —
|
|
7
|
+
* the same take renders the same twice — and styled with the same theme as
|
|
8
|
+
* the caption band, so cards look like Retake, not like a slide deck.
|
|
9
|
+
*
|
|
10
|
+
* All of it is render-time: change a title, `retake render`, seconds.
|
|
11
|
+
*/
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { chromium } from "playwright";
|
|
16
|
+
import { execFileSync } from "node:child_process";
|
|
17
|
+
const AMBER = "#e8b04b";
|
|
18
|
+
const ease = "t<0?0:t>1?1:t*t*(3-2*t)"; // smoothstep, in the page
|
|
19
|
+
function cardHtml(w, h, theme, title, subtitle, ms) {
|
|
20
|
+
const titleSize = Math.round(Math.min(w * 0.055, h * 0.14));
|
|
21
|
+
const subSize = Math.round(titleSize * 0.4);
|
|
22
|
+
return `<!doctype html><meta charset="utf-8"><style>
|
|
23
|
+
html,body{margin:0;width:${w}px;height:${h}px;overflow:hidden;background:${theme.background}}
|
|
24
|
+
.wrap{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","Helvetica Neue",Arial,sans-serif}
|
|
25
|
+
.title{font-size:${titleSize}px;font-weight:700;letter-spacing:-.02em;color:${theme.ink};opacity:0}
|
|
26
|
+
.rule{height:${Math.max(3, Math.round(titleSize * 0.07))}px;background:${AMBER};border-radius:99px;margin:${Math.round(titleSize * 0.35)}px 0;width:0}
|
|
27
|
+
.sub{font-size:${subSize}px;color:${theme.ink};opacity:0;font-weight:400}
|
|
28
|
+
</style>
|
|
29
|
+
<div class="wrap"><div class="title"></div><div class="rule"></div><div class="sub"></div></div>
|
|
30
|
+
<script>
|
|
31
|
+
const T = document.querySelector(".title"), R = document.querySelector(".rule"), S = document.querySelector(".sub");
|
|
32
|
+
T.textContent = ${JSON.stringify(title)}; S.textContent = ${JSON.stringify(subtitle ?? "")};
|
|
33
|
+
const DUR = ${ms}, FADE = Math.min(420, DUR * 0.22), OUT = Math.min(320, DUR * 0.18);
|
|
34
|
+
const ez = (t) => (${ease});
|
|
35
|
+
window.__seek = (ms) => {
|
|
36
|
+
const a = ez(ms / FADE); // entrance
|
|
37
|
+
const o = ez((DUR - ms) / OUT); // exit
|
|
38
|
+
const v = Math.min(a, o);
|
|
39
|
+
T.style.opacity = v; T.style.transform = "translateY(" + (1 - a) * 14 + "px)";
|
|
40
|
+
R.style.width = (ez((ms - FADE * 0.4) / (FADE * 1.6)) * ${Math.round(titleSize * 3.2)}) * Math.min(1, o + 0.0) + "px";
|
|
41
|
+
R.style.opacity = Math.min(1, o + 0.2);
|
|
42
|
+
S.style.opacity = v; S.style.transform = "translateY(" + (1 - a) * 10 + "px)";
|
|
43
|
+
return true;
|
|
44
|
+
};
|
|
45
|
+
</script>`;
|
|
46
|
+
}
|
|
47
|
+
function calloutHtml(w, h, box, label, ms, fontSize, ink) {
|
|
48
|
+
const pad = 10;
|
|
49
|
+
const r = { x: box.x - pad, y: box.y - pad, w: box.width + 2 * pad, h: box.height + 2 * pad };
|
|
50
|
+
const below = r.y + r.h + 18 + fontSize * 2 < h; // label under the ring when there is room
|
|
51
|
+
return `<!doctype html><meta charset="utf-8"><style>
|
|
52
|
+
html,body{margin:0;width:${w}px;height:${h}px;overflow:hidden;background:transparent}
|
|
53
|
+
.ring{position:absolute;left:${r.x}px;top:${r.y}px;width:${r.w}px;height:${r.h}px;border:3px solid ${AMBER};border-radius:14px;box-shadow:0 0 0 3px rgba(0,0,0,.06);opacity:0}
|
|
54
|
+
.tag{position:absolute;left:${Math.max(8, Math.min(w - 8, r.x))}px;top:${below ? r.y + r.h + 12 : Math.max(8, r.y - fontSize * 1.9 - 12)}px;background:${ink};color:#f4f4ef;font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text",Arial,sans-serif;font-size:${fontSize}px;font-weight:600;line-height:1;padding:${Math.round(fontSize * 0.55)}px ${Math.round(fontSize * 0.8)}px;border-radius:10px;opacity:0;max-width:${Math.round(w * 0.6)}px;white-space:nowrap}
|
|
55
|
+
</style>
|
|
56
|
+
<div class="ring"></div>${label ? '<div class="tag"></div>' : ""}
|
|
57
|
+
<script>
|
|
58
|
+
const ring = document.querySelector(".ring"), tag = document.querySelector(".tag");
|
|
59
|
+
if (tag) tag.textContent = ${JSON.stringify(label ?? "")};
|
|
60
|
+
const DUR = ${ms}, IN = Math.min(360, DUR * 0.25), OUT = Math.min(280, DUR * 0.2);
|
|
61
|
+
const ez = (t) => (${ease});
|
|
62
|
+
window.__seek = (ms) => {
|
|
63
|
+
const a = ez(ms / IN), o = ez((DUR - ms) / OUT), v = Math.min(a, o);
|
|
64
|
+
ring.style.opacity = v;
|
|
65
|
+
ring.style.transform = "scale(" + (1.12 - 0.12 * a) + ")";
|
|
66
|
+
if (tag) { tag.style.opacity = Math.min(ez((ms - IN * 0.6) / IN), o); tag.style.transform = "translateY(" + (1 - a) * 6 + "px)"; }
|
|
67
|
+
return true;
|
|
68
|
+
};
|
|
69
|
+
</script>`;
|
|
70
|
+
}
|
|
71
|
+
/** Screenshot a __seek()-driven page at every frame; returns the frame dir. */
|
|
72
|
+
async function renderFrames(html, w, h, ms, fps, transparent) {
|
|
73
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "retake-frames-"));
|
|
74
|
+
const browser = await chromium.launch();
|
|
75
|
+
const page = await browser.newPage({ viewport: { width: w, height: h } });
|
|
76
|
+
await page.setContent(html, { waitUntil: "load" });
|
|
77
|
+
const frames = Math.max(2, Math.round((ms / 1000) * fps));
|
|
78
|
+
for (let i = 0; i < frames; i++) {
|
|
79
|
+
await page.evaluate((t) => window.__seek(t), (i / fps) * 1000);
|
|
80
|
+
await page.screenshot({ path: path.join(dir, `f${String(i).padStart(5, "0")}.png`), omitBackground: transparent });
|
|
81
|
+
}
|
|
82
|
+
await browser.close();
|
|
83
|
+
return dir;
|
|
84
|
+
}
|
|
85
|
+
/** A title card as an H.264 segment at the final canvas size, plus (for the
|
|
86
|
+
intro) the settled middle frame as cover.png — the poster. */
|
|
87
|
+
export async function renderCard(ffmpeg, outDir, kind, spec, w, h, fps, theme) {
|
|
88
|
+
const dir = await renderFrames(cardHtml(w, h, theme, spec.title, spec.subtitle, spec.ms), w, h, spec.ms, fps, false);
|
|
89
|
+
const mp4 = path.join(outDir, `.card-${kind}.mp4`);
|
|
90
|
+
execFileSync(ffmpeg, ["-y", "-loglevel", "error", "-framerate", String(fps), "-i", path.join(dir, "f%05d.png"), "-c:v", "libx264", "-crf", "17", "-preset", "veryfast", "-pix_fmt", "yuv420p", mp4], { stdio: "inherit" });
|
|
91
|
+
let cover;
|
|
92
|
+
if (kind === "intro") {
|
|
93
|
+
const frames = fs.readdirSync(dir).sort();
|
|
94
|
+
const settled = frames[Math.min(frames.length - 1, Math.round(frames.length * 0.55))];
|
|
95
|
+
cover = path.join(outDir, "cover.png");
|
|
96
|
+
fs.copyFileSync(path.join(dir, settled), cover);
|
|
97
|
+
}
|
|
98
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
99
|
+
return { mp4, cover };
|
|
100
|
+
}
|
|
101
|
+
/** A callout as a VP9 segment with alpha, overlaid onto the take at render. */
|
|
102
|
+
export async function renderCalloutOverlay(ffmpeg, outDir, index, c, w, h, fps, fontSize, ink) {
|
|
103
|
+
const dir = await renderFrames(calloutHtml(w, h, c.box, c.label, c.ms, fontSize, ink), w, h, c.ms, fps, true);
|
|
104
|
+
const webm = path.join(outDir, `.callout-${index}.webm`);
|
|
105
|
+
execFileSync(ffmpeg, ["-y", "-loglevel", "error", "-framerate", String(fps), "-i", path.join(dir, "f%05d.png"), "-c:v", "libvpx-vp9", "-pix_fmt", "yuva420p", "-b:v", "0", "-crf", "28", webm], { stdio: "inherit" });
|
|
106
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
107
|
+
return webm;
|
|
108
|
+
}
|
|
109
|
+
/** A cover built from a real frame of the video with the title laid over it —
|
|
110
|
+
a screenshot plus a few words, which is what actually stops a scroll. Not
|
|
111
|
+
a skin of the centred card: the product is the picture. */
|
|
112
|
+
export async function renderTitledCover(ffmpeg, frame, out, spec, w, h, theme) {
|
|
113
|
+
const b64 = fs.readFileSync(frame).toString("base64");
|
|
114
|
+
const titleSize = Math.round(Math.min(w * 0.045, h * 0.1));
|
|
115
|
+
const subSize = Math.round(titleSize * 0.42);
|
|
116
|
+
const html = `<!doctype html><meta charset="utf-8"><style>
|
|
117
|
+
html,body{margin:0;width:${w}px;height:${h}px;overflow:hidden;background:${theme.background};font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","Helvetica Neue",Arial,sans-serif}
|
|
118
|
+
.shot{position:absolute;inset:0;background:url(data:image/png;base64,${b64}) center/cover no-repeat}
|
|
119
|
+
.veil{position:absolute;inset:0;background:linear-gradient(to bottom, rgba(0,0,0,0) 38%, rgba(0,0,0,.14) 58%, ${theme.background} 78%)}
|
|
120
|
+
.txt{position:absolute;left:0;right:0;bottom:${Math.round(h * 0.055)}px;text-align:center;padding:0 ${Math.round(w * 0.08)}px}
|
|
121
|
+
.t{font-size:${titleSize}px;font-weight:700;letter-spacing:-.02em;color:${theme.ink};margin:0}
|
|
122
|
+
.r{height:${Math.max(3, Math.round(titleSize * 0.08))}px;width:${Math.round(titleSize * 2.6)}px;background:${AMBER};border-radius:99px;margin:${Math.round(titleSize * 0.3)}px auto}
|
|
123
|
+
.s{font-size:${subSize}px;color:${theme.ink};margin:0;opacity:.75}
|
|
124
|
+
</style>
|
|
125
|
+
<div class="shot"></div><div class="veil"></div>
|
|
126
|
+
<div class="txt"><p class="t">${escapeHtml(spec.title)}</p><div class="r"></div>${spec.subtitle ? `<p class="s">${escapeHtml(spec.subtitle)}</p>` : ""}</div>`;
|
|
127
|
+
const browser = await chromium.launch();
|
|
128
|
+
const page = await browser.newPage({ viewport: { width: w, height: h } });
|
|
129
|
+
await page.setContent(html, { waitUntil: "load" });
|
|
130
|
+
await page.screenshot({ path: out });
|
|
131
|
+
await browser.close();
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
const escapeHtml = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|