Merge pull request #157 from bherbruck/docs/enclosure-demo-refresh

docs: restore the enclosure demo with the 0.2.0 UI
This commit is contained in:
bherbruck authored and GitHub committed 2026-10-11 12:27:22 -04:00
commit 7abfe69603
7 files changed
+137 -45

No files matched your search

+2 -1
View File
@@ -18,7 +18,8 @@ Generated-in-code assets are original and have no file to list:
| File | Author | Source | Licence | | File | Author | Source | Licence |
|---|---|---|---| |---|---|---|---|
| docs/screenshots/sample-plate.png | SolveCraft contributors | screenshot of SolveCraft itself (`solvecraft --sample`) | MIT OR Apache-2.0 | | docs/screenshots/sample-plate.png | SolveCraft contributors | screenshot of SolveCraft itself (`solvecraft --sample`) | MIT OR Apache-2.0 |
| docs/screenshots/demo.gif | SolveCraft contributors | original screen recording of the actual SolveCraft 0.2.0 English desktop UI (2× GIF preview; full video at real-time speed) | MIT OR Apache-2.0 | | docs/screenshots/demo.gif | SolveCraft contributors | original electronics enclosure screen recording of the actual SolveCraft 0.2.0 English desktop UI, generated with examples/demo/record_ui.sh and its original animated pointer overlay (4× playback) | MIT OR Apache-2.0 |
| docs/screenshots/enclosure-demo-0.2.0.mp4 | SolveCraft contributors | original electronics enclosure screen recording of the actual SolveCraft 0.2.0 English desktop UI, generated with examples/demo/record_ui.sh and its original animated pointer overlay (4× playback) | MIT OR Apache-2.0 |
| docs/book/src/generated/sample-plate.png | SolveCraft contributors | copy of docs/screenshots/sample-plate.png made by `cargo xtask book` (gitignored) | MIT OR Apache-2.0 | | docs/book/src/generated/sample-plate.png | SolveCraft contributors | copy of docs/screenshots/sample-plate.png made by `cargo xtask book` (gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/bracket.png | SolveCraft contributors | rendered by `cargo xtask book` (`solvecraft-cli snapshot examples/bracket.json`; gitignored) | MIT OR Apache-2.0 | | docs/book/src/generated/bracket.png | SolveCraft contributors | rendered by `cargo xtask book` (`solvecraft-cli snapshot examples/bracket.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/enclosure.png | SolveCraft contributors | rendered by `cargo xtask book` (`solvecraft-cli snapshot docs/book/scripts/enclosure.json`; gitignored) | MIT OR Apache-2.0 | | docs/book/src/generated/enclosure.png | SolveCraft contributors | rendered by `cargo xtask book` (`solvecraft-cli snapshot docs/book/scripts/enclosure.json`; gitignored) | MIT OR Apache-2.0 |
+2 -2
View File
@@ -7,9 +7,9 @@ Sketch, constrain, and build solids with a parametric feature timeline. Change a
and watch your model rebuild. Run it on your desktop, in your browser, or let AI agents and watch your model rebuild. Run it on your desktop, in your browser, or let AI agents
model through the built-in MCP server. Part of the storytold "craft" family. model through the built-in MCP server. Part of the storytold "craft" family.
![SolveCraft 0.2.0: dimension a line, extrude a plate with an elliptical hole, and change its width while preserving the hole](docs/screenshots/demo.gif) ![SolveCraft 0.2.0: build a rounded electronics enclosure with a USB port and four mounting bosses, then resize it through Parameters](docs/screenshots/demo.gif)
*The actual 0.2.0 desktop UI: dimension a line from its endpoints, build a plate with an elliptical hole, then change its width from 25 → 35 mm and watch it rebuild. Includes the new axis navigation. 2× GIF preview; [full video at real-time speed](https://github.com/bherbruck/solvecraft/releases/download/v0.2.0/solvecraft-0.2.0-demo.mp4).* *An AI agent builds a parametric electronics enclosure in the actual 0.2.0 desktop UI: rounded corners, a shell, a USB port and a four-boss pattern. Change width from 80 → 100 mm and wall thickness from 2 → 3 mm in Parameters and watch it rebuild. Original animated pointer and click cues; 4× playback. [Full video](https://github.com/bherbruck/solvecraft/raw/refs/heads/main/docs/screenshots/enclosure-demo-0.2.0.mp4), generated with the [demo script](examples/demo/record_ui.sh).*
**Documentation:** the [SolveCraft book](https://bherbruck.github.io/solvecraft/) (user guide, **Documentation:** the [SolveCraft book](https://bherbruck.github.io/solvecraft/) (user guide,
developer guide and command reference; sources in [docs/book](docs/book)), and the developer guide and command reference; sources in [docs/book](docs/book)), and the
+7 -3
View File
@@ -85,9 +85,10 @@ headline counts in-scope tabs only and lists the deferred ones separately.
preventing the reproduced thin-web loss. Unknown additional trims refuse; inherited preventing the reproduced thin-web loss. Unknown additional trims refuse; inherited
common-cylinder trims retain their existing policy, so this is not a general Boolean proof. common-cylinder trims retain their existing policy, so this is not a general Boolean proof.
- Exact full-path assembly joint editing remains separate work and is not part of this batch. - Exact full-path assembly joint editing remains separate work and is not part of this batch.
- The replacement README demo records the actual English 0.2.0 UI, with a 2× preview and - Stable 0.2.0 is published: all release payload checksums, native cold reopening, deployed
a full video at real-time speed. Package verification and publication follow web app and both Flatpak architectures were verified. The replacement README demo uses
[the release workflow](docs/releasing.md); development pushes are not application releases. the original electronics enclosure story and animated pointer in the actual English
0.2.0 UI, at 4× playback. Publication follows [the release workflow](docs/releasing.md).
## View navigation ## View navigation
@@ -98,6 +99,9 @@ headline counts in-scope tabs only and lists the deferred ones separately.
## Known limitations ## Known limitations
- Some shelled planar faces cannot supply the exact source required for an associative
face sketch. The enclosure demo uses parameter-driven offset planes (`width/2` for the
USB port, `wall` for the mounting bosses), so width and wall edits still rebuild the case.
- Fillets and chamfers: constant, variable-radius and chord-length fillets; a radius per edge, - Fillets and chamfers: constant, variable-radius and chord-length fillets; a radius per edge,
rounds next to earlier rounds of another radius, and smooth corner patches where three rounds rounds next to earlier rounds of another radius, and smooth corner patches where three rounds
meet (a sphere for equal radii); straight edges (convex or concave) between planar faces with perpendicular meet (a sphere for equal radii); straight edges (convex or concave) between planar faces with perpendicular
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 MiB

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.
+106 -31
View File
@@ -11,8 +11,9 @@ Rectangular Pattern dialog, then width and wall changed in the Parameters dialog
Modelling steps go through the app's UI over its control channel: the pointer glides to a 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 toolbar button and clicks it (the real dialog opens), glides to the face or point it picks and
clicks, and values are typed into the dialog, as by hand. Sketches go on the model's faces and clicks, and values are typed into the dialog, as by hand. The port and boss sketches use
the camera follows like a user's would (Look At on a new sketch, back out to a 3/4 view on parametric datum planes (YZ + width/2 and XY + wall), so both follow the edited enclosure.
The camera follows like a user's would (Look At on a new sketch, back out to a 3/4 view on
Finish Sketch, a little orbit between features). The parameters and the dimensions that use them Finish Sketch, a little orbit between features). The parameters and the dimensions that use them
are MCP `execute` calls through `solvecraft-cli mcp --connect` (enclosure_mcp.py's client). are MCP `execute` calls through `solvecraft-cli mcp --connect` (enclosure_mcp.py's client).
@@ -37,8 +38,9 @@ MIN_CAPTION = 1.4
class Demo: class Demo:
def __init__(self, port, mcp, log, speed): def __init__(self, port, mcp, log, speed, minimum_caption=MIN_CAPTION):
self.port, self.mcp, self.log, self.speed = port, mcp, log, speed self.port, self.mcp, self.log, self.speed = port, mcp, log, speed
self.minimum_caption = minimum_caption
self.pos = None self.pos = None
self.stop = 10**6 self.stop = 10**6
@@ -54,8 +56,8 @@ class Demo:
raise StopIteration raise StopIteration
# Every caption stays up long enough to read. # Every caption stays up long enough to read.
shown = time.time() - getattr(self, "shown_at", 0.0) shown = time.time() - getattr(self, "shown_at", 0.0)
if shown < MIN_CAPTION: if shown < self.minimum_caption:
time.sleep(MIN_CAPTION - shown) time.sleep(self.minimum_caption - shown)
self.shown_at = time.time() self.shown_at = time.time()
print(f" {text} [{via}]", file=sys.stderr) print(f" {text} [{via}]", file=sys.stderr)
self.mark(caption=text, via=via) self.mark(caption=text, via=via)
@@ -71,8 +73,16 @@ class Demo:
return r.get("result") return r.get("result")
def at(self, **where): def at(self, **where):
p = self.ui("ui.at", where) # A clicked combo publishes its popup handles on the next rendered frame.
return (p[0], p[1]) # Observe that bounded transition; never substitute a command for the click.
for attempt in range(50 if "handle" in where else 1):
r = control(self.port, "ui.at", where)
if r.get("ok", False):
p = r["result"]
return (p[0], p[1])
if attempt + 1 < 50 and "handle" in where:
time.sleep(0.1)
raise RuntimeError(f"ui.at {where}: {r.get('error')}")
def glide(self, to, dur=0.3): def glide(self, to, dur=0.3):
"""Move the pointer to `to` along an eased path (the app sees every step: hover """Move the pointer to `to` along an eased path (the app sees every step: hover
@@ -93,10 +103,16 @@ class Demo:
self.ui("ui.click", {"x": to[0], "y": to[1]}) self.ui("ui.click", {"x": to[0], "y": to[1]})
self.mark(cursor=list(to), click=True) self.mark(cursor=list(to), click=True)
self.wait(settle) self.wait(settle)
self.settle_camera()
def tool(self, command, panel=None): def tool(self, command, panel=None):
"""Click a toolbar button (opening its panel's drop-down first when it's in one).""" """Click a toolbar button (opening its panel's drop-down first when it's in one)."""
if panel: self.settle_camera()
dialog = self.ui("ui.inspect").get("dialog") or ""
if "error: Some(" in dialog:
raise RuntimeError(f"the previous UI operation was refused: {dialog}")
handles = self.ui("ui.handles")
if panel and not any(h["name"] == f"toolbar:{command}" for h in handles):
self.click(self.at(handle=panel), settle=0.3) self.click(self.at(handle=panel), settle=0.3)
self.click(self.at(handle=f"toolbar:{command}"), settle=0.35) self.click(self.at(handle=f"toolbar:{command}"), settle=0.35)
@@ -106,13 +122,19 @@ class Demo:
if enter: if enter:
self.ui("ui.key", {"key": "Enter"}) self.ui("ui.key", {"key": "Enter"})
self.wait(0.5) self.wait(0.5)
self.settle_camera()
def key(self, k): def key(self, k):
self.ui("ui.key", {"key": k}) self.ui("ui.key", {"key": k})
self.wait(0.15) self.wait(0.15)
self.settle_camera()
def view(self, v, settle=0.7): def view(self, v, settle=0.7):
self.ui("ui.view", {"view": v, "animate": True}) if v == "home":
# The shipping Axis View's Home button, with the same camera/style as v3.
self.click(self.at(handle="axis_view:home"), settle=0.3)
else:
self.ui("ui.view", {"view": v, "animate": True})
self.wait(settle) self.wait(settle)
self.settle_camera() self.settle_camera()
@@ -139,18 +161,38 @@ class Demo:
self.pos = tuple(to) self.pos = tuple(to)
def settle_camera(self): def settle_camera(self):
"""Wait until the app has played queued pointer events (the camera stops moving).""" """Wait for queued pointer events, moving dialogs and geometry previews."""
last, still = None, 0 last, still = None, 0
for _ in range(80): for _ in range(1200):
time.sleep(0.1) time.sleep(0.1)
cam = json.dumps(self.ui("ui.inspect").get("camera")) state = self.ui("ui.inspect")
still = still + 1 if cam == last else 0 current = json.dumps([state.get("camera"), state.get("tool"), self.ui("ui.handles")], sort_keys=True)
last = cam busy = state.get("preview", {}).get("busy", False)
if still >= 2: still = still + 1 if current == last and not busy else 0
last = current
if still >= 3:
return return
raise RuntimeError("the camera/dialog/preview did not settle within 120 seconds")
def execute(self, command, params): def execute(self, command, params):
self.settle_camera()
self.mcp.tool("execute", {"command": command, "params": params}) self.mcp.tool("execute", {"command": command, "params": params})
self.settle_camera()
def datum_sketch(d, base, offset):
"""Create a parametric datum in the real dialog, then select its browser row."""
d.ui("ui.set", {"showOrigin": True})
d.tool("construct.plane.offset", panel="panel:SOLID:CONSTRUCT")
d.click(d.at(plane=base), settle=0.3)
d.type(offset)
names = [h["name"] for h in d.ui("ui.handles") if h["name"].startswith("timeline:")]
plane = names[-1].split(":", 1)[1]
d.datums = getattr(d, "datums", []) + [plane]
d.tool("sketch.create")
d.click(d.at(handle=f"row:{plane}"), settle=0.8)
d.settle_camera()
d.ui("ui.set", {"showOrigin": False})
def build_box(d, params): def build_box(d, params):
@@ -195,10 +237,11 @@ def build_box(d, params):
d.caption("Fillet the four vertical edges: 6 mm", "UI click") d.caption("Fillet the four vertical edges: 6 mm", "UI click")
d.tool("solid.fillet") d.tool("solid.fillet")
for p in ([40, -30, 15], [-40, -30, 15], [40, 30, 15]): for p in ([40, -30, 15], [-40, -30, 15]):
d.click(d.at(world=p), settle=0.15) d.click(d.at(world=p), settle=0.15)
d.orbit(-260, 20) d.orbit(-260, 20)
d.click(d.at(world=[-40, 30, 15]), settle=0.2) for p in ([40, 30, 15], [-40, 30, 15]):
d.click(d.at(world=p), settle=0.2)
d.type("6") d.type("6")
d.view("home", 0.5) d.view("home", 0.5)
@@ -207,10 +250,8 @@ def build_box(d, params):
d.click(d.at(world=[0, 0, 30]), settle=0.3) d.click(d.at(world=[0, 0, 30]), settle=0.3)
d.type("wall") d.type("wall")
d.caption("Sketch on the side face for a USB port", "UI click") d.caption("USB port datum: YZ + width/2", "UI click · parametric plane")
d.tool("sketch.create") datum_sketch(d, "YZ", "width/2")
d.click(d.at(world=[40, 10, 20]), settle=0.9)
d.settle_camera()
d.tool("sketch.rectangle.center") d.tool("sketch.rectangle.center")
d.click(d.at(world=[40, 0, 12]), settle=0.15) 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) d.click(d.at(world=[40, 6, 15.5]), dur=0.4, settle=0.2)
@@ -228,10 +269,8 @@ def build_box(d, params):
def run_v2(d): def run_v2(d):
"""v2: four standoffs from one sketch, then the width changed over MCP.""" """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")]) 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.caption("Four screw bosses datum: XY + wall", "UI click · parametric plane")
d.tool("sketch.create") datum_sketch(d, "XY", "wall")
d.click(d.at(world=[6, -9, 2]), settle=0.9)
d.settle_camera()
d.tool("sketch.circle.center") d.tool("sketch.circle.center")
for x, y in [(-30, -20), (30, -20), (30, 20), (-30, 20)]: 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) d.click(d.at(world=[x, y, 2]), dur=0.3, settle=0.1)
@@ -267,10 +306,8 @@ def run_v3(d):
dialog, then width and wall changed in the Parameters dialog.""" 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")]) 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.caption("Screw boss datum: XY + wall", "UI click · parametric plane")
d.tool("sketch.create") datum_sketch(d, "XY", "wall")
d.click(d.at(world=[6, -9, 2]), settle=0.9)
d.settle_camera()
d.tool("sketch.circle.center") d.tool("sketch.circle.center")
d.click(d.at(world=[-30, 20, 2]), dur=0.3, settle=0.1) 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.click(d.at(world=[-26.5, 20, 2]), dur=0.2, settle=0.15)
@@ -313,6 +350,12 @@ def run_v3(d):
# Up a little, so the corners behind the walls show. # Up a little, so the corners behind the walls show.
d.orbit(-60, 45) d.orbit(-60, 45)
# Hide the datum planes with their actual browser eye buttons.
for plane in d.datums:
x, y = d.at(handle=f"row:{plane}")
d.click((x - 66.5, y), settle=0.2)
d.key("Escape")
d.caption("Parameters dialog: width 80 → 100 mm", "UI click") d.caption("Parameters dialog: width 80 → 100 mm", "UI click")
d.tool("parameters.change", panel="panel:SOLID:MODIFY") d.tool("parameters.change", panel="panel:SOLID:MODIFY")
d.wait(0.3) d.wait(0.3)
@@ -332,7 +375,8 @@ def run_v3(d):
d.orbit(300, 0, steps=45) d.orbit(300, 0, steps=45)
# End on a high 3/4 view: all four standoffs in sight. # End on a high 3/4 view: all four standoffs in sight.
d.view("home", 0.5) d.view("home", 0.5)
d.orbit(0, 45, steps=25) d.orbit(0, 80, steps=40)
d.view("fit", 0.5)
d.wait(1.5) d.wait(1.5)
@@ -344,17 +388,48 @@ def main():
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("--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("--until", type=int, default=10**6, help="stop before step N (trying the script out)")
ap.add_argument("--speed", type=float, default=0.55, 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)")
ap.add_argument("--save", help="save the completed healthy enclosure and measured proof")
ap.add_argument("--minimum-caption", type=float, default=MIN_CAPTION, help="minimum real-time caption duration before playback acceleration")
a = ap.parse_args() a = ap.parse_args()
if not math.isfinite(a.speed) or a.speed <= 0 or not math.isfinite(a.minimum_caption) or a.minimum_caption < 0:
ap.error("pause scale must be positive and caption duration nonnegative")
mcp = Mcp([a.cli, "mcp", "--connect", f"127.0.0.1:{a.port}"]) 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"}}) mcp.send("initialize", {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "enclosure-demo-v2", "version": "2"}})
mcp.send("notifications/initialized", notify=True) mcp.send("notifications/initialized", notify=True)
d = Demo(a.port, mcp, open(a.log, "w") if a.log else None, a.speed) d = Demo(a.port, mcp, open(a.log, "w") if a.log else None, a.speed, a.minimum_caption)
d.ui("ui.prefs", {"language": "en", "theme": "dark", "ui_scale": 1, "nav": "fusion", "button": "apply"})
d.ui("ui.resize", {"width": 1920, "height": 1080})
d.ui("ui.home", {"open": False})
d.ui("ui.documents", {"action": "new"})
d.ui("ui.home", {"open": False})
d.ui("ui.set", {"showOrigin": True})
d.settle_camera()
d.stop = a.until - 1 d.stop = a.until - 1
t0 = time.time() t0 = time.time()
try: try:
run_v3(d) if a.story == "v3" else run_v2(d) run_v3(d) if a.story == "v3" else run_v2(d)
except StopIteration: except StopIteration:
pass pass
if a.save and a.until == 10**6:
d.settle_camera()
design = d.ui("document.inspect", {"measure": True})
if len(design.get("bodies", [])) != 1 or any(f.get("error") or f.get("warning") for f in design.get("timeline", [])):
raise RuntimeError(f"the completed enclosure is unhealthy: {design}")
expected_width = 100
bbox = design["bodies"][0]["bbox"]
if any(abs(v - want) > 1e-5 for side, expected in [("min", [-expected_width / 2, -30, 0]), ("max", [expected_width / 2, 30, 30])] for v, want in zip(bbox[side], expected)):
raise RuntimeError(f"the enclosure did not rebuild to 100 x 60 x 30 mm: {design}")
values = {p["name"]: p.get("value") for p in design.get("params", []) if not p.get("error")}
for name, expected in [("width", 100), ("wall", 3 if a.story == "v3" else 2)]:
if values.get(name) != expected:
raise RuntimeError(f"the final {name} parameter is not {expected}: {design}")
d.execute("file.save_as", {"path": a.save})
saved = json.load(open(a.save))
fillets = [f for f in saved["features"] if f.get("type") == "fillet"]
if len(fillets) != 1 or len(fillets[0].get("edges", [])) != 4:
raise RuntimeError("the enclosure did not retain all four selected corner fillets")
with open(a.save + ".measure.json", "w") as proof:
json.dump(design, proof, indent=2)
d.mark(caption="", via="") d.mark(caption="", via="")
mcp.close() mcp.close()
print(f"done in {time.time() - t0:.1f}s", file=sys.stderr) print(f"done in {time.time() - t0:.1f}s", file=sys.stderr)
+20 -8
View File
@@ -1,29 +1,41 @@
#!/bin/bash #!/bin/bash
# record_ui.sh DISPLAY PORT OUTDIR [STORY]: record enclosure_ui_mcp.py --story STORY (v2 or v3, # record_ui.sh DISPLAY PORT OUTDIR [STORY] [PLAYBACK_RATE]: 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 # 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 # 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 # gets synthetic pointer events, so the screen's own pointer never moves). Needs ffmpeg with
# x11grab and libass. # x11grab and libass.
# PLAYBACK_RATE defaults to 1; label accelerated previews with their rate when publishing.
# SOLVECRAFT_DEMO_NAME overrides the filename stem; PAUSE scales UI pauses, CAPTION is the
# minimum real-time caption duration, and HOLD keeps the live completed model on screen.
# For a readable 4x preview use SOLVECRAFT_DEMO_CAPTION=4 and SOLVECRAFT_DEMO_HOLD=8.
set -e set -e
DISP=${1:-:0}; PORT=${2:-7878}; OUT=$(cd "${3:-.}" && pwd); STORY=${4:-v3} DISP=${1:-:0}; PORT=${2:-7878}; OUT=$(cd "${3:-.}" && pwd); STORY=${4:-v3}; RATE=${5:-1}
HERE=$(cd "$(dirname "$0")" && pwd) HERE=$(cd "$(dirname "$0")" && pwd)
CLI=${SOLVECRAFT_CLI:-solvecraft-cli} CLI=${SOLVECRAFT_CLI:-solvecraft-cli}
NAME=${SOLVECRAFT_DEMO_NAME:-enclosure-demo-$STORY}
PAUSE=${SOLVECRAFT_DEMO_PAUSE:-0.55}
CAPTION=${SOLVECRAFT_DEMO_CAPTION:-1.4}
HOLD=${SOLVECRAFT_DEMO_HOLD:-1}
python3 -c 'import math,sys; r=float(sys.argv[1]); assert math.isfinite(r) and 0<r<=16' "$RATE"
# x11grab stamps frames with the wall clock: its "start:" is when the first frame was taken. # 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" \ 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" & -c:v libx264 -preset veryfast -crf 16 -pix_fmt yuv420p "$OUT/raw.mp4" 2> "$OUT/ffmpeg.log" &
FF=$! FF=$!
trap 'kill -INT $FF 2>/dev/null || true' EXIT trap 'kill -INT $FF 2>/dev/null || true' EXIT
sleep 1 sleep 1
python3 "$HERE/enclosure_ui_mcp.py" --port "$PORT" --cli "$CLI" --story "$STORY" --log "$OUT/steps.log" python3 "$HERE/enclosure_ui_mcp.py" --port "$PORT" --cli "$CLI" --story "$STORY" --speed "$PAUSE" --minimum-caption "$CAPTION" --log "$OUT/steps.log" --save "$OUT/enclosure-$STORY.solvecraft"
sleep 1 sleep "$HOLD"
kill -INT $FF; wait $FF || true kill -INT $FF; wait $FF || true
START=$(grep -o "start: [0-9.]*" "$OUT/ffmpeg.log" | head -1 | cut -d" " -f2) START=$(grep -o "start: [0-9.]*" "$OUT/ffmpeg.log" | head -1 | cut -d" " -f2)
python3 "$HERE/overlay.py" "$OUT/steps.log" "$START" "$OUT" python3 "$HERE/overlay.py" "$OUT/steps.log" "$START" "$OUT"
read CX CY < "$OUT/cursor.start" 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 \ 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]" \ "[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-$STORY.mp4" -map "[v]" -c:v libx264 -preset slow -crf 20 -pix_fmt yuv420p -movflags +faststart "$OUT/real-time.mp4"
ffmpeg -loglevel error -y -i "$OUT/enclosure-demo-$STORY.mp4" \ # Accelerate the completed composite: cursor, ripples and captions stay aligned with the UI.
ffmpeg -loglevel error -y -i "$OUT/real-time.mp4" -vf "setpts=PTS/$RATE" -an \
-c:v libx264 -preset slow -crf 20 -pix_fmt yuv420p -movflags +faststart "$OUT/$NAME.mp4"
ffmpeg -loglevel error -y -i "$OUT/$NAME.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" \ -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" "$OUT/$NAME.gif"
ls -la "$OUT"/enclosure-demo-$STORY.* ls -la "$OUT/$NAME.mp4" "$OUT/$NAME.gif"