@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 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()))