MCP demo v3 (enclosure_ui_mcp.py --story v3, default): one standoff placed from the parameters (width/2 - inset, depth/2 - inset) and patterned 2 x 2 in the Rectangular Pattern dialog with spacings width - 2*inset and depth - 2*inset, then width 80 -> 100 and wall 2 -> 3 typed into the Parameters dialog; record_v2.sh is now record_ui.sh STORY. A pattern's Direction can be a straight model edge (an edge along an origin axis points the axis's way), scenario pattern_direction_edge. Dialog rows publish their widgets as dialog:<label> handles (combo items dialog:<label>:<item>, OK), the Parameters dialog its expressions (param:<name>), title and close button

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-09 15:09:29 +00:00
1 parent 3b9a975299
commit 8601611d0c
7 files changed
+277 -33

No files matched your search

+50 -8
View File
@@ -363,7 +363,11 @@ impl Dialog {
}
"solid.pattern.rectangular" => Dialog::new(
Kind::PatternRect { count: "3".into(), spacing: "20 mm".into(), count2: "1".into(), spacing2: "20 mm".into() },
vec![SelInput::new("Objects", BODIES, true), SelInput::new("Direction", AXES, false), SelInput::new("Direction 2", AXES, false)],
vec![
SelInput::new("Objects", BODIES, true),
SelInput::new("Direction", AXES | EDGES, false),
SelInput::new("Direction 2", AXES | EDGES, false),
],
),
"solid.pattern.circular" => Dialog::new(
Kind::PatternCirc { count: "6".into(), angle: "360 deg".into() },
@@ -713,16 +717,30 @@ fn plane_value(s: &Session, sel: Option<&Sel>) -> Option<Value> {
/// A value field; true when Enter was pressed in it.
pub(crate) fn field(ui: &mut egui::Ui, s: &mut String) -> bool {
let r = ui.text_edit_singleline(s);
publish_row(&r);
crate::params_dialog::complete(ui, &r, s);
r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))
}
pub(crate) fn combo(ui: &mut egui::Ui, id: &str, labels: &[&str], sel: &mut usize) {
egui::ComboBox::from_id_salt(id).selected_text(labels.get(*sel).copied().unwrap_or("")).width(FIELD_W).show_ui(ui, |ui| {
let row = ROW.with(|r| r.borrow().clone());
let r = egui::ComboBox::from_id_salt(id).selected_text(labels.get(*sel).copied().unwrap_or("")).width(FIELD_W).show_ui(ui, |ui| {
for (i, l) in labels.iter().enumerate() {
ui.selectable_value(sel, i, *l);
let item = ui.selectable_value(sel, i, *l);
crate::scenario::publish_handle(&format!("dialog:{row}:{l}"), item.rect.center());
}
});
publish_row(&r.response);
}
thread_local! {
/// The label of the dialog row being laid out (its widgets publish `dialog:<label>` handles).
static ROW: std::cell::RefCell<String> = const { std::cell::RefCell::new(String::new()) };
}
/// A dialog row's widget, for scenarios and demos: the handle `dialog:<row label>`.
fn publish_row(r: &egui::Response) {
ROW.with(|row| crate::scenario::publish_handle(&format!("dialog:{}", row.borrow()), r.rect.center()));
}
fn title(k: &Kind) -> &'static str {
@@ -805,6 +823,7 @@ const LABEL_W: f32 = 92.0;
/// A row label in the dialog's label column.
pub(crate) fn row_label(ui: &mut egui::Ui, text: &str) {
ROW.with(|r| *r.borrow_mut() = text.to_string());
ui.allocate_ui_with_layout(vec2(LABEL_W, 22.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.set_width(LABEL_W);
ui.add(egui::Label::new(text).truncate()).on_hover_text(text);
@@ -926,7 +945,11 @@ fn placement_row(d: &mut Dialog, ui: &mut egui::Ui) {
let (kind, mut inputs) = match ty {
0 => (
Kind::PatternRect { count: "3".into(), spacing: "20 mm".into(), count2: "1".into(), spacing2: "20 mm".into() },
vec![SelInput::new("Objects", BODIES, true), SelInput::new("Direction", AXES, false), SelInput::new("Direction 2", AXES, false)],
vec![
SelInput::new("Objects", BODIES, true),
SelInput::new("Direction", AXES | EDGES, false),
SelInput::new("Direction 2", AXES | EDGES, false),
],
),
1 => (
Kind::PatternCirc { count: "6".into(), angle: "360 deg".into() },
@@ -988,7 +1011,9 @@ fn input_rows(d: &mut Dialog, ui: &mut egui::Ui) {
.stroke(stroke)
.min_size(vec2(0.0, 22.0))
.sense(egui::Sense::CLICK);
if ui.add(b).on_hover_text(format!("{} (click to pick into this input)", hint(inp))).clicked() {
let chip = ui.add(b);
publish_row(&chip);
if chip.on_hover_text(format!("{} (click to pick into this input)", hint(inp))).clicked() {
activate = Some(i);
}
if !inp.items.is_empty() && ui.small_button("×").on_hover_text("Clear the selection").clicked() {
@@ -1571,7 +1596,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
let b = egui::Button::new(RichText::new(label).color(if valid { Color32::WHITE } else { t.text_dim }))
.fill(if valid { t.accent } else { Color32::TRANSPARENT })
.min_size(vec2(64.0, 24.0));
if ui.add_enabled(valid, b).clicked() {
let r = ui.add_enabled(valid, b);
crate::scenario::publish_handle(&format!("dialog:{label}"), r.rect.center());
if r.clicked() {
ok = true;
}
}
@@ -1898,9 +1925,9 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
need(0, "objects")?;
let kept = d.extra.get("dir1").and_then(|v| serde_json::from_value::<[f64; 3]>(v.clone()).ok()).map(|a| Vec3::new(a[0], a[1], a[2]));
let d1 = match (sels(d, 1).first(), kept) {
(Some(x), _) => axis_of(app, x).ok_or("the direction must be an axis or a sketch line")?.1,
(Some(x), _) => axis_of(app, x).ok_or("the direction must be an axis, a sketch line or a straight edge")?.1,
(None, Some(k)) => k,
(None, None) => return Err("select a direction (an axis or a sketch line) first".into()),
(None, None) => return Err("select a direction (an axis, a sketch line or a straight edge) first".into()),
};
let mut p = json!({"features": pattern_features(s, sels(d, 0)), "dir1": pt(d1), "count1": count, "spacing1": spacing});
if let Some((_, d2)) = sels(d, 2).first().and_then(|x| axis_of(app, x)) {
@@ -2276,6 +2303,21 @@ pub fn axis_of(app: &SolveApp, sel: &Sel) -> Option<(Vec3, Vec3)> {
let (pa, pb) = (ss.plane.to_world(ss.sketch.point(a)?), ss.plane.to_world(ss.sketch.point(b)?));
Some((pa, (pb - pa).normalized()?))
}
// A straight model edge, along it.
Sel::Edge { body, index, .. } => {
let b = app.session.world_state().body(body).cloned()?;
let m = b.mesh();
let e = m.edges.get(*index)?;
let (a, z) = (*e.first()?, *e.last()?);
let dir = (z - a).normalized()?;
let tol = 1e-6 * a.dist(z).max(1.0);
if !e.iter().all(|p| (*p - a).cross(dir).len() < tol) {
return None;
}
// An edge along an origin axis points the way the axis does (as picking the axis).
let axis = [Vec3::X, Vec3::Y, Vec3::Z].into_iter().find(|x| dir.dot(*x).abs() > 1.0 - 1e-9);
Some((a, axis.unwrap_or(dir)))
}
_ => None,
}
}
+6
View File
@@ -188,6 +188,11 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
}
});
});
// The title bar's close button, for scenarios and demos.
if let Some(r) = ctx.memory(|m| m.area_rect(egui::Id::new("sc_params_dialog"))) {
crate::scenario::publish_handle("params:close", egui::pos2(r.right() - 16.0, r.top() + 15.0));
crate::scenario::publish_handle("params:title", egui::pos2(r.center().x, r.top() + 15.0));
}
if !open {
app.params = None;
return;
@@ -271,6 +276,7 @@ fn table(app: &mut SolveApp, ui: &mut egui::Ui, d: &mut ParamsDialog, rows: &[&R
te = te.text_color(t.error);
}
let resp = ui.add_sized([170.0, 20.0], te);
crate::scenario::publish_handle(&format!("param:{}", r.name), resp.rect.center());
let completed = complete(ui, &resp, &mut e);
if resp.changed() || completed {
d.editing.insert(r.name.clone(), e.clone());
@@ -0,0 +1,79 @@
[
{
"note": "A Rectangular Pattern's direction can be a straight model edge (as in Fusion), not only an origin axis or a sketch line."
},
{
"call": "engine.execute",
"params": {
"command": "solid.box",
"params": {
"length": 20,
"width": 10,
"height": 10
}
}
},
{
"call": "ui.view",
"params": {
"view": "home"
}
},
{
"start": "solid.pattern.rectangular"
},
{
"click": {
"world": [
10,
5,
10
]
}
},
{
"click": {
"handle": "dialog:Direction"
}
},
{
"click": {
"world": [
10,
0,
10
]
}
},
{
"click": {
"handle": "dialog:OK"
}
},
{
"expect": {
"dialog": null,
"bodies": 3
}
},
{
"note": "Along the picked edge (an edge along X points +X, as the axis would): copies 20 mm apart."
},
{
"expect": {
"bbox": {
"body": "Body3",
"min": [
40,
0,
0
],
"max": [
60,
10,
10
]
}
}
}
]
+3 -1
View File
@@ -85,7 +85,9 @@ with each step's caption burnt in.
modelling goes through the app's UI over its control channel. The pointer glides to toolbar
buttons and faces and clicks them, the real dialogs open, the sketches go on the model's faces,
and the camera follows. The parameters, the sketch dimensions bound to them and two extrudes go
over MCP. `record_v2.sh` records it and draws the pointer and the captions.
over MCP. With `--story v3` (the default) one standoff is patterned 2 × 2 in the Rectangular
Pattern dialog with spacings from the parameters, and width and wall are then changed in the
Parameters dialog. `record_ui.sh` records it and draws the pointer and the captions.
## A typical session
+123 -10
View File
@@ -1,9 +1,13 @@
#!/usr/bin/env python3
"""Demo v2: build an electronics enclosure in a running SolveCraft the way a person would, with
the parametric steps sent over MCP.
"""Demo v2/v3: build an electronics enclosure in a running SolveCraft the way a person would,
with the parametric steps sent over MCP.
--story v3 (default): one standoff placed from the parameters and patterned 2 x 2 in the
Rectangular Pattern dialog, then width and wall changed in the Parameters dialog.
--story v2: four standoffs from one sketch, then the width changed over MCP.
solvecraft --control 7878 &
python3 examples/demo/enclosure_ui_mcp.py --port 7878 --cli solvecraft-cli
python3 examples/demo/enclosure_ui_mcp.py --port 7878 --cli solvecraft-cli [--story v2|v3]
Modelling steps go through the app's UI over its control channel: the pointer glides to a
toolbar button and clicks it (the real dialog opens), glides to the face or point it picks and
@@ -28,6 +32,10 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from enclosure_mcp import Mcp, control # noqa: E402
# Seconds a caption stays up at least.
MIN_CAPTION = 1.4
class Demo:
def __init__(self, port, mcp, log, speed):
self.port, self.mcp, self.log, self.speed = port, mcp, log, speed
@@ -44,6 +52,11 @@ class Demo:
self.n = getattr(self, "n", 0) + 1
if self.n > self.stop:
raise StopIteration
# Every caption stays up long enough to read.
shown = time.time() - getattr(self, "shown_at", 0.0)
if shown < MIN_CAPTION:
time.sleep(MIN_CAPTION - shown)
self.shown_at = time.time()
print(f" {text} [{via}]", file=sys.stderr)
self.mark(caption=text, via=via)
@@ -61,7 +74,7 @@ class Demo:
p = self.ui("ui.at", where)
return (p[0], p[1])
def glide(self, to, dur=0.35):
def glide(self, to, dur=0.3):
"""Move the pointer to `to` along an eased path (the app sees every step: hover
highlights follow it)."""
frm = self.pos or (1150.0, 620.0)
@@ -75,7 +88,7 @@ class Demo:
time.sleep(dur / n)
self.pos = to
def click(self, to, dur=0.35, settle=0.25):
def click(self, to, dur=0.3, settle=0.2):
self.glide(to, dur)
self.ui("ui.click", {"x": to[0], "y": to[1]})
self.mark(cursor=list(to), click=True)
@@ -101,6 +114,7 @@ class Demo:
def view(self, v, settle=0.7):
self.ui("ui.view", {"view": v, "animate": True})
self.wait(settle)
self.settle_camera()
def orbit(self, dx, dy=0, steps=40):
"""A right-button drag in the viewport, as a user orbits."""
@@ -114,6 +128,16 @@ class Demo:
self.mark(drag_end=[cx + dx, cy + dy])
self.pos = (cx + dx, cy + dy)
def drag(self, frm, to, button="left", steps=30):
"""Press at `frm`, move to `to` and let go (a window's title bar, or a middle-button pan)."""
self.glide(frm, 0.25)
self.mark(drag_start=list(frm))
self.ui("ui.drag", {"x0": frm[0], "y0": frm[1], "x1": to[0], "y1": to[1], "button": button, "steps": steps})
self.settle_camera()
self.wait(0.2)
self.mark(drag_end=list(to))
self.pos = tuple(to)
def settle_camera(self):
"""Wait until the app has played queued pointer events (the camera stops moving)."""
last, still = None, 0
@@ -129,9 +153,9 @@ class Demo:
self.mcp.tool("execute", {"command": command, "params": params})
def run(d):
d.caption("Parameters over MCP: width 80, depth 60, height 30, wall 2", "MCP tools/call execute parameters.change")
for name, expr in [("width", "80 mm"), ("depth", "60 mm"), ("height", "30 mm"), ("wall", "2 mm")]:
def build_box(d, params):
d.caption("Parameters over MCP: " + ", ".join(f"{n} {e.split()[0]}" for n, e in params), "MCP tools/call execute parameters.change")
for name, expr in params:
d.execute("parameters.change", {"name": name, "expression": expr})
d.wait(0.25)
d.wait(0.6)
@@ -139,6 +163,7 @@ def run(d):
d.caption("Create Sketch on the XY plane", "UI click")
d.tool("sketch.create")
d.click(d.at(plane="XY"), settle=0.8)
d.settle_camera()
d.caption("Center rectangle from the origin", "UI click")
d.tool("sketch.rectangle.center")
@@ -185,6 +210,7 @@ def run(d):
d.caption("Sketch on the side face for a USB port", "UI click")
d.tool("sketch.create")
d.click(d.at(world=[40, 10, 20]), settle=0.9)
d.settle_camera()
d.tool("sketch.rectangle.center")
d.click(d.at(world=[40, 0, 12]), settle=0.15)
d.click(d.at(world=[40, 6, 15.5]), dur=0.4, settle=0.2)
@@ -199,9 +225,13 @@ def run(d):
d.wait(0.6)
d.orbit(-140, 30)
def run_v2(d):
"""v2: four standoffs from one sketch, then the width changed over MCP."""
build_box(d, [("width", "80 mm"), ("depth", "60 mm"), ("height", "30 mm"), ("wall", "2 mm")])
d.caption("Sketch on the inner floor: four standoffs", "UI click")
d.tool("sketch.create")
d.click(d.at(world=[6, -9, 2]), settle=0.9)
d.settle_camera()
d.tool("sketch.circle.center")
for x, y in [(-30, -20), (30, -20), (30, 20), (-30, 20)]:
d.click(d.at(world=[x, y, 2]), dur=0.3, settle=0.1)
@@ -224,13 +254,96 @@ def run(d):
d.view("home", 1.5)
def select_all_and_type(d, at, text, enter=True):
"""Click a value field, select what is there (Ctrl+A) and type over it."""
d.click(at, settle=0.15)
d.ui("ui.key", {"key": "A", "cmd": True})
d.wait(0.1)
d.type(text, enter)
def run_v3(d):
"""v3: one standoff placed from the parameters and patterned 2 x 2 in the Rectangular Pattern
dialog, then width and wall changed in the Parameters dialog."""
build_box(d, [("width", "80 mm"), ("depth", "60 mm"), ("height", "30 mm"), ("wall", "2 mm"), ("inset", "10 mm")])
d.caption("Sketch one standoff on the inner floor", "UI click")
d.tool("sketch.create")
d.click(d.at(world=[6, -9, 2]), settle=0.9)
d.settle_camera()
d.tool("sketch.circle.center")
d.click(d.at(world=[-30, 20, 2]), dur=0.3, settle=0.1)
d.click(d.at(world=[-26.5, 20, 2]), dur=0.2, settle=0.15)
d.key("Escape")
d.caption("Place it from the parameters: width/2 − inset, depth/2 − inset", "MCP tools/call execute sketch.dimension")
sk = json.loads(d.mcp.tool("execute", {"command": "sketch.inspect", "params": {}}))
sk = sk.get("result", sk)
circles = [c["id"] for c in sk["curves"] if c["type"] == "circle" and not c.get("fixed")]
if not circles:
raise RuntimeError(f"no circle in the standoff sketch: {[c['id'] for c in sk['curves']]}")
circle = circles[0]
d.execute("sketch.dimension", {"entities": ["origin", f"{circle}.center"], "type": "horizontal", "value": "width / 2 - inset"})
d.execute("sketch.dimension", {"entities": ["origin", f"{circle}.center"], "type": "vertical", "value": "depth / 2 - inset"})
d.execute("sketch.dimension", {"entities": [circle], "type": "diameter", "value": "7 mm"})
d.wait(0.8)
d.tool("sketch.finish")
d.view("home", 0.5)
d.caption("Extrude the standoff: 12 mm", "MCP tools/call execute solid.extrude")
d.execute("solid.extrude", {"profiles": [[circle]], "distance": 12, "operation": "join"})
d.wait(0.7)
standoff = d.ui("ui.handles", {"prefix": "timeline:"})[-1]["name"]
d.caption("Rectangular Pattern: 2 × 2, spaced width − 2·inset by depth − 2·inset", "UI click")
d.tool("solid.pattern.rectangular", panel="panel:SOLID:CREATE")
d.click(d.at(handle="dialog:Object Type"), settle=0.25)
d.click(d.at(handle="dialog:Object Type:Features"), settle=0.25)
d.click(d.at(handle=standoff), settle=0.25)
d.click(d.at(handle="dialog:Direction"), settle=0.15)
d.click(d.at(world=[0, 30, 30]), settle=0.2)
d.click(d.at(handle="dialog:Direction 2"), settle=0.15)
d.click(d.at(world=[-40, 0, 30]), settle=0.2)
select_all_and_type(d, d.at(handle="dialog:Quantity"), "2", enter=False)
select_all_and_type(d, d.at(handle="dialog:Spacing"), "width - 2*inset", enter=False)
select_all_and_type(d, d.at(handle="dialog:Quantity 2"), "2", enter=False)
select_all_and_type(d, d.at(handle="dialog:Spacing 2"), "-(depth - 2*inset)", enter=False)
d.wait(0.4)
d.click(d.at(handle="dialog:OK"), settle=0.4)
# Up a little, so the corners behind the walls show.
d.orbit(-60, 45)
d.caption("Parameters dialog: width 80 → 100 mm", "UI click")
d.tool("parameters.change", panel="panel:SOLID:MODIFY")
d.wait(0.3)
# Move the dialog aside and the model over, so both show.
title = d.at(handle="params:title")
d.drag(title, (title[0] - 330, title[1] + 20), steps=25)
vp = d.ui("ui.inspect").get("viewport") or [250, 126, 1350, 800]
d.drag((vp[0] + vp[2] * 0.62, vp[1] + vp[3] * 0.5), (vp[0] + vp[2] * 0.62 + 280, vp[1] + vp[3] * 0.5), button="middle", steps=25)
select_all_and_type(d, d.at(handle="param:width"), "100 mm")
d.wait(1.0)
d.caption("… and wall 2 → 3 mm", "UI click")
select_all_and_type(d, d.at(handle="param:wall"), "3 mm")
d.wait(0.8)
d.click(d.at(handle="params:close"), settle=0.4)
d.caption("", "")
d.orbit(300, 0, steps=45)
# End on a high 3/4 view: all four standoffs in sight.
d.view("home", 0.5)
d.orbit(0, 45, steps=25)
d.wait(1.5)
def main():
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--port", type=int, default=7878, help="the app's --control port")
ap.add_argument("--cli", default="solvecraft-cli", help="path to solvecraft-cli")
ap.add_argument("--log", help="write a JSON line per caption and pointer event")
ap.add_argument("--story", choices=["v2", "v3"], default="v3", help="v2: standoffs from one sketch, width over MCP; v3: one standoff patterned, Parameters dialog")
ap.add_argument("--until", type=int, default=10**6, help="stop before step N (trying the script out)")
ap.add_argument("--speed", type=float, default=1.0, help="scale the pauses (2 = twice as long)")
ap.add_argument("--speed", type=float, default=0.55, help="scale the pauses (2 = twice as long)")
a = ap.parse_args()
mcp = Mcp([a.cli, "mcp", "--connect", f"127.0.0.1:{a.port}"])
mcp.send("initialize", {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "enclosure-demo-v2", "version": "2"}})
@@ -239,7 +352,7 @@ def main():
d.stop = a.until - 1
t0 = time.time()
try:
run(d)
run_v3(d) if a.story == "v3" else run_v2(d)
except StopIteration:
pass
d.mark(caption="", via="")
+1 -1
View File
@@ -2,7 +2,7 @@
"""overlay.py LOG START OUTDIR: from enclosure_ui_mcp.py's --log (captions and pointer events)
and the recording's start (epoch seconds), write OUTDIR/captions.ass, OUTDIR/cursor.cmd
(ffmpeg sendcmd moves for the drawn pointer and the click ripple) and the two sprites
(cursor.png, ripple.png). record_v2.sh composites them onto the screen recording."""
(cursor.png, ripple.png). record_ui.sh composites them onto the screen recording."""
import json
import math
@@ -1,27 +1,29 @@
#!/bin/bash
# record_v2.sh DISPLAY PORT OUTDIR: record enclosure_ui_mcp.py driving the app on X display
# DISPLAY (1920x1080, started with --control PORT) to OUTDIR/enclosure-demo-v2.mp4 (H.264) and
# .gif, with captions and a drawn pointer (the app gets synthetic pointer events, so the
# screen's own pointer never moves). Needs ffmpeg with x11grab and libass.
# record_ui.sh DISPLAY PORT OUTDIR [STORY]: record enclosure_ui_mcp.py --story STORY (v2 or v3,
# default v3) driving the app on X display DISPLAY (1920x1080, started with --control PORT) to
# OUTDIR/enclosure-demo-STORY.mp4 (H.264) and .gif, with captions and a drawn pointer (the app
# gets synthetic pointer events, so the screen's own pointer never moves). Needs ffmpeg with
# x11grab and libass.
set -e
DISP=${1:-:0}; PORT=${2:-7878}; OUT=$(cd "${3:-.}" && pwd)
DISP=${1:-:0}; PORT=${2:-7878}; OUT=$(cd "${3:-.}" && pwd); STORY=${4:-v3}
HERE=$(cd "$(dirname "$0")" && pwd)
CLI=${SOLVECRAFT_CLI:-solvecraft-cli}
# x11grab stamps frames with the wall clock: its "start:" is when the first frame was taken.
ffmpeg -loglevel info -nostats -y -f x11grab -draw_mouse 0 -video_size 1920x1080 -framerate 30 -i "$DISP" \
-c:v libx264 -preset veryfast -crf 16 -pix_fmt yuv420p "$OUT/raw.mp4" 2> "$OUT/ffmpeg.log" &
FF=$!
sleep 1.5
python3 "$HERE/enclosure_ui_mcp.py" --port "$PORT" --cli "$CLI" --log "$OUT/steps.log"
sleep 1.5
trap 'kill -INT $FF 2>/dev/null || true' EXIT
sleep 1
python3 "$HERE/enclosure_ui_mcp.py" --port "$PORT" --cli "$CLI" --story "$STORY" --log "$OUT/steps.log"
sleep 1
kill -INT $FF; wait $FF || true
START=$(grep -o "start: [0-9.]*" "$OUT/ffmpeg.log" | head -1 | cut -d" " -f2)
python3 "$HERE/overlay.py" "$OUT/steps.log" "$START" "$OUT"
read CX CY < "$OUT/cursor.start"
ffmpeg -loglevel error -y -i "$OUT/raw.mp4" -loop 1 -i "$OUT/cursor.png" -loop 1 -i "$OUT/ripple.png" -filter_complex \
"[0:v]ass=$OUT/captions.ass[v0];[v0][2:v]overlay@rip=x=-100:y=-100:enable='$(cat "$OUT/ripple.enable")':shortest=1[v1];[v1][1:v]overlay@cur=x=$CX:y=$CY:shortest=1,sendcmd=f=$OUT/cursor.cmd[v]" \
-map "[v]" -c:v libx264 -preset slow -crf 20 -pix_fmt yuv420p -movflags +faststart "$OUT/enclosure-demo-v2.mp4"
ffmpeg -loglevel error -y -i "$OUT/enclosure-demo-v2.mp4" \
-vf "fps=10,scale=800:-1:flags=lanczos,split[a][b];[a]palettegen=max_colors=96:stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=4" \
"$OUT/enclosure-demo-v2.gif"
ls -la "$OUT"/enclosure-demo-v2.*
-map "[v]" -c:v libx264 -preset slow -crf 20 -pix_fmt yuv420p -movflags +faststart "$OUT/enclosure-demo-$STORY.mp4"
ffmpeg -loglevel error -y -i "$OUT/enclosure-demo-$STORY.mp4" \
-vf "fps=8,scale=720:-1:flags=lanczos,split[a][b];[a]palettegen=max_colors=80:stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=4" \
"$OUT/enclosure-demo-$STORY.gif"
ls -la "$OUT"/enclosure-demo-$STORY.*