@xcelsior/demo-pipeline 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/README.md +47 -0
- package/bin/cli.js +88 -0
- package/package.json +19 -0
- package/template/.claude/commands/demo-video.md +42 -0
- package/template/README.md +69 -0
- package/template/demo.config.json +15 -0
- package/template/package.json +13 -0
- package/template/pipeline/capture_mobile.sh +60 -0
- package/template/pipeline/capture_web.py +186 -0
- package/template/pipeline/gen_music.py +52 -0
- package/template/pipeline/gen_vo.py +29 -0
- package/template/pipeline/run.sh +45 -0
- package/template/remotion/package.json +16 -0
- package/template/remotion/public/brand/logo-white.png +0 -0
- package/template/remotion/remotion.config.ts +5 -0
- package/template/remotion/src/DemoVideo.tsx +18 -0
- package/template/remotion/src/Root.tsx +47 -0
- package/template/remotion/src/Scene.tsx +131 -0
- package/template/remotion/src/edit/Cursor.tsx +83 -0
- package/template/remotion/src/edit/EditVideo.tsx +68 -0
- package/template/remotion/src/edit/data.ts +134 -0
- package/template/remotion/src/edit/mobile.events.json +11 -0
- package/template/remotion/src/edit/segments.tsx +268 -0
- package/template/remotion/src/edit/vo-manifest.json +38 -0
- package/template/remotion/src/edit/web.events.json +274 -0
- package/template/remotion/src/index.ts +4 -0
- package/template/remotion/src/scenes.ts +111 -0
- package/template/remotion/src/ui.tsx +161 -0
- package/template/remotion/tsconfig.json +13 -0
- package/template/setup.sh +29 -0
package/README.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# @xcelsior/demo-pipeline
|
|
2
|
+
|
|
3
|
+
Scaffold an **automated demo-video pipeline** into any repo. Records the real app
|
|
4
|
+
(Playwright web + iOS sim), generates an open-source voice-over (Kokoro), and edits it
|
|
5
|
+
OpenScreen/Screen-Studio style (event-driven zoom, moving cursor, click ripples, captions,
|
|
6
|
+
branding) with Remotion. Driven interactively by Claude via `/demo-video`.
|
|
7
|
+
|
|
8
|
+
## Install into a repo
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npx @xcelsior/demo-pipeline init # or: pnpm dlx @xcelsior/demo-pipeline init
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Drops two things (never overwrites existing):
|
|
15
|
+
- `tools/demo-pipeline/` — capture scripts + Remotion engine + `setup.sh` + `demo.config.json`
|
|
16
|
+
- `.claude/commands/demo-video.md` — the Claude command
|
|
17
|
+
|
|
18
|
+
## Use
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
# 1. configure for this project
|
|
22
|
+
edit tools/demo-pipeline/demo.config.json # baseUrl, brand logo, voice, iOS sim
|
|
23
|
+
|
|
24
|
+
# 2. one-time machine setup
|
|
25
|
+
pnpm --dir tools/demo-pipeline setup # uv, Playwright Chromium, Kokoro model, deps
|
|
26
|
+
|
|
27
|
+
# 3. produce a video
|
|
28
|
+
# in Claude:
|
|
29
|
+
/demo-video # authors/uses a flow, captures, edits, renders
|
|
30
|
+
# or CLI:
|
|
31
|
+
LNG_PWD='…' pnpm --dir tools/demo-pipeline demo:all
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Outputs per run: `raw/` (recordings) · `vo/` (voice-over) · `edited/` (visual edit) · `final/`.
|
|
35
|
+
|
|
36
|
+
## What's app-specific vs reusable
|
|
37
|
+
- **Reusable (this package):** the capture harness, Kokoro VO, the Remotion edit engine
|
|
38
|
+
(camera/cursor/captions derived from captured events), setup, the Claude command.
|
|
39
|
+
- **Per-project:** `demo.config.json` + the capture *flow* (`pipeline/capture_web.py`) — Claude
|
|
40
|
+
authors/extends the flow interactively by exploring the UI.
|
|
41
|
+
|
|
42
|
+
## Updating
|
|
43
|
+
Re-run `npx @xcelsior/demo-pipeline@latest init` to pull engine improvements (won't clobber your
|
|
44
|
+
edited config/flow). See `tools/demo-pipeline/README.md` (copied in) for the full guide.
|
|
45
|
+
|
|
46
|
+
## Origin
|
|
47
|
+
Extracted from the Load & Go pipeline. First consumer: `load-go` (`tools/demo-pipeline/`).
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @xcelsior/demo-pipeline — scaffolder.
|
|
4
|
+
*
|
|
5
|
+
* xcelsior-demo init copy the pipeline in (never overwrites existing)
|
|
6
|
+
* xcelsior-demo update pull engine updates (overwrites engine files,
|
|
7
|
+
* PRESERVES your per-project config + flow + brand)
|
|
8
|
+
*
|
|
9
|
+
* Targets:
|
|
10
|
+
* - tools/demo-pipeline/ (capture scripts + Remotion engine + setup)
|
|
11
|
+
* - .claude/commands/demo-video.md (the Claude command)
|
|
12
|
+
*/
|
|
13
|
+
const fs = require("fs");
|
|
14
|
+
const path = require("path");
|
|
15
|
+
|
|
16
|
+
const TEMPLATE = path.join(__dirname, "..", "template");
|
|
17
|
+
const CWD = process.cwd();
|
|
18
|
+
const cmd = process.argv[2] || "init";
|
|
19
|
+
|
|
20
|
+
// files an `update` must NOT clobber (they are yours, per-project)
|
|
21
|
+
const PRESERVE = new Set([
|
|
22
|
+
"demo.config.json",
|
|
23
|
+
"pipeline/capture_web.py", // the app-specific flow
|
|
24
|
+
"pipeline/capture_mobile.sh",
|
|
25
|
+
]);
|
|
26
|
+
// directories whose contents are yours
|
|
27
|
+
const PRESERVE_DIRS = ["remotion/public/brand"];
|
|
28
|
+
|
|
29
|
+
function isPreserved(rel) {
|
|
30
|
+
if (PRESERVE.has(rel)) return true;
|
|
31
|
+
return PRESERVE_DIRS.some((d) => rel === d || rel.startsWith(d + "/"));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function copyTree(src, dstRoot, baseRel, { overwrite }) {
|
|
35
|
+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
36
|
+
const rel = baseRel ? `${baseRel}/${entry.name}` : entry.name;
|
|
37
|
+
const s = path.join(src, entry.name);
|
|
38
|
+
const d = path.join(dstRoot, rel);
|
|
39
|
+
if (entry.isDirectory()) {
|
|
40
|
+
fs.mkdirSync(d, { recursive: true });
|
|
41
|
+
copyTree(s, dstRoot, rel, { overwrite });
|
|
42
|
+
} else {
|
|
43
|
+
const exists = fs.existsSync(d);
|
|
44
|
+
if (exists && (!overwrite || isPreserved(rel))) continue;
|
|
45
|
+
fs.mkdirSync(path.dirname(d), { recursive: true });
|
|
46
|
+
fs.copyFileSync(s, d);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function run(overwrite) {
|
|
52
|
+
const toolDst = path.join(CWD, "tools", "demo-pipeline");
|
|
53
|
+
// everything except the .claude folder -> tools/demo-pipeline
|
|
54
|
+
for (const entry of fs.readdirSync(TEMPLATE)) {
|
|
55
|
+
if (entry === ".claude") continue;
|
|
56
|
+
const s = path.join(TEMPLATE, entry);
|
|
57
|
+
if (fs.statSync(s).isDirectory()) {
|
|
58
|
+
fs.mkdirSync(path.join(toolDst, entry), { recursive: true });
|
|
59
|
+
copyTree(s, toolDst, entry, { overwrite });
|
|
60
|
+
} else {
|
|
61
|
+
const d = path.join(toolDst, entry);
|
|
62
|
+
if (!(fs.existsSync(d) && (!overwrite || isPreserved(entry)))) {
|
|
63
|
+
fs.mkdirSync(toolDst, { recursive: true });
|
|
64
|
+
fs.copyFileSync(s, d);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// claude command (engine — safe to refresh on update)
|
|
69
|
+
const cmdSrc = path.join(TEMPLATE, ".claude", "commands", "demo-video.md");
|
|
70
|
+
if (fs.existsSync(cmdSrc)) {
|
|
71
|
+
const cmdDir = path.join(CWD, ".claude", "commands");
|
|
72
|
+
fs.mkdirSync(cmdDir, { recursive: true });
|
|
73
|
+
const cmdDst = path.join(cmdDir, "demo-video.md");
|
|
74
|
+
if (!fs.existsSync(cmdDst) || overwrite) fs.copyFileSync(cmdSrc, cmdDst);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (cmd === "init") {
|
|
79
|
+
run(false);
|
|
80
|
+
console.log("✓ demo-pipeline scaffolded -> tools/demo-pipeline/ + .claude/commands/demo-video.md");
|
|
81
|
+
console.log("Next: edit tools/demo-pipeline/demo.config.json → pnpm --dir tools/demo-pipeline setup → /demo-video");
|
|
82
|
+
} else if (cmd === "update") {
|
|
83
|
+
run(true);
|
|
84
|
+
console.log("✓ engine updated (kept your demo.config.json, capture flow, and brand assets)");
|
|
85
|
+
} else {
|
|
86
|
+
console.log("usage: xcelsior-demo <init|update>");
|
|
87
|
+
process.exit(cmd === "--help" || cmd === "-h" ? 0 : 1);
|
|
88
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@xcelsior/demo-pipeline",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Scaffold an automated demo-video pipeline (capture real UI → Kokoro voice-over → OpenScreen-style Remotion edit) into any repo. Driven by Claude via /demo-video.",
|
|
5
|
+
"bin": {
|
|
6
|
+
"xcelsior-demo": "bin/cli.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"bin",
|
|
10
|
+
"template"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=18.0.0"
|
|
14
|
+
},
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"scripts": {
|
|
17
|
+
"lint": "biome check bin || true"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Record a real UI flow and produce a polished demo video (capture → voice-over → OpenScreen-style edit → render)
|
|
3
|
+
argument-hint: "[flow name or description, e.g. 'day sheet job creation']"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
You are running the **demo-pipeline** at `tools/demo-pipeline/`. Goal: produce demo-video artifacts
|
|
7
|
+
(`raw/`, `vo/`, `edited/`, `final/`) for the flow the user names in `$ARGUMENTS` (default: the Day Sheet
|
|
8
|
+
job-creation flow already implemented in `pipeline/capture_web.py`).
|
|
9
|
+
|
|
10
|
+
Read `tools/demo-pipeline/README.md` and `demo.config.json` first. Then:
|
|
11
|
+
|
|
12
|
+
## 1. Ensure setup
|
|
13
|
+
- If `tools/demo-pipeline/.venv` or `models/kokoro.onnx` is missing, run `pnpm --dir tools/demo-pipeline setup` and wait.
|
|
14
|
+
- Mobile capture needs a booted iOS sim (`xcrun simctl list devices booted`) with the driver app. If none, do web-only (`MOBILE=0`).
|
|
15
|
+
|
|
16
|
+
## 2. Get the password
|
|
17
|
+
- The capture logs into `baseUrl` as `auth.defaultEmail`. It needs `LNG_PWD` in the env.
|
|
18
|
+
- Ask the user for the UAT password (or tell them to `export LNG_PWD=...`). NEVER hardcode or commit it.
|
|
19
|
+
|
|
20
|
+
## 3. Author the flow (only if the user asks for a NEW flow)
|
|
21
|
+
- The capture flow is `pipeline/capture_web.py` (Playwright). For a new flow:
|
|
22
|
+
- Explore the target screens live with the **chrome-devtools MCP** (navigate, snapshot, find selectors).
|
|
23
|
+
- Grid cells edit on **double-click → type**; autocompletes commit with **type → ArrowDown → Enter**;
|
|
24
|
+
log each action with `log(kind, locator, label)` so the editor's camera/cursor can track it.
|
|
25
|
+
- Set a clean empty date if the screen accumulates rows (see `DAY_OFFSET`).
|
|
26
|
+
- For the default Day Sheet flow, skip this — it's already written.
|
|
27
|
+
|
|
28
|
+
## 4. Run the pipeline
|
|
29
|
+
```bash
|
|
30
|
+
LNG_PWD='<pwd>' pnpm --dir tools/demo-pipeline demo:all # web + mobile + vo + render
|
|
31
|
+
# flags: MOBILE=0 (web only) · MUSIC=1 (music bed)
|
|
32
|
+
```
|
|
33
|
+
Run it in the background; it takes a few minutes.
|
|
34
|
+
|
|
35
|
+
## 5. Deliver
|
|
36
|
+
- Verify `runs/<timestamp>/final/demo-final.mp4` exists, then send the artifacts to the user
|
|
37
|
+
(final + edited at minimum). Summarize what was captured.
|
|
38
|
+
|
|
39
|
+
## Tuning the look
|
|
40
|
+
- Camera/cursor/captions derive from the captured `events.json`. Tune zoom/pacing in
|
|
41
|
+
`remotion/src/edit/data.ts` (`WEB_CAM`, beats), then re-render `cd remotion && remotion render Edit ...`.
|
|
42
|
+
- Variants are flags on the `Edit` composition: `--props='{"framed":true,"music":true}'`.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Demo Pipeline
|
|
2
|
+
|
|
3
|
+
Turns a real UI flow into a polished demo video — **automatically**. Records the actual app
|
|
4
|
+
(web via Playwright, mobile via the iOS sim), generates an open-source voice-over (Kokoro),
|
|
5
|
+
and edits it OpenScreen/Screen-Studio style (zoom-to-action, moving cursor, click ripples,
|
|
6
|
+
captions, branding) with Remotion.
|
|
7
|
+
|
|
8
|
+
Outputs, per run, as **separate artifacts**: `raw/` (recordings) · `vo/` (voice-over) ·
|
|
9
|
+
`edited/` (visual edit, no voice) · `final/` (edited + voice).
|
|
10
|
+
|
|
11
|
+
## Setup on a fresh machine (once)
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# from the repo root
|
|
15
|
+
pnpm install
|
|
16
|
+
pnpm --dir tools/demo-pipeline setup # installs uv, Playwright Chromium, Kokoro model, Remotion deps
|
|
17
|
+
```
|
|
18
|
+
Requirements: macOS, Node + pnpm, Claude Code. Mobile capture also needs Xcode + a booted iOS sim + Maestro.
|
|
19
|
+
|
|
20
|
+
## Run it
|
|
21
|
+
|
|
22
|
+
**Via Claude (recommended):**
|
|
23
|
+
```
|
|
24
|
+
/demo-video # Claude authors/uses a flow, then captures → VO → edits → renders
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
**Via CLI:**
|
|
28
|
+
```bash
|
|
29
|
+
export LNG_PWD='<uat password>' # never commit
|
|
30
|
+
pnpm --dir tools/demo-pipeline demo:all # full run -> runs/<timestamp>/final/demo-final.mp4
|
|
31
|
+
MOBILE=0 ... demo:all # web only
|
|
32
|
+
MUSIC=1 ... demo:all # add the ambient music bed
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## How it works (stages)
|
|
36
|
+
|
|
37
|
+
| Stage | Script | Output |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| capture web | `pipeline/capture_web.py` (Playwright recordVideo + action event log) | `raw/web.webm`, `events/web.events.json` |
|
|
40
|
+
| capture mobile | `pipeline/capture_mobile.sh` (simctl recordVideo + Maestro + push) | `raw/mobile.mov`, `events/mobile.events.json` |
|
|
41
|
+
| voice-over | `pipeline/gen_vo.py` (Kokoro `af_heart`) | `vo/*.wav`, `vo/voiceover.wav` |
|
|
42
|
+
| edit + render | `remotion/` (event-driven camera, cursor, captions) | `edited/`, `final/` |
|
|
43
|
+
| orchestrate | `pipeline/run.sh` | all of the above |
|
|
44
|
+
|
|
45
|
+
The edit is **data-driven**: the camera/cursor/captions derive from the captured
|
|
46
|
+
`events.json` (so the camera tracks the grid's own scroll). Tune the look in
|
|
47
|
+
`remotion/src/edit/data.ts`.
|
|
48
|
+
|
|
49
|
+
## Per-project config (`demo.config.json`)
|
|
50
|
+
|
|
51
|
+
```jsonc
|
|
52
|
+
{
|
|
53
|
+
"baseUrl": "...", // admin URL to record
|
|
54
|
+
"auth": { "defaultEmail": "..." }, // password via env LNG_PWD
|
|
55
|
+
"brandLogo": "...", // logo for intro/outro
|
|
56
|
+
"voice": "af_heart", // Kokoro voice
|
|
57
|
+
"sim": { "udid": "...", "appId": "..." } // iOS sim for mobile
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The **flow** (which screens/actions to record) lives in `pipeline/capture_web.py` — this is the
|
|
62
|
+
app-specific part. Claude can author/extend it interactively (it explores the UI via the
|
|
63
|
+
chrome-devtools MCP, then writes the steps).
|
|
64
|
+
|
|
65
|
+
## Notes
|
|
66
|
+
- `runs/`, `models/`, `.venv/`, `node_modules/`, recordings → gitignored.
|
|
67
|
+
- Recording uses an empty future date so the day sheet has one clean row + no booking conflict.
|
|
68
|
+
- Reusable engine is being extracted to `@xcelsior/demo-pipeline` (excelsior-packages) for
|
|
69
|
+
cross-project use; this folder is load-go's working copy + example flow.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"project": "load-go",
|
|
3
|
+
"baseUrl": "https://uat-admin.landg.com.au",
|
|
4
|
+
"auth": {
|
|
5
|
+
"defaultEmail": "contact@xcelsior.co",
|
|
6
|
+
"note": "Password via env LNG_PWD (never commit it). Email overridable via LNG_EMAIL."
|
|
7
|
+
},
|
|
8
|
+
"brandLogo": "../../apps/admin/public/uploads/full-logo-white.png",
|
|
9
|
+
"voice": "af_heart",
|
|
10
|
+
"sim": {
|
|
11
|
+
"udid": "A3C855FF-C151-423C-ABFD-E2AB3278E3DD",
|
|
12
|
+
"appId": "au.com.landg",
|
|
13
|
+
"note": "iOS simulator UDID for the driver app. xcrun simctl list devices booted"
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@xcelsior/demo-pipeline",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "Automated demo-video pipeline: capture real UI -> voice-over -> OpenScreen-style edit -> render.",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"setup": "bash setup.sh",
|
|
8
|
+
"demo:all": "bash pipeline/run.sh",
|
|
9
|
+
"demo:web": "bash pipeline/run.sh",
|
|
10
|
+
"render": "cd remotion && ./node_modules/.bin/remotion render Edit ../runs/latest/final/demo-final.mp4 --image-format=jpeg",
|
|
11
|
+
"studio": "cd remotion && ./node_modules/.bin/remotion studio"
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Capture-mobile: record the iOS sim as the driver receives a push and opens the job.
|
|
3
|
+
# Produces raw/mobile.mov + events/mobile.events.json
|
|
4
|
+
set -u
|
|
5
|
+
UDID="${UDID:-A3C855FF-C151-423C-ABFD-E2AB3278E3DD}"
|
|
6
|
+
RUN="${RUN_DIR:-/Users/huyhoang/Projects/load-go/plans/260619-demo-video-pipeline/runs/260619}"
|
|
7
|
+
APP=au.com.landg
|
|
8
|
+
MAESTRO=~/.maestro/bin/maestro
|
|
9
|
+
mkdir -p "$RUN/raw" "$RUN/events"
|
|
10
|
+
|
|
11
|
+
cat > /tmp/lng-push.apns <<'JSON'
|
|
12
|
+
{ "Simulator Target Bundle": "au.com.landg",
|
|
13
|
+
"aps": { "alert": { "title": "New Job Allocation", "body": "You have been allocated a new job" }, "sound": "default", "badge": 1 } }
|
|
14
|
+
JSON
|
|
15
|
+
|
|
16
|
+
# go to home screen so the banner is visible
|
|
17
|
+
cat > /tmp/m-home.yaml <<'YAML'
|
|
18
|
+
appId: au.com.landg
|
|
19
|
+
---
|
|
20
|
+
- pressKey: Home
|
|
21
|
+
YAML
|
|
22
|
+
$MAESTRO --device "$UDID" test /tmp/m-home.yaml >/dev/null 2>&1 || true
|
|
23
|
+
sleep 1
|
|
24
|
+
|
|
25
|
+
# start recording (background)
|
|
26
|
+
rm -f "$RUN/raw/mobile.mov"
|
|
27
|
+
xcrun simctl io "$UDID" recordVideo --codec h264 --force "$RUN/raw/mobile.mov" &
|
|
28
|
+
REC=$!
|
|
29
|
+
sleep 1.0
|
|
30
|
+
|
|
31
|
+
# fire push (banner slides in) — t~1.0
|
|
32
|
+
xcrun simctl push "$UDID" "$APP" /tmp/lng-push.apns >/dev/null 2>&1
|
|
33
|
+
sleep 3.5
|
|
34
|
+
|
|
35
|
+
# open app + go to a job's details — t~4.5
|
|
36
|
+
xcrun simctl launch "$UDID" "$APP" >/dev/null 2>&1
|
|
37
|
+
sleep 2.2
|
|
38
|
+
cat > /tmp/m-jd.yaml <<'YAML'
|
|
39
|
+
appId: au.com.landg
|
|
40
|
+
---
|
|
41
|
+
- tapOn: "Jobs"
|
|
42
|
+
- tapOn:
|
|
43
|
+
point: "50%,27%"
|
|
44
|
+
YAML
|
|
45
|
+
$MAESTRO --device "$UDID" test /tmp/m-jd.yaml >/dev/null 2>&1 || true
|
|
46
|
+
sleep 3.0
|
|
47
|
+
|
|
48
|
+
kill -INT $REC 2>/dev/null
|
|
49
|
+
sleep 2.0
|
|
50
|
+
|
|
51
|
+
cat > "$RUN/events/mobile.events.json" <<'JSON'
|
|
52
|
+
{ "video": "raw/mobile.mov", "w": 1206, "h": 2622,
|
|
53
|
+
"events": [
|
|
54
|
+
{ "t": 0.0, "kind": "scene", "label": "home" },
|
|
55
|
+
{ "t": 1.2, "kind": "notify", "x": 603, "y": 150, "label": "push banner" },
|
|
56
|
+
{ "t": 5.0, "kind": "scene", "label": "open" },
|
|
57
|
+
{ "t": 8.0, "kind": "scene", "label": "jobdetails" }
|
|
58
|
+
] }
|
|
59
|
+
JSON
|
|
60
|
+
echo "MOBILE_DONE $(ls -la "$RUN/raw/mobile.mov" 2>/dev/null | awk '{print $5}')"
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Capture-web stage of the LnG demo pipeline.
|
|
3
|
+
Logs into UAT admin, drives the Day Sheet flow (create job inline -> site contacts ->
|
|
4
|
+
allocate crew incl. truck -> publish), RECORDS the real screen as video, and logs every
|
|
5
|
+
action (coords + timestamps) to events.json for the OpenScreen-style auto-zoom/cursor edit.
|
|
6
|
+
|
|
7
|
+
Run: uv run --project apps/e2e python pipeline/capture_web.py
|
|
8
|
+
Env: RUN_DIR (output dir), HEADLESS=0 to watch.
|
|
9
|
+
"""
|
|
10
|
+
import json, os, time, datetime, re
|
|
11
|
+
from playwright.sync_api import sync_playwright
|
|
12
|
+
|
|
13
|
+
BASE = os.environ.get("LNG_BASE_URL", "https://uat-admin.landg.com.au")
|
|
14
|
+
EMAIL = os.environ.get("LNG_EMAIL", "contact@xcelsior.co")
|
|
15
|
+
PWD = os.environ.get("LNG_PWD", "") # required via env — never committed
|
|
16
|
+
if not PWD:
|
|
17
|
+
raise SystemExit("LNG_PWD env var is required (the UAT password). Set it before running.")
|
|
18
|
+
RUN_DIR = os.environ.get("RUN_DIR", os.path.expanduser(
|
|
19
|
+
"~/Projects/load-go/plans/260619-demo-video-pipeline/runs/260619"))
|
|
20
|
+
RAW = os.path.join(RUN_DIR, "raw")
|
|
21
|
+
EVD = os.path.join(RUN_DIR, "events")
|
|
22
|
+
os.makedirs(RAW, exist_ok=True); os.makedirs(EVD, exist_ok=True)
|
|
23
|
+
HEADLESS = os.environ.get("HEADLESS", "1") != "0"
|
|
24
|
+
W, H = 1920, 1080
|
|
25
|
+
|
|
26
|
+
# column index in the day-sheet grid
|
|
27
|
+
COL = {"jobname":2,"customer":3,"time":4,"pickup":5,"delivery":6,"loaddesc":7,
|
|
28
|
+
"estqty":8,"unit":9,"trucks":10,"crew":11,"jobtype":12,"desc":13,"status":14}
|
|
29
|
+
|
|
30
|
+
events = []
|
|
31
|
+
t0 = [None]
|
|
32
|
+
def stamp():
|
|
33
|
+
return round(time.monotonic() - t0[0], 3) if t0[0] else 0.0
|
|
34
|
+
def log(kind, loc=None, label="", x=None, y=None):
|
|
35
|
+
box = None
|
|
36
|
+
if loc is not None:
|
|
37
|
+
try:
|
|
38
|
+
b = loc.bounding_box()
|
|
39
|
+
if b:
|
|
40
|
+
box = {"x": round(b["x"]), "y": round(b["y"]), "w": round(b["width"]), "h": round(b["height"])}
|
|
41
|
+
x = round(b["x"] + b["width"]/2); y = round(b["y"] + b["height"]/2)
|
|
42
|
+
except Exception:
|
|
43
|
+
pass
|
|
44
|
+
events.append({"t": stamp(), "kind": kind, "x": x, "y": y, "label": label, "box": box})
|
|
45
|
+
print(f" [{stamp():6.2f}] {kind:6} {label}")
|
|
46
|
+
|
|
47
|
+
def main():
|
|
48
|
+
with sync_playwright() as p:
|
|
49
|
+
b = p.chromium.launch(headless=HEADLESS)
|
|
50
|
+
ctx = b.new_context(viewport={"width": W, "height": H},
|
|
51
|
+
record_video_dir=RAW, record_video_size={"width": W, "height": H},
|
|
52
|
+
device_scale_factor=1)
|
|
53
|
+
pg = ctx.new_page()
|
|
54
|
+
pg.set_default_timeout(15000)
|
|
55
|
+
t_ctx = time.monotonic() # ~video start
|
|
56
|
+
|
|
57
|
+
# --- login (off-timeline) ---
|
|
58
|
+
print("login...")
|
|
59
|
+
pg.goto(BASE + "/sign-in", wait_until="domcontentloaded")
|
|
60
|
+
pg.get_by_test_id("sign-in-email-input").fill(EMAIL)
|
|
61
|
+
pg.get_by_test_id("sign-in-password-input").fill(PWD)
|
|
62
|
+
pg.get_by_test_id("sign-in-submit-button").click()
|
|
63
|
+
pg.wait_for_timeout(4500)
|
|
64
|
+
# use an empty future date so the grid has a single clean row + no double-booking conflict
|
|
65
|
+
days = int(os.environ.get("DAY_OFFSET", "3"))
|
|
66
|
+
today = (datetime.date.today() + datetime.timedelta(days=days)).isoformat()
|
|
67
|
+
pg.goto(f"{BASE}/daily-allocation?date={today}", wait_until="domcontentloaded")
|
|
68
|
+
pg.wait_for_selector("table tbody tr", timeout=20000)
|
|
69
|
+
pg.wait_for_timeout(1500)
|
|
70
|
+
|
|
71
|
+
# target the first BLANK row (customer empty) so re-runs stay clean
|
|
72
|
+
rows = pg.locator("table tbody tr")
|
|
73
|
+
RIDX = 0
|
|
74
|
+
for i in range(rows.count()):
|
|
75
|
+
try:
|
|
76
|
+
if rows.nth(i).locator("td").nth(COL["customer"]).inner_text().strip() in ("-", ""):
|
|
77
|
+
RIDX = i; break
|
|
78
|
+
except Exception:
|
|
79
|
+
pass
|
|
80
|
+
print("target blank row index:", RIDX)
|
|
81
|
+
def trow(): return pg.locator("table tbody tr").nth(RIDX)
|
|
82
|
+
def cell(name): return trow().locator("td").nth(COL[name])
|
|
83
|
+
|
|
84
|
+
def fill_autocomplete(name, query, label):
|
|
85
|
+
c = cell(name)
|
|
86
|
+
log("move", c, label); c.scroll_into_view_if_needed(); c.dblclick()
|
|
87
|
+
log("click", c, f"open {label}")
|
|
88
|
+
pg.keyboard.type(query, delay=70)
|
|
89
|
+
pg.wait_for_timeout(1300)
|
|
90
|
+
pg.keyboard.press("ArrowDown"); pg.wait_for_timeout(200)
|
|
91
|
+
pg.keyboard.press("Enter")
|
|
92
|
+
pg.wait_for_timeout(1200) # dwell so the filled value is followable
|
|
93
|
+
|
|
94
|
+
# --- demo timeline starts ---
|
|
95
|
+
t0[0] = time.monotonic()
|
|
96
|
+
log("scene", label="daysheet")
|
|
97
|
+
pg.wait_for_timeout(800)
|
|
98
|
+
|
|
99
|
+
# 1. Customer
|
|
100
|
+
fill_autocomplete("customer", "QA", "Customer")
|
|
101
|
+
# 2. Loading time (today)
|
|
102
|
+
c = cell("time"); log("move", c, "Loading Time"); c.dblclick(); log("click", c, "open Loading Time")
|
|
103
|
+
ti = pg.locator('input[type=time]').first
|
|
104
|
+
ti.fill("10:30"); pg.keyboard.press("Enter"); pg.wait_for_timeout(700)
|
|
105
|
+
# 3. Pickup + 4. Delivery
|
|
106
|
+
fill_autocomplete("pickup", "Sydney Olympic Park", "Pickup")
|
|
107
|
+
fill_autocomplete("delivery", "Lucas Heights NSW", "Delivery")
|
|
108
|
+
|
|
109
|
+
# 5. Site contacts (expand details)
|
|
110
|
+
log("scene", label="sitecontacts")
|
|
111
|
+
exp = trow().locator("button").first
|
|
112
|
+
log("click", exp, "expand details"); exp.click(); pg.wait_for_timeout(700)
|
|
113
|
+
try:
|
|
114
|
+
psc = pg.locator("xpath=(//*[normalize-space(text())='Site Contact'])[1]/following::input[1]").first
|
|
115
|
+
psc.scroll_into_view_if_needed(); log("move", psc, "Pickup site contact")
|
|
116
|
+
psc.click(); pg.keyboard.type("Dave - 0412 345 678", delay=45); pg.wait_for_timeout(1100)
|
|
117
|
+
dsc = pg.locator("xpath=(//*[normalize-space(text())='Site Contact'])[2]/following::input[1]").first
|
|
118
|
+
log("move", dsc, "Delivery site contact")
|
|
119
|
+
dsc.click(); pg.keyboard.type("Mia - 0413 222 111", delay=45); pg.wait_for_timeout(1300)
|
|
120
|
+
except Exception as e:
|
|
121
|
+
print("site-contact fill error:", e)
|
|
122
|
+
exp.click(); pg.wait_for_timeout(500) # collapse
|
|
123
|
+
|
|
124
|
+
# 6. Crew: driver + truck
|
|
125
|
+
log("scene", label="crew")
|
|
126
|
+
cc = cell("crew")
|
|
127
|
+
crew_add = cc.get_by_role("button", name=re.compile("Add crew", re.I))
|
|
128
|
+
if crew_add.count() == 0:
|
|
129
|
+
crew_add = cc.get_by_role("button", name="+")
|
|
130
|
+
crew_add = crew_add.first
|
|
131
|
+
crew_add.scroll_into_view_if_needed(); log("click", crew_add, "add crew"); crew_add.click()
|
|
132
|
+
pg.wait_for_timeout(1000)
|
|
133
|
+
# anchor on the Combination input; driver/truck are the following inputs
|
|
134
|
+
combo = pg.get_by_placeholder(re.compile("Search saved combinations", re.I)).last
|
|
135
|
+
combo.wait_for(timeout=8000)
|
|
136
|
+
driver = combo.locator("xpath=following::input[1]").first
|
|
137
|
+
truck = combo.locator("xpath=following::input[2]").first
|
|
138
|
+
log("move", driver, "Driver"); driver.click(); pg.keyboard.type("Contact", delay=70)
|
|
139
|
+
pg.wait_for_timeout(1200); pg.keyboard.press("ArrowDown"); pg.keyboard.press("Enter")
|
|
140
|
+
pg.wait_for_timeout(700)
|
|
141
|
+
# truck: type broad query, pick first
|
|
142
|
+
log("move", truck, "Truck"); truck.click()
|
|
143
|
+
for q in ["1", "T", "A", "0"]:
|
|
144
|
+
truck.fill(""); pg.keyboard.type(q, delay=70); pg.wait_for_timeout(1100)
|
|
145
|
+
pg.keyboard.press("ArrowDown"); pg.keyboard.press("Enter"); pg.wait_for_timeout(600)
|
|
146
|
+
if truck.input_value().strip():
|
|
147
|
+
break
|
|
148
|
+
log("info", truck, f"truck={truck.input_value()!r}")
|
|
149
|
+
save = pg.get_by_role("button", name="Save crew").last
|
|
150
|
+
log("click", save, "save crew"); save.click(); pg.wait_for_timeout(1500)
|
|
151
|
+
|
|
152
|
+
# 7. Publish
|
|
153
|
+
log("scene", label="publish")
|
|
154
|
+
pub = pg.get_by_role("button", name=lambda n: "Publish day" in (n or "")) if False else pg.locator("button", has_text="Publish day").last
|
|
155
|
+
log("move", pub, "Publish day"); pub.scroll_into_view_if_needed(); pub.click()
|
|
156
|
+
log("click", pub, "publish")
|
|
157
|
+
pg.wait_for_timeout(3500)
|
|
158
|
+
body = pg.locator("body").inner_text()
|
|
159
|
+
published_ok = "not published" not in body.lower()
|
|
160
|
+
# capture outcome text
|
|
161
|
+
outcome = ""
|
|
162
|
+
for kw in ["not published", "published", "required to publish"]:
|
|
163
|
+
if kw in body.lower():
|
|
164
|
+
idx = body.lower().find(kw); outcome = body[max(0, idx-120): idx+120]; break
|
|
165
|
+
print("PUBLISH_OK=", published_ok)
|
|
166
|
+
print("OUTCOME=", outcome.replace("\n", " ")[:240])
|
|
167
|
+
pg.screenshot(path=os.path.join(EVD, "web_final.png"))
|
|
168
|
+
pg.wait_for_timeout(1200)
|
|
169
|
+
|
|
170
|
+
ctx.close() # finalizes the video
|
|
171
|
+
# rename video
|
|
172
|
+
vids = [f for f in os.listdir(RAW) if f.endswith(".webm")]
|
|
173
|
+
vids.sort(key=lambda f: os.path.getmtime(os.path.join(RAW, f)))
|
|
174
|
+
if vids:
|
|
175
|
+
src = os.path.join(RAW, vids[-1]); dst = os.path.join(RAW, "web.webm")
|
|
176
|
+
os.replace(src, dst); print("VIDEO=", dst)
|
|
177
|
+
video_offset = round(t0[0] - t_ctx, 3) # seconds from video start to demo start
|
|
178
|
+
with open(os.path.join(EVD, "web.events.json"), "w") as f:
|
|
179
|
+
json.dump({"video": "raw/web.webm", "w": W, "h": H,
|
|
180
|
+
"video_offset": video_offset, "events": events}, f, indent=2)
|
|
181
|
+
print("VIDEO_OFFSET=", video_offset)
|
|
182
|
+
print("EVENTS=", os.path.join(EVD, "web.events.json"), "count=", len(events))
|
|
183
|
+
b.close()
|
|
184
|
+
|
|
185
|
+
if __name__ == "__main__":
|
|
186
|
+
main()
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import numpy as np, soundfile as sf, sys
|
|
2
|
+
|
|
3
|
+
sr = 44100
|
|
4
|
+
dur = 42.0
|
|
5
|
+
t = np.linspace(0, dur, int(sr * dur), endpoint=False)
|
|
6
|
+
|
|
7
|
+
# calm Am progression: Am - F - C - G, 8s each, looped
|
|
8
|
+
chords = [
|
|
9
|
+
[220.00, 261.63, 329.63], # Am
|
|
10
|
+
[174.61, 220.00, 261.63], # F
|
|
11
|
+
[196.00, 261.63, 329.63], # C/G
|
|
12
|
+
[196.00, 246.94, 293.66], # G
|
|
13
|
+
]
|
|
14
|
+
seg = 8.0
|
|
15
|
+
L = np.zeros_like(t); R = np.zeros_like(t)
|
|
16
|
+
|
|
17
|
+
def env(local, length):
|
|
18
|
+
a = np.clip(local / 1.2, 0, 1) # attack
|
|
19
|
+
r = np.clip((length - local) / 1.5, 0, 1) # release
|
|
20
|
+
return np.minimum(a, r)
|
|
21
|
+
|
|
22
|
+
for i in range(int(np.ceil(dur / seg))):
|
|
23
|
+
f_set = chords[i % len(chords)]
|
|
24
|
+
t0 = i * seg
|
|
25
|
+
mask = (t >= t0) & (t < t0 + seg)
|
|
26
|
+
local = t[mask] - t0
|
|
27
|
+
e = env(local, seg)
|
|
28
|
+
for n, f in enumerate(f_set):
|
|
29
|
+
detL = f * (1 + 0.0015 * (n - 1))
|
|
30
|
+
detR = f * (1 - 0.0015 * (n - 1))
|
|
31
|
+
amp = 0.5 / (n + 1)
|
|
32
|
+
L[mask] += amp * e * (np.sin(2*np.pi*detL*local) + 0.25*np.sin(2*np.pi*2*detL*local))
|
|
33
|
+
R[mask] += amp * e * (np.sin(2*np.pi*detR*local) + 0.25*np.sin(2*np.pi*2*detR*local))
|
|
34
|
+
# soft sub
|
|
35
|
+
L[mask] += 0.3 * e * np.sin(2*np.pi*(f_set[0]/2)*local)
|
|
36
|
+
R[mask] += 0.3 * e * np.sin(2*np.pi*(f_set[0]/2)*local)
|
|
37
|
+
|
|
38
|
+
# slow tremolo + gentle shimmer
|
|
39
|
+
trem = 0.92 + 0.08*np.sin(2*np.pi*0.12*t)
|
|
40
|
+
L *= trem; R *= trem
|
|
41
|
+
L += 0.04*np.sin(2*np.pi*660*t)*np.clip(np.sin(2*np.pi*0.05*t),0,1)
|
|
42
|
+
R += 0.04*np.sin(2*np.pi*880*t)*np.clip(np.sin(2*np.pi*0.05*t),0,1)
|
|
43
|
+
|
|
44
|
+
stereo = np.stack([L, R], axis=1)
|
|
45
|
+
stereo /= np.max(np.abs(stereo)) + 1e-9
|
|
46
|
+
stereo *= 0.16 # quiet bed
|
|
47
|
+
# global fade in/out
|
|
48
|
+
fl = int(sr*1.5)
|
|
49
|
+
stereo[:fl] *= np.linspace(0,1,fl)[:,None]
|
|
50
|
+
stereo[-fl:] *= np.linspace(1,0,fl)[:,None]
|
|
51
|
+
sf.write(sys.argv[1], stereo, sr)
|
|
52
|
+
print("music", round(dur,1), "s ->", sys.argv[1])
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import soundfile as sf, sys, os, json
|
|
2
|
+
from kokoro_onnx import Kokoro
|
|
3
|
+
|
|
4
|
+
MODEL_DIR = sys.argv[1] if len(sys.argv) > 1 else "models"
|
|
5
|
+
OUT = sys.argv[2] if len(sys.argv) > 2 else "vo"
|
|
6
|
+
os.makedirs(OUT, exist_ok=True)
|
|
7
|
+
k = Kokoro(os.path.join(MODEL_DIR, "kokoro.onnx"), os.path.join(MODEL_DIR, "voices.bin"))
|
|
8
|
+
|
|
9
|
+
LINES = {
|
|
10
|
+
"s0_intro": "Load and Go. From dispatch, to delivery.",
|
|
11
|
+
"s1_daysheet":"It starts on the Day Sheet. Every job for the day, in one grid.",
|
|
12
|
+
"s2_create": "Add a job inline. Customer, loading time, pickup and delivery, with address lookup as you type.",
|
|
13
|
+
"s3_details": "Site contacts and job details, right in the row.",
|
|
14
|
+
"s4_crew": "Allocate the crew. Driver and truck, straight from the row.",
|
|
15
|
+
"s5_publish": "Publish, and it goes straight to the driver's phone.",
|
|
16
|
+
"s6_push": "On the truck, an instant notification. A new job.",
|
|
17
|
+
"s7_jobdet": "They open it to the full picture. Pickup, delivery, load, and contacts.",
|
|
18
|
+
"s8_outro": "Load and Go. One platform, from dispatch to delivery.",
|
|
19
|
+
}
|
|
20
|
+
manifest = {}
|
|
21
|
+
for name, text in LINES.items():
|
|
22
|
+
s, sr = k.create(text, voice="af_heart", speed=1.04, lang="en-us")
|
|
23
|
+
path = os.path.join(OUT, name + ".wav")
|
|
24
|
+
sf.write(path, s, sr)
|
|
25
|
+
dur = round(len(s) / sr, 3)
|
|
26
|
+
manifest[name] = {"text": text, "dur": dur}
|
|
27
|
+
print(f"{name:12} {dur:5.2f}s")
|
|
28
|
+
json.dump(manifest, open(os.path.join(OUT, "manifest.json"), "w"), indent=2)
|
|
29
|
+
print("VO_DONE", sum(v["dur"] for v in manifest.values()))
|