@mineo71/showtime 0.0.0-stage → 0.2.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/CHANGELOG.md +18 -0
- package/LICENSE +21 -0
- package/README.md +365 -2
- package/bin/showtime.mjs +5 -0
- package/docs/script.schema.json +1346 -0
- package/package.json +71 -4
- package/skill/SKILL.md +112 -0
- package/src/cli/format.ts +19 -0
- package/src/cli/main.ts +115 -0
- package/src/ffmpeg.ts +72 -0
- package/src/paths.ts +30 -0
- package/src/pipeline.ts +38 -0
- package/src/render/remotion/BrowserBar.tsx +61 -0
- package/src/render/remotion/Cursor.tsx +40 -0
- package/src/render/remotion/Intro.tsx +29 -0
- package/src/render/remotion/Overlays.tsx +95 -0
- package/src/render/remotion/Root.tsx +21 -0
- package/src/render/remotion/ShowtimeVideo.tsx +92 -0
- package/src/render/remotion/index.ts +4 -0
- package/src/render/render.ts +46 -0
- package/src/runner/browser.ts +122 -0
- package/src/runner/capture.ts +53 -0
- package/src/runner/check.ts +73 -0
- package/src/runner/encode.ts +49 -0
- package/src/runner/events.ts +15 -0
- package/src/runner/record.ts +96 -0
- package/src/runner/steps.ts +217 -0
- package/src/runner/time.ts +17 -0
- package/src/script/hash.ts +29 -0
- package/src/script/load.ts +36 -0
- package/src/script/schema.ts +144 -0
- package/src/setup.ts +41 -0
- package/src/take.ts +24 -0
- package/src/timeline/build.ts +103 -0
- package/src/timeline/camera.ts +69 -0
- package/src/timeline/cursor.ts +59 -0
- package/src/timeline/frames.ts +60 -0
- package/src/timeline/layout.ts +29 -0
- package/src/timeline/math.ts +7 -0
- package/src/timeline/timemap.ts +52 -0
- package/src/timeline/types.ts +111 -0
package/package.json
CHANGED
|
@@ -1,6 +1,73 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mineo71/showtime",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Product demos, directed by AI: Claude writes a JSON script, showTime records your web app and renders a polished MP4",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"demo",
|
|
7
|
+
"product-demo",
|
|
8
|
+
"screencast",
|
|
9
|
+
"screen-recording",
|
|
10
|
+
"video",
|
|
11
|
+
"playwright",
|
|
12
|
+
"remotion",
|
|
13
|
+
"claude-code",
|
|
14
|
+
"ai",
|
|
15
|
+
"agent"
|
|
16
|
+
],
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"author": "Oleh Rylskyj",
|
|
19
|
+
"homepage": "https://github.com/mineo71/showTime#readme",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/mineo71/showTime.git"
|
|
23
|
+
},
|
|
24
|
+
"bugs": {
|
|
25
|
+
"url": "https://github.com/mineo71/showTime/issues"
|
|
26
|
+
},
|
|
27
|
+
"type": "module",
|
|
28
|
+
"bin": {
|
|
29
|
+
"showtime": "bin/showtime.mjs"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"bin/",
|
|
33
|
+
"src/",
|
|
34
|
+
"skill/",
|
|
35
|
+
"docs/script.schema.json",
|
|
36
|
+
"CHANGELOG.md"
|
|
37
|
+
],
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=20"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"showtime": "node bin/showtime.mjs",
|
|
46
|
+
"test": "vitest run test/unit",
|
|
47
|
+
"test:int": "vitest run test/integration",
|
|
48
|
+
"typecheck": "tsc --noEmit",
|
|
49
|
+
"fixture": "tsx test/helpers/serve.ts",
|
|
50
|
+
"example": "node scripts/serve.mjs examples 4800",
|
|
51
|
+
"media": "tsx scripts/media.ts",
|
|
52
|
+
"schema:write": "node bin/showtime.mjs schema > docs/script.schema.json",
|
|
53
|
+
"install-skill": "node bin/showtime.mjs setup --no-browsers"
|
|
54
|
+
},
|
|
55
|
+
"dependencies": {
|
|
56
|
+
"@remotion/bundler": "4.0.535",
|
|
57
|
+
"@remotion/renderer": "4.0.535",
|
|
58
|
+
"commander": "^14.0.3",
|
|
59
|
+
"playwright": "^1.64.0",
|
|
60
|
+
"react": "^19.3.0",
|
|
61
|
+
"react-dom": "^19.3.0",
|
|
62
|
+
"remotion": "4.0.535",
|
|
63
|
+
"tsx": "^4.23.15",
|
|
64
|
+
"zod": "^4.6.7"
|
|
65
|
+
},
|
|
66
|
+
"devDependencies": {
|
|
67
|
+
"@types/node": "^20.19.43",
|
|
68
|
+
"@types/react": "^19.3.0",
|
|
69
|
+
"@types/react-dom": "^19.3.0",
|
|
70
|
+
"typescript": "^5.9.3",
|
|
71
|
+
"vitest": "^4.1.11"
|
|
72
|
+
}
|
|
73
|
+
}
|
package/skill/SKILL.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: showtime
|
|
3
|
+
description: Use when the user wants a product demo video, walkthrough, screencast or feature recording of a web app (for a pitch, talk, README or social post). Write a showTime JSON script, rehearse it, record it and render a polished MP4 with auto-zoom, smooth cursor, captions and sped-up loading via the `showtime` CLI.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# showTime — scripted demo videos
|
|
7
|
+
|
|
8
|
+
`showtime` turns a JSON script into a polished 1080p MP4. You are the director: you write the
|
|
9
|
+
script; the CLI clicks through the app, records it and edits it (zoom, cursor, ripples,
|
|
10
|
+
spotlights, captions, speed-ups, intro card).
|
|
11
|
+
|
|
12
|
+
## Setup
|
|
13
|
+
|
|
14
|
+
Run `showtime --help`. If the command is missing, install it (needs Node ≥ 20 and ffmpeg):
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install -g @mineo71/showtime
|
|
18
|
+
showtime setup # downloads the browsers it records with
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Workflow
|
|
22
|
+
|
|
23
|
+
1. **Story first.** Settle the audience, target length (30–120 s), the 2–4 moments that sell the
|
|
24
|
+
product, and the URL of a running app (a local dev server is best). Read the pitch, PRD or
|
|
25
|
+
routes if they exist. Plan one scene per moment.
|
|
26
|
+
2. **Explore the app** with a browser tool if you have one (e.g. the Playwright MCP:
|
|
27
|
+
`browser_navigate`, `browser_snapshot`) to learn the flow and pick selectors from the
|
|
28
|
+
accessibility snapshot. Without one, read the routes and templates and let `showtime check`
|
|
29
|
+
show you the page (it prints an ARIA snapshot when a step fails). Prefer
|
|
30
|
+
`role=button[name='Save']`, `text=…`, `#id`, `[data-testid=…]` over long CSS chains.
|
|
31
|
+
3. **Write the script** in the project, e.g. `demo/pitch.json`. Run `showtime schema` for the
|
|
32
|
+
complete format.
|
|
33
|
+
4. **Rehearse:** `showtime check demo/pitch.json`. On failure read the error, the ARIA snapshot it
|
|
34
|
+
prints and `.showtime/<name>/check/failure.png`; fix the selector or add a `wait`, repeat until
|
|
35
|
+
green.
|
|
36
|
+
5. **Record:** `showtime record demo/pitch.json` (runs in real time).
|
|
37
|
+
6. **Render:** `showtime render demo/pitch.json` → `demo/pitch.mp4`.
|
|
38
|
+
7. **QA:** `showtime frames demo/pitch.json`, then Read the PNGs it lists. Check that zoom lands on
|
|
39
|
+
the right control, captions are legible and do not hide the action, and pacing feels right.
|
|
40
|
+
8. **Iterate:** edits to `show`, `style` or `meta` only need `render`. Edits to actions (`do`,
|
|
41
|
+
targets, text, `hold`, `app`) need `record` again — `render` refuses a stale take.
|
|
42
|
+
|
|
43
|
+
`showtime make <script>` runs check → record → render in one go.
|
|
44
|
+
|
|
45
|
+
## Long-running commands
|
|
46
|
+
|
|
47
|
+
`record` runs in real time and then encodes; `render`, `frames` and `make` bundle and render with
|
|
48
|
+
Remotion. They routinely take several minutes. In Claude Code run them with `timeout: 600000`, or
|
|
49
|
+
with `run_in_background: true` and wait for the completion notification. In Codex and other
|
|
50
|
+
agents, give the command a timeout of at least 10 minutes.
|
|
51
|
+
Never let a `record` be killed halfway: it replaces the previous take.
|
|
52
|
+
|
|
53
|
+
## Side effects
|
|
54
|
+
|
|
55
|
+
`check` performs every action for real (it is a full run, just fast), and `record` repeats them.
|
|
56
|
+
Flows that create data ("New project", sign-up, checkout) can collide on the second run
|
|
57
|
+
("name already exists"). Use a resettable demo account or seed data, make names unique, or undo
|
|
58
|
+
the change in `setup`; after a green `check`, reset the data before `record`.
|
|
59
|
+
|
|
60
|
+
## Example
|
|
61
|
+
|
|
62
|
+
```json
|
|
63
|
+
{
|
|
64
|
+
"version": 1,
|
|
65
|
+
"meta": { "title": "Acme Analytics", "subtitle": "Reports in two clicks" },
|
|
66
|
+
"app": {
|
|
67
|
+
"url": "http://localhost:3000/",
|
|
68
|
+
"viewport": { "width": 1440, "height": 900 },
|
|
69
|
+
"mask": [".customer-email"]
|
|
70
|
+
},
|
|
71
|
+
"setup": [{ "do": "click", "target": "text=Accept cookies" }],
|
|
72
|
+
"style": { "accent": "#6366f1" },
|
|
73
|
+
"scenes": [
|
|
74
|
+
{
|
|
75
|
+
"id": "report",
|
|
76
|
+
"steps": [
|
|
77
|
+
{ "do": "click", "target": "role=button[name='New report']", "show": { "caption": "A report in one click" } },
|
|
78
|
+
{ "do": "type", "target": "#report-name", "text": "Q3 revenue" },
|
|
79
|
+
{ "do": "select", "target": "#period", "value": "30d" },
|
|
80
|
+
{ "do": "click", "target": "role=button[name='Create']" },
|
|
81
|
+
{ "do": "wait", "for": "text=Report ready" },
|
|
82
|
+
{ "do": "hover", "target": ".cashback", "show": { "spotlight": true, "caption": "Cashback at a glance", "hold": 1500 } }
|
|
83
|
+
]
|
|
84
|
+
}
|
|
85
|
+
]
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Reference
|
|
90
|
+
|
|
91
|
+
- **Actions (`do`):** `goto {url}` · `click {target}` · `type {target, text, pace?: human|fast|ms, clear?}` ·
|
|
92
|
+
`press {key}` · `hover {target}` · `select {target, value}` · `scroll {target} | {by}` ·
|
|
93
|
+
`wait {for} | {ms}` · `pause {ms}`
|
|
94
|
+
- **`show` (per step):** `zoom` (number, or `false`), `caption`, `spotlight`, `hold` (ms, real time),
|
|
95
|
+
`speed`.
|
|
96
|
+
- **`style`:** `background` (any CSS background), `chrome` (`browser` window frame | `none`), `address`
|
|
97
|
+
(address-bar text), `padding`, `radius`, `shadow`, `font`, `accent`, `autoZoom`, `autoSpeed`, `maxZoom`.
|
|
98
|
+
- **`app`:** `url`, `viewport`, `deviceScaleFactor`, `storageState` (Playwright storage-state JSON,
|
|
99
|
+
path relative to the script), `colorScheme`, `locale`, `timezoneId`, `mask` (CSS selectors to blur —
|
|
100
|
+
plain CSS only, not `text=` / `role=`).
|
|
101
|
+
- Output lands next to the script; working files in `.showtime/<script-name>/`.
|
|
102
|
+
|
|
103
|
+
## Direction tips
|
|
104
|
+
|
|
105
|
+
- One idea per caption, at most ~6 words. Caption the benefit ("Paid in two clicks"), not the
|
|
106
|
+
click ("Click Pay").
|
|
107
|
+
- After a payoff screen add `hold` (800–2000 ms) so viewers can read it; put `spotlight` on the one
|
|
108
|
+
number that matters.
|
|
109
|
+
- Never `pause` to wait for loading — use `wait {for}`; auto-speed compresses the loader.
|
|
110
|
+
- Use `setup` for cookie banners and onboarding, `app.storageState` to start logged in, `app.mask`
|
|
111
|
+
to blur personal data.
|
|
112
|
+
- Keep the default 1440×900 viewport at `deviceScaleFactor` 2 for crisp 16:9 zooms.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import type { CheckReport } from '../runner/check';
|
|
3
|
+
|
|
4
|
+
const ARIA_LINES = 80;
|
|
5
|
+
|
|
6
|
+
export function formatCheck(report: CheckReport): string {
|
|
7
|
+
if (report.ok) {
|
|
8
|
+
const dir = report.steps[0]?.screenshot ? path.dirname(report.steps[0].screenshot) : '';
|
|
9
|
+
return `✓ all ${report.steps.length} steps passed — screenshots in ${dir}`;
|
|
10
|
+
}
|
|
11
|
+
const f = report.failure!;
|
|
12
|
+
const aria = f.aria.split('\n').slice(0, ARIA_LINES);
|
|
13
|
+
return [
|
|
14
|
+
`✗ ${f.error}`,
|
|
15
|
+
` screenshot: ${f.screenshot}`,
|
|
16
|
+
' page (ARIA snapshot) — pick a selector from here:',
|
|
17
|
+
...aria.map((line) => ` ${line}`),
|
|
18
|
+
].join('\n');
|
|
19
|
+
}
|
package/src/cli/main.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { workPaths } from '../paths';
|
|
4
|
+
import { loadScript } from '../script/load';
|
|
5
|
+
import { scriptJsonSchema } from '../script/schema';
|
|
6
|
+
import { formatCheck } from './format';
|
|
7
|
+
|
|
8
|
+
const say = (line: string) => process.stdout.write(`${line}\n`);
|
|
9
|
+
const progress = (p: number) => {
|
|
10
|
+
if (process.stdout.isTTY) process.stdout.write(`\rRendering ${Math.round(p * 100)}%`);
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const { version } = createRequire(import.meta.url)('../../package.json') as { version: string };
|
|
14
|
+
|
|
15
|
+
const program = new Command('showtime')
|
|
16
|
+
.description('AI-directed product demo videos: JSON script in, polished MP4 out')
|
|
17
|
+
.version(version);
|
|
18
|
+
|
|
19
|
+
program
|
|
20
|
+
.command('setup')
|
|
21
|
+
.description('download the browsers showTime records with and link the skill into Claude Code and Codex')
|
|
22
|
+
.option('--agent <agent>', 'claude, codex or all', 'all')
|
|
23
|
+
.option('--no-browsers', 'only link the skill')
|
|
24
|
+
.action(async (o: { agent: string; browsers: boolean }) => {
|
|
25
|
+
const { AGENTS, installBrowsers, linkSkill } = await import('../setup');
|
|
26
|
+
const agents = o.agent === 'all' ? AGENTS : AGENTS.filter((a) => a === o.agent);
|
|
27
|
+
if (!agents.length) throw new Error(`--agent must be ${AGENTS.join(', ')} or all`);
|
|
28
|
+
if (o.browsers) {
|
|
29
|
+
say('Downloading Chromium and the Remotion renderer browser…');
|
|
30
|
+
await installBrowsers();
|
|
31
|
+
}
|
|
32
|
+
for (const dest of linkSkill(agents)) say(`✓ skill linked: ${dest}`);
|
|
33
|
+
say('Ready. Ask your agent for a demo, or run `showtime make <script>`.');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
program
|
|
37
|
+
.command('schema')
|
|
38
|
+
.description('print the JSON Schema of demo scripts')
|
|
39
|
+
.action(() => {
|
|
40
|
+
say(JSON.stringify(scriptJsonSchema(), null, 2));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
program
|
|
44
|
+
.command('check <script>')
|
|
45
|
+
.description('rehearse: run every step fast, screenshot each, report the first failure')
|
|
46
|
+
.option('--json', 'print the machine-readable report')
|
|
47
|
+
.option('--headed', 'show the browser')
|
|
48
|
+
.action(async (file: string, o: { json?: boolean; headed?: boolean }) => {
|
|
49
|
+
const loaded = await loadScript(file);
|
|
50
|
+
const { check } = await import('../runner/check');
|
|
51
|
+
const report = await check(loaded, { headed: o.headed, log: o.json ? undefined : say });
|
|
52
|
+
say(o.json ? JSON.stringify(report, null, 2) : formatCheck(report));
|
|
53
|
+
if (!report.ok) process.exitCode = 1;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
program
|
|
57
|
+
.command('record <script>')
|
|
58
|
+
.description('record a take (raw video + event log) into .showtime/<name>/take')
|
|
59
|
+
.option('--headed', 'show the browser')
|
|
60
|
+
.option('--keep-frames', 'keep the captured JPEG frames')
|
|
61
|
+
.action(async (file: string, o: { headed?: boolean; keepFrames?: boolean }) => {
|
|
62
|
+
const loaded = await loadScript(file);
|
|
63
|
+
const { record } = await import('../runner/record');
|
|
64
|
+
const ev = await record(loaded, { headed: o.headed, keepFrames: o.keepFrames, log: say });
|
|
65
|
+
say(`✓ take: ${(ev.video.durationMs / 1000).toFixed(1)} s, ${ev.steps.length} steps → ${workPaths(loaded).take}`);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
program
|
|
69
|
+
.command('render <script>')
|
|
70
|
+
.description('render the polished video from the last take (no re-recording)')
|
|
71
|
+
.option('-o, --out <file>', 'output MP4 (default: <script-name>.mp4 next to the script)')
|
|
72
|
+
.action(async (file: string, o: { out?: string }) => {
|
|
73
|
+
const loaded = await loadScript(file);
|
|
74
|
+
const { renderDemo } = await import('../pipeline');
|
|
75
|
+
const out = await renderDemo(loaded, { out: o.out, onProgress: progress });
|
|
76
|
+
say(`\n✓ ${out}`);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
program
|
|
80
|
+
.command('frames <script>')
|
|
81
|
+
.description('render QA stills (intro + one per step) for visual review')
|
|
82
|
+
.action(async (file: string) => {
|
|
83
|
+
const loaded = await loadScript(file);
|
|
84
|
+
const { renderQaFrames } = await import('../pipeline');
|
|
85
|
+
const files = await renderQaFrames(loaded);
|
|
86
|
+
say(files.join('\n'));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
program
|
|
90
|
+
.command('make <script>')
|
|
91
|
+
.description('check → record → render')
|
|
92
|
+
.option('-o, --out <file>', 'output MP4 (default: <script-name>.mp4 next to the script)')
|
|
93
|
+
.option('--headed', 'show the browser')
|
|
94
|
+
.action(async (file: string, o: { out?: string; headed?: boolean }) => {
|
|
95
|
+
const loaded = await loadScript(file);
|
|
96
|
+
const { check } = await import('../runner/check');
|
|
97
|
+
const report = await check(loaded, { headed: o.headed });
|
|
98
|
+
if (!report.ok) {
|
|
99
|
+
say(formatCheck(report));
|
|
100
|
+
process.exitCode = 1;
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
say(`✓ check passed (${report.steps.length} steps)`);
|
|
104
|
+
const { record } = await import('../runner/record');
|
|
105
|
+
const ev = await record(loaded, { headed: o.headed, log: say });
|
|
106
|
+
say(`✓ take: ${(ev.video.durationMs / 1000).toFixed(1)} s`);
|
|
107
|
+
const { renderDemo } = await import('../pipeline');
|
|
108
|
+
const out = await renderDemo(loaded, { out: o.out, onProgress: progress });
|
|
109
|
+
say(`\n✓ ${out}`);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
program.parseAsync().catch((err: Error) => {
|
|
113
|
+
process.stderr.write(`✖ ${err.message}\n`);
|
|
114
|
+
process.exit(1);
|
|
115
|
+
});
|
package/src/ffmpeg.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
export function run(cmd: string, args: string[]): Promise<string> {
|
|
4
|
+
return new Promise((resolve, reject) => {
|
|
5
|
+
const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
6
|
+
let out = '';
|
|
7
|
+
let err = '';
|
|
8
|
+
child.stdout.on('data', (d) => (out += d));
|
|
9
|
+
child.stderr.on('data', (d) => (err += d));
|
|
10
|
+
child.on('error', reject);
|
|
11
|
+
child.on('close', (code) =>
|
|
12
|
+
code === 0 ? resolve(out) : reject(new Error(`${cmd} exited with ${code}: ${err.trim().slice(-2000)}`)),
|
|
13
|
+
);
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Run a command and stream data into its stdin; `feed` awaits back-pressure through `write`. */
|
|
18
|
+
export function runWithInput(
|
|
19
|
+
cmd: string,
|
|
20
|
+
args: string[],
|
|
21
|
+
feed: (write: (chunk: Buffer) => Promise<void>) => Promise<void>,
|
|
22
|
+
): Promise<void> {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
const child = spawn(cmd, args, { stdio: ['pipe', 'ignore', 'pipe'] });
|
|
25
|
+
let err = '';
|
|
26
|
+
child.stderr.on('data', (d) => (err += d));
|
|
27
|
+
child.on('error', reject);
|
|
28
|
+
child.on('close', (code) =>
|
|
29
|
+
code === 0 ? resolve() : reject(new Error(`${cmd} exited with ${code}: ${err.trim().slice(-2000)}`)),
|
|
30
|
+
);
|
|
31
|
+
child.stdin.on('error', () => undefined); // surfaced through the exit code
|
|
32
|
+
const write = (chunk: Buffer) =>
|
|
33
|
+
new Promise<void>((done) => {
|
|
34
|
+
if (child.stdin.write(chunk)) done();
|
|
35
|
+
else child.stdin.once('drain', done);
|
|
36
|
+
});
|
|
37
|
+
feed(write).then(
|
|
38
|
+
() => child.stdin.end(),
|
|
39
|
+
(e) => {
|
|
40
|
+
child.kill();
|
|
41
|
+
reject(e);
|
|
42
|
+
},
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function assertFfmpeg(): Promise<void> {
|
|
48
|
+
try {
|
|
49
|
+
await run('ffmpeg', ['-version']);
|
|
50
|
+
await run('ffprobe', ['-version']);
|
|
51
|
+
} catch {
|
|
52
|
+
throw new Error('ffmpeg/ffprobe not found on PATH. Install it: brew install ffmpeg (macOS) or apt install ffmpeg (Linux).');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface VideoProbe {
|
|
57
|
+
width: number;
|
|
58
|
+
height: number;
|
|
59
|
+
durationMs: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function probeVideo(file: string): Promise<VideoProbe> {
|
|
63
|
+
const out = await run('ffprobe', [
|
|
64
|
+
'-v', 'error',
|
|
65
|
+
'-select_streams', 'v:0',
|
|
66
|
+
'-show_entries', 'stream=width,height:format=duration',
|
|
67
|
+
'-of', 'json',
|
|
68
|
+
file,
|
|
69
|
+
]);
|
|
70
|
+
const j = JSON.parse(out) as { streams: Array<{ width: number; height: number }>; format: { duration: string } };
|
|
71
|
+
return { width: j.streams[0].width, height: j.streams[0].height, durationMs: Math.round(Number(j.format.duration) * 1000) };
|
|
72
|
+
}
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import type { LoadedScript } from './script/load';
|
|
3
|
+
|
|
4
|
+
export interface WorkPaths {
|
|
5
|
+
root: string;
|
|
6
|
+
check: string;
|
|
7
|
+
take: string;
|
|
8
|
+
rawFrames: string;
|
|
9
|
+
raw: string;
|
|
10
|
+
events: string;
|
|
11
|
+
timeline: string;
|
|
12
|
+
stills: string;
|
|
13
|
+
video: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function workPaths(loaded: LoadedScript): WorkPaths {
|
|
17
|
+
const root = path.join(loaded.dir, '.showtime', loaded.name);
|
|
18
|
+
const take = path.join(root, 'take');
|
|
19
|
+
return {
|
|
20
|
+
root,
|
|
21
|
+
check: path.join(root, 'check'),
|
|
22
|
+
take,
|
|
23
|
+
rawFrames: path.join(take, 'frames'),
|
|
24
|
+
raw: path.join(take, 'raw.mp4'),
|
|
25
|
+
events: path.join(take, 'events.json'),
|
|
26
|
+
timeline: path.join(root, 'timeline.json'),
|
|
27
|
+
stills: path.join(root, 'frames'),
|
|
28
|
+
video: path.join(loaded.dir, `${loaded.name}.mp4`),
|
|
29
|
+
};
|
|
30
|
+
}
|
package/src/pipeline.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { workPaths } from './paths';
|
|
4
|
+
import { renderStills, renderVideo } from './render/render';
|
|
5
|
+
import type { LoadedScript } from './script/load';
|
|
6
|
+
import { loadTake } from './take';
|
|
7
|
+
import { buildTimeline } from './timeline/build';
|
|
8
|
+
import { qaShots } from './timeline/frames';
|
|
9
|
+
import type { TakeEvents, Timeline } from './timeline/types';
|
|
10
|
+
|
|
11
|
+
export async function timelineFor(loaded: LoadedScript): Promise<{ timeline: Timeline; events: TakeEvents }> {
|
|
12
|
+
const events = await loadTake(loaded);
|
|
13
|
+
const timeline = buildTimeline(loaded.script, events);
|
|
14
|
+
const paths = workPaths(loaded);
|
|
15
|
+
await mkdir(paths.root, { recursive: true });
|
|
16
|
+
await writeFile(paths.timeline, JSON.stringify(timeline, null, 2));
|
|
17
|
+
return { timeline, events };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function renderDemo(
|
|
21
|
+
loaded: LoadedScript,
|
|
22
|
+
opts: { out?: string; onProgress?: (progress: number) => void } = {},
|
|
23
|
+
): Promise<string> {
|
|
24
|
+
const { timeline } = await timelineFor(loaded);
|
|
25
|
+
const paths = workPaths(loaded);
|
|
26
|
+
const out = path.resolve(opts.out ?? paths.video);
|
|
27
|
+
await mkdir(path.dirname(out), { recursive: true });
|
|
28
|
+
await renderVideo(timeline, paths.take, out, opts.onProgress);
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function renderQaFrames(loaded: LoadedScript): Promise<string[]> {
|
|
33
|
+
const { timeline, events } = await timelineFor(loaded);
|
|
34
|
+
const paths = workPaths(loaded);
|
|
35
|
+
await rm(paths.stills, { recursive: true, force: true });
|
|
36
|
+
await mkdir(paths.stills, { recursive: true });
|
|
37
|
+
return renderStills(timeline, paths.take, paths.stills, qaShots(timeline, events.steps));
|
|
38
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { FC } from 'react';
|
|
2
|
+
|
|
3
|
+
type Props = { width: number; height: number; address: string; dark: boolean; font: string };
|
|
4
|
+
|
|
5
|
+
const THEMES = {
|
|
6
|
+
light: { bar: 'linear-gradient(180deg, #f6f7f8, #eceef1)', edge: 'rgba(15, 23, 42, 0.09)', pill: '#ffffff', pillEdge: 'rgba(15, 23, 42, 0.07)', text: '#475569' },
|
|
7
|
+
dark: { bar: 'linear-gradient(180deg, #2b2c31, #232428)', edge: 'rgba(255, 255, 255, 0.06)', pill: '#18191c', pillEdge: 'rgba(255, 255, 255, 0.07)', text: '#c4c7cf' },
|
|
8
|
+
};
|
|
9
|
+
const LIGHTS = ['#ff5f57', '#febc2e', '#28c840'];
|
|
10
|
+
|
|
11
|
+
/** macOS-style browser bar: traffic lights and a centred address pill. Sizes follow the bar height. */
|
|
12
|
+
export const BrowserBar: FC<Props> = ({ width, height: h, address, dark, font }) => {
|
|
13
|
+
const c = dark ? THEMES.dark : THEMES.light;
|
|
14
|
+
const dot = h * 0.27;
|
|
15
|
+
return (
|
|
16
|
+
<div
|
|
17
|
+
style={{
|
|
18
|
+
position: 'absolute',
|
|
19
|
+
left: 0,
|
|
20
|
+
top: 0,
|
|
21
|
+
width,
|
|
22
|
+
height: h,
|
|
23
|
+
background: c.bar,
|
|
24
|
+
borderBottom: `1px solid ${c.edge}`,
|
|
25
|
+
display: 'flex',
|
|
26
|
+
alignItems: 'center',
|
|
27
|
+
justifyContent: 'center',
|
|
28
|
+
fontFamily: font,
|
|
29
|
+
}}
|
|
30
|
+
>
|
|
31
|
+
<div style={{ position: 'absolute', left: h * 0.45, top: (h - dot) / 2, display: 'flex', gap: h * 0.19 }}>
|
|
32
|
+
{LIGHTS.map((color) => (
|
|
33
|
+
<div key={color} style={{ width: dot, height: dot, borderRadius: '50%', background: color, boxShadow: 'inset 0 0 0 0.5px rgba(0, 0, 0, 0.12)' }} />
|
|
34
|
+
))}
|
|
35
|
+
</div>
|
|
36
|
+
<div
|
|
37
|
+
style={{
|
|
38
|
+
width: Math.min(width * 0.44, h * 14),
|
|
39
|
+
height: h * 0.62,
|
|
40
|
+
borderRadius: h * 0.18,
|
|
41
|
+
background: c.pill,
|
|
42
|
+
boxShadow: `0 0 0 1px ${c.pillEdge}`,
|
|
43
|
+
display: 'flex',
|
|
44
|
+
alignItems: 'center',
|
|
45
|
+
justifyContent: 'center',
|
|
46
|
+
gap: h * 0.14,
|
|
47
|
+
color: c.text,
|
|
48
|
+
fontSize: h * 0.31,
|
|
49
|
+
fontWeight: 500,
|
|
50
|
+
letterSpacing: 0.1,
|
|
51
|
+
}}
|
|
52
|
+
>
|
|
53
|
+
<svg width={h * 0.27} height={h * 0.27} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.4} strokeLinecap="round">
|
|
54
|
+
<rect x="5" y="11" width="14" height="10" rx="2" />
|
|
55
|
+
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
|
|
56
|
+
</svg>
|
|
57
|
+
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{address}</span>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { FC } from 'react';
|
|
2
|
+
import { cursorAt } from '../../timeline/cursor';
|
|
3
|
+
import type { Timeline } from '../../timeline/types';
|
|
4
|
+
|
|
5
|
+
const SIZE = 30;
|
|
6
|
+
const ARROW = 'M5 2 L5 24 L10.6 18.9 L14.3 26.7 L18.1 25.1 L14.4 17.4 L21.7 17.4 Z';
|
|
7
|
+
const IBEAM = 'M10 4.5 H13 Q14 4.5 14 5.5 Q14 4.5 15 4.5 H18 M14 5.5 V22.5 M10 23.5 H13 Q14 23.5 14 22.5 Q14 23.5 15 23.5 H18';
|
|
8
|
+
const HOTSPOT = { arrow: { x: 5, y: 2 }, text: { x: 14, y: 14 } } as const;
|
|
9
|
+
|
|
10
|
+
export const Cursor: FC<{ timeline: Timeline; srcMs: number }> = ({ timeline, srcMs }) => {
|
|
11
|
+
const c = cursorAt(timeline.moves, timeline.clicks, srcMs);
|
|
12
|
+
if (!c.visible) return null;
|
|
13
|
+
const shape = c.kind === 'text' ? 'text' : 'arrow';
|
|
14
|
+
const hs = HOTSPOT[shape];
|
|
15
|
+
const scale = (SIZE / 28) * c.press;
|
|
16
|
+
return (
|
|
17
|
+
<div
|
|
18
|
+
style={{
|
|
19
|
+
position: 'absolute',
|
|
20
|
+
left: c.x,
|
|
21
|
+
top: c.y,
|
|
22
|
+
opacity: c.opacity,
|
|
23
|
+
transformOrigin: '0 0',
|
|
24
|
+
transform: `scale(${scale}) translate(${-hs.x}px, ${-hs.y}px)`,
|
|
25
|
+
filter: 'drop-shadow(0 2px 3px rgba(0, 0, 0, 0.35))',
|
|
26
|
+
}}
|
|
27
|
+
>
|
|
28
|
+
<svg width={28} height={28} viewBox="0 0 28 28">
|
|
29
|
+
{shape === 'arrow' ? (
|
|
30
|
+
<path d={ARROW} fill="#0b0b0f" stroke="#fff" strokeWidth={1.8} strokeLinejoin="round" />
|
|
31
|
+
) : (
|
|
32
|
+
<g fill="none" strokeLinecap="round">
|
|
33
|
+
<path d={IBEAM} stroke="#fff" strokeWidth={4.5} />
|
|
34
|
+
<path d={IBEAM} stroke="#0b0b0f" strokeWidth={2} />
|
|
35
|
+
</g>
|
|
36
|
+
)}
|
|
37
|
+
</svg>
|
|
38
|
+
</div>
|
|
39
|
+
);
|
|
40
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { FC } from 'react';
|
|
2
|
+
import { AbsoluteFill, Easing, interpolate } from 'remotion';
|
|
3
|
+
import { introFrames } from '../../timeline/frames';
|
|
4
|
+
import type { Timeline } from '../../timeline/types';
|
|
5
|
+
|
|
6
|
+
export const Intro: FC<{ timeline: Timeline; frame: number }> = ({ timeline: t, frame }) => {
|
|
7
|
+
const total = introFrames(t);
|
|
8
|
+
const clampBoth = { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' } as const;
|
|
9
|
+
const enter = interpolate(frame, [0, 18], [0, 1], { ...clampBoth, easing: Easing.out(Easing.cubic) });
|
|
10
|
+
const exit = interpolate(frame, [Math.max(1, total - 10), Math.max(2, total)], [1, 0], clampBoth);
|
|
11
|
+
return (
|
|
12
|
+
<AbsoluteFill
|
|
13
|
+
style={{
|
|
14
|
+
background: t.style.background,
|
|
15
|
+
fontFamily: t.style.font,
|
|
16
|
+
alignItems: 'center',
|
|
17
|
+
justifyContent: 'center',
|
|
18
|
+
color: '#fff',
|
|
19
|
+
textAlign: 'center',
|
|
20
|
+
}}
|
|
21
|
+
>
|
|
22
|
+
<div style={{ opacity: Math.min(enter, exit), transform: `scale(${0.94 + 0.06 * enter})` }}>
|
|
23
|
+
<div style={{ fontSize: 88, fontWeight: 800, letterSpacing: -1.5 }}>{t.intro?.title}</div>
|
|
24
|
+
{t.intro?.subtitle ? <div style={{ marginTop: 18, fontSize: 36, opacity: 0.75 }}>{t.intro.subtitle}</div> : null}
|
|
25
|
+
<div style={{ margin: '28px auto 0', width: 72, height: 6, borderRadius: 3, background: t.style.accent }} />
|
|
26
|
+
</div>
|
|
27
|
+
</AbsoluteFill>
|
|
28
|
+
);
|
|
29
|
+
};
|