Skip to content

feat(demo-recording-polish): 60 fps capture, drawn cursor + ripple, eased scroll, zoom rendered after the take - #404

Merged
MendixMau merged 5 commits into
masterfrom
feat/demo-recording-polish
Oct 16, 2026
Merged

MendixMau merged 5 commits into
masterfrom
feat/demo-recording-polish

Conversation

@MendixMau

@MendixMau MendixMau commented Oct 15, 2026 •

Copy link
Copy Markdown
Owner

What

  • project-tests/e2e/demo-polish.js (installed as tests/e2e/demo-polish.js, added to MXTK_PROJECT_TESTS) gives demo films:
    • 60 fps capture through CDP Page.startScreencast at device pixels (3840x2160 for a 1920x1080 viewport);
    • a drawn Bezier cursor with a click ripple that parks off the content between actions;
    • eased scrolling of the element's real scroll container;
    • an event log of clicks, focus notes and scrolls;
    • a separate render() pass that plans a Screen Studio-style zoom from that log and draws each frame from a fractional source rectangle in headless Chromium.
  • demo-polish.selftest.js checks 25 camera rules: lead, push-in, hold, zoom-out, the 1.75x cap, the 1.25x floor, merging, scroll boundaries, ending at 1x, cuts and a kept caption band. No browser or app is needed.
  • skills/demo-recording-polish.md covers the method, the knobs, the camera rules, why the zoom is rendered afterwards, the measured results and the failure modes. record-demo-video.md rules 5 and 6 link to it.
  • The skill-routing.tsv row is test / 6 / ondemand. Routing outputs were regenerated with render-routing.sh.

Why

  • Playwright's recordVideo is built for test evidence: playwright-core 1.59 videoRecorder.js encodes at a fixed 25 fps, vp8 -deadline realtime -b:v 1M. In films, motion steps, there is no pointer, scrollIntoView smooth scrolling cannot be tuned, and a dense page cannot be zoomed.
  • The zoom is rendered after the take, never in the page, for three reasons:
    • a CSS transform breaks position:fixed caption bands, popups and hit-testing;
    • re-framing becomes a re-render instead of a re-shoot, which matters for scenes that change data;
    • ffmpeg zoompan judders because it rounds the crop to whole pixels.

Measured (one read-only 82 s scene in a Mendix app, recorded both ways against the same data)

Changed frames use ffmpeg mpdecimate=hi=64*4:lo=64*2:frac=0.1; the windows are approximate for the old take, because the two takes differ by a few hundred ms in their timing.

before (recordVideo → mux) after (demo-polish)
ffprobe h264 1920x1080 r_frame_rate=30/1 (source webm vp8 25/1), 2466 frames, 4.63 MB h264 1920x1080 r_frame_rate=60/1, 4940 frames, 5.75 MB
changed frames, whole clip 223/2466 761/4940
changed frames, 1.3 s cursor glide + click 1/39 33/78
changed frames, 0.8 s scroll 6/24 32/48
changed frames, 1.0 s push-in — 57/60
source frame gap during motion — p50 22.6 ms, p90 31 ms (3840x2160 frames)
zoom shots — 2, both centred: 1.256x on a 4-row grid (u .49, v .54) and 1.379x on a network canvas (u .50, v .51), each held to its narration line's end; 0 skipped; render 53 s for 82 s

Round 2 changes (after looking at the first take's stills):

  • The zoom frames the element itself with 8% padding, centred or not at all. A target near a frame edge would slide off-centre, so it is skipped and listed in shots.json; a target cut by the edge never goes past 1.25x.
  • The ripple is a dark ring with a white halo, 0→60 px over 450 ms. The recorder's outline is no longer drawn on clicked elements.
  • park(): between actions the cursor rests in a side gutter or beside the next target, never mid-content.
  • A narration line already playing when a shot starts now holds the shot. This was found in the take and fixed by re-rendering the same take, without touching the app.

Read honestly:

  • The app paints at about 44 Hz at 4K, so the real motion is below the container's 60.
  • Zoomed frames show neighbouring cards cut at the frame edge; the target itself is whole and centred.
  • Two dense cards that hug a frame edge are point-at beats now, not zooms.

The stills and videos stay in the recording project, not in this PR: they show a demo app's data.

How to use

const polish = require('./demo-polish');
const browser = await chromium.launch({args: polish.launchArgs(2)});
const ctx = await browser.newContext({viewport: {width: 1920, height: 1080}, deviceScaleFactor: 2});
await polish.prepare(ctx);
const page = await ctx.newPage();
const rec = await polish.startCapture(page, {dir: 'take/'});
await polish.scrollTo(page, page.locator('.grid'), {block: 'center'});
await polish.click(page, page.getByRole('button', {name: 'Open'}), {zoom: true});
await rec.stop();
await polish.render('take/', 'take.mp4', {fps: 60, keepBottom: 120});

Then run node tests/e2e/demo-polish.js stats take/ and node tests/e2e/demo-polish.selftest.js, and look at four stills (see the skill).

Checks

  • check-scripts: 116/116 shell, 24/24 node.
  • render-routing --check: exit 0.
  • check-skill-routing: all checks pass.
  • check-pr-discipline: clean.
  • check-portability: clean, 261 files.
  • check-no-client-data: no client data in 1067 tracked files.
  • check-no-private-citations: clean.
  • install-tests.sh into a temp project copies both files, and the selftest passes there ("all camera rules held").

No existing PR or issue covers this (searched open and closed: #6, #57 and #313 are the earlier demo-video skill; #237 is closed).

Update 2026-10-16

Merged origin/master (d1cbd41) into the branch (CHANGELOG-only overlap, no conflicts). Routing check re-run on the merged head. Output checked by eye: a synthetic take of a generic local page (no app data) was captured and rendered with the PR's own startCapture, click and render; the still at the click shows the drawn cursor with the dark ring and white halo ripple on the target, and shots.json listed the zoom as skipped because the target sat at the frame edge, as the camera rule says. node project-tests/e2e/demo-polish.selftest.js: 25 of 25 camera rules ok.

Impact

Row Answer
Process None. Opt-in test tooling for demo films; no gate, no new human step, nothing blocks, never touches the model.
Dependencies bin/lib/skill-routing.tsv and its re-render (README.md, ROUTING.md, agents/test-agent.md, bin/gate-check.sh stage map), bin/lib/install-manifest.sh (MXTK_PROJECT_TESTS gains demo-polish.js), skills/record-demo-video.md (rules 5 and 6 link to the new skill). Needs playwright (chromium) and ffmpeg on PATH; reads no artifact the gates produce.
Context cost Unchanged. bin/render-routing.sh --check on master (d1cbd41) and on this head: baseline 75,225 → 75,225 words (budget 80,000); Stage 5 test 21,667 → 21,667 and Stage 6 test 21,442 → 21,442. Row is test / 6 / ondemand, so the new skill costs 0 here.
Behaviour NOT RUN: on-demand row only (one generated routing row in agents/test-agent.md, Context cost 0). The skill is situational text; the measured one-scene before/after above is the evidence.
Runtime No exec, gate or check gains time. A take renders in about 53 s for 82 s of output (measured above); capture is 60 fps CDP screencast at 4K for a 1080p viewport.

Size cap

Over the cap: 10 files (at the limit) but 662 changed lines excluding CHANGELOG against 400 (script 338, selftest 95, skill 212, other 17). Split plan if the maintainer wants it: (A) demo-polish.js, its selftest and the manifest line, 433 lines, still 33 over on its own; (B) the skill, the record-demo-video.md links and the routing row, 229 lines. The script and its selftest are one unit, so a split only helps by moving the text out. Left whole pending a maintainer decision to waive or split.

🤖 Generated with Claude Code

MendixMau and others added 2 commits October 16, 2026 09:42
…ased scroll, zoom rendered after the take

Playwright recordVideo encodes 25 fps vp8 at 1 Mbit/s realtime; demo films look stepped and the
viewer cannot see clicks. New generic helpers (project-tests/e2e/demo-polish.js + selftest)
capture with CDP screencast at device pixels, assemble a constant 60 fps, draw a Bezier cursor
with a click ripple, scroll the real scroll container with easing, log clicks/focus/scrolls and
plan a Screen Studio-style zoom from that log in a separate render pass.

New skill skills/demo-recording-polish.md (rules, knobs, why zoom afterwards, measured results,
failure modes), linked from record-demo-video.md; skill-routing row; install manifest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pple, cursor parking, narration holds

Round 2 after looking at the first take's stills: zoom frames the element with 8% padding,
centred or not at all (edge targets skipped, cut targets capped at 1.25x); ripple 0->60 px
over 450 ms, dark ring + white halo; park() rests the cursor in a side gutter or beside the
next target; a narration line already playing when a shot starts holds it. Selftest 25 rules.
Skill and changelog carry the re-measured 82 s take.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MendixMau
MendixMau force-pushed the feat/demo-recording-polish branch from 02d66e4 to 87741b5 Compare October 16, 2026 01:49
MendixMau and others added 2 commits October 16, 2026 15:44
Stage 6 routing keeps master's rest-between-mendix-apps and this PR's demo-recording-polish.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MendixMau
MendixMau merged commit ce5a552 into master Oct 16, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant