diff --git a/README.md b/README.md
index 81aa61a..15ba133 100644
--- a/README.md
+++ b/README.md
@@ -5,9 +5,9 @@ scan observations. It keeps selections, analytic primitives, reference geometry,
exact relationships, reuse lineage, residuals, and output coordinates explicit
instead of treating scan-to-CAD as an opaque automatic conversion.
-[](https://youtu.be/oP44yI9DAoE)
+[](https://youtu.be/KLs3RinJzSg)
-**[Watch the narrated Scansor prototype demonstration](https://youtu.be/oP44yI9DAoE)**
+**[Watch the narrated Scansor prototype demonstration](https://youtu.be/KLs3RinJzSg)**
The current local browser prototype demonstrates:
diff --git a/docs/src/project/planning/demo-video-narration.md b/docs/src/project/planning/demo-video-narration.md
index a1046ae..80762be 100644
--- a/docs/src/project/planning/demo-video-narration.md
+++ b/docs/src/project/planning/demo-video-narration.md
@@ -1,176 +1,168 @@
# Demo video narration
-**Working production script, 2026-09-25.** This script accompanies the
-[two-workflow demo plan](demo-video.md). It describes an exploratory prototype,
-not a product release or an accuracy claim. The working voice is Kokoro
-`af_heart` at normal speed. Pronounce Scansor as “SCAN-sor.”
+**Working production script, 2026-10-08.** This script accompanies the
+[two-workflow demo plan](demo-video.md). The working voice is Kokoro `af_heart`
+at normal speed. Pronounce Scansor as “SCAN-sor.” Shot IDs remain stable across
+the capture, narration, captions, and rendered timeline.
## 00 — Title
-**Focus:** Title card, then the clean nozzle mesh.
+**Focus:** Title card introducing aligned, usable geometry.
**Narration:**
-Scansor is an exploratory workspace for turning scan observations into explicit
-geometric intent. This demonstration builds up parts of two workflows: retaining
-and fitting selections on captured data, then reusing fitted features and
-recovering an output coordinate system on a generated example.
+Scansor helps you turn a scan into clean, aligned geometry. Choose the surfaces
+that matter, fit them, and describe how they belong together.
-## 01 — Captured nozzle
+## 01 — Nozzle bayonet mount
-**Focus:** Oblique nozzle overview with a short, restrained orbit.
+**Focus:** The captured nozzle's bayonet mount in Model, with a short orbit.
**Narration:**
-We begin with a captured nozzle scan. Its surface is irregular, its units are
-not confirmed, and nothing here is presented as physical validation. The mesh is
-the evidence layer. Named features in the tree preserve the interpretation we
-add to that evidence.
+Start with this captured nozzle. We're modeling its bayonet mount: the round
+body, rim, and repeated locking features. The model should follow the scan
+while keeping a common center, square shoulders, and evenly spaced details.
-## 02 — Build retained selections
+## 02 — Select the surfaces that matter
-**Focus:** Add several retained selections to the visible inspection set.
+**Focus:** Begin with the bare scan; create meaningful outer-wall, top-rim, and
+repeated-slope selections through real pointer strokes.
**Narration:**
-The colored patches appear as we select named regions from the feature tree.
-They are retained vertex selections, not temporary click state. Several can be
-inspected together without a modifier key, clicked again to remove them, or
-cleared as a set. They remain editable inputs for later operations.
+Start by selecting the outside wall, then the top rim. Keep each surface in its
+own named selection. Paint a strip of the wall, rotate the view, and add another
+strip. Looking down onto the rim helps keep that selection on the flat face.
+A smaller selection captures a slope on one bayonet feature. These are the
+observations we'll use to describe the mount.
-## 03 — Build reference geometry and fits
+## 03 — Turn selections into surfaces
-**Focus:** Add the center axis and two cone fits to the inspection set.
+**Focus:** Pick the newly created selections in Model and create representative
+cone and plane fits that actually use their recorded observations.
**Narration:**
-The next layer is analytic geometry. A free axis is an explicit datum with its
-own identity. Cone fits are separate graph features that retain their source
-selections and can be recomputed. A fit may stand alone, refer to fixed geometry,
-or participate in a combined solve when the design intent requires it.
+Click a selection directly in the model to use it for a fit. The outside wall
+becomes a cone; the top rim becomes a plane. These simple surfaces describe
+the shape while keeping the scan available to inspect.
-## 04 — Rotational symmetry
+## 04 — Align the bayonet mount's features
-**Focus:** Highlight the three repeated recess-slope selections around the nozzle.
+**Focus:** Clearly cut to the remaining prepared features, retaining the filmed
+selections and fits. Create a parallel-plane relationship through Model picks,
+then inspect common-axis, perpendicular, and threefold relationships.
**Narration:**
-These three slope patches repeat around the center. After fitting the patches,
-Scansor can declare exact threefold rotational symmetry about an axis. The three
-observations then influence a shared solve while keeping their individual
-selection lineage. Rotational symmetry is an explicit relationship, not an
-assumption inferred merely because the patches look similar.
+With the remaining surfaces prepared, choose a relationship and pick its
+surfaces in the model. Parallel planes share a direction. Other relationships
+share a center axis, square the top to it, and align the repeated bayonet features.
-## 05 — Residuals
+## 05 — Check the fit against the scan
-**Focus:** Switch to residual coloring and pause on the signed scale legend.
+**Focus:** Residual coloring with the signed scale visible.
**Narration:**
-Residual display replaces selection colors with signed distance from the fitted
-surface. The legend shows both the display scale and the observed negative and
-positive peaks. These are local fit residuals in scan coordinates. They are not,
-by themselves, manufacturing error or an acceptance decision.
+Use residual colors to see where the fitted surface sits above or below the
+scan. The scale shows the size of those differences. This helps you inspect
+the fit; it does not establish manufacturing accuracy.
-## 06 — Transition
+## 06 — Repeated-feature transition
-**Focus:** Reuse and coordinate recovery title card.
+**Focus:** Brief transition card: repeated features and aligned surfaces.
**Narration:**
-The second workflow uses a generated, noisy fixture so repeated features,
-relationships, and coordinate recovery are easy to inspect.
+Now let's look at repeated features on a generated, noisy example.
-## 07 — Source scan frame
+## 07 — Repeated bosses
-**Focus:** Rotated boss plate with a small orbit before applying the output transform.
+**Focus:** Boss plate in its source orientation, with a restrained orbit.
**Narration:**
-The plate begins in its source scan frame, deliberately rotated and scaled away
-from the desired output coordinates. Four bosses vary in height and orientation,
-and three spherical references sit near the plate corners. The colored points
-again represent retained observations on the noisy mesh.
+This plate has four bosses, including one taller than the others. The scan
+starts tilted. We'll describe the surfaces and their relationships before
+choosing the model's final orientation.
-## 08 — Build a reusable source
+## 08 — Define one boss
-**Focus:** Add source selections, datums, and a cylinder fit to the inspection set.
+**Focus:** Source boss cylinder, shared axis, and perpendicular shoulder plane.
**Narration:**
-The first boss supplies a partial reusable definition. Its named outer, bore,
-shoulder, and clock regions preserve distinct observations. An axis and two
-planes provide a local frame, while cylinder and plane fits describe surfaces.
-These pieces stay general: the reusable unit is a graph lineage, not a special
-hard-coded boss feature.
+On the first boss, the outside and bore share an axis. The shoulder is square
+to it. These relationships keep the feature coherent as its surfaces fit
+the scan.
-## 09 — Feature reuse
+## 09 — Reuse the selection work
-**Focus:** Expand Feature reuse and enable the visible reuse volumes.
+**Focus:** Feature reuse and its visible selection volumes at the other bosses.
**Narration:**
-Feature reuse combines those source fits with one simple reference patch and
-several target patches. Approximate matches place surface-relative selection
-volumes at each target. The volumes generate new target selections, and those
-selections drive fresh local fits. The initial match is only a starting pose;
-it does not become the final fitted transform or force all results to be equal.
+Reuse carries that selection work to the other bosses. Each gets its own
+local observations and fresh fits, so differences between the features remain
+available to inspect.
-## 10 — Exact relationships
+## 10 — Pick surfaces and relate them
-**Focus:** Inspect equal-radius and parallel-plane relationships with residuals.
+**Focus:** Pick fitted planes in Model for a parallel relationship; inspect
+existing coincident-plane and equal-radius relationships.
**Narration:**
-Reused fits remain independent unless a relationship says otherwise. Here,
-selected cylinder families share exact radii, while the plate top and chosen
-shoulder planes share an exact direction. The taller boss remains offset but
-parallel. These declarations influence fitted quantities and are distinct from
-a later tolerance or closeness check.
+To align planes, choose a relationship and click the fitted surfaces in the
+model. Parallel planes share a direction while keeping their offsets.
+Coincident planes share a surface. Equal-radius relationships keep matching
+round features the same size.
-## 11 — Measurable datums
+## 11 — Inspect the aligned model
-**Focus:** Add sphere fits, point datums, a directed axis, frame, and scale.
+**Focus:** Cleanly aligned shoulder and plate planes, round features, and
+evaluated constructed faces where available.
**Narration:**
-The three sphere fits provide measurable centers. Two centers initialize point
-datums, and their ordered pair defines a directed axis. A coordinate frame maps
-chosen direction references onto explicit output axes. Separately, one or more
-known distances between points determine a least-squares uniform scale, so
-additional readings can contribute rather than being discarded.
+The result is geometry that fits together: level shoulders, aligned planes,
+and consistent round features. The taller boss stays taller. You choose
+which dimensions and directions belong together rather than accepting a
+collection of unrelated fits.
-## 12 — Applied transform
+Small gaps between faces that should align, off-center bores, or end faces not
+quite perpendicular to their cylinder axes make sketches and mating parts
+awkward to work with.
+Scansor's relationships give you the control to eliminate that mess.
-**Focus:** Select Output transform, switch to Top, then make one small view adjustment.
+## 12 — Choose the output orientation
+
+**Focus:** Apply Output transform continuously, then show an aligned view.
**Narration:**
-The Output transform composes the chosen frame and scale. Selecting it applies
-that similarity transform to the view: the model moves into its intended output
-orientation and size, while upstream fitted values remain in source coordinates.
-The mesh, selections, analytic guides, and residual markers all share the same
-display placement.
+Reference points and directions let you choose the output orientation.
+Known distances set its scale. Apply the transform to place the model in
+useful coordinates while keeping the original scan available for inspection.
-## 13 — Export boundary
+## 13 — CAD handoff
-**Focus:** Open CAD export and inspect the chosen transform and units.
+**Focus:** Current Export CAD dialog with an available scope, units, and transform.
**Narration:**
-The same transform is available at export together with an explicit unit choice.
-That makes coordinate recovery a visible handoff decision instead of silently
-rewriting the fitted geometry. This export dialog demonstrates the current
-boundary; it is not a claim of a finished production CAD integration.
+At export, choose the geometry, units, and transform for the CAD handoff.
+Those choices stay visible, so the exported geometry has the orientation
+and size you intend.
## 14 — Close
-**Focus:** Closing card.
+**Focus:** Closing card emphasizing selection, fitting, and relationships.
**Narration:**
-Together, the examples show the current direction for Scansor: preserve user
-observations, fit simple analytic primitives, express exact geometric intent,
-reuse selection effort without freezing local fits, and make output scale and
-coordinates explicit. The prototype is still evolving, but these operations
-remain inspectable graph features rather than a flattened final result.
+Select the evidence, fit the surfaces, and connect them with relationships.
+That's how Scansor helps turn a rough scan into a well-organized model
+with cleanly aligned planes and features.
diff --git a/docs/src/project/planning/demo-video.md b/docs/src/project/planning/demo-video.md
index 8363639..ef6748f 100644
--- a/docs/src/project/planning/demo-video.md
+++ b/docs/src/project/planning/demo-video.md
@@ -1,24 +1,28 @@
# Two-workflow demo video
-**Planned, 2026-09-24.** This is a production plan for one concise Scansor
-prototype walkthrough. It does not establish product readiness, physical
-accuracy, supported input formats, or a public recipe schema.
+**Production plan, updated 2026-10-08.** This is a concise walkthrough of
+Scansor's selection, fitting, relationships, and CAD handoff workflow.
## Purpose and audience
-The video should let a technically interested viewer understand the current
-interaction model without requiring a live presenter. It will use two different
-examples because they carry complementary evidence:
-
-- the captured nozzle scan shows retained user selection effort and local
- analytic surface fitting on imperfect real-world geometry;
-- the generated repeated-boss scan shows feature reuse, generated subtrees,
- exact relationships, reference geometry, calibration, and an applied output
- transform in a controlled example.
-
-The target is an approximately **5:15** silent master at `1920x1080`, accompanied by a
-timestamped TTS script and WebVTT or SRT captions. Use Kokoro's `af_heart` voice
-for the working voiced edition, pronouncing the project name as “SCAN-sor.”
+The video should help a prospective user see how to turn scan observations into
+a model with cleanly aligned planes and features. Show painting a selection and
+choosing geometry directly in the Model view, then explain the result of each
+relationship in terms of the part: common centers, square shoulders, parallel
+planes, shared surfaces, and matching radii. Use two complementary examples:
+
+- the captured nozzle scan begins bare and focuses on its bayonet mount: create
+ representative wall, rim, and repeated locking-feature selections, fit those
+ observations through Model picks, and align the surfaces with relationships;
+- the generated repeated-boss scan shows reusing selection work, picking fitted
+ planes for a relationship, and inspecting aligned surfaces before choosing
+ output orientation and CAD export settings.
+
+Keep the narration below **500 words**, with enough time for real interactions
+and pauses to inspect their results. Produce a silent master at `1920x1080`, a
+working voiced edition, a timestamped script, and SRT captions. Use Kokoro's
+`af_heart` voice, pronouncing the project name as “SCAN-sor.” Final timing follows
+the dry-run capture and synthesized narration rather than a fixed runtime.
## Named inputs
@@ -30,68 +34,91 @@ names, never the old Downloads filenames:
| Nozzle selection and fitting | [`nozzle-selection-and-fitting-demo.json`](../../../../examples/nozzle-bayonette-simplified/recipes/nozzle-selection-and-fitting-demo.json) | Checked-in captured nozzle example |
| Repeated-boss reuse and alignment | [`repeated-boss-reuse-and-alignment-demo.json`](../../../../examples/repeated-boss-selection/recipes/repeated-boss-reuse-and-alignment-demo.json) | Generated `scan-coarse` realization |
-The boss realization must be regenerated and hash-checked before capture. Both
-recipes must validate and complete **Evaluate all** without errors.
+Generate the boss realization in the session's temporary directory and hash-check
+it before capture. Both recipes must validate; every fit and relationship shown
+must evaluate successfully. The current boss recipe also includes constructed
+faces and a Body feature. Verify their actual evaluated state before showing
+them or making an assembly claim; a saved Body definition alone does not prove a
+closed solid.
## Storyboard
-| Time | Picture and interaction | Narration purpose |
+| Shot | Picture and interaction | User-facing purpose |
| --- | --- | --- |
-| 0:00–0:12 | Title: **Scansor — selections, fits, reuse, and alignment** over a clean oblique mesh view | State that this is a working exploratory prototype, not a product or accuracy claim. |
-| 0:12–0:38 | Nozzle scan alone with a restrained orbit | Introduce captured, irregular geometry and the evidence layer. |
-| 0:38–0:58 | Add `outer`, `recesses`, and three slope selections one at a time | Show retained selections accumulating as reusable feature inputs. |
-| 0:58–1:19 | Add `the middle`, `outer cone fit`, and `recess cone fit`, then make a small view adjustment | Build the distinction between observations, datums, and independently recomputable primitives. |
-| 1:19–1:43 | Highlight the three repeated recess-slope selections | Explain exact threefold rotational symmetry as an explicit relationship around an axis. |
-| 1:43–2:03 | Enable residual colors and pause on the signed scale | Show local signed deviation without describing it as physical error or acceptance. |
-| 2:03–2:13 | Transition card: **Reuse and coordinate recovery** | Mark the move to a generated controlled example. |
-| 2:13–2:32 | Rotated noisy boss plate with a restrained orbit | Establish the source scan frame and repeated but imperfect features. |
-| 2:32–2:54 | Add source selections, its axis, a plane, and a cylinder fit | Partially build the general feature lineage that will be reused. |
-| 2:54–3:20 | Expand `Feature reuse`, select it, and enable reuse volumes | Show approximate placement producing target-local selections and fresh local fits. |
-| 3:20–3:42 | Inspect equal-radius and parallel-plane relationships, then show residuals | Distinguish independent local fits from optional exact relationships. |
-| 3:42–4:07 | Add sphere fits, point datums, directed axis, `Output frame`, and `Output scale` | Explain measurable landmarks, orientation, and multi-reading uniform scale. |
-| 4:07–4:30 | Select `Output transform`, switch to Top, and make one small view adjustment | Deliver the visible before/after alignment while preserving source-coordinate fit values. |
-| 4:30–4:50 | Open CAD export and show Transform and units | Show the explicit STEP-bundle handoff boundary without implying production CAD integration. |
-| 4:50–5:15 | Closing card | Recap retained observations, primitives, relationships, reuse, and explicit output coordinates. |
-
-The cut must not remove rotational symmetry, the nozzle residual view, boss
-reuse volumes, or visible transform application. Those moments distinguish the
-two workflows and the kinds of geometric intent they demonstrate. The graph
-workspace is intentionally omitted from this cut so the available time stays on
-the primary modeling workflow.
+| 00 | Title: **Scansor — clean, aligned geometry** | Introduce the modeling task. |
+| 01 | Bare captured nozzle with a restrained orbit | Introduce its bayonet mount before selections and fits exist. |
+| 02 | Create outer-wall, top-rim, and one repeated-slope selection through real strokes | Show how useful modeling inputs are chosen. |
+| 03 | Pick those selections in Model and create representative cone and plane fits | Turn the actual recorded observations into simple surfaces. |
+| 04 | Clearly cut to the remaining prepared fits, create a representative relationship through Model picks, and inspect the common-axis, perpendicular, and threefold results | Show how relationships align the mount's body, rim, and repeated bayonet features without recording every feature's creation. |
+| 05 | Enable residual colors with the signed scale visible | Briefly check fitted geometry against the scan. |
+| 06 | Transition card: **Repeated features, aligned surfaces** | Identify the generated example. |
+| 07 | Rotated boss plate with a restrained orbit | Introduce four bosses, including a taller one. |
+| 08 | Show the first boss's cylinder fits, common axis, and shoulder plane | Show a coherent round feature with a square shoulder. |
+| 09 | Expand Feature reuse and briefly show reuse volumes | Carry selection effort to other bosses while fitting their own observations. |
+| 10 | Choose a parallel-plane relationship, pick its fitted participants in Model, and apply; inspect existing coincident-plane and equal-radius relationships | Explain shared directions, shared surfaces, and matching sizes using visible results. |
+| 11 | Inspect aligned shoulder and plate planes, round features, and evaluated constructed faces where available | Show a well-organized model that keeps the taller boss's distinct height. |
+| 12 | Apply Output transform continuously, then show an aligned view | Choose useful orientation and scale without losing the scan reference. |
+| 13 | Open Export CAD and select an available scope, Transform, and units | Make the CAD handoff choices visible. |
+| 14 | Closing card | Recap selecting evidence, fitting surfaces, and connecting them with relationships. |
+
+Retain rotational symmetry, the nozzle residual check, boss reuse volumes, and
+visible transform application. Give selection painting and fitted-surface picking
+enough screen time to see the pointer, input selection, and resulting geometry.
+Keep the Graph panel closed so the emphasis stays on modeling the part.
## Capture staging
Capture the two applications on separate local ports so one continuous browser
automation can move between them without shell windows appearing onscreen. Use a
fresh browser profile, fixed viewport, 100% page zoom, hidden bookmarks and
-developer UI, and a deterministic feature-panel split. Do not capture the
+developer UI, and deterministic Features, Model, and Edit placement. Use the
+current dockable toolbar and input-selection controls. Do not capture the
desktop, terminal, Downloads directory, or unrelated browser content.
-The nozzle segment loads its checked-in recipe directly. The boss segment needs
-two staged states:
+The nozzle segment starts with only the source node from its checked-in recipe.
+Create genuine outer-wall, top-rim, and repeated-slope selections through the UI;
+use those recorded memberships in the fits shown immediately afterward. Gather
+the wall observations from more than one viewing direction so its cone fit has
+useful coverage. The rim selection should stay on the flat face.
+
+Save that filmed-stage recipe separately. Use a clear cut and “With the remaining
+surfaces prepared…” to move to the remaining features. Keep the actual recorded
+selection memberships and fits in that checkpoint, remapping the prepared
+features to use them. Record both checkpoints in the manifest. Do not silently
+substitute the saved selections for the recorded ones.
+
+The boss segment needs two staged states:
1. an establishing state in the source scan frame, before the output transform
is active in the view;
-2. the complete checked-in graph with every action evaluated, where selecting
- `Output transform` visibly applies it.
+2. the evaluated model, where selecting `Output transform` visibly applies it.
The staging script may change the in-memory graph output pointer for the first
-boss shot, but it must not rewrite the checked-in recipe. The visible workflow
-must use actual application behavior; no geometry or result may be composited to
-fake a successful fit, relationship, or transform.
-
-Use slow eased cursor motion, a visible click indicator, and at least `500 ms`
-after tree expansion or camera motion and `900 ms` after meaningful result
-changes. Record those interactions as live application clips. Extend the last
-frame when narration outlasts a clip; do not loop the cursor or camera motion.
+boss shot. Painting, fitting, and relationship creation may add features through
+the real UI on these disposable servers. Preserve the checked-in recipes and
+record the resulting session recipes with the capture artifacts. No geometry or
+result may be composited to fake a successful fit, relationship, or transform.
+
+Find eligible model targets through the application's hover and picking behavior,
+then record actual pointer clicks. Show an overlapping-item chooser when the
+application needs one. Confirm that picked inputs contain the intended features
+before applying the form, and wait for the resulting fit or relationship to
+finish evaluating.
+
+Use brisk eased cursor motion and a visible click indicator. Keep painting,
+geometry picks, Apply, and their results readable. Shorten navigation, repeated
+tree clicks, and idle time when they create gaps after the narration; an overlong
+clip should be edited or recaptured rather than automatically retaining all of
+its quiet time. Record those interactions as live application clips. Extend the
+last frame when narration outlasts a clip; do not loop the cursor or camera motion.
Avoid scrolling while narration asks the viewer to inspect geometry. Camera
presets should do most of the movement; free orbit is reserved for opening views
and one restrained post-transform adjustment.
## Narration and captions
-Write the final narration after a dry-run capture establishes real interaction
-durations. The script should contain one block per storyboard row with:
+Adjust the working narration after a dry-run capture establishes real interaction
+durations. Keep one block per storyboard row with:
- shot ID and time window;
- plain text for TTS;
@@ -99,6 +126,17 @@ durations. The script should contain one block per storyboard row with:
- the matching caption text;
- the intended on-screen focus.
+Preserve section IDs `00` through `14` and their existing capture filenames so
+the renderer can assemble the revised shots. No segment may cut off painting,
+input picking, applying a form, or the visible result; allow additional quiet
+screen time when an interaction outlasts its narration.
+Modest acceleration of an overlong recorded clip is allowed to tighten the
+pacing, with its rate recorded in the manifest. Keep strokes, input choices,
+and results readable; do not imply that the cut measures evaluation speed.
+Long evaluation waits may be shortened while retaining the submitted action and
+the real evaluated result. Record those omissions in the capture manifest;
+do not imply that the edited video demonstrates evaluation speed.
+
Target `125–140` spoken words per minute and leave approximately `500 ms` of
silence around section transitions. Pronounce **Scansor** consistently as
“SCAN-sor” unless the project owner chooses another pronunciation. Captions
@@ -159,8 +197,17 @@ linked into, bundled with, or distributed as part of Scansor.
## Acceptance checklist
-- Both named checked-in recipes validate and evaluate without errors.
+- Both named checked-in recipes validate, and all demonstrated geometry and
+ relationships evaluate successfully; any assembly failure remains explicit.
- No source selection IDs, fit results, or transforms are fabricated for video.
+- Representative selections are visibly created from the bare scan and then
+ used by their demonstrated fits.
+- The transition to prepared remaining features is clear.
+- Navigation and pauses do not leave long quiet tails after the narration.
+- A fit input and relationship participants are visibly picked in Model, and
+ the recorded form selections match the geometry being discussed.
+- Common-axis, perpendicular, parallel, coincident, and equal-radius examples
+ show their intended geometric effect without implying unverified solids.
- Text remains readable at `1920x1080` without depending on fullscreen playback.
- Cursor, camera, and tree motion are real application capture and never compete
with narration.
diff --git a/scripts/demo-video/README.md b/scripts/demo-video/README.md
index b58fb7d..889c9a2 100644
--- a/scripts/demo-video/README.md
+++ b/scripts/demo-video/README.md
@@ -15,8 +15,24 @@ node scripts/demo-video/capture.mjs /tmp/scansor-demo/capture
The capture uses Chrome's DevTools protocol directly and adds no browser-driving
package to Scansor. Override `SCANSOR_DEMO_CHROME`,
`SCANSOR_DEMO_BOSS_URL`, or `SCANSOR_DEMO_NOZZLE_URL` when needed. The capture
-changes only the boss server's in-memory output pointer while staging the
-pre-transform shot; it does not modify the checked-in recipe.
+stages the boss server's in-memory output pointer for the pre-transform shot.
+For the nozzle, it maps physical painting targets using the saved example's
+hover picker, resets to the bare source, creates wall, rim, and slope selections,
+and fits their actual observations through Model picks. It then adds the prepared
+remaining features at a visible cut, preserving the filmed selections and fits
+as their inputs, and creates a parallel-plane relationship through the real UI.
+The filmed stage and completed checkpoint are saved separately in the manifest.
+Use disposable servers: these changes stay in their sessions.
+The checked-in recipes are preserved. `SCANSOR_DEMO_CDP_PORT` changes
+the debugging port; `SCANSOR_DEMO_WORKFLOW=boss` or `nozzle` recaptures just that
+workflow into the same capture directory.
+`SCANSOR_DEMO_FROM_SHOT=10` retains earlier clips while replaying their staging
+interactions and recording from shot 10 onward. Reset the disposable server to
+its original recipe before recapturing, to avoid accumulating demo features.
+For nozzle shots 4 and 5, it restores the saved filmed stage and prepared
+checkpoint, preserving the selections and fits recorded in the earlier clips.
+`SCANSOR_DEMO_REUSE_STROKES=1` reuses the saved nozzle stroke plan when recapturing
+with the same source mesh, fixed viewport, and docked panel arrangement.
The narration source is
[`demo-video-narration.md`](../../docs/src/project/planning/demo-video-narration.md).
@@ -36,18 +52,34 @@ node scripts/demo-video/render.mjs \
/tmp/scansor-demo/render
```
-The capture records short application clips with a visible cursor, actual tree
-interaction, and restrained camera motion. Title and transition cards remain
+The capture records short application clips with a visible cursor, scan painting,
+input picks on rendered geometry, tree interaction, and restrained camera motion.
+Title and transition cards remain
still images. The renderer synthesizes one audio file per narration section so
timing and wording remain reviewable, builds sentence-level SRT captions, and
-extends each clip's final frame to match its narration rather than looping the
-interaction.
+extends each clip's final frame to match its narration. When painting or picking
+outlasts the speech, it pads the audio with silence so the entire interaction and
+result remain visible. Overlong clips receive a modest playback adjustment,
+capped at `1.35` by default, to shorten quiet tails after the narration.
+`SCANSOR_DEMO_MAX_CLIP_RATE=1` preserves the capture speed. Actual rates are
+recorded per shot in the manifest. It also writes a timestamped narration script and records
+source mesh metadata and hashes alongside the capture sources.
+Long evaluation waits are shortened between the submitted action and its real
+completed result. The capture retains two seconds at the start and one at the
+end of the wait, and records omitted intervals in each shot's edits JSON and the
+render manifest. Pointer gestures and the evaluated result are preserved.
+Naming dialogs and brush setup are also omitted from the selection sequence;
+their reasons and intervals are recorded alongside the evaluation edits.
Set `SCANSOR_DEMO_REUSE_TTS=1` to reuse already-rendered section WAV files after
changing only assembly settings. The working cut uses a small `1.04` post-process
playback adjustment; `SCANSOR_DEMO_PLAYBACK_RATE` can override it without
resynthesizing the voice.
+`SCANSOR_DEMO_TTS_ONLY=1` synthesizes the narration without assembling video.
+The renderer also accepts `SCANSOR_DEMO_WORKFLOW=boss` or `nozzle` to assemble a
+preview of one workflow from its captures and narration.
+
Kokoro's English phonemizer requires the spaCy `en_core_web_sm` model. It is
declared explicitly in `mise.demo.toml`; relying on Kokoro's runtime
auto-installer can put the model outside the isolated tool environment.
diff --git a/scripts/demo-video/capture.mjs b/scripts/demo-video/capture.mjs
index 9c53010..bdef735 100644
--- a/scripts/demo-video/capture.mjs
+++ b/scripts/demo-video/capture.mjs
@@ -1,7 +1,7 @@
#!/usr/bin/env node
import { execFileSync, spawn } from 'node:child_process';
-import { mkdir, writeFile } from 'node:fs/promises';
+import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
const outputDirectory = resolve(process.argv[2] || '/tmp/scansor-demo-capture');
@@ -9,6 +9,10 @@ const chrome = process.env.SCANSOR_DEMO_CHROME || 'google-chrome';
const debuggingPort = Number(process.env.SCANSOR_DEMO_CDP_PORT || 9223);
const bossUrl = process.env.SCANSOR_DEMO_BOSS_URL || 'http://127.0.0.1:8765/';
const nozzleUrl = process.env.SCANSOR_DEMO_NOZZLE_URL || 'http://127.0.0.1:8766/';
+const workflow = process.env.SCANSOR_DEMO_WORKFLOW || 'all';
+if (!['all', 'boss', 'nozzle'].includes(workflow)) throw new Error(`Unknown workflow: ${workflow}`);
+const firstShot = Number(process.env.SCANSOR_DEMO_FROM_SHOT || 0);
+if (!Number.isInteger(firstShot) || firstShot < 0 || firstShot > 14) throw new Error('Invalid starting shot');
const profile = resolve(outputDirectory, 'chrome-profile');
function command(executable, arguments_) {
@@ -25,6 +29,7 @@ function mise(tool, arguments_) {
}
await mkdir(outputDirectory, { recursive: true });
+await rm(profile, { recursive: true, force: true });
const browser = spawn(
chrome,
@@ -105,6 +110,7 @@ class Cdp {
}
let cdp;
+let evaluationWaits = null;
try {
const target = await retry(async () => {
const response = await fetch(`http://127.0.0.1:${debuggingPort}/json/list`);
@@ -153,6 +159,8 @@ try {
await waitFor("document.querySelector('#status')?.textContent.includes('All actions evaluated')");
await sleep(1000);
await installDemoCursor();
+ const metadata = await evaluate("fetch('/api/meta').then(response => response.json())");
+ await writeFile(resolve(outputDirectory, url === bossUrl ? 'boss-source.json' : 'nozzle-source.json'), `${JSON.stringify(metadata, null, 2)}\n`);
}
async function screenshot(name) {
@@ -179,7 +187,7 @@ try {
})()`);
}
- async function moveCursorToPoint(x, y, duration = 650) {
+ async function moveCursorToPoint(x, y, duration = 280, buttons = 0) {
await evaluate(`new Promise((resolvePromise) => {
const cursor = document.querySelector('#scansor-demo-cursor');
const animation = cursor.animate(
@@ -192,7 +200,8 @@ try {
resolvePromise();
};
})`);
- await cdp.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y });
+ await cdp.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y, buttons,
+ ...(buttons ? {button: 'left'} : {}) });
}
async function elementPoint(expression) {
@@ -201,6 +210,7 @@ try {
if (!element) return null;
element.scrollIntoView({block: 'center', inline: 'nearest'});
const rectangle = element.getBoundingClientRect();
+ if (!rectangle.width || !rectangle.height) throw new Error('Element is not visible');
return {x: rectangle.left + rectangle.width / 2, y: rectangle.top + rectangle.height / 2};
})()`);
if (!point) throw new Error(`Missing element: ${expression}`);
@@ -214,7 +224,7 @@ try {
[{transform: cursor.style.transform, background: '#58d4eb99'},
{transform: cursor.style.transform + ' scale(.55)', background: '#fff'},
{transform: cursor.style.transform, background: '#58d4eb99'}],
- {duration: 260, easing: 'ease-out'},
+ {duration: 130, easing: 'ease-out'},
);
animation.onfinish = resolvePromise;
})`);
@@ -224,7 +234,7 @@ try {
await cdp.send('Input.dispatchMouseEvent', {
type: 'mousePressed', x, y, button: 'left', buttons: 1, clickCount: 1,
});
- await sleep(90);
+ await sleep(55);
await cdp.send('Input.dispatchMouseEvent', {
type: 'mouseReleased', x, y, button: 'left', buttons: 0, clickCount: 1,
});
@@ -232,10 +242,11 @@ try {
}
async function click(selector) {
- const point = await elementPoint(`document.querySelector(${JSON.stringify(selector)})`);
+ const proxy = /^#[\w-]+$/.test(selector) ? `[data-command-id="${selector.slice(1)}"]` : selector;
+ const point = await elementPoint(`document.querySelector(${JSON.stringify(proxy)}) || document.querySelector(${JSON.stringify(selector)})`);
await moveCursorToPoint(point.x, point.y);
await clickPoint(point.x, point.y);
- await sleep(500);
+ await sleep(180);
}
async function action(id) {
@@ -245,13 +256,16 @@ try {
.find((candidate) => candidate.dataset.actionId === ${quoted})
|| [...document.querySelectorAll('summary[data-action-id]')]
.find((candidate) => candidate.dataset.actionId === ${quoted});
+ for (let parent = element?.parentElement; parent; parent = parent.parentElement) {
+ if (parent.tagName === 'DETAILS') parent.open = true;
+ }
return element?.classList.contains('action-select')
- ? element : element?.querySelector('.group-action');
+ ? element : element?.querySelector('.action-name');
})()`;
const point = await elementPoint(expression);
await moveCursorToPoint(point.x, point.y);
await clickPoint(point.x, point.y);
- await sleep(500);
+ await sleep(250);
}
async function orbit(deltaX, deltaY, duration = 1600) {
@@ -263,7 +277,7 @@ try {
x: box.left + box.width * 0.58,
y: box.top + box.height * 0.52,
};
- await moveCursorToPoint(start.x, start.y, 600);
+ await moveCursorToPoint(start.x, start.y, 280);
await cdp.send('Input.dispatchMouseEvent', {
type: 'mousePressed', ...start, button: 'right', buttons: 2, clickCount: 1,
});
@@ -288,6 +302,11 @@ try {
}
async function recordClip(name, choreography, tailMilliseconds = 700) {
+ if (Number(name.slice(0, 2)) < firstShot) {
+ await choreography();
+ return;
+ }
+ console.log(`Capturing ${name}`);
const framesDirectory = resolve(outputDirectory, `${name}-frames`);
await mkdir(framesDirectory, { recursive: true });
const frames = [];
@@ -302,23 +321,34 @@ try {
await cdp.send('Page.startScreencast', {
format: 'jpeg', quality: 88, maxWidth: 1920, maxHeight: 1080, everyNthFrame: 1,
});
+ evaluationWaits = [];
await sleep(350);
await choreography();
await sleep(tailMilliseconds);
await cdp.send('Page.stopScreencast');
removeListener();
await Promise.all(writes);
+ await screenshot(name);
if (!frames.length) throw new Error(`No screencast frames captured for ${name}`);
const timeline = ['ffconcat version 1.0'];
+ const edits = evaluationWaits;
+ evaluationWaits = null;
for (let index = 0; index < frames.length; index++) {
- const duration = index + 1 < frames.length
- ? Math.max(1 / 60, frames[index + 1].timestamp - frames[index].timestamp)
- : tailMilliseconds / 1000;
- timeline.push(`file '${frames[index].filename}'`, `duration ${duration.toFixed(6)}`);
+ const start = frames[index].timestamp;
+ const end = index + 1 < frames.length ? frames[index + 1].timestamp : start + tailMilliseconds / 1000;
+ let duration = end - start;
+ for (const edit of edits) duration -= Math.max(0, Math.min(end, edit.end) - Math.max(start, edit.start));
+ if (duration > 0) timeline.push(`file '${frames[index].filename}'`, `duration ${Math.max(1 / 60, duration).toFixed(6)}`);
}
timeline.push(`file '${frames.at(-1).filename}'`);
const timelinePath = resolve(framesDirectory, 'frames.ffconcat');
await writeFile(timelinePath, `${timeline.join('\n')}\n`);
+ await writeFile(resolve(outputDirectory, `${name}-edits.json`), `${JSON.stringify({
+ omitted_intervals: edits.map(edit => ({
+ reason:edit.reason || 'evaluation_wait',
+ start: edit.start - frames[0].timestamp, end: edit.end - frames[0].timestamp,
+ })),
+ }, null, 2)}\n`);
mise('ffmpeg', [
'-hide_banner', '-loglevel', 'error', '-y', '-f', 'concat', '-safe', '0',
'-i', timelinePath, '-vf', 'fps=30,format=yuv420p', '-c:v', 'libx264',
@@ -331,6 +361,18 @@ try {
await click('#clear-feature-selection');
}
+ async function closeEditPanel() {
+ const point = await elementPoint(`[...document.querySelectorAll('[role="tab"]')]
+ .find(tab => tab.textContent.startsWith('Edit'))?.querySelector('[title="Close"]')`);
+ await moveCursorToPoint(point.x, point.y);
+ await clickPoint(point.x, point.y);
+ await sleep(500);
+ }
+
+ async function dockEdit() {
+ if (await evaluate("document.querySelector('#edit-popup').open")) await click('#dock-edit');
+ }
+
async function setChecked(selector, checked) {
const quoted = JSON.stringify(selector);
const point = await elementPoint(`document.querySelector(${quoted})`);
@@ -347,6 +389,7 @@ try {
async function setSelect(selector, value) {
const quotedSelector = JSON.stringify(selector);
const quotedValue = JSON.stringify(value);
+ if (await evaluate(`document.querySelector(${quotedSelector})?.value === ${quotedValue}`)) return;
const point = await elementPoint(`document.querySelector(${quotedSelector})`);
await moveCursorToPoint(point.x, point.y);
await evaluate(`(() => {
@@ -362,14 +405,277 @@ try {
async function toggleActionGroup(id) {
const quoted = JSON.stringify(id);
const expression = `[...document.querySelectorAll('summary[data-action-id]')]
- .find((candidate) => candidate.dataset.actionId === ${quoted})`;
+ .find((candidate) => candidate.dataset.actionId === ${quoted})?.querySelector('.tree-toggle')`;
const point = await elementPoint(expression);
- point.x -= 26;
await moveCursorToPoint(point.x, point.y);
await clickPoint(point.x, point.y);
await sleep(500);
}
+ async function fill(selector, value) {
+ await click(selector);
+ await evaluate(`(() => {
+ const input = document.querySelector(${JSON.stringify(selector)});
+ input.value = ${JSON.stringify(value)};
+ input.dispatchEvent(new Event('input', {bubbles: true}));
+ input.dispatchEvent(new Event('change', {bubbles: true}));
+ })()`);
+ }
+
+ async function featureContext(id) {
+ const point = await elementPoint(`[...document.querySelectorAll('.action-select, summary[data-action-id]')].find(element => element.dataset.actionId === ${JSON.stringify(id)})`);
+ await moveCursorToPoint(point.x, point.y);
+ await cdp.send('Input.dispatchMouseEvent', {type: 'mousePressed', ...point, button: 'right', buttons: 2, clickCount: 1});
+ await cdp.send('Input.dispatchMouseEvent', {type: 'mouseReleased', ...point, button: 'right', buttons: 0, clickCount: 1});
+ await sleep(300);
+ }
+
+ // Locate patches through the application's public hover/raycast interaction.
+ // Only the eventual CDP mouse click commits a pick; no feature IDs are injected.
+ async function patch(label, clearance = 0) {
+ await evaluate('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))');
+ const point = await evaluate(`(() => {
+ const canvas = document.querySelector('#viewport canvas'), box = canvas.getBoundingClientRect();
+ const hit = (x, y) => {
+ if (document.elementFromPoint(x, y) !== canvas) return false;
+ canvas.dispatchEvent(new PointerEvent('pointermove', {clientX: x, clientY: y, pointerId: 1}));
+ const message = document.querySelector('#model-pick-message').textContent.trim();
+ return message === ${JSON.stringify(label)} || message.startsWith(${JSON.stringify(`${label} — `)});
+ };
+ const step = ${clearance} ? 8 : 14;
+ for (let y = box.top + 85; y < box.bottom - 55; y += step) {
+ for (let x = box.left + 10; x < box.right - 10; x += step) {
+ if (hit(x, y) && (!${clearance} || [[${clearance},0],[-${clearance},0],[0,${clearance}],[0,-${clearance}]]
+ .every(([dx,dy]) => hit(x + dx, y + dy)))) return {x, y};
+ }
+ }
+ return null;
+ })()`);
+ if (!point) throw new Error(`No visible model patch: ${label}`);
+ return point;
+ }
+
+ async function pickPatch(label, checkedSelector, hint) {
+ const point = hint || await patch(label);
+ await moveCursorToPoint(point.x, point.y);
+ await sleep(500);
+ await clickPoint(point.x, point.y);
+ const choice = await evaluate(`(() => {
+ const button = [...document.querySelectorAll('#model-pick-choices button')]
+ .find(button => button.textContent.includes(${JSON.stringify(label)}));
+ if (!button || !button.getBoundingClientRect().width) return null;
+ const box = button.getBoundingClientRect();
+ return {x: box.left + box.width/2, y: box.top + box.height/2};
+ })()`);
+ if (choice) {
+ await moveCursorToPoint(choice.x, choice.y);
+ await clickPoint(choice.x, choice.y);
+ }
+ if (checkedSelector) await waitFor(`document.querySelector(${JSON.stringify(checkedSelector)})?.checked`);
+ await sleep(500);
+ return point;
+ }
+
+ async function evaluated() {
+ const waitStart = Date.now() / 1000;
+ await waitFor(`(async () => {
+ const graph = await fetch('/api/graph').then(response => response.json());
+ return !graph.evaluation_running && Object.values(graph.states).every(state => state === 'ready');
+ })()`);
+ const graph = await evaluate("fetch('/api/graph').then(response => response.json())");
+ const failures = Object.entries(graph.states).filter(([,state]) => state !== 'ready');
+ if (failures.length) throw new Error(`Failed demo features: ${JSON.stringify(failures)}`);
+ const waitEnd = Date.now() / 1000;
+ if (evaluationWaits && waitEnd - waitStart > 6) {
+ evaluationWaits.push({start: waitStart + 2, end: waitEnd - 1});
+ }
+ return graph;
+ }
+
+ async function replaceRecipe(recipe) {
+ await evaluate(`(async () => {
+ const state = await fetch('/api/graph').then(response => response.json());
+ const response = await fetch('/api/graph', {
+ method: 'POST', headers: {'Content-Type': 'application/json', 'X-Scansor-Request': '1'},
+ body: JSON.stringify({token: state.token, recipe: ${JSON.stringify(recipe)}}),
+ });
+ if (!response.ok) throw new Error(await response.text());
+ })()`);
+ await navigate(nozzleUrl);
+ }
+
+ async function selectionView(view) {
+ await click(view === 'top' ? '#top' : '#side');
+ if (view === 'other-side') await orbit(240, 0, 450);
+ }
+
+ // Find physical regions before filming. Hover only locates strokes: the
+ // subsequent real left-button gestures determine every recorded membership.
+ async function strokePaths(label, diameter, maximum, view = 'side') {
+ await evaluate('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))');
+ const paths = await evaluate(`(async () => {
+ const canvas = document.querySelector('#viewport canvas'), box = canvas.getBoundingClientRect();
+ const step = ${diameter} <= 4 ? 2 : ${diameter} <= 8 ? 4 : 8, radius = ${diameter} / 2;
+ let left=box.left+10, right=box.right-10, top=box.top+85, bottom=box.bottom-55;
+ if (${JSON.stringify(view)} !== 'other-side') {
+ // Public source coordinates narrow the search; the application's actual
+ // hover result and brush clearance still qualify every planned stroke.
+ const THREE = await import('three');
+ const metadata = await fetch('/api/meta').then(response => response.json());
+ const positions = new Float32Array(await fetch(metadata.positions.url).then(response => response.arrayBuffer()));
+ const geometry = new THREE.BufferGeometry();
+ geometry.setAttribute('position',new THREE.BufferAttribute(positions,3)); geometry.computeBoundingSphere();
+ const sphere=geometry.boundingSphere, camera=new THREE.PerspectiveCamera(45,box.width/box.height,Math.max(.001,sphere.radius/1000),sphere.radius*1000);
+ camera.up.set(0,0,1);
+ const distance=sphere.radius/Math.sin(Math.min(camera.fov*Math.PI/360,Math.atan(Math.tan(camera.fov*Math.PI/360)*camera.aspect)))*1.12;
+ const direction=${JSON.stringify(view)} === 'top' ? new THREE.Vector3(0,-.001,1) : new THREE.Vector3(0,-1,0);
+ camera.position.copy(sphere.center).addScaledVector(direction.normalize(),distance); camera.lookAt(sphere.center); camera.updateMatrixWorld();
+ const graph=await fetch('/api/graph').then(response => response.json());
+ const ids=graph.recipe.nodes.find(node => node.label===${JSON.stringify(label)}).ids;
+ const projected=ids.map(id => new THREE.Vector3().fromArray(positions,id*3).project(camera));
+ const xs=projected.map(p => box.left+(p.x+1)*box.width/2), ys=projected.map(p => box.top+(1-p.y)*box.height/2);
+ left=Math.max(left,Math.min(...xs)-16); right=Math.min(right,Math.max(...xs)+16);
+ top=Math.max(top,Math.min(...ys)-16); bottom=Math.min(bottom,Math.max(...ys)+16);
+ geometry.dispose();
+ }
+ const hit = (x, y) => {
+ if (document.elementFromPoint(x, y) !== canvas) return false;
+ canvas.dispatchEvent(new PointerEvent('pointermove', {clientX:x, clientY:y, pointerId:1}));
+ const message = document.querySelector('#model-pick-message').textContent.trim();
+ return message === ${JSON.stringify(label)} || message.startsWith(${JSON.stringify(`${label} — `)});
+ };
+ const points = [];
+ for (let y = top; y < bottom; y += step)
+ for (let x = left; x < right; x += step)
+ if (hit(x,y) && [[radius,0],[-radius,0],[0,radius],[0,-radius]].every(([dx,dy]) => hit(x+dx,y+dy)))
+ points.push({x,y});
+ const candidates = [];
+ for (const [fixed, variable, diagonal] of [['y','x',0],['x','y',0],['y','x',1],['y','x',-1]]) {
+ const groups = new Map();
+ for (const point of points) {
+ const key=diagonal ? Math.round((point.y-diagonal*point.x)/step) : point[fixed];
+ const group = groups.get(key) || [];
+ group.push(point); groups.set(key,group);
+ }
+ for (const group of groups.values()) {
+ group.sort((a,b) => a[variable]-b[variable]);
+ let run = [];
+ for (const point of group) {
+ if (run.length && point[variable]-run.at(-1)[variable] > step+0.1) {
+ if (run.length >= 3) candidates.push(run);
+ run = [];
+ }
+ run.push(point);
+ }
+ if (run.length >= 3) candidates.push(run);
+ }
+ }
+ candidates.sort((a,b) => b.length-a.length);
+ const chosen = [];
+ for (const path of candidates) {
+ const center = path[Math.floor(path.length/2)];
+ if (chosen.every(other => {
+ const middle = other[Math.floor(other.length/2)];
+ return Math.hypot(center.x-middle.x,center.y-middle.y) > ${diameter}*2;
+ })) chosen.push(path);
+ if (chosen.length === ${maximum}) break;
+ }
+ return chosen;
+ })()`);
+ if (!paths.length) throw new Error(`No useful painting stroke for ${label}`);
+ console.log(`Mapped ${label}: ${paths.map(path => path.length).join(',')} points`);
+ return paths;
+ }
+
+ async function paintPaths(paths, diameter) {
+ await omitSetup('brush_setup', async () => {
+ await setSelect('#selection-shape', 'paint');
+ await fill('#brush-size', String(diameter));
+ });
+ for (const path of paths) {
+ await moveCursorToPoint(path[0].x, path[0].y);
+ await cdp.send('Input.dispatchMouseEvent', {type:'mousePressed', ...path[0], button:'left', buttons:1, clickCount:1});
+ // The normal brush sweeps the full segment between pointer events.
+ // Fewer evenly spaced events keep this straight stroke brisk.
+ const moves=path.slice(1).filter((_,index) => index%4===3);
+ if (moves.at(-1) !== path.at(-1)) moves.push(path.at(-1));
+ for (const point of moves) await moveCursorToPoint(point.x, point.y, 45, 1);
+ await cdp.send('Input.dispatchMouseEvent', {type:'mouseReleased', ...path.at(-1), button:'left', buttons:0, clickCount:1});
+ await waitFor("!document.querySelector('#evaluate-all').disabled");
+ await evaluated();
+ }
+ }
+
+ async function createSelection(label) {
+ await click('#add-selection');
+ await waitFor("!document.querySelector('#selection-tools').hidden");
+ const id = await evaluate("fetch('/api/graph').then(response => response.json()).then(graph => graph.recipe.nodes.at(-1).id)");
+ await omitSetup('selection_naming', async () => {
+ await featureContext(id);
+ await click('#feature-context-rename');
+ await fill('#rename-feature-label', label);
+ await click('#rename-feature-form button');
+ });
+ return id;
+ }
+
+ async function omitSetup(reason, operation) {
+ const start=Date.now()/1000;
+ await operation();
+ if (evaluationWaits) evaluationWaits.push({start,end:Date.now()/1000,reason});
+ }
+
+ async function createFit(label, selection, kind, hint) {
+ await click('#new-fit');
+ await setSelect('#new-fit-kind', kind);
+ await fill('#new-fit-label', label);
+ await click('[data-pick-control="new-fit-inputs"]');
+ await pickPatch(selection, undefined, hint);
+ await waitFor(`[...document.querySelector('#new-fit-inputs').selectedOptions].some(option => option.textContent === ${JSON.stringify(selection)})`);
+ await click('#stop-model-picking');
+ await click('#add-fit-form > button');
+ const state = await evaluated();
+ const fit = state.recipe.nodes.find(node => node.label === label);
+ if (state.states[fit?.id] !== 'ready') throw new Error(`Fit not ready: ${label}`);
+ console.log(`Created ${label}: RMS ${state.results[fit.id].weighted_rms}`);
+ return fit.id;
+ }
+
+ async function projectedSelection(label, view = 'top') {
+ return evaluate(`(async () => {
+ const THREE=await import('three'), metadata=await fetch('/api/meta').then(response => response.json());
+ const positions=new Float32Array(await fetch(metadata.positions.url).then(response => response.arrayBuffer()));
+ const geometry=new THREE.BufferGeometry();geometry.setAttribute('position',new THREE.BufferAttribute(positions,3));geometry.computeBoundingSphere();
+ const sphere=geometry.boundingSphere,box=document.querySelector('#viewport canvas').getBoundingClientRect();
+ const camera=new THREE.PerspectiveCamera(45,box.width/box.height,Math.max(.001,sphere.radius/1000),sphere.radius*1000);camera.up.set(0,0,1);
+ const distance=sphere.radius/Math.sin(Math.min(camera.fov*Math.PI/360,Math.atan(Math.tan(camera.fov*Math.PI/360)*camera.aspect)))*1.12;
+ const direction=${JSON.stringify(view)} === 'top' ? new THREE.Vector3(0,-.001,1) : new THREE.Vector3(0,-1,0);
+ camera.position.copy(sphere.center).addScaledVector(direction.normalize(),distance);camera.lookAt(sphere.center);camera.updateMatrixWorld();
+ const graph=await fetch('/api/graph').then(response => response.json()),ids=graph.recipe.nodes.find(node=>node.label===${JSON.stringify(label)}).ids;
+ const points=ids.filter((_,index)=>index%4===0).map(id=>{
+ const p=new THREE.Vector3().fromArray(positions,id*3).project(camera);
+ return {x:box.left+(p.x+1)*box.width/2,y:box.top+(1-p.y)*box.height/2};
+ });geometry.dispose();return points;
+ })()`);
+ }
+
+ async function locateRelationshipPick(label, id, candidates) {
+ for (const point of candidates) {
+ await moveCursorToPoint(point.x,point.y);
+ await clickPoint(point.x,point.y);
+ const found=await evaluate(`(() => {
+ const popup=document.querySelector('#model-pick-choices');
+ const match=popup.matches(':popover-open') && [...popup.querySelectorAll('button')].some(button=>button.textContent.includes(${JSON.stringify(label)}));
+ const selected=document.querySelector('input[data-participant-id="${id}"]')?.checked;
+ if(popup.matches(':popover-open'))popup.hidePopover();
+ return match||selected;
+ })()`);
+ if(found)return point;
+ await click('#clear-relationship-participants');
+ }
+ throw new Error(`No selectable relationship surface: ${label}`);
+ }
+
async function titleCard(kicker, title, subtitle, name) {
const html = `