Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,64 @@
# Changelog

## Unreleased

### Breaking

- A prebaked `clip:` that does not exist at its resolved path now fails immediately,
naming the path that was tried. Previously the declared path was returned verbatim and
the run failed later, inside ffmpeg, against a path nobody had resolved. (#14)
- `clip:` paths no longer resolve against the process working directory. A bare filename
resolves inside `clipsDir`; a path carrying its own directory resolves against the
config file's directory; an absolute path is used as given. Configs that referenced
clips relative to their config file, or by bare filename as the README described, keep
working. A config that relied on being invoked from a particular directory does not.

### Added

- Pre-flight selector gate. Every selector a script declares is resolved against the page
its own shot opens at that point, before any narration is synthesized, and the run
refuses to start when a selector provably will not do what the script says. It also
reports a shot that references a selector but never navigates (capture builds a fresh
context per shot, so that shot runs against `about:blank`), a `prebaked` shot declaring
selector actions capture never runs, a selector-requiring action declared without a
selector, and a prebaked clip missing at its resolved path. Fail-closed; `preflight:
false` or `--no-preflight` declines it, and a declined run says so. (#13)

The gate blocks only on claims it can actually make. It resolves with the same selector
engine capture uses (so Playwright syntax such as `>> nth=` and shadow-DOM piercing
behave identically), against a page carrying the same injected overlay, and waits for an
element that hydrates in after load. Where its evidence is genuinely weaker than the
render's it reports at `INFO` instead: a selector behind an earlier `click`/`type` in the
same shot, an ambiguous `hover` (which capture resolves non-strictly), an auth-walled
`live` shot, and a page that did not settle before counting.
- `out/render-report.json` records whether the gate ran, whether it was declined, and which
shots it could not adjudicate, so a finished video states how it was checked.
- `preflightWaitMs` (default 30000) budgets how long the gate waits for a selector absent at
first count. It defaults to the render's own selector budget, which capture now passes
explicitly from a shared constant so the two cannot drift. Lowering it makes the gate
faster and stops it BLOCKING on absence: with a shorter budget its evidence is weaker
than the render's, so absence is reported at `INFO` instead.
- `clipsDir` is now read. It was declared in the schema, defaulted, and documented in two
places while no code path consulted it. (#14)

### Fixed

- `window.__demoHighlight` no longer fails open. A selector matching nothing hid the
highlight box and returned, so the shot rendered, the run exited 0, and the highlight
never happened. An ambiguous selector is a failure too, because `document.querySelector`
silently takes the first match, which is how a bare `p` or `code` selector looks correct
while pointing at the wrong element. (#13)
- A failing `highlight` action now names the shot, the selector, and the real match count.
It previously surfaced as a raw Playwright strict-mode violation, or as a bare
`Timeout 30000ms exceeded` on a zero match, identifying neither the shot nor the action.
The locator still does the waiting, so an element that appears after load still works,
and a failure that is NOT a count problem (a hidden but unique element, a closed page)
now rethrows the original error rather than being mislabelled as one.
- The highlight overlay is driven by a rectangle capture already resolved with Playwright,
instead of re-resolving the selector in the page with `document.querySelectorAll`. The
two engines disagree on shadow DOM and on Playwright-only selector syntax, so an element
inside an open shadow root previously drew no highlight at all.

## 0.3.0

### Breaking
Expand Down
66 changes: 63 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,12 +135,60 @@ The script is a Markdown file. Each shot is a `### SHOT <id>` heading followed b
| `click` | `selector` | Moves fake cursor then clicks |
| `type` | `selector`, `text` | Types character-by-character (60 ms delay) |
| `hover` | `selector` | Hovers (no fake cursor move) |
| `highlight` | `selector` | Injects a highlight overlay |
| `highlight` | `selector` | Injects a highlight overlay. The selector must match **exactly one** element |
| `chapter` | `label` or `text` | Shows a chapter card overlay |
| `wait` | `ms` | Pauses for N milliseconds |

For `target: prebaked`, set `clip` to the path of an existing video file; no browser is launched for that shot.

## Pre-flight selector gate

Before any narration is synthesized, the pipeline resolves every selector your script
declares against the page that shot opens at that point, and refuses to start when one of
them provably will not do what the script says.

```text
$ demo-video demo.config.json
BLOCKING shot "12-execution-boundary": selector "p" is ambiguous, 43 matches on http://localhost:3000/guide (the strict locator needs exactly one)
BLOCKING shot "05-plate": selector "[data-line='82']" matches nothing on http://localhost:3000/guide
BLOCKING shot "08-recap" uses 2 selector(s) but declares no goto action; capture builds a fresh context per shot, so this shot runs against about:blank and every locator waits out its full timeout
INFO shot "04-approve": selector ".verdict-box" matches nothing on http://localhost:3000/ (an earlier click or type in this shot can change the DOM, and the gate runs no actions, so this could not be verified)
✗ [agent-demo-video] preflight gate failed: 3 finding(s); no narration was synthesized.
```

It reports four things a script cannot tell you on its own:

- a selector that matches **nothing**, which the highlight overlay used to turn into a
silent no-op;
- a selector that matches **more than one** element, because `document.querySelector`
takes the first match, so a bare `p` or `code` selector looks correct while pointing
somewhere else entirely;
- a shot that references a selector but **never navigates**. Capture builds a fresh
browser context per shot, so such a shot runs against `about:blank` and every locator
waits out its full timeout;
- a `prebaked` shot that declares selector actions, which capture never runs.

The gate resolves selectors with the same engine capture uses, against a page carrying
the same injected overlay, and waits for an element that hydrates in after load. Where it
resolves differently from the render it would be reporting on its own limitations, not on
your script.

It therefore only BLOCKS on a claim it can actually make. These are reported at `INFO` and
never fail a run:

- a selector that a preceding `click` or `type` in the same shot would have revealed (the
gate runs no actions, so it cannot see that DOM);
- an ambiguous `hover`, because `page.hover` resolves non-strictly and renders fine;
- an auth-walled `live` shot, because the gate runs unauthenticated and would see the
login wall;
- a page that did not settle before its selectors were counted.

`out/render-report.json` records whether the gate ran, whether it was declined, and which
shots it could not adjudicate, so a finished video says for itself how it was checked.

The gate is fail-closed. Set `preflight: false` in the config, or pass `--no-preflight`
for a single run, to decline it; a declined run says so in its output.

## Authenticated SaaS capture (`target: live`)

`target: live` drives an authenticated SaaS app (Slack, Notion, Linear, Stripe, any
Expand Down Expand Up @@ -225,7 +273,8 @@ Key fields in `demo.config.json` (full schema in `src/types.ts`):
| `audio.soundDesign` | `true` | Synthesized ambient bed ducked under narration, click ticks, segment sweeps |
| `motion.livingCamera` | `true` | Continuous camera path with drift; `motion.zoomOnAction: false` disables all camera motion |
| `brand` | (off) | `{ title, subtitle, url, accent, cards }` adds branded title and end cards |
| `clipsDir` | `"clips/prebaked"` | Directory scanned for prebaked clips |
| `clipsDir` | `"clips/prebaked"` | Where a **bare** prebaked clip filename resolves. Resolved against the config file's directory unless absolute |
| `preflight` | `true` | Fail-closed pre-flight selector gate; see [Pre-flight selector gate](#pre-flight-selector-gate). `false` (or `--no-preflight`) declines it |
| `maxDurationSec` | `300` | Hard ceiling for the finished video. The render fails if the result exceeds it. Set it to the length limit you are shipping against. |
| `capture.settleMs` | `500` | Budget for the post-navigation readiness wait (fonts ready, visible images decoded). `0` disables the probe. Exceeding the budget warns and records anyway. Under the default `screencast` engine the wait happens BEFORE recording starts, so unsettled frames are excluded; the legacy `recordvideo` engine binds capture at context creation, so there the wait shifts those frames later rather than excluding them. |

Expand All @@ -242,7 +291,18 @@ For surfaces you cannot or should not drive live (SaaS login walls, desktop apps
- narration: UiPath Studio opens the workflow we exported earlier.
```

Place the clip in `clipsDir`. The pipeline passes it through normalize/mux/caption without launching a browser.
Clip paths resolve independently of the working directory you run from:

| `clip:` value | Resolves to |
|---|---|
| `uipath-studio.mp4` (bare filename) | `<config dir>/<clipsDir>/uipath-studio.mp4` |
| `clips/prebaked/uipath-studio.mp4` (carries a directory) | `<config dir>/clips/prebaked/uipath-studio.mp4` |
| `/srv/clips/uipath-studio.mp4` (absolute) | used exactly as given |

Place the clip in `clipsDir` and reference it by bare filename, or give a path relative to
your config file. Either way the pipeline passes it through normalize/mux/caption without
launching a browser. A clip that is not present at the resolved path fails immediately,
naming the path that was tried.

## Replicability

Expand Down
165 changes: 165 additions & 0 deletions specs/preflight-selector-gate-spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
# Pre-flight Selector Gate - Spec

Status: active (closes issues #13 and #14)

## Problem

A DEMO_SCRIPT can declare selectors that do not identify what the author meant, and
nothing tells the author until narration has already been paid for.

Three distinct defects, each verified against `0d940d5`:

1. **The highlight overlay is fail-open as a function.** `window.__demoHighlight`
hides its box and returns when `document.querySelector` misses, and
`querySelector` silently takes the FIRST match when a selector is ambiguous.
The capture path currently happens to guard this with a strict Playwright
locator, so the overlay's own fail-open behaviour is masked rather than absent:
any other caller of the overlay API gets a silent no-op, and the two resolutions
(locator, then in-page `querySelector`) are independent, so they can disagree.

2. **A selector mistake is reported late, expensively, and unreadably.** A
zero-match highlight selector stalls the full 30s locator timeout mid-render and
then reports `locator.scrollIntoViewIfNeeded: Timeout 30000ms exceeded` - naming
neither the shot, nor the selector, nor the fact that a highlight was involved.
An ambiguous selector reports a raw Playwright strict-mode violation. Both land
AFTER every shot's narration has been synthesized, so a script-authoring typo
costs TTS spend. The sibling `click` action already throws a shot-scoped message;
`highlight` is the only selector-bearing action without one.

3. **A shot that never navigates is a structural trap.** `captureShot` builds a
fresh browser context per shot, so a browser-driven shot carrying selectors but
no `goto` action runs against `about:blank`, where every locator waits out its
full timeout. Nothing detects this before the render.

Separately, `clipsDir` is declared in the config schema and documented in the README
but read by no code path: prebaked `clip:` paths resolve against the process CWD, so
the same config finds different files depending on where it was invoked from.

## Goal

A script author learns that a selector is wrong BEFORE any narration is synthesized,
in a message that names the shot, the selector, and what the selector actually
matched. The overlay API is fail-closed on its own terms rather than by accident of
its caller. Prebaked clip resolution is deterministic and independent of CWD.

Existing configs that are correct keep rendering unchanged.

## Scenarios (tracer-bullet slices, dependency order)

### S1 - Structural findings without a browser

Given a parsed manifest, when the pre-flight stage inspects it, then every
browser-driven shot that references a selector but declares no `goto` action is
reported as a finding that names the shot and states that a fresh context per shot
means the shot would run against a blank page. A `prebaked` shot that declares
selector-bearing actions is reported too, because a prebaked shot short-circuits
capture and never runs its actions. A shot with no selectors is not reported. This
analysis requires no browser and no network.

### S2 - Selector resolution against the page each shot actually opens

Given a manifest whose shots carry selectors, when the pre-flight stage resolves
them, then each selector is resolved against the URL in effect AT THAT POINT in the
shot (a shot may navigate more than once), and a selector is a finding unless it
matches exactly one element. Zero matches, more than one match, and an unparseable
selector are reported as three distinguishable findings, each naming the shot, the
selector, and the observed match count. Shots that share a URL resolve against a
single navigation, in a context as fresh as the one capture builds, so the gate's
verdict never depends on the order shots are declared in.

### S2b - The gate may only BLOCK on a claim it can actually make

The gate resolves against a freshly-loaded page and deliberately runs no actions,
so its evidence is weaker than the render's in four specific ways. In each, the
finding is reported at INFO and does not block:

- a selector downstream of a click or type in the same shot, which the gate cannot
see because it runs no actions;
- an ambiguous `hover`, because capture resolves hover non-strictly and it renders;
- an auth-walled `live` shot, because the gate runs unauthenticated and would see
the login wall;
- a page that did not settle before counting.

Conversely, the gate must be no stricter than the render where it CAN judge: it
resolves with the same engine capture uses (so Playwright selector syntax and
shadow-DOM piercing behave identically), it counts the page with the same overlay
elements capture injects, and it waits for a late-hydrating element rather than
counting once instantly.

### S3 - The gate runs before spend, and can be declined

Given a demo run, when the pipeline starts, then the pre-flight stage runs before
any narration is synthesized and before any capture, and a run with findings fails
with a report of every finding and no TTS spend. An operator may decline the gate
through configuration or a command-line flag, and declining is reported in the run
output rather than silent. A script with no findings renders exactly as it did
before the gate existed.

### S4 - The highlight overlay fails closed on its own terms

Given the highlight action in a capture, when its selector does not resolve to
exactly one element, then the run fails with a message naming the shot, the
selector, and the observed count, and pointing at the pre-flight gate. A selector
that resolves to exactly one element after an initial delay still succeeds: the
action retains the auto-waiting behaviour it has today, and a failure that is not
a cardinality problem surfaces its original error rather than being relabelled.

Capture resolves the element itself and hands the overlay the resulting
rectangle, so the page never resolves the selector a second time. That is what
makes the cardinality rule single-valued: a second in-page resolution used a
different engine and could disagree with the first, silently drawing nothing for
an element inside an open shadow root. The selector-taking overlay entry point
remains, and fails closed on a non-unique match, for any caller that has no
resolved rectangle to pass.

### S5 - Prebaked clips resolve deterministically

Given a config that declares `clipsDir`, when a prebaked shot references a clip,
then a bare filename resolves inside `clipsDir`, a path containing a separator
resolves against the config file's directory, and an absolute path is used as
given. `clipsDir` itself resolves against the config file's directory. The same
config finds the same clip from any working directory. A clip that does not exist
at the resolved path fails with a message naming the path that was tried, never a
silent fallback to a different path.

## Constraints

- The gate is fail-closed by default with an explicit, reported opt-out.
- Pre-flight must not become a second, divergent definition of "resolves": it
reports a selector as good only under the same exactly-one rule the capture path
enforces.
- No new runtime dependency. Pre-flight uses the Playwright already required.
- Selectors are passed to the page as arguments, never interpolated into evaluated
source.
- Existing configs and scripts that are correct today must render unchanged.

## Acceptance criteria

- A script with a zero-match, an ambiguous, and a never-navigating shot fails
pre-flight naming all three, and writes no audio artifact.
- A shot whose element hydrates in after load passes the gate, as it renders.
- A selector using Playwright engine syntax passes the gate, as it renders.
- A selector revealed by an earlier click is reported at INFO and does not block.
- The render receipt records whether the gate ran, was declined, and which shots
it could not adjudicate.
- The same script with those three corrected renders to completion.
- Declining the gate restores the pre-gate behaviour and says so in the output.
- The overlay draws from a capture-resolved rectangle, and its selector-taking
entry point still raises on a non-unique match.
- A prebaked config renders identically from two different working directories.

## Test seams

Two seams, both already load-bearing in this repo:

- `src/preflight.ts` unit tests (`src/preflight.test.ts`) for S1 and for finding
formatting - pure functions over a parsed manifest, no browser.
- `tests/preflight.smoke.test.ts` for S2/S3/S4 - real Chromium against
`tests/fixtures/page.html`, the same seam `tests/capture.smoke.test.ts` uses.
- S5 rides the existing `src/config.test.ts` seam for resolution and
`tests/capture.smoke.test.ts` for the prebaked short-circuit.

## Verification

- run: `pnpm test`
- expect: `Test Files` all passed, exit 0
Loading