Skip to content

Commit d4cea78

Browse files
committed
feat(video): the backup video the brief mandates, built from real responses (BRO-2232)
Venue wifi fails at demo time; the brief schedules this for Saturday night and it was never built. It is now `bun run video`. `frame.html` draws frame(t) as a pure function of t -- no clock, no animation, no randomness -- so rendering is a screenshot loop rather than a screen capture: reproducible, no dropped frames, and re-renderable after any copy edit. 615 frames in 28s, one Chrome held open over CDP because a process launch per frame costs more than the frame. `capture.ts` drives the deployed hub and writes content.json, so every message in the video is a response a server actually sent. A hand-typed script drifts from the product the moment either changes, and a backup video that shows something the live demo does not is worse than no backup at all. It also refuses to write content.json unless the receipt served 200. Two things worth keeping: The video is written to `docs/`, not `out/`. `out/` is gitignored, so a backup video written there exists on exactly one laptop -- which is the same mistake that shipped a landing page whose every asset 404ed. CI now typechecks. It ran lint and tests and never ran tsc, which is how this repo went 175-green with a real TS error, and how the `export {}` module marker on demo-live.ts got through earlier tonight. Trap avoided: the URL chrome prints on stderr is the BROWSER endpoint, where Page.* and Runtime.* silently resolve empty and the page never navigates. The page target has to be looked up over /json/list.
1 parent 2101cb8 commit d4cea78

8 files changed

Lines changed: 527 additions & 3 deletions

File tree

.github/workflows/ci.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ jobs:
3232
- name: Lint
3333
run: bun run lint
3434

35+
- name: Typecheck
36+
# bun test does NOT typecheck. This suite went 175-green with a real TS
37+
# error present, and a top-level-await module marker slipped through the
38+
# same way. tsc runs as its own step so the failure is its own line.
39+
run: bunx tsc --noEmit
40+
3541
- name: Test
3642
run: bun test
3743

docs/backup-demo.mp4

1.01 MB
Binary file not shown.

docs/demo-script.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ scripts/warm-hub.sh # or: curl -s https://parallax-hub.onrender.com/
1616
Render's free tier spins down after 15 minutes idle. Measured cold start: **12.4s**. Warm: **0.22s**.
1717
Warm it within 10 minutes of going on stage, and again while the team before you is presenting.
1818

19-
Have open, in this order: a terminal, and `out/backup-demo.mp4` minimised behind it.
19+
Have open, in this order: a terminal, and `docs/backup-demo.mp4` minimised behind it.
2020

2121
## The command
2222

@@ -76,7 +76,7 @@ checker and the accept gate exist and run.
7676

7777
| Failure | What you do |
7878
|---|---|
79-
| **Venue wifi is down** | Play `out/backup-demo.mp4`. Say "this is a recording, the live one needs the network" — do not pretend. |
79+
| **Venue wifi is down** | Play `docs/backup-demo.mp4`. Say "this is a recording, the live one needs the network" — do not pretend. |
8080
| **Hub is cold / slow** | It still works, it just takes ~12s on the first call. Talk over it: that beat is the trust opener anyway. |
8181
| **Hub is unreachable** | `bun run demo:whatsapp` — the same flow, no network. Say the hosted one is on a free tier. |
8282
| **The receipt 404s** | The script will refuse to name the link and exit. That is the guard working. Fall back to `demo:whatsapp`, which writes a self-contained receipt to `out/`. |

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@
88
"test": "bun test",
99
"lint": "biome check .",
1010
"demo:whatsapp": "bun run src/whatsapp-demo.ts",
11-
"demo:live": "bun run src/demo-live.ts"
11+
"demo:live": "bun run src/demo-live.ts",
12+
"video:capture": "bun run scripts/video/capture.ts",
13+
"video": "bun run scripts/video/render.ts",
14+
"typecheck": "tsc --noEmit"
1215
},
1316
"devDependencies": {
1417
"@biomejs/biome": "2.5.10",

scripts/video/capture.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/**
2+
* Capture a real run of the deployed hub into `content.json`, so the backup
3+
* video is built from responses a server actually sent. A hand-typed script
4+
* would drift from the product the moment either changed, and a backup video
5+
* that shows something the live demo does not is worse than no backup at all.
6+
*
7+
* Run with: bun run scripts/video/capture.ts
8+
*/
9+
import { writeFileSync } from "node:fs";
10+
import { join } from "node:path";
11+
12+
const HUB = (process.env.PARALLAX_HUB ?? "https://parallax-hub.onrender.com").replace(/\/+$/, "");
13+
const FROM = "+57 301 775 8620";
14+
15+
async function post(path: string, body: unknown): Promise<Record<string, unknown>> {
16+
const res = await fetch(`${HUB}${path}`, {
17+
method: "POST",
18+
headers: { "content-type": "application/json" },
19+
body: JSON.stringify(body),
20+
signal: AbortSignal.timeout(120_000),
21+
});
22+
if (!res.ok) throw new Error(`${path} -> HTTP ${res.status}`);
23+
return (await res.json()) as Record<string, unknown>;
24+
}
25+
26+
function texts(body: Record<string, unknown>): string[] {
27+
const raw = body.messages;
28+
if (!Array.isArray(raw)) throw new Error("no messages in response");
29+
return raw.map((m) => String((m as { text: string }).text));
30+
}
31+
32+
const healthRes = await fetch(`${HUB}/health`, { signal: AbortSignal.timeout(120_000) });
33+
const health = (await healthRes.json()) as { commit?: string };
34+
const commit = String(health.commit ?? "");
35+
if (commit === "") throw new Error("/health did not report a commit");
36+
37+
const threadId = `video-${Date.now()}`;
38+
const openingText = "hola, quiero simular un cambio de precio antes de aplicarlo";
39+
const proposal = texts(
40+
await post("/api/whatsapp/turn", { from: FROM, text: openingText, threadId }),
41+
);
42+
43+
const blocking = (proposal.join("\n").match(/^\s*\d+\.\s/gm) ?? []).length;
44+
const acceptText = `${Array.from({ length: blocking }, (_, i) => `${i + 1}. unidades`).join("\n")}\nsí, dale`;
45+
const result = texts(await post("/api/whatsapp/turn", { from: FROM, text: acceptText, threadId }));
46+
47+
const receiptUrl = (result.join("\n").match(/https?:\/\/\S+\/r\/[a-f0-9]+/) ?? [])[0];
48+
if (receiptUrl === undefined) throw new Error("no receipt URL came back");
49+
50+
// Name nothing the video cannot show being served.
51+
const receiptRes = await fetch(receiptUrl, { signal: AbortSignal.timeout(60_000) });
52+
const receiptBody = await receiptRes.text();
53+
if (receiptRes.status !== 200 || receiptBody.length === 0) {
54+
throw new Error(`receipt did not serve: HTTP ${receiptRes.status}, ${receiptBody.length} bytes`);
55+
}
56+
57+
const content = {
58+
capturedAt: new Date().toISOString(),
59+
hub: HUB,
60+
commit,
61+
from: FROM,
62+
openingText,
63+
proposal,
64+
acceptText,
65+
result,
66+
receiptUrl,
67+
receiptStatus: receiptRes.status,
68+
receiptBytes: receiptBody.length,
69+
};
70+
71+
const out = join(import.meta.dir, "content.json");
72+
writeFileSync(out, `${JSON.stringify(content, null, 2)}\n`);
73+
console.log(`captured -> ${out}`);
74+
console.log(` commit ${commit}`);
75+
console.log(` receipt ${receiptUrl} HTTP ${receiptRes.status}, ${receiptBody.length} bytes`);

scripts/video/content.json

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"capturedAt": "2026-08-23T03:04:31.049Z",
3+
"hub": "https://parallax-hub.onrender.com",
4+
"commit": "d5516b00f7f6a5cf49e60249b3874eea23f9c279",
5+
"from": "+57 301 775 8620",
6+
"openingText": "hola, quiero simular un cambio de precio antes de aplicarlo",
7+
"proposal": [
8+
"*src (proposed from agent-workspace)*\nRead from your workspace. Nothing runs until you accept it.\n\n*State* (13 fields)\n design_count = 0 <- directory design/\n landing_count = 0 <- directory landing/\n test_count = 0 <- directory test/\n scripts_count = 0 <- directory scripts/\n src_count = 0 <- directory src/\n node_modules_count = 0 <- directory node_modules/\n files_md = 6 <- 6 file(s) with extension .md\n files_lock = 1 <- 1 file(s) with extension .lock\n files_png = 1 <- 1 file(s) with extension .png\n files_json = 3 <- 3 file(s) with extension .json\n files_yaml = 1 <- 1 file(s) with extension .yaml\n files_none_ = 1 <- 1 file(s) with extension (none)\n files_jsonc = 1 <- 1 file(s) with extension .jsonc\n\n*Actions* (6)\n add_to_design(count) by operator\n add_to_landing(count) by operator\n add_to_test(count) by operator\n add_to_scripts(count) by operator\n add_to_src(count) by operator\n add_to_node_modules(count) by operator\n\n*Before this can run* (6)\n 1. What unit is \"count\" measured in for add_to_design? Materialisation fails closed without it.\n 2. What unit is \"count\" measured in for add_to_landing? Materialisation fails closed without it.\n 3. What unit is \"count\" measured in for add_to_test? Materialisation fails closed without it.\n 4. What unit is \"count\" measured in for add_to_scripts? Materialisation fails closed without it.\n 5. What unit is \"count\" measured in for add_to_src? Materialisation fails closed without it.\n 6. What unit is \"count\" measured in for add_to_node_modules? Materialisation fails closed without it.\n\n*Worth answering*\n - Which quantity here is conserved? A conservation invariant is the cheapest oracle available and this proposal has none.\n\nReply with the numbered answers, then ACCEPT. Reply REJECT to discard.\nref e709741b610c"
9+
],
10+
"acceptText": "1. unidades\n2. unidades\n3. unidades\n4. unidades\n5. unidades\n6. unidades\nsí, dale",
11+
"result": [
12+
"Accepted. The executable model adds one derived field, parallax_applied_total, so a conservation invariant has a second quantity to check against; everything else is what you were shown.\n\nRan 12 steps at seed 42 under the governor.\nViolations: 0 (ungoverned baseline: 9).\nBranch class: PINNED.\nsteps_applied: 12 (admissible, simulated)\nviolations: 0 (admissible, simulated)\n\nReceipt: https://parallax-hub.onrender.com/r/9c562549262d4d368278b8e2683217c2"
13+
],
14+
"receiptUrl": "https://parallax-hub.onrender.com/r/9c562549262d4d368278b8e2683217c2",
15+
"receiptStatus": 200,
16+
"receiptBytes": 9937
17+
}

scripts/video/frame.html

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
<!-- Deterministic: render(t) draws the exact frame at t ms. No animation, no
2+
clocks, no randomness -- so the renderer can screenshot it frame by frame
3+
and get the same video every time. -->
4+
<title>Parallax — backup demo</title>
5+
<style>
6+
:root {
7+
--bg: #0b0f14; --panel: #121821; --line: #1f2937; --ink: #e6edf3;
8+
--dim: #8b98a9; --blue: #4c8dff; --green: #3fb950; --red: #f85149;
9+
--wa: #005c4b; --wa-in: #1f2c34;
10+
--mono: "SF Mono", "Menlo", "DejaVu Sans Mono", monospace;
11+
--sans: -apple-system, "Segoe UI", Inter, system-ui, sans-serif;
12+
}
13+
* { box-sizing: border-box; margin: 0; padding: 0; }
14+
body { width: 1920px; height: 1080px; background: var(--bg); color: var(--ink);
15+
font-family: var(--sans); overflow: hidden; }
16+
#stage { display: grid; grid-template-rows: 104px 1fr 128px; height: 1080px; }
17+
18+
header { display: flex; align-items: center; gap: 20px; padding: 0 56px;
19+
border-bottom: 1px solid var(--line); }
20+
.word { font-size: 34px; font-weight: 640; letter-spacing: -0.5px; }
21+
.tag { font-size: 21px; color: var(--dim); }
22+
.badge { margin-left: auto; font-family: var(--mono); font-size: 16px; color: var(--dim);
23+
border: 1px solid var(--line); border-radius: 999px; padding: 8px 18px; }
24+
25+
.cols { display: grid; grid-template-columns: 712px 1fr; gap: 44px; padding: 36px 56px;
26+
min-height: 0; }
27+
28+
/* phone */
29+
.phone { background: #0d1418; border: 1px solid var(--line); border-radius: 30px;
30+
display: grid; grid-template-rows: 74px 1fr; overflow: hidden; min-height: 0; }
31+
.phone-bar { display: flex; align-items: center; gap: 14px; padding: 0 24px;
32+
background: var(--wa-in); border-bottom: 1px solid #000; }
33+
.avatar { width: 40px; height: 40px; border-radius: 50%; background: var(--blue);
34+
display: grid; place-items: center; font-weight: 700; font-size: 17px; color: #04101f; }
35+
.who { font-size: 19px; font-weight: 600; }
36+
.who small { display: block; font-size: 14px; color: var(--dim); font-weight: 400; }
37+
.thread { padding: 22px; overflow: hidden; display: flex; flex-direction: column;
38+
gap: 14px; justify-content: flex-end; min-height: 0;
39+
-webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 58px, #000 100%); }
40+
.msg { max-width: 90%; padding: 12px 16px; border-radius: 14px; font-size: 15px;
41+
line-height: 1.45; white-space: pre-wrap; word-break: break-word;
42+
font-family: var(--mono); }
43+
.msg.in { align-self: flex-end; background: var(--wa); }
44+
.msg.out { align-self: flex-start; background: var(--wa-in); }
45+
.msg b { color: #7ee0c0; font-weight: 600; }
46+
.typing { align-self: flex-start; background: var(--wa-in); padding: 15px 20px;
47+
border-radius: 14px; display: flex; gap: 7px; }
48+
.typing i { width: 9px; height: 9px; border-radius: 50%; background: var(--dim); display: block; }
49+
50+
/* proof */
51+
.proof { background: var(--panel); border: 1px solid var(--line); border-radius: 18px;
52+
padding: 30px 34px; font-family: var(--mono); font-size: 19px; line-height: 1.75;
53+
overflow: hidden; min-height: 0; }
54+
.proof .k { color: var(--dim); }
55+
.proof .ok { color: var(--green); }
56+
.proof .no { color: var(--red); }
57+
.proof .hi { color: var(--ink); font-weight: 600; }
58+
.proof .b { color: var(--blue); }
59+
.proof h3 { font-family: var(--sans); font-size: 15px; letter-spacing: 1.6px;
60+
text-transform: uppercase; color: var(--dim); font-weight: 600;
61+
margin: 26px 0 12px; }
62+
.proof h3:first-child { margin-top: 0; }
63+
.note { color: var(--dim); font-size: 16px; line-height: 1.6; font-family: var(--sans); }
64+
.steps { list-style: none; font-family: var(--sans); font-size: 18px; }
65+
.steps li { display: flex; align-items: center; gap: 14px; padding: 7px 0; color: var(--dim); }
66+
.steps li .dot { width: 11px; height: 11px; border-radius: 50%; background: #2b3648;
67+
flex: none; }
68+
.steps li.on { color: var(--ink); }
69+
.steps li.on .dot { background: var(--blue); box-shadow: 0 0 0 5px rgba(76,141,255,.16); }
70+
.steps li.done { color: var(--dim); }
71+
.steps li.done .dot { background: var(--green); box-shadow: none; }
72+
73+
.caption { border-top: 1px solid var(--line); padding: 0 56px; display: flex;
74+
align-items: center; }
75+
.caption p { font-size: 27px; line-height: 1.35; max-width: 1600px; }
76+
.caption .em { color: var(--blue); }
77+
.hidden { display: none; }
78+
</style>
79+
80+
<div id="stage">
81+
<header>
82+
<span class="word">Parallax</span>
83+
<span class="tag">Simula el cambio antes de aplicarlo. Y te decimos cuánto de eso fue real.</span>
84+
<span class="badge" id="badge">Platanus Hack 26 · Bogotá · team-5</span>
85+
</header>
86+
<div class="cols">
87+
<div class="phone">
88+
<div class="phone-bar">
89+
<div class="avatar">P</div>
90+
<div class="who">Parallax<small id="presence">en línea</small></div>
91+
</div>
92+
<div class="thread" id="thread"></div>
93+
</div>
94+
<div class="proof" id="proof"></div>
95+
</div>
96+
<div class="caption"><p id="caption"></p></div>
97+
</div>
98+
99+
<script>
100+
const CONTENT = __CONTENT__;
101+
const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
102+
// WhatsApp *bold* -> <b>, applied after escaping so content can never inject markup.
103+
const wa = (s) => esc(s).replace(/\*([^*\n]+)\*/g, "<b>$1</b>");
104+
105+
/** Reveal a string progressively: 0 -> "", 1 -> whole thing. */
106+
function upto(s, p) {
107+
if (p <= 0) return "";
108+
if (p >= 1) return s;
109+
return s.slice(0, Math.max(1, Math.floor(s.length * p)));
110+
}
111+
const span = (t, a, b) => Math.max(0, Math.min(1, (t - a) / (b - a)));
112+
113+
const T = {
114+
healthReq: 1200, healthRes: 2600,
115+
inboundType: 5000, inboundDone: 7000,
116+
typing1: 7400, proposalStart: 8600, proposalDone: 18600,
117+
readBeat: 21200,
118+
acceptType: 21200, acceptDone: 23200,
119+
typing2: 23600, resultStart: 24600, resultDone: 28600,
120+
receiptReq: 29400, receiptRes: 31000,
121+
linkMsg: 32200, closing: 35000, end: 41000,
122+
};
123+
124+
const CAPTIONS = [
125+
[0, 4600, 'No existe un ambiente de pruebas para la forma en que opera un negocio.'],
126+
[4600, 8600, 'Llega un mensaje por WhatsApp — donde ya opera América Latina.'],
127+
[8600, 19400, 'Lee el contexto y <span class="em">propone</span> un modelo con lo que realmente hay ahí. Nada ha corrido.'],
128+
[19400, 23400, 'No se activa sin unidades. Falla cerrado — no adivina.'],
129+
[23400, 29200, 'El humano acepta. Ese paso no es un trámite: es el producto.'],
130+
[29200, 34800, 'Verifica el recibo <span class="em">antes</span> de nombrarlo. No publicamos enlaces que no comprobamos.'],
131+
[34800, 41000, 'La meta no es un simulador que acierte. Es <span class="em">un simulador que no pueda mentir sobre ser un simulador.</span>'],
132+
];
133+
134+
function render(t) {
135+
// ---- proof panel
136+
const P = [];
137+
P.push('<h3>¿El código desplegado es el del repo?</h3>');
138+
if (t >= T.healthReq) {
139+
P.push('<div><span class="k">GET</span> ' + esc(CONTENT.hub) + '/health</div>');
140+
}
141+
if (t >= T.healthRes) {
142+
P.push('<div><span class="k">commit</span> <span class="hi">' + esc(CONTENT.commit) + '</span></div>');
143+
P.push('<div class="note">`version` es una constante del código y el panel de deploy '
144+
+ 'reporta intención. El commit es el único campo que una imagen vieja no puede fingir.</div>');
145+
}
146+
if (t >= T.receiptReq) {
147+
P.push('<h3>El recibo, comprobado antes de entregarlo</h3>');
148+
P.push('<div><span class="k">GET</span> <span class="b">' + esc(CONTENT.receiptUrl) + '</span></div>');
149+
}
150+
if (t >= T.receiptRes) {
151+
P.push('<div><span class="ok">HTTP ' + CONTENT.receiptStatus + '</span> · '
152+
+ CONTENT.receiptBytes + ' bytes · <span class="k">servido, no afirmado</span></div>');
153+
}
154+
P.push('<h3>El flujo</h3>');
155+
const STEPS = [
156+
['propone una ontología desde el contexto', T.proposalStart, T.acceptType],
157+
['el humano acepta — nada corrió antes', T.acceptType, T.resultStart],
158+
['corre el modelo hacia adelante', T.resultStart, T.receiptReq],
159+
['recibo, comprobado antes de nombrarlo', T.receiptReq, T.end],
160+
];
161+
P.push('<ul class="steps">' + STEPS.map(([label, a, b]) => {
162+
const cls = t >= b ? 'done' : t >= a ? 'on' : '';
163+
return '<li class="' + cls + '"><span class="dot"></span>' + label + '</li>';
164+
}).join('') + '</ul>');
165+
166+
if (t >= T.closing) {
167+
P.push('<h3>Por qué se le puede creer</h3>');
168+
P.push('<div class="note">La transición y las invariantes son <b>código</b>, nunca un modelo.<br>'
169+
+ 'Una política <b>no puede certificarse a sí misma</b>.<br>'
170+
+ 'Misma semilla → mismo hash de traza. Semilla+1 → diverge.<br>'
171+
+ 'Cada valor viene tipado <b>observado | simulado</b>.</div>');
172+
}
173+
document.getElementById('proof').innerHTML = P.join('');
174+
175+
// ---- thread
176+
const M = [];
177+
if (t >= T.inboundType) {
178+
M.push('<div class="msg in">' + wa(upto(CONTENT.openingText, span(t, T.inboundType, T.inboundDone))) + '</div>');
179+
}
180+
const showTyping1 = t >= T.typing1 && t < T.proposalStart;
181+
const showTyping2 = t >= T.typing2 && t < T.resultStart;
182+
if (t >= T.proposalStart) {
183+
const full = CONTENT.proposal.join('\n\n');
184+
const lines = full.split('\n');
185+
const n = Math.max(1, Math.floor(lines.length * span(t, T.proposalStart, T.proposalDone)));
186+
M.push('<div class="msg out">' + wa(lines.slice(0, n).join('\n')) + '</div>');
187+
}
188+
if (t >= T.acceptType) {
189+
const flat = CONTENT.acceptText.split('\n').join(' ');
190+
M.push('<div class="msg in">' + wa(upto(flat, span(t, T.acceptType, T.acceptDone))) + '</div>');
191+
}
192+
if (t >= T.resultStart) {
193+
const full = CONTENT.result.join('\n\n').replace(/\s*Receipt:.*$/s, '');
194+
const lines = full.split('\n');
195+
const n = Math.max(1, Math.floor(lines.length * span(t, T.resultStart, T.resultDone)));
196+
M.push('<div class="msg out">' + wa(lines.slice(0, n).join('\n')) + '</div>');
197+
}
198+
if (t >= T.linkMsg) {
199+
M.push('<div class="msg out">listo. el recibo:\n' + esc(CONTENT.receiptUrl) + '</div>');
200+
}
201+
if (showTyping1 || showTyping2) {
202+
M.push('<div class="typing"><i></i><i></i><i></i></div>');
203+
}
204+
document.getElementById('thread').innerHTML = M.join('');
205+
document.getElementById('presence').textContent =
206+
(showTyping1 || showTyping2) ? 'escribiendo…' : 'en línea';
207+
208+
// ---- caption
209+
const c = CAPTIONS.find(([a, b]) => t >= a && t < b);
210+
document.getElementById('caption').innerHTML = c ? c[2] : '';
211+
return true;
212+
}
213+
render(0);
214+
</script>

0 commit comments

Comments
 (0)