demotape 0.1.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/LICENSE +21 -0
- package/README.md +416 -0
- package/bin/demotape.mjs +6 -0
- package/configs/dogfood.json +51 -0
- package/configs/examples/instagram-story.json +23 -0
- package/configs/examples/landing-page.json +23 -0
- package/configs/examples/product-hunt.json +14 -0
- package/dist/auth/cookies.d.ts +7 -0
- package/dist/auth/cookies.d.ts.map +1 -0
- package/dist/auth/cookies.js +19 -0
- package/dist/auth/cookies.js.map +1 -0
- package/dist/auth/index.d.ts +25 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +39 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth/local-storage.d.ts +8 -0
- package/dist/auth/local-storage.d.ts.map +1 -0
- package/dist/auth/local-storage.js +14 -0
- package/dist/auth/local-storage.js.map +1 -0
- package/dist/auth/supabase.d.ts +8 -0
- package/dist/auth/supabase.d.ts.map +1 -0
- package/dist/auth/supabase.js +114 -0
- package/dist/auth/supabase.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +165 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +562 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +108 -0
- package/dist/config.js.map +1 -0
- package/dist/ffmpeg.d.ts +31 -0
- package/dist/ffmpeg.d.ts.map +1 -0
- package/dist/ffmpeg.js +55 -0
- package/dist/ffmpeg.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/overlays.d.ts +20 -0
- package/dist/overlays.d.ts.map +1 -0
- package/dist/overlays.js +60 -0
- package/dist/overlays.js.map +1 -0
- package/dist/recorder.d.ts +13 -0
- package/dist/recorder.d.ts.map +1 -0
- package/dist/recorder.js +155 -0
- package/dist/recorder.js.map +1 -0
- package/dist/scroll.d.ts +7 -0
- package/dist/scroll.d.ts.map +1 -0
- package/dist/scroll.js +14 -0
- package/dist/scroll.js.map +1 -0
- package/dist/segments.d.ts +21 -0
- package/dist/segments.d.ts.map +1 -0
- package/dist/segments.js +62 -0
- package/dist/segments.js.map +1 -0
- package/dist/utils.d.ts +25 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +120 -0
- package/dist/utils.js.map +1 -0
- package/package.json +67 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jan Faris
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
# demotape
|
|
2
|
+
|
|
3
|
+
**Automated demo videos from your live web app. JSON config in, polished MP4 out.**
|
|
4
|
+
|
|
5
|
+
Stop manually re-recording your demo video every time you change a button. Define page segments, scroll choreography, and text overlays in a JSON config — get pixel-perfect, skeleton-free videos for landing pages, Product Hunt, Instagram Stories, and docs.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx demotape init
|
|
9
|
+
# edit demotape.json with your app's URL and pages
|
|
10
|
+
npx demotape record --config demotape.json
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Why demotape?
|
|
16
|
+
|
|
17
|
+
| Problem | demotape solution |
|
|
18
|
+
|---------|-------------------|
|
|
19
|
+
| Re-record manually after every UI change | Run one command, get an updated video |
|
|
20
|
+
| Loading skeletons ruin the recording | Trims loading frames per-segment automatically |
|
|
21
|
+
| App is behind login | Auth-aware — Supabase, cookies, localStorage |
|
|
22
|
+
| Need different formats (landing page, IG Stories) | Multi-format from one config |
|
|
23
|
+
| Can't automate in CI/CD | Runs headlessly, updates videos on deploy |
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm install -g demotape
|
|
29
|
+
|
|
30
|
+
# Playwright browsers (one-time)
|
|
31
|
+
npx playwright install chromium
|
|
32
|
+
|
|
33
|
+
# FFmpeg (required for encoding)
|
|
34
|
+
brew install ffmpeg # macOS
|
|
35
|
+
sudo apt install ffmpeg # Ubuntu/Debian
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
> **Requires:** Node.js >= 18, FFmpeg, Playwright
|
|
39
|
+
|
|
40
|
+
## Quick Start
|
|
41
|
+
|
|
42
|
+
### 1. Generate a starter config
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
demotape init
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
This creates `demotape.json` with a landing page preset:
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{
|
|
52
|
+
"baseUrl": "http://localhost:3000",
|
|
53
|
+
"viewport": { "width": 1280, "height": 800 },
|
|
54
|
+
"output": { "format": "both", "name": "demo" },
|
|
55
|
+
"colorScheme": "dark",
|
|
56
|
+
"segments": [
|
|
57
|
+
{
|
|
58
|
+
"name": "Home",
|
|
59
|
+
"path": "/",
|
|
60
|
+
"waitFor": "h1",
|
|
61
|
+
"settleMs": 1500,
|
|
62
|
+
"dwellMs": 3000
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"name": "Dashboard",
|
|
66
|
+
"path": "/dashboard",
|
|
67
|
+
"waitFor": "main",
|
|
68
|
+
"settleMs": 2000,
|
|
69
|
+
"scroll": { "distance": 400, "duration": 2500 },
|
|
70
|
+
"dwellMs": 1500
|
|
71
|
+
}
|
|
72
|
+
]
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### 2. Edit the config
|
|
77
|
+
|
|
78
|
+
Point `baseUrl` to your running app. Add segments for each page you want to show.
|
|
79
|
+
|
|
80
|
+
### 3. Record
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
demotape record --config demotape.json
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Output lands in `./videos/demo.mp4` (and `demo.webm` if format is `"both"`).
|
|
87
|
+
|
|
88
|
+
## How It Works
|
|
89
|
+
|
|
90
|
+
demotape uses **segment-based recording** to produce clean, skeleton-free videos:
|
|
91
|
+
|
|
92
|
+
1. **Authenticate** — Logs into your app if auth is configured
|
|
93
|
+
2. **Setup** — Sets localStorage keys to dismiss banners, onboarding, etc.
|
|
94
|
+
3. **Warmup** — Visits every page once to prime the browser HTTP cache (images, fonts)
|
|
95
|
+
4. **Record** — Opens each segment as a new page, waits for content to render, then records the scroll/dwell actions. Measures the loading time per segment.
|
|
96
|
+
5. **Encode** — FFmpeg trims the loading frames from each segment, concatenates them, scales to output size, applies text overlays, and encodes to MP4/WebM
|
|
97
|
+
|
|
98
|
+
The result: every frame in the final video shows fully rendered content. No spinners, no skeleton screens, no progressive image loading.
|
|
99
|
+
|
|
100
|
+
## Config Reference
|
|
101
|
+
|
|
102
|
+
### Top-level fields
|
|
103
|
+
|
|
104
|
+
| Field | Type | Default | Description |
|
|
105
|
+
|-------|------|---------|-------------|
|
|
106
|
+
| `baseUrl` | `string` | *required* | Base URL of your app (e.g. `http://localhost:3000`) |
|
|
107
|
+
| `auth` | `object` | — | Authentication config (see [Auth Providers](#auth-providers)) |
|
|
108
|
+
| `viewport` | `{width, height}` | `1280x800` | Browser viewport size (CSS pixels) |
|
|
109
|
+
| `output` | `object` | — | Output config (see below) |
|
|
110
|
+
| `colorScheme` | `"dark" \| "light"` | `"dark"` | Browser color scheme |
|
|
111
|
+
| `removeDevOverlays` | `boolean` | `true` | Remove Next.js, PostHog, Vercel overlays |
|
|
112
|
+
| `suppressAnimations` | `boolean` | `true` | Disable CSS transitions/animations |
|
|
113
|
+
| `setup` | `object` | — | Pre-recording setup (see below) |
|
|
114
|
+
| `overlays` | `object` | — | Text overlays burned into the video |
|
|
115
|
+
| `segments` | `array` | *required* | Pages to record |
|
|
116
|
+
|
|
117
|
+
### `output`
|
|
118
|
+
|
|
119
|
+
| Field | Type | Default | Description |
|
|
120
|
+
|-------|------|---------|-------------|
|
|
121
|
+
| `size` | `{width, height}` | same as viewport | Final video dimensions (FFmpeg scales up) |
|
|
122
|
+
| `format` | `"mp4" \| "webm" \| "both"` | `"mp4"` | Output format(s) |
|
|
123
|
+
| `fps` | `number` | `30` | Frames per second |
|
|
124
|
+
| `crf` | `number` | `28` | Quality (0-51, lower = better, bigger file) |
|
|
125
|
+
| `name` | `string` | `"demo"` | Output filename (without extension) |
|
|
126
|
+
| `dir` | `string` | `"./videos"` | Output directory |
|
|
127
|
+
|
|
128
|
+
### `setup`
|
|
129
|
+
|
|
130
|
+
| Field | Type | Description |
|
|
131
|
+
|-------|------|-------------|
|
|
132
|
+
| `localStorage` | `Record<string, string>` | Key-value pairs to set before recording (dismiss banners, set theme, etc.) |
|
|
133
|
+
| `waitAfterSetup` | `number` | Milliseconds to wait after setup |
|
|
134
|
+
|
|
135
|
+
### `overlays`
|
|
136
|
+
|
|
137
|
+
Text bands burned into the video via FFmpeg (useful for Instagram Stories, branded videos):
|
|
138
|
+
|
|
139
|
+
| Field | Type | Description |
|
|
140
|
+
|-------|------|-------------|
|
|
141
|
+
| `top` | `{text, height?, fontSize?}` | Top overlay band (default height: 120, fontSize: 42) |
|
|
142
|
+
| `bottom` | `{text, height?, fontSize?}` | Bottom overlay band (default height: 100, fontSize: 32) |
|
|
143
|
+
|
|
144
|
+
### `segments[]`
|
|
145
|
+
|
|
146
|
+
Each segment records one page of your app:
|
|
147
|
+
|
|
148
|
+
| Field | Type | Default | Description |
|
|
149
|
+
|-------|------|---------|-------------|
|
|
150
|
+
| `name` | `string` | *required* | Display name for logging |
|
|
151
|
+
| `path` | `string` | *required* | URL path (e.g. `/dashboard`) |
|
|
152
|
+
| `waitFor` | `string` | — | CSS selector to wait for before recording |
|
|
153
|
+
| `settleMs` | `number` | `1000` | Ms to wait after content loads |
|
|
154
|
+
| `scroll` | `{distance, duration?}` | — | Scroll down by `distance` px over `duration` ms |
|
|
155
|
+
| `dwellMs` | `number` | `2000` | Ms to hold after all actions |
|
|
156
|
+
| `actions` | `array` | — | Click/hover actions before scroll |
|
|
157
|
+
|
|
158
|
+
#### `segments[].actions[]`
|
|
159
|
+
|
|
160
|
+
| Field | Type | Description |
|
|
161
|
+
|-------|------|-------------|
|
|
162
|
+
| `type` | `"click" \| "hover"` | Action type |
|
|
163
|
+
| `selector` | `string` | CSS selector for the target element |
|
|
164
|
+
| `delay` | `number` | Ms to wait before executing |
|
|
165
|
+
|
|
166
|
+
## Auth Providers
|
|
167
|
+
|
|
168
|
+
demotape can record apps behind login. Configure auth in your JSON config.
|
|
169
|
+
|
|
170
|
+
### Supabase (magic link)
|
|
171
|
+
|
|
172
|
+
Generates a magic link via Supabase admin API and injects session cookies. No `@supabase/supabase-js` dependency needed — uses raw `fetch()`.
|
|
173
|
+
|
|
174
|
+
```json
|
|
175
|
+
{
|
|
176
|
+
"auth": {
|
|
177
|
+
"provider": "supabase",
|
|
178
|
+
"supabaseUrl": "https://abc.supabase.co",
|
|
179
|
+
"supabaseServiceRoleKey": "your-service-role-key",
|
|
180
|
+
"supabaseAnonKey": "your-anon-key",
|
|
181
|
+
"email": "demo@yourapp.com"
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Or use environment variables (recommended for secrets):
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
export DEMOTAPE_SUPABASE_URL=https://abc.supabase.co
|
|
190
|
+
export DEMOTAPE_SUPABASE_ANON_KEY=your-anon-key
|
|
191
|
+
export DEMOTAPE_SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
|
|
192
|
+
export DEMOTAPE_EMAIL=demo@yourapp.com
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
```json
|
|
196
|
+
{
|
|
197
|
+
"auth": {
|
|
198
|
+
"provider": "supabase"
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### Cookies
|
|
204
|
+
|
|
205
|
+
Inject raw cookies (works with any cookie-based auth):
|
|
206
|
+
|
|
207
|
+
```json
|
|
208
|
+
{
|
|
209
|
+
"auth": {
|
|
210
|
+
"provider": "cookies",
|
|
211
|
+
"cookies": [
|
|
212
|
+
{ "name": "session", "value": "abc123", "domain": "localhost" },
|
|
213
|
+
{ "name": "token", "value": "xyz789", "domain": "localhost" }
|
|
214
|
+
]
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
### localStorage
|
|
220
|
+
|
|
221
|
+
Inject localStorage key-value pairs (works with JWT-based auth that stores tokens in localStorage):
|
|
222
|
+
|
|
223
|
+
```json
|
|
224
|
+
{
|
|
225
|
+
"auth": {
|
|
226
|
+
"provider": "localStorage",
|
|
227
|
+
"localStorage": {
|
|
228
|
+
"auth_token": "eyJhbGciOiJIUzI1NiIs...",
|
|
229
|
+
"user_id": "123"
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
## Presets
|
|
236
|
+
|
|
237
|
+
Generate starter configs for common use cases:
|
|
238
|
+
|
|
239
|
+
```bash
|
|
240
|
+
# Landscape for landing pages (1280x800, MP4+WebM)
|
|
241
|
+
demotape init --preset landing-page
|
|
242
|
+
|
|
243
|
+
# Vertical for Instagram Stories (1080x1920 with text overlays)
|
|
244
|
+
demotape init --preset instagram-story
|
|
245
|
+
|
|
246
|
+
# 16:9 for Product Hunt (1920x1080)
|
|
247
|
+
demotape init --preset product-hunt
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
List all presets:
|
|
251
|
+
|
|
252
|
+
```bash
|
|
253
|
+
demotape presets
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
## CI/CD
|
|
257
|
+
|
|
258
|
+
### GitHub Actions
|
|
259
|
+
|
|
260
|
+
Automatically re-record your demo video on every deploy:
|
|
261
|
+
|
|
262
|
+
```yaml
|
|
263
|
+
name: Record Demo Video
|
|
264
|
+
on:
|
|
265
|
+
push:
|
|
266
|
+
branches: [main]
|
|
267
|
+
|
|
268
|
+
jobs:
|
|
269
|
+
record:
|
|
270
|
+
runs-on: ubuntu-latest
|
|
271
|
+
steps:
|
|
272
|
+
- uses: actions/checkout@v4
|
|
273
|
+
|
|
274
|
+
- uses: actions/setup-node@v4
|
|
275
|
+
with:
|
|
276
|
+
node-version: 20
|
|
277
|
+
|
|
278
|
+
- name: Install dependencies
|
|
279
|
+
run: |
|
|
280
|
+
npm ci
|
|
281
|
+
npx playwright install chromium --with-deps
|
|
282
|
+
sudo apt-get install -y ffmpeg
|
|
283
|
+
|
|
284
|
+
- name: Start app
|
|
285
|
+
run: npm run dev &
|
|
286
|
+
env:
|
|
287
|
+
PORT: 3000
|
|
288
|
+
|
|
289
|
+
- name: Wait for app
|
|
290
|
+
run: npx wait-on http://localhost:3000
|
|
291
|
+
|
|
292
|
+
- name: Record demo
|
|
293
|
+
run: npx demotape record --config demotape.json
|
|
294
|
+
env:
|
|
295
|
+
HEADLESS: true
|
|
296
|
+
DEMOTAPE_SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
|
|
297
|
+
DEMOTAPE_SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }}
|
|
298
|
+
DEMOTAPE_SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}
|
|
299
|
+
DEMOTAPE_EMAIL: ${{ secrets.DEMO_EMAIL }}
|
|
300
|
+
|
|
301
|
+
- name: Upload video
|
|
302
|
+
uses: actions/upload-artifact@v4
|
|
303
|
+
with:
|
|
304
|
+
name: demo-video
|
|
305
|
+
path: videos/
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
## CLI Reference
|
|
309
|
+
|
|
310
|
+
```bash
|
|
311
|
+
# Record using a config file
|
|
312
|
+
demotape record --config demotape.json
|
|
313
|
+
|
|
314
|
+
# Record with overrides
|
|
315
|
+
demotape record --config demotape.json --format webm --output ./dist
|
|
316
|
+
|
|
317
|
+
# Generate a starter config
|
|
318
|
+
demotape init
|
|
319
|
+
demotape init --preset instagram-story
|
|
320
|
+
demotape init --preset product-hunt
|
|
321
|
+
|
|
322
|
+
# Validate a config without recording
|
|
323
|
+
demotape validate --config demotape.json
|
|
324
|
+
|
|
325
|
+
# Show available presets
|
|
326
|
+
demotape presets
|
|
327
|
+
|
|
328
|
+
# Show help
|
|
329
|
+
demotape --help
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
## Programmatic API
|
|
333
|
+
|
|
334
|
+
Use demotape as a library in your own scripts:
|
|
335
|
+
|
|
336
|
+
```typescript
|
|
337
|
+
import { record, loadConfig } from "demotape";
|
|
338
|
+
|
|
339
|
+
const config = loadConfig("./demotape.json");
|
|
340
|
+
await record(config);
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
Or build a config object directly:
|
|
344
|
+
|
|
345
|
+
```typescript
|
|
346
|
+
import { record, type DemotapeConfig } from "demotape";
|
|
347
|
+
|
|
348
|
+
const config: DemotapeConfig = {
|
|
349
|
+
baseUrl: "http://localhost:3000",
|
|
350
|
+
viewport: { width: 1280, height: 800 },
|
|
351
|
+
output: { format: "mp4", fps: 30, crf: 28, name: "demo", dir: "./videos" },
|
|
352
|
+
colorScheme: "dark",
|
|
353
|
+
removeDevOverlays: true,
|
|
354
|
+
suppressAnimations: true,
|
|
355
|
+
segments: [
|
|
356
|
+
{ name: "Home", path: "/", waitFor: "h1", settleMs: 1500, dwellMs: 3000 },
|
|
357
|
+
],
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
await record(config);
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
## Environment Variables
|
|
364
|
+
|
|
365
|
+
| Variable | Description |
|
|
366
|
+
|----------|-------------|
|
|
367
|
+
| `DEMOTAPE_SUPABASE_URL` | Supabase project URL |
|
|
368
|
+
| `DEMOTAPE_SUPABASE_ANON_KEY` | Supabase anon/public key |
|
|
369
|
+
| `DEMOTAPE_SUPABASE_SERVICE_ROLE_KEY` | Supabase service role key |
|
|
370
|
+
| `DEMOTAPE_EMAIL` | Demo account email for Supabase auth |
|
|
371
|
+
| `HEADLESS` | Set to `"false"` to see the browser during recording |
|
|
372
|
+
|
|
373
|
+
## FAQ
|
|
374
|
+
|
|
375
|
+
### Do I need FFmpeg?
|
|
376
|
+
|
|
377
|
+
Yes. demotape uses FFmpeg to trim loading frames, concatenate segments, apply overlays, and encode the final video. Install it with `brew install ffmpeg` (macOS) or `sudo apt install ffmpeg` (Linux).
|
|
378
|
+
|
|
379
|
+
### Why are there gray bars in my video?
|
|
380
|
+
|
|
381
|
+
This happens when `recordVideo.size` doesn't match the viewport. demotape handles this automatically — it sets both to the same value and uses FFmpeg to scale up to the output size. If you see gray bars, make sure `output.size` is a multiple of your `viewport` dimensions.
|
|
382
|
+
|
|
383
|
+
### Can I record apps that need authentication?
|
|
384
|
+
|
|
385
|
+
Yes. demotape supports three auth providers: Supabase (magic link), raw cookies, and localStorage injection. See [Auth Providers](#auth-providers).
|
|
386
|
+
|
|
387
|
+
### How do I dismiss banners/modals before recording?
|
|
388
|
+
|
|
389
|
+
Use the `setup.localStorage` field to set keys that your app checks. For example, if your app hides an onboarding modal when `onboarding-done` is in localStorage:
|
|
390
|
+
|
|
391
|
+
```json
|
|
392
|
+
{
|
|
393
|
+
"setup": {
|
|
394
|
+
"localStorage": {
|
|
395
|
+
"onboarding-done": "1",
|
|
396
|
+
"cookie-consent": "accepted"
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
### Can I run this in CI/CD?
|
|
403
|
+
|
|
404
|
+
Yes. Set `HEADLESS=true` (the default) and make sure Playwright browsers and FFmpeg are installed. See [CI/CD](#cicd) for a GitHub Actions example.
|
|
405
|
+
|
|
406
|
+
### How do I reduce file size?
|
|
407
|
+
|
|
408
|
+
Increase the `crf` value in your output config. The default is 28. Try 32-35 for smaller files with slightly lower quality.
|
|
409
|
+
|
|
410
|
+
### Why is the first frame of a segment blurry?
|
|
411
|
+
|
|
412
|
+
The warmup phase should prevent this by priming the browser cache. If you still see blurry first frames, increase `settleMs` for that segment to give images more time to load.
|
|
413
|
+
|
|
414
|
+
## License
|
|
415
|
+
|
|
416
|
+
MIT
|
package/bin/demotape.mjs
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"baseUrl": "http://localhost:5500/site",
|
|
3
|
+
"viewport": { "width": 1280, "height": 800 },
|
|
4
|
+
"output": {
|
|
5
|
+
"format": "both",
|
|
6
|
+
"name": "demotape-demo",
|
|
7
|
+
"dir": "./videos"
|
|
8
|
+
},
|
|
9
|
+
"colorScheme": "dark",
|
|
10
|
+
"segments": [
|
|
11
|
+
{
|
|
12
|
+
"name": "Hero",
|
|
13
|
+
"path": "/index.html",
|
|
14
|
+
"waitFor": "h1",
|
|
15
|
+
"settleMs": 2000,
|
|
16
|
+
"dwellMs": 4000
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"name": "Before/After",
|
|
20
|
+
"path": "/index.html",
|
|
21
|
+
"waitFor": ".comparison-row",
|
|
22
|
+
"settleMs": 1500,
|
|
23
|
+
"scroll": { "distance": 900, "duration": 3000 },
|
|
24
|
+
"dwellMs": 2000
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"name": "Config Preview",
|
|
28
|
+
"path": "/index.html",
|
|
29
|
+
"waitFor": ".syn-key",
|
|
30
|
+
"settleMs": 1500,
|
|
31
|
+
"scroll": { "distance": 1800, "duration": 4000 },
|
|
32
|
+
"dwellMs": 2000
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"name": "Features",
|
|
36
|
+
"path": "/index.html",
|
|
37
|
+
"waitFor": ".feature-card",
|
|
38
|
+
"settleMs": 1500,
|
|
39
|
+
"scroll": { "distance": 2800, "duration": 5000 },
|
|
40
|
+
"dwellMs": 2000
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"name": "Pricing",
|
|
44
|
+
"path": "/index.html",
|
|
45
|
+
"waitFor": ".pricing-popular",
|
|
46
|
+
"settleMs": 1500,
|
|
47
|
+
"scroll": { "distance": 3800, "duration": 5000 },
|
|
48
|
+
"dwellMs": 3000
|
|
49
|
+
}
|
|
50
|
+
]
|
|
51
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"baseUrl": "http://localhost:3000",
|
|
3
|
+
"viewport": { "width": 540, "height": 960 },
|
|
4
|
+
"output": {
|
|
5
|
+
"size": { "width": 1080, "height": 1920 },
|
|
6
|
+
"format": "mp4",
|
|
7
|
+
"name": "story"
|
|
8
|
+
},
|
|
9
|
+
"overlays": {
|
|
10
|
+
"top": { "text": "Your App Name", "height": 120 },
|
|
11
|
+
"bottom": { "text": "Try it free", "height": 100 }
|
|
12
|
+
},
|
|
13
|
+
"segments": [
|
|
14
|
+
{
|
|
15
|
+
"name": "Feature Page",
|
|
16
|
+
"path": "/features",
|
|
17
|
+
"waitFor": "h1",
|
|
18
|
+
"settleMs": 2000,
|
|
19
|
+
"scroll": { "distance": 800, "duration": 5000 },
|
|
20
|
+
"dwellMs": 2000
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"baseUrl": "http://localhost:3000",
|
|
3
|
+
"viewport": { "width": 1280, "height": 800 },
|
|
4
|
+
"output": { "format": "both", "name": "demo" },
|
|
5
|
+
"colorScheme": "dark",
|
|
6
|
+
"segments": [
|
|
7
|
+
{
|
|
8
|
+
"name": "Home",
|
|
9
|
+
"path": "/",
|
|
10
|
+
"waitFor": "h1",
|
|
11
|
+
"settleMs": 1500,
|
|
12
|
+
"dwellMs": 3000
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"name": "Dashboard",
|
|
16
|
+
"path": "/dashboard",
|
|
17
|
+
"waitFor": "main",
|
|
18
|
+
"settleMs": 2000,
|
|
19
|
+
"scroll": { "distance": 400, "duration": 2500 },
|
|
20
|
+
"dwellMs": 1500
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"baseUrl": "http://localhost:3000",
|
|
3
|
+
"viewport": { "width": 1920, "height": 1080 },
|
|
4
|
+
"output": { "format": "mp4", "name": "product-hunt-demo" },
|
|
5
|
+
"segments": [
|
|
6
|
+
{
|
|
7
|
+
"name": "Hero",
|
|
8
|
+
"path": "/",
|
|
9
|
+
"waitFor": "h1",
|
|
10
|
+
"settleMs": 2000,
|
|
11
|
+
"dwellMs": 4000
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { AuthConfig } from "../config.js";
|
|
2
|
+
import type { AuthResult } from "./index.js";
|
|
3
|
+
/**
|
|
4
|
+
* Build auth result from raw cookie definitions in the config.
|
|
5
|
+
*/
|
|
6
|
+
export declare function injectCookies(auth: AuthConfig): AuthResult;
|
|
7
|
+
//# sourceMappingURL=cookies.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cookies.d.ts","sourceRoot":"","sources":["../../src/auth/cookies.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAC/C,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAE7C;;GAEG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,UAAU,GAAG,UAAU,CAgB1D"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build auth result from raw cookie definitions in the config.
|
|
3
|
+
*/
|
|
4
|
+
export function injectCookies(auth) {
|
|
5
|
+
if (!auth.cookies || auth.cookies.length === 0) {
|
|
6
|
+
throw new Error("Cookie auth provider requires `cookies` array in config");
|
|
7
|
+
}
|
|
8
|
+
const cookies = auth.cookies.map((c) => ({
|
|
9
|
+
name: c.name,
|
|
10
|
+
value: c.value,
|
|
11
|
+
domain: c.domain ?? "localhost",
|
|
12
|
+
path: c.path ?? "/",
|
|
13
|
+
httpOnly: false,
|
|
14
|
+
secure: false,
|
|
15
|
+
sameSite: "Lax",
|
|
16
|
+
}));
|
|
17
|
+
return { cookies };
|
|
18
|
+
}
|
|
19
|
+
//# sourceMappingURL=cookies.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cookies.js","sourceRoot":"","sources":["../../src/auth/cookies.ts"],"names":[],"mappings":"AAGA;;GAEG;AACH,MAAM,UAAU,aAAa,CAAC,IAAgB;IAC5C,IAAI,CAAC,IAAI,CAAC,OAAO,IAAI,IAAI,CAAC,OAAO,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC/C,MAAM,IAAI,KAAK,CAAC,yDAAyD,CAAC,CAAC;IAC7E,CAAC;IAED,MAAM,OAAO,GAA0B,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;QAC9D,IAAI,EAAE,CAAC,CAAC,IAAI;QACZ,KAAK,EAAE,CAAC,CAAC,KAAK;QACd,MAAM,EAAE,CAAC,CAAC,MAAM,IAAI,WAAW;QAC/B,IAAI,EAAE,CAAC,CAAC,IAAI,IAAI,GAAG;QACnB,QAAQ,EAAE,KAAK;QACf,MAAM,EAAE,KAAK;QACb,QAAQ,EAAE,KAAc;KACzB,CAAC,CAAC,CAAC;IAEJ,OAAO,EAAE,OAAO,EAAE,CAAC;AACrB,CAAC"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { BrowserContext } from "playwright";
|
|
2
|
+
import type { AuthConfig } from "../config.js";
|
|
3
|
+
export interface AuthResult {
|
|
4
|
+
cookies: Array<{
|
|
5
|
+
name: string;
|
|
6
|
+
value: string;
|
|
7
|
+
domain: string;
|
|
8
|
+
path: string;
|
|
9
|
+
httpOnly?: boolean;
|
|
10
|
+
secure?: boolean;
|
|
11
|
+
sameSite?: "Strict" | "Lax" | "None";
|
|
12
|
+
}>;
|
|
13
|
+
localStorage?: Record<string, string>;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Authenticate using the configured provider and return cookies/localStorage
|
|
17
|
+
* to inject into the browser context.
|
|
18
|
+
*/
|
|
19
|
+
export declare function authenticate(auth: AuthConfig, baseUrl: string): Promise<AuthResult>;
|
|
20
|
+
/**
|
|
21
|
+
* Apply auth result to a browser context — sets cookies and optionally
|
|
22
|
+
* injects localStorage values.
|
|
23
|
+
*/
|
|
24
|
+
export declare function applyAuth(context: BrowserContext, authResult: AuthResult, baseUrl: string): Promise<void>;
|
|
25
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/auth/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AACjD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAK/C,MAAM,WAAW,UAAU;IACzB,OAAO,EAAE,KAAK,CAAC;QACb,IAAI,EAAE,MAAM,CAAC;QACb,KAAK,EAAE,MAAM,CAAC;QACd,MAAM,EAAE,MAAM,CAAC;QACf,IAAI,EAAE,MAAM,CAAC;QACb,QAAQ,CAAC,EAAE,OAAO,CAAC;QACnB,MAAM,CAAC,EAAE,OAAO,CAAC;QACjB,QAAQ,CAAC,EAAE,QAAQ,GAAG,KAAK,GAAG,MAAM,CAAC;KACtC,CAAC,CAAC;IACH,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACvC;AAED;;;GAGG;AACH,wBAAsB,YAAY,CAChC,IAAI,EAAE,UAAU,EAChB,OAAO,EAAE,MAAM,GACd,OAAO,CAAC,UAAU,CAAC,CAWrB;AAED;;;GAGG;AACH,wBAAsB,SAAS,CAC7B,OAAO,EAAE,cAAc,EACvB,UAAU,EAAE,UAAU,EACtB,OAAO,EAAE,MAAM,GACd,OAAO,CAAC,IAAI,CAAC,CAef"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { authenticateSupabase } from "./supabase.js";
|
|
2
|
+
import { injectCookies } from "./cookies.js";
|
|
3
|
+
import { injectLocalStorage } from "./local-storage.js";
|
|
4
|
+
/**
|
|
5
|
+
* Authenticate using the configured provider and return cookies/localStorage
|
|
6
|
+
* to inject into the browser context.
|
|
7
|
+
*/
|
|
8
|
+
export async function authenticate(auth, baseUrl) {
|
|
9
|
+
switch (auth.provider) {
|
|
10
|
+
case "supabase":
|
|
11
|
+
return authenticateSupabase(auth, baseUrl);
|
|
12
|
+
case "cookies":
|
|
13
|
+
return injectCookies(auth);
|
|
14
|
+
case "localStorage":
|
|
15
|
+
return injectLocalStorage(auth);
|
|
16
|
+
default:
|
|
17
|
+
throw new Error(`Unknown auth provider: ${auth.provider}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Apply auth result to a browser context — sets cookies and optionally
|
|
22
|
+
* injects localStorage values.
|
|
23
|
+
*/
|
|
24
|
+
export async function applyAuth(context, authResult, baseUrl) {
|
|
25
|
+
if (authResult.cookies.length > 0) {
|
|
26
|
+
await context.addCookies(authResult.cookies);
|
|
27
|
+
}
|
|
28
|
+
if (authResult.localStorage && Object.keys(authResult.localStorage).length > 0) {
|
|
29
|
+
const page = await context.newPage();
|
|
30
|
+
await page.goto(baseUrl, { waitUntil: "domcontentloaded" });
|
|
31
|
+
await page.evaluate((items) => {
|
|
32
|
+
for (const [key, value] of Object.entries(items)) {
|
|
33
|
+
localStorage.setItem(key, value);
|
|
34
|
+
}
|
|
35
|
+
}, authResult.localStorage);
|
|
36
|
+
await page.close();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/auth/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAC;AACrD,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAC7C,OAAO,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AAexD;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,YAAY,CAChC,IAAgB,EAChB,OAAe;IAEf,QAAQ,IAAI,CAAC,QAAQ,EAAE,CAAC;QACtB,KAAK,UAAU;YACb,OAAO,oBAAoB,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;QAC7C,KAAK,SAAS;YACZ,OAAO,aAAa,CAAC,IAAI,CAAC,CAAC;QAC7B,KAAK,cAAc;YACjB,OAAO,kBAAkB,CAAC,IAAI,CAAC,CAAC;QAClC;YACE,MAAM,IAAI,KAAK,CAAC,0BAA0B,IAAI,CAAC,QAAQ,EAAE,CAAC,CAAC;IAC/D,CAAC;AACH,CAAC;AAED;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,SAAS,CAC7B,OAAuB,EACvB,UAAsB,EACtB,OAAe;IAEf,IAAI,UAAU,CAAC,OAAO,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QAClC,MAAM,OAAO,CAAC,UAAU,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC;IAC/C,CAAC;IAED,IAAI,UAAU,CAAC,YAAY,IAAI,MAAM,CAAC,IAAI,CAAC,UAAU,CAAC,YAAY,CAAC,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QAC/E,MAAM,IAAI,GAAG,MAAM,OAAO,CAAC,OAAO,EAAE,CAAC;QACrC,MAAM,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,EAAE,SAAS,EAAE,kBAAkB,EAAE,CAAC,CAAC;QAC5D,MAAM,IAAI,CAAC,QAAQ,CAAC,CAAC,KAAK,EAAE,EAAE;YAC5B,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC;gBACjD,YAAY,CAAC,OAAO,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;YACnC,CAAC;QACH,CAAC,EAAE,UAAU,CAAC,YAAY,CAAC,CAAC;QAC5B,MAAM,IAAI,CAAC,KAAK,EAAE,CAAC;IACrB,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { AuthConfig } from "../config.js";
|
|
2
|
+
import type { AuthResult } from "./index.js";
|
|
3
|
+
/**
|
|
4
|
+
* Build auth result from localStorage key-value pairs.
|
|
5
|
+
* These get injected into the browser via page.evaluate().
|
|
6
|
+
*/
|
|
7
|
+
export declare function injectLocalStorage(auth: AuthConfig): AuthResult;
|
|
8
|
+
//# sourceMappingURL=local-storage.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"local-storage.d.ts","sourceRoot":"","sources":["../../src/auth/local-storage.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAC/C,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAE7C;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,UAAU,GAAG,UAAU,CAW/D"}
|