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:
7 files changed
+137
-45
No files matched your search
+2
-1
@@ -18,7 +18,8 @@ Generated-in-code assets are original and have no file to list:
|
||||
| File | Author | Source | Licence |
|
||||
|---|---|---|---|
|
||||
| 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/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 |
|
||||
|
||||
@@ -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
|
||||
model through the built-in MCP server. Part of the storytold "craft" family.
|
||||
|
||||

|
||||

|
||||
|
||||
*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,
|
||||
developer guide and command reference; sources in [docs/book](docs/book)), and the
|
||||
|
||||
+7
-3
@@ -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
|
||||
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.
|
||||
- The replacement README demo records the actual English 0.2.0 UI, with a 2× preview and
|
||||
a full video at real-time speed. Package verification and publication follow
|
||||
[the release workflow](docs/releasing.md); development pushes are not application releases.
|
||||
- Stable 0.2.0 is published: all release payload checksums, native cold reopening, deployed
|
||||
web app and both Flatpak architectures were verified. The replacement README demo uses
|
||||
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
|
||||
|
||||
@@ -98,6 +99,9 @@ headline counts in-scope tabs only and lists the deferred ones separately.
|
||||
|
||||
## 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,
|
||||
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
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 6.2 MiB After Width: | Height: | Size: 4.2 MiB |
Binary file not shown.
@@ -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
|
||||
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
|
||||
the camera follows like a user's would (Look At on a new sketch, back out to a 3/4 view on
|
||||
clicks, and values are typed into the dialog, as by hand. The port and boss sketches use
|
||||
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
|
||||
are MCP `execute` calls through `solvecraft-cli mcp --connect` (enclosure_mcp.py's client).
|
||||
|
||||
@@ -37,8 +38,9 @@ MIN_CAPTION = 1.4
|
||||
|
||||
|
||||
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.minimum_caption = minimum_caption
|
||||
self.pos = None
|
||||
self.stop = 10**6
|
||||
|
||||
@@ -54,8 +56,8 @@ class Demo:
|
||||
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)
|
||||
if shown < self.minimum_caption:
|
||||
time.sleep(self.minimum_caption - shown)
|
||||
self.shown_at = time.time()
|
||||
print(f" {text} [{via}]", file=sys.stderr)
|
||||
self.mark(caption=text, via=via)
|
||||
@@ -71,8 +73,16 @@ class Demo:
|
||||
return r.get("result")
|
||||
|
||||
def at(self, **where):
|
||||
p = self.ui("ui.at", where)
|
||||
return (p[0], p[1])
|
||||
# A clicked combo publishes its popup handles on the next rendered frame.
|
||||
# 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):
|
||||
"""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.mark(cursor=list(to), click=True)
|
||||
self.wait(settle)
|
||||
self.settle_camera()
|
||||
|
||||
def tool(self, command, panel=None):
|
||||
"""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=f"toolbar:{command}"), settle=0.35)
|
||||
|
||||
@@ -106,13 +122,19 @@ class Demo:
|
||||
if enter:
|
||||
self.ui("ui.key", {"key": "Enter"})
|
||||
self.wait(0.5)
|
||||
self.settle_camera()
|
||||
|
||||
def key(self, k):
|
||||
self.ui("ui.key", {"key": k})
|
||||
self.wait(0.15)
|
||||
self.settle_camera()
|
||||
|
||||
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.settle_camera()
|
||||
|
||||
@@ -139,18 +161,38 @@ class Demo:
|
||||
self.pos = tuple(to)
|
||||
|
||||
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
|
||||
for _ in range(80):
|
||||
for _ in range(1200):
|
||||
time.sleep(0.1)
|
||||
cam = json.dumps(self.ui("ui.inspect").get("camera"))
|
||||
still = still + 1 if cam == last else 0
|
||||
last = cam
|
||||
if still >= 2:
|
||||
state = self.ui("ui.inspect")
|
||||
current = json.dumps([state.get("camera"), state.get("tool"), self.ui("ui.handles")], sort_keys=True)
|
||||
busy = state.get("preview", {}).get("busy", False)
|
||||
still = still + 1 if current == last and not busy else 0
|
||||
last = current
|
||||
if still >= 3:
|
||||
return
|
||||
raise RuntimeError("the camera/dialog/preview did not settle within 120 seconds")
|
||||
|
||||
def execute(self, command, params):
|
||||
self.settle_camera()
|
||||
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):
|
||||
@@ -195,10 +237,11 @@ def build_box(d, params):
|
||||
|
||||
d.caption("Fillet the four vertical edges: 6 mm", "UI click")
|
||||
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.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.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.type("wall")
|
||||
|
||||
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.caption("USB port datum: YZ + width/2", "UI click · parametric plane")
|
||||
datum_sketch(d, "YZ", "width/2")
|
||||
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)
|
||||
@@ -228,10 +269,8 @@ def build_box(d, params):
|
||||
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.caption("Four screw bosses datum: XY + wall", "UI click · parametric plane")
|
||||
datum_sketch(d, "XY", "wall")
|
||||
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)
|
||||
@@ -267,10 +306,8 @@ def run_v3(d):
|
||||
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.caption("Screw boss datum: XY + wall", "UI click · parametric plane")
|
||||
datum_sketch(d, "XY", "wall")
|
||||
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)
|
||||
@@ -313,6 +350,12 @@ def run_v3(d):
|
||||
# Up a little, so the corners behind the walls show.
|
||||
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.tool("parameters.change", panel="panel:SOLID:MODIFY")
|
||||
d.wait(0.3)
|
||||
@@ -332,7 +375,8 @@ def run_v3(d):
|
||||
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.orbit(0, 80, steps=40)
|
||||
d.view("fit", 0.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("--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("--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()
|
||||
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.send("initialize", {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "enclosure-demo-v2", "version": "2"}})
|
||||
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
|
||||
t0 = time.time()
|
||||
try:
|
||||
run_v3(d) if a.story == "v3" else run_v2(d)
|
||||
except StopIteration:
|
||||
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="")
|
||||
mcp.close()
|
||||
print(f"done in {time.time() - t0:.1f}s", file=sys.stderr)
|
||||
|
||||
@@ -1,29 +1,41 @@
|
||||
#!/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
|
||||
# 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.
|
||||
# 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
|
||||
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)
|
||||
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.
|
||||
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=$!
|
||||
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
|
||||
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 "$HOLD"
|
||||
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-$STORY.mp4"
|
||||
ffmpeg -loglevel error -y -i "$OUT/enclosure-demo-$STORY.mp4" \
|
||||
-map "[v]" -c:v libx264 -preset slow -crf 20 -pix_fmt yuv420p -movflags +faststart "$OUT/real-time.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" \
|
||||
"$OUT/enclosure-demo-$STORY.gif"
|
||||
ls -la "$OUT"/enclosure-demo-$STORY.*
|
||||
"$OUT/$NAME.gif"
|
||||
ls -la "$OUT/$NAME.mp4" "$OUT/$NAME.gif"
|
||||
Reference in new issue
Block a user