diff --git a/CHANGELOG.md b/CHANGELOG.md index f4af0865..b6c37348 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Changed +- README `docs/media/intro.gif` sits on a photographed green forest desktop (not teal blobs): Terminal chrome, a pointer that walks titlebar → composer → slash / model → Shell, and the signed lock TUI. Local CLI only — no Cortex Cloud handoff in the banner story. + ## 0.1.9 ### Changed diff --git a/README.md b/README.md index b3eee7f2..c68c8b28 100644 --- a/README.md +++ b/README.md @@ -18,11 +18,11 @@ cortex.foundation
- + -*Generated macOS-styled demo, not a desktop recording. The green CLI frames -come from the offline `readme_hero` renderer; the desktop and pointer are -composited. No live coding request is performed.* +*Generated macOS desktop demo, not a screen recording. The TUI frames come +from the offline `readme_hero` lock boards; the forest wallpaper, Terminal +chrome and pointer are composited. No live coding request is performed.* Regenerate with `./scripts/render-demo-gif.sh` (Cargo, Python with Pillow, and FFmpeg required); terminal pixels are preserved 1:1 before GIF quantization. @@ -184,8 +184,9 @@ The banner above is generated from this repository, not captured by hand: ./scripts/render-demo-gif.sh ``` -That records the signed lock TUI (splash → typing → working) headlessly -through `generate_tui_demo` and rasterises the frames into `docs/media/intro.gif`. +That records the signed lock TUI (splash → type a prompt → working → slash +palette → `/model` → Shell → composer) headlessly through `generate_tui_demo` +and composites it onto the forest desktop in `docs/media/intro.gif`. ## Release and CI secrets diff --git a/docs/README.md b/docs/README.md index 28b471c5..2c807b0c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,7 +8,7 @@ servers, skills, agents, plugins, hooks, sessions and permissions. New here? Start with **[Getting started](guides/getting-started.md)**, then keep **[CLI reference](reference/cli.md)** open in another tab. - + ## Contents diff --git a/docs/media/intro-preview/composer.png b/docs/media/intro-preview/composer.png new file mode 100644 index 00000000..5a0d56ba Binary files /dev/null and b/docs/media/intro-preview/composer.png differ diff --git a/docs/media/intro-preview/model.png b/docs/media/intro-preview/model.png new file mode 100644 index 00000000..c7d6134d Binary files /dev/null and b/docs/media/intro-preview/model.png differ diff --git a/docs/media/intro-preview/palette.png b/docs/media/intro-preview/palette.png new file mode 100644 index 00000000..492e6715 Binary files /dev/null and b/docs/media/intro-preview/palette.png differ diff --git a/docs/media/intro-preview/shell.png b/docs/media/intro-preview/shell.png new file mode 100644 index 00000000..90b29c24 Binary files /dev/null and b/docs/media/intro-preview/shell.png differ diff --git a/docs/media/intro-preview/splash.png b/docs/media/intro-preview/splash.png new file mode 100644 index 00000000..e33d2041 Binary files /dev/null and b/docs/media/intro-preview/splash.png differ diff --git a/docs/media/intro-preview/working.png b/docs/media/intro-preview/working.png new file mode 100644 index 00000000..87132674 Binary files /dev/null and b/docs/media/intro-preview/working.png differ diff --git a/docs/media/intro.gif b/docs/media/intro.gif index 30f9331f..095aac90 100644 Binary files a/docs/media/intro.gif and b/docs/media/intro.gif differ diff --git a/docs/media/macos-wallpaper-green.jpg b/docs/media/macos-wallpaper-green.jpg new file mode 100644 index 00000000..d9e695f8 Binary files /dev/null and b/docs/media/macos-wallpaper-green.jpg differ diff --git a/scripts/ansi-frames-to-gif.py b/scripts/ansi-frames-to-gif.py index 986b6f25..f5a03949 100755 --- a/scripts/ansi-frames-to-gif.py +++ b/scripts/ansi-frames-to-gif.py @@ -376,6 +376,12 @@ def main() -> int: default=None, help="Write named PNGs from manifest labels to this directory and skip the GIF", ) + parser.add_argument( + "--preview-dir", + type=Path, + default=None, + help="Also write one composed still per storyboard label", + ) args = parser.parse_args() manifest_path = args.frames / "manifest.json" @@ -420,6 +426,23 @@ def main() -> int: ) macos = importlib.util.module_from_spec(spec) spec.loader.exec_module(macos) + storyboard = tuple( + label + for entry in manifest["frames"] + for label in [entry.get("label") or Path(entry["file"]).stem] * entry["hold"] + ) + preview_dir = args.preview_dir + previewed: set[str] = set() + preview_labels = { + "splash", + "working", + "palette", + "model", + "shell", + "composer", + } + if preview_dir is not None: + preview_dir.mkdir(parents=True, exist_ok=True) for entry in manifest["frames"]: ansi = (args.frames / entry["file"]).read_text() grid = parse_ansi(ansi, width, height) @@ -432,10 +455,29 @@ def main() -> int: output_index += 1 else: desktop = macos.build_desktop(image, f"Cortex CLI — cortex — {width}×{height}") + label = entry.get("label") or Path(entry["file"]).stem for _ in range(entry["hold"]): - macos.animate_mouse(desktop, output_index, total_frames).save( - png_root / f"{output_index:05d}.png" + frame = macos.animate_mouse( + desktop, + output_index, + total_frames, + label=label, + labels=storyboard, + content_size=image.size, ) + frame.save(png_root / f"{output_index:05d}.png") + if ( + preview_dir is not None + and label in preview_labels + and label not in previewed + ): + safe = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in label) + preview = frame + if preview.width > 1000: + preview_height = round(preview.height * 1000 / preview.width) + preview = preview.resize((1000, preview_height), Image.LANCZOS) + preview.save(preview_dir / f"{safe}.png", optimize=True) + previewed.add(label) output_index += 1 if named_dir is not None: diff --git a/scripts/check-macos-demo.py b/scripts/check-macos-demo.py index 55863e30..2c8e00f5 100644 --- a/scripts/check-macos-demo.py +++ b/scripts/check-macos-demo.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Run with the renderer's Pillow environment: python3 scripts/check-macos-demo.py.""" -import importlib.util import hashlib +import importlib.util from pathlib import Path from PIL import Image, ImageChops @@ -17,30 +17,73 @@ raw.putpixel((0, 0), (255, 255, 255)) desktop = macos.build_desktop(raw, "Cortex CLI — cortex — 120×40") assert desktop.size == (1416, 1140), desktop.size +assert macos.WALLPAPER_PATH.is_file(), macos.WALLPAPER_PATH + x = macos.DESKTOP_MARGIN + macos.CONTENT_INSET y = macos.WINDOW_TOP + macos.TITLEBAR_H + macos.CONTENT_INSET -box = (x, y, x+raw.width, y+raw.height) +box = (x, y, x + raw.width, y + raw.height) +assert desktop.crop(box).tobytes() == raw.tobytes(), "CLI pixels must stay 1:1 before the pointer" + +# A photograph has far more unique colours than the retired teal ellipses. +forest = desktop.crop((8, macos.MENUBAR_H + 8, 72, 220)) +assert len({pixel for pixel in forest.getdata()}) > 80, "desktop backdrop is not a forest photograph" + +labels = ( + ("splash",) * 12 + + ("typing",) * 36 + + ("prompt-ready",) * 6 + + ("working",) * 12 + + ("palette",) * 12 + + ("model",) * 10 + + ("shell",) * 12 + + ("composer",) * 10 +) +total = len(labels) +titlebar_hits = 0 +terminal_hits = 0 previous = None -for frame in range(98): - image = macos.animate_mouse(desktop, frame, 98) - assert image.crop(box).tobytes() == raw.tobytes(), f"CLI pixels changed at {frame}" +for frame in range(total): + image = macos.animate_mouse( + desktop, frame, total, label=labels[frame], labels=labels, content_size=raw.size + ) + px, py, _pressed = macos.pointer_pose( + frame, + total, + desktop_size=desktop.size, + content_size=raw.size, + label=labels[frame], + labels=labels, + ) + if macos.WINDOW_TOP <= py <= macos.WINDOW_TOP + macos.TITLEBAR_H: + titlebar_hits += 1 + if box[0] <= px <= box[2] and box[1] <= py <= box[3]: + terminal_hits += 1 if previous is not None: assert ImageChops.difference(previous, image).getbbox(), f"Pointer stalled at {frame}" previous = image -assert macos.animate_mouse(desktop, 0, 98).tobytes() == macos.animate_mouse(desktop, 98, 98).tobytes() + +assert titlebar_hits >= 2, f"pointer never visited the titlebar ({titlebar_hits})" +assert terminal_hits >= 8, f"pointer never entered the terminal ({terminal_hits})" +assert macos.animate_mouse(desktop, 0, total, labels=labels).tobytes() == macos.animate_mouse( + desktop, total, total, labels=labels +).tobytes() gif = Path(__file__).resolve().parents[1] / "docs/media/intro.gif" -assert gif.stat().st_size < 5 * 1024 * 1024 +assert gif.stat().st_size < 5 * 1024 * 1024, gif.stat().st_size with Image.open(gif) as image: - assert image.size == desktop.size - assert image.n_frames == 98, image.n_frames + assert image.size == desktop.size, image.size + n_frames = image.n_frames + assert 100 <= n_frames <= 160, n_frames assert image.info["loop"] == 0 pixels = set() duration = 0 - for frame in range(image.n_frames): + for frame in range(n_frames): image.seek(frame) pixels.add(hashlib.sha256(image.convert("RGB").tobytes()).digest()) duration += image.info["duration"] - assert len(pixels) == 98, len(pixels) + assert len(pixels) >= 70, len(pixels) assert 8000 <= duration <= 15000, duration -print("PASS: desktop dimensions, exact CLI pixels, moving looping pointer, 98 unique GIF frames, duration and <5 MiB") +print( + "PASS: photo wallpaper, exact CLI pixels, titlebar+terminal pointer, " + f"{n_frames} GIF frames, duration and <5 MiB" +) diff --git a/scripts/compose-macos-terminal.py b/scripts/compose-macos-terminal.py index d00a8c69..48f20c50 100644 --- a/scripts/compose-macos-terminal.py +++ b/scripts/compose-macos-terminal.py @@ -2,7 +2,8 @@ """Composite raw TUI captures into generated macOS-styled chrome. The CLI below retains window-only lock captures. The README rasteriser uses -build_desktop and animate_mouse for wallpaper, menu bar, Dock and pointer. +build_desktop and animate_mouse for a photographed desktop, menu bar, Dock +and a pointer that walks the TUI storyboard. Each REAL capture from `docs/media/tui-lock/{40x12,120x40}/` is pasted 1:1 under a Terminal.app title bar (traffic lights, `cortex-api — cortex — W×H` @@ -183,66 +184,288 @@ def compose(raw_png: Path, out_png: Path, title: str) -> tuple[int, int]: window.save(out_png) return window.size -# ponytail: procedural macOS-styled chrome, not an OS recording; use a real -# desktop capture only when demonstrating native OS interactions. +# Photographed desktop for the README GIF. Lock captures stay window-only +# (see `compose`); this chrome is only composited onto `intro.gif`. DESKTOP_MARGIN = 80 WINDOW_TOP = 76 CONTENT_INSET = 12 +MENUBAR_H = 27 +# Green forest photo, free license (Unsplash sunlight-through-trees still). +WALLPAPER_PATH = Path(__file__).resolve().parents[1] / "docs/media/macos-wallpaper-green.jpg" +CLICK_LABELS = frozenset({"prompt-ready", "palette", "model", "composer"}) -@lru_cache(maxsize=2) -def desktop_backdrop(content_size: tuple[int, int]) -> Image.Image: - """Static desktop and shadow; never paint over the terminal capture.""" + +def desktop_metrics(content_size: tuple[int, int]) -> tuple[int, int, int, int]: + """Return (desktop_w, desktop_h, window_w, window_h) for a 1:1 capture.""" cw, ch = content_size ww, wh = cw + 2 * CONTENT_INSET, ch + 2 * CONTENT_INSET + TITLEBAR_H - width, height = ww + 2 * DESKTOP_MARGIN, wh + WINDOW_TOP + 100 - desktop = Image.new("RGB", (width, height)) + return ww + 2 * DESKTOP_MARGIN, wh + WINDOW_TOP + 100, ww, wh + + +def cover_crop(image: Image.Image, size: tuple[int, int]) -> Image.Image: + """Scale-to-cover and center-crop `image` to `size`.""" + target_w, target_h = size + src_w, src_h = image.size + scale = max(target_w / src_w, target_h / src_h) + new_w = max(target_w, round(src_w * scale)) + new_h = max(target_h, round(src_h * scale)) + resized = image.resize((new_w, new_h), Image.LANCZOS) + left = (new_w - target_w) // 2 + top = (new_h - target_h) // 2 + return resized.crop((left, top, left + target_w, top + target_h)) + + +def _frosted(photo: Image.Image, box: tuple[int, int, int, int], darken: float) -> Image.Image: + x0, y0, x1, y1 = box + strip = photo.crop((x0, y0, x1, y1)) + width, height = strip.size + small = strip.resize((max(1, width // 12), max(1, height // 8)), Image.BILINEAR) + blur = small.resize((width, height), Image.BILINEAR) + overlay = Image.new("RGB", (width, height), (20, 22, 24)) + return Image.blend(blur, overlay, darken) + + +def _paint_menubar(desktop: Image.Image, photo: Image.Image) -> None: + width, _ = desktop.size + frost = _frosted(photo, (0, 0, width, MENUBAR_H), 0.48) + desktop.paste(frost, (0, 0)) draw = ImageDraw.Draw(desktop) - for y in range(height): - t = y / height - draw.line((0, y, width, y), fill=(round(18 + 22*t), round(43 + 40*t), round(58 + 27*t))) - draw.ellipse((-width//2, height//3, width, height*2), fill=(38, 103, 103)) - draw.ellipse((width//3, height//2, width*2, height*2), fill=(62, 133, 119)) - draw.ellipse((width//2, height*3//4, width*2, height*2), fill=(96, 158, 130)) - draw.rectangle((0, 0, width, 27), fill=(22, 37, 43)) + draw.line((0, MENUBAR_H - 1, width, MENUBAR_H - 1), fill=(255, 255, 255, 28)) ui_font = font(SANS_REGULAR, 13) - draw.text((20, 6), "● Terminal Shell Edit View Window Help", font=ui_font, fill=(238, 243, 241)) - draw.text((width-207, 6), "Wi-Fi 100% Mon 9:41", font=ui_font, fill=(238, 243, 241)) - - dock_x, dock_y = width//2 - 133, height-76 - draw.rounded_rectangle((dock_x, dock_y, dock_x+266, height-12), radius=18, - fill=(88, 125, 123), outline=(147, 174, 166)) - for i, (label, colour) in enumerate((("Finder", (69, 154, 226)), ("Web", (62, 153, 177)), - (">_", (27, 29, 32)), ("Files", (72, 151, 216)))): - x = dock_x + 13 + i*61 - draw.rounded_rectangle((x, dock_y+8, x+48, dock_y+51), radius=10, fill=colour) - draw.text((x+24, dock_y+29), label, font=font(SANS_REGULAR, 11 if i != 2 else 22), - fill="white", anchor="mm") - draw.ellipse((dock_x+157, height-20, dock_x+161, height-16), fill=(229, 242, 236)) - shadow = Image.new("RGBA", desktop.size) + # Simple apple mark — is missing from the Linux sans faces we ship. + draw.ellipse((16, 7, 28, 20), fill=(244, 246, 244)) + draw.polygon([(22, 5), (26, 9), (18, 9)], fill=(244, 246, 244)) + draw.text( + (36, 6), + "Terminal Shell Edit View Window Help", + font=ui_font, + fill=(244, 246, 244), + ) + draw.text((width - 188, 6), "Wi-Fi 100% Mon 9:41", font=ui_font, fill=(244, 246, 244)) + + +def _paint_dock(desktop: Image.Image, photo: Image.Image) -> None: + width, height = desktop.size + dock_w, dock_h = 292, 64 + dock_x = (width - dock_w) // 2 + dock_y = height - dock_h - 10 + frost = _frosted(photo, (dock_x, dock_y, dock_x + dock_w, dock_y + dock_h), 0.36) + plate = Image.new("RGBA", (dock_w, dock_h), (0, 0, 0, 0)) + ImageDraw.Draw(plate).rounded_rectangle( + (0, 0, dock_w - 1, dock_h - 1), + radius=18, + fill=(32, 34, 36, 210), + outline=(255, 255, 255, 48), + ) + desktop.paste(frost, (dock_x, dock_y), plate.split()[-1]) + desktop.alpha_composite(plate, (dock_x, dock_y)) + draw = ImageDraw.Draw(desktop) + icons = ( + (69, 154, 226), + (232, 88, 80), + (27, 29, 32), + (88, 168, 92), + (90, 96, 108), + ) + for i, colour in enumerate(icons): + x = dock_x + 14 + i * 56 + y = dock_y + 8 + draw.rounded_rectangle((x, y, x + 46, y + 40), radius=10, fill=colour) + if i == 2: + draw.text((x + 23, y + 20), ">_", font=font(SANS_REGULAR, 16), fill="white", anchor="mm") + draw.ellipse((x + 20, dock_y + dock_h - 9, x + 26, dock_y + dock_h - 4), fill=(236, 240, 236)) + + +def _paint_shadow(desktop: Image.Image, window_w: int, window_h: int) -> Image.Image: + shadow = Image.new("RGBA", desktop.size, (0, 0, 0, 0)) ImageDraw.Draw(shadow).rounded_rectangle( - (DESKTOP_MARGIN, WINDOW_TOP+14, DESKTOP_MARGIN+ww, WINDOW_TOP+wh+14), - radius=CORNER_RADIUS, fill=(0, 0, 0, 155)) - return Image.alpha_composite(desktop.convert("RGBA"), shadow.filter(ImageFilter.GaussianBlur(18))) + (DESKTOP_MARGIN, WINDOW_TOP + 14, DESKTOP_MARGIN + window_w, WINDOW_TOP + window_h + 14), + radius=CORNER_RADIUS, + fill=(0, 0, 0, 155), + ) + return Image.alpha_composite(desktop, shadow.filter(ImageFilter.GaussianBlur(18))) + + +@lru_cache(maxsize=2) +def desktop_backdrop(content_size: tuple[int, int]) -> Image.Image: + """Photographed desktop, menu bar, Dock and window shadow — never the TUI.""" + if not WALLPAPER_PATH.is_file(): + raise SystemExit(f"Missing wallpaper photo: {WALLPAPER_PATH}") + width, height, window_w, window_h = desktop_metrics(content_size) + photo = cover_crop(Image.open(WALLPAPER_PATH).convert("RGB"), (width, height)) + desktop = photo.convert("RGBA") + _paint_menubar(desktop, photo) + _paint_dock(desktop, photo) + return _paint_shadow(desktop, window_w, window_h) + def build_desktop(content: Image.Image, title: str) -> Image.Image: """Inset preserves every capture pixel, including the rounded corners.""" - padded = Image.new("RGB", (content.width+2*CONTENT_INSET, content.height+2*CONTENT_INSET)) + padded = Image.new("RGB", (content.width + 2 * CONTENT_INSET, content.height + 2 * CONTENT_INSET)) padded.paste(content, (CONTENT_INSET, CONTENT_INSET)) desktop = desktop_backdrop(content.size).copy() desktop.alpha_composite(build_window(padded, title), (DESKTOP_MARGIN, WINDOW_TOP)) return desktop.convert("RGB") -def animate_mouse(desktop: Image.Image, frame: int, total: int) -> Image.Image: - """Smooth looping pointer in the desktop gutter; no obscured CLI pixels.""" - phase = 2 * math.pi * frame / max(1, total) - x = round(desktop.width - 51 + 15 * math.sin(phase)) - y = round(118 + (desktop.height - 264) * (1 - math.cos(phase)) / 2) - image = desktop.copy() - draw = ImageDraw.Draw(image) - points = [(x, y), (x, y+23), (x+6, y+17), (x+11, y+27), - (x+15, y+25), (x+10, y+15), (x+19, y+15)] + +def _lerp(a: tuple[int, int], b: tuple[int, int], t: float) -> tuple[int, int]: + t = max(0.0, min(1.0, t)) + return round(a[0] + (b[0] - a[0]) * t), round(a[1] + (b[1] - a[1]) * t) + + +def pointer_anchors( + content_size: tuple[int, int], desktop_size: tuple[int, int] +) -> dict[str, tuple[int, int]]: + """Named hits for the README pointer: titlebar, composer, slash, tools.""" + cw, ch = content_size + dw, _dh = desktop_size + content_x = DESKTOP_MARGIN + CONTENT_INSET + content_y = WINDOW_TOP + TITLEBAR_H + CONTENT_INSET + window_w = cw + 2 * CONTENT_INSET + return { + "rest": (dw - 51, 118), + "titlebar": (DESKTOP_MARGIN + window_w // 2, WINDOW_TOP + TITLEBAR_H // 2), + "composer": (content_x + 88, content_y + max(48, int(ch * 0.12))), + "palette": (content_x + 76, content_y + int(ch * 0.30)), + "model": (content_x + 104, content_y + int(ch * 0.22)), + "working": (content_x + 148, content_y + int(ch * 0.16)), + "shell": (content_x + 128, content_y + int(ch * 0.24)), + } + + +def _label_anchor(label: str) -> str: + if label in {"typing", "prompt-ready", "composer"}: + return "composer" + if label in {"working", "palette", "model", "shell", "titlebar", "rest"}: + return label + if label == "splash": + return "titlebar" + return "rest" + + +def _ease_label_hold( + label: str, index_in_label: int, label_count: int, anchors: dict[str, tuple[int, int]] +) -> tuple[int, int]: + t = index_in_label / max(1, label_count - 1) + if label == "splash": + if t < 0.4: + return _lerp(anchors["rest"], anchors["titlebar"], t / 0.4) + return _lerp(anchors["titlebar"], anchors["composer"], (t - 0.4) / 0.6) + if label == "composer": + return _lerp(anchors["composer"], anchors["rest"], t) + if label == "palette": + return _lerp(anchors["composer"], anchors["palette"], min(1.0, t * 1.4)) + return anchors[_label_anchor(label)] + + +@lru_cache(maxsize=8) +def pointer_track( + labels: tuple[str, ...], + desktop_size: tuple[int, int], + content_size: tuple[int, int], +) -> tuple[tuple[int, int, bool], ...]: + """One (x, y, pressed) per playback frame; last pose matches the first.""" + anchors = pointer_anchors(content_size, desktop_size) + n = len(labels) + if n == 0: + return () + first_of: dict[str, int] = {} + counts: dict[str, int] = {} + for i, label in enumerate(labels): + first_of.setdefault(label, i) + counts[label] = counts.get(label, 0) + 1 + seen: dict[str, int] = {} + poses: list[tuple[int, int, bool]] = [] + for i, label in enumerate(labels): + seen[label] = seen.get(label, 0) + x, y = _ease_label_hold(label, seen[label], counts[label], anchors) + seen[label] += 1 + pressed = label in CLICK_LABELS and first_of[label] <= i < first_of[label] + 2 + poses.append((x, y, pressed)) + + wrap = min(8, max(3, n // 10)) + start = poses[0][:2] + for k in range(wrap): + t = (k + 1) / wrap + i = n - wrap + k + x, y = _lerp(poses[i][:2], start, t) + poses[i] = (x, y, False) + return tuple(poses) + + +def pointer_pose( + frame: int, + total: int, + *, + desktop_size: tuple[int, int], + content_size: tuple[int, int] = (1232, 912), + label: str = "", + labels: tuple[str, ...] | None = None, +) -> tuple[int, int, bool]: + """Looping pointer pose. Optional storyboard labels drive the proud path.""" + if labels: + track = pointer_track(labels, desktop_size, content_size) + x, y, pressed = track[frame % len(track)] + else: + anchors = pointer_anchors(content_size, desktop_size) + tour = ( + anchors["rest"], + anchors["titlebar"], + anchors["composer"], + anchors["palette"], + anchors["working"], + anchors["shell"], + anchors["rest"], + ) + phase = (frame % max(1, total)) / max(1, total) + scaled = phase * (len(tour) - 1) + i = min(len(tour) - 2, int(scaled)) + x, y = _lerp(tour[i], tour[i + 1], scaled - i) + pressed = i in {2, 3} and (scaled - i) < 0.18 + # 10-step hover circle (14px). 110 GIF frames divide by 10, so frame 0 + # and frame `total` match; neighbours are 8–9px apart after rounding. + angle = 2 * math.pi * (frame % 10) / 10 + x += round(14 * math.cos(angle)) + y += round(14 * math.sin(angle)) + _ = label + return x, y, pressed + + +def _draw_pointer(draw: ImageDraw.ImageDraw, x: int, y: int, pressed: bool) -> None: + if pressed: + draw.ellipse((x - 10, y - 10, x + 18, y + 18), outline=(255, 255, 255, 180), width=2) + points = [ + (x, y), + (x, y + 23), + (x + 6, y + 17), + (x + 11, y + 27), + (x + 15, y + 25), + (x + 10, y + 15), + (x + 19, y + 15), + ] draw.polygon(points, fill=(18, 20, 22)) draw.line(points + [points[0]], fill="white", width=2) + + +def animate_mouse( + desktop: Image.Image, + frame: int, + total: int, + label: str = "", + labels: tuple[str, ...] | None = None, + content_size: tuple[int, int] = (1232, 912), +) -> Image.Image: + """Pointer tours titlebar → composer / slash → tools, then arcs back.""" + x, y, pressed = pointer_pose( + frame, + total, + desktop_size=desktop.size, + content_size=content_size, + label=label, + labels=labels, + ) + image = desktop.copy() + _draw_pointer(ImageDraw.Draw(image), x, y, pressed) return image diff --git a/scripts/render-demo-gif.sh b/scripts/render-demo-gif.sh index 3660b6d0..906c3181 100755 --- a/scripts/render-demo-gif.sh +++ b/scripts/render-demo-gif.sh @@ -2,15 +2,16 @@ # Regenerate the README banner at docs/media/intro.gif. # # Two stages: -# 1. generate_tui_demo paints the signed lock TUI (splash → typing → working) -# at 120×40 through cortex-tui. -# 2. scripts/ansi-frames-to-gif.py rasterises those frames, composites a -# macOS-styled desktop and moving pointer, and calls ffmpeg. +# 1. generate_tui_demo paints the signed lock TUI (splash → type → working → +# slash palette → /model → Shell → composer) at 120×40 through cortex-tui. +# 2. scripts/ansi-frames-to-gif.py rasterises those frames, composites the +# green forest wallpaper + Terminal chrome + pointer, and calls ffmpeg. # # Requires: cargo, ffmpeg, python3 with Pillow, and a supported sans font # (Liberation Sans, DejaVu Sans, Noto Sans, or macOS Helvetica/Arial). # Raw raster: 1232×912 at the default 16px font and padding. # Composed GIF: 1416×1140. Validate with python3 scripts/check-macos-demo.py. +# Preview stills: docs/media/intro-preview/. set -euo pipefail @@ -23,5 +24,13 @@ output="${OUTPUT:-docs/media/intro.gif}" echo "==> Recording the lock TUI to $frames_dir" cargo run --quiet -p cortex-tui --bin generate_tui_demo -- --output "$frames_dir" --width 120 --height 40 +preview_dir="${PREVIEW_DIR:-docs/media/intro-preview}" + echo "==> Rasterising frames into $output" -python3 scripts/ansi-frames-to-gif.py --frames "$frames_dir" --output "$output" +python3 scripts/ansi-frames-to-gif.py \ + --frames "$frames_dir" \ + --output "$output" \ + --preview-dir "$preview_dir" + +echo "==> Checking $output" +python3 scripts/check-macos-demo.py diff --git a/src/cortex-tui-capture/src/demo/mod.rs b/src/cortex-tui-capture/src/demo/mod.rs index 062e56dd..d636666d 100644 --- a/src/cortex-tui-capture/src/demo/mod.rs +++ b/src/cortex-tui-capture/src/demo/mod.rs @@ -1,8 +1,9 @@ //! Frame I/O for the README hero recording. //! //! Scene painting lives in `cortex-tui::readme_hero` so the GIF is the signed -//! lock TUI (dual hairline, banner green `>`, splash → typing → working). This -//! module owns the manifest format `scripts/ansi-frames-to-gif.py` consumes. +//! lock TUI (dual hairline, banner green `>`, splash → type → working → slash +//! / model → Shell → composer). This module owns the manifest format +//! `scripts/ansi-frames-to-gif.py` consumes. use std::path::{Path, PathBuf}; diff --git a/src/cortex-tui/src/bin/generate_tui_demo.rs b/src/cortex-tui/src/bin/generate_tui_demo.rs index 7db37432..09f6cd4d 100644 --- a/src/cortex-tui/src/bin/generate_tui_demo.rs +++ b/src/cortex-tui/src/bin/generate_tui_demo.rs @@ -1,7 +1,8 @@ //! Renders the README hero from the signed lock chrome to ANSI frames. //! //! The frames are the input to `scripts/render-demo-gif.sh`, which produces -//! `docs/media/intro.gif` (splash → typing the rate-limit prompt → working). +//! `docs/media/intro.gif` (splash → type → working → slash / model → Shell → +//! composer). //! //! ```bash //! cargo run -p cortex-tui --bin generate_tui_demo -- --output target/tui-demo diff --git a/src/cortex-tui/src/lib.rs b/src/cortex-tui/src/lib.rs index f54d5426..39d979f0 100644 --- a/src/cortex-tui/src/lib.rs +++ b/src/cortex-tui/src/lib.rs @@ -104,6 +104,7 @@ pub mod lock_boards; pub mod lock_proof; pub mod lock_v2; pub mod readme_hero; +pub mod readme_hero_boards; // Backtracking system for conversation history navigation pub mod backtrack; diff --git a/src/cortex-tui/src/lock_boards.rs b/src/cortex-tui/src/lock_boards.rs index 34d4a2f5..1135fdbe 100644 --- a/src/cortex-tui/src/lock_boards.rs +++ b/src/cortex-tui/src/lock_boards.rs @@ -822,40 +822,34 @@ fn paint_launch_header(area: Rect, buf: &mut Buffer, full: bool) -> u16 { // Boards 01–10 // --------------------------------------------------------------------------- +fn paint_splash_chrome(area: Rect, buf: &mut Buffer, composer: Composer<'_>) { + let y = paint_launch_header(area, buf, true); + paint_composer(area, buf, y, composer); + paint_footer(area, buf, &format!("{MODEL} · Agent · 100% context")); +} + fn board_splash(area: Rect, buf: &mut Buffer) { - paint_hero(area, buf, HeroScene::Splash); + paint_splash_chrome(area, buf, Composer::Ghost(GHOST_IDLE)); } fn board_typing(area: Rect, buf: &mut Buffer) { - paint_hero(area, buf, HeroScene::Typing(USER_PROMPT)); + paint_splash_chrome(area, buf, Composer::Typed(USER_PROMPT)); } -/// README hero beat: signed splash, in-progress typing, or the working lock. -#[derive(Debug, Clone, Copy)] -pub enum HeroScene<'a> { - /// Dual-hairline splash with the idle placeholder. - Splash, - /// Splash chrome with `text` in the composer (partial or full prompt). - Typing(&'a str), - /// Submitted prompt plus the working indicator. - Working, +/// README hero typing frames use a partial prompt; lock board 02 uses the full one. +pub(crate) fn paint_typed_splash(area: Rect, buf: &mut Buffer, text: &str) { + paint_splash_chrome(area, buf, Composer::Typed(text)); } -/// Paint one README-hero / lock frame from the signed chrome. -pub fn paint_hero(area: Rect, buf: &mut Buffer, scene: HeroScene<'_>) { - match scene { - HeroScene::Splash => { - let y = paint_launch_header(area, buf, true); - paint_composer(area, buf, y, Composer::Ghost(GHOST_IDLE)); - paint_footer(area, buf, &format!("{MODEL} · Agent · 100% context")); - } - HeroScene::Typing(text) => { - let y = paint_launch_header(area, buf, true); - paint_composer(area, buf, y, Composer::Typed(text)); - paint_footer(area, buf, &format!("{MODEL} · Agent · 100% context")); - } - HeroScene::Working => board_working(area, buf), - } +/// README hero return-to-composer beat: past user turn + idle ghost, no splash. +pub(crate) fn paint_hero_composer(area: Rect, buf: &mut Buffer) { + paint_session( + area, + buf, + user_prompt_lines(area), + &format!("{MODEL} · Agent · 92% context"), + GHOST_IDLE, + ); } const PALETTE_ROWS: &[(&str, &str)] = &[ diff --git a/src/cortex-tui/src/readme_hero.rs b/src/cortex-tui/src/readme_hero.rs index b79bf365..efb28515 100644 --- a/src/cortex-tui/src/readme_hero.rs +++ b/src/cortex-tui/src/readme_hero.rs @@ -1,7 +1,9 @@ -//! README hero GIF: signed lock chrome, splash → typing → working. +//! README hero GIF: signed lock chrome, local TUI command tour. //! //! [`record`] paints the visual-lock boards through [`MockTerminal`] so the -//! banner cannot drift onto the retired welcome-card splash. `scripts/render-demo-gif.sh` +//! banner cannot drift onto the retired welcome-card splash. Sequence is +//! splash → type a prompt → working → slash palette → `/model` → Shell tool +//! row → idle composer. No Cortex Cloud handoff. `scripts/render-demo-gif.sh` //! rasterises the frames into `docs/media/intro.gif`. use cortex_tui_capture::{ @@ -10,7 +12,8 @@ use cortex_tui_capture::{ }; use ratatui::widgets::Clear; -use crate::lock_boards::{self, HeroScene, USER_PROMPT}; +use crate::lock_boards::USER_PROMPT; +use crate::readme_hero_boards::{self, HeroScene}; /// The prompt the hero types — same copy as the typing lock board. pub const HERO_PROMPT: &str = USER_PROMPT; @@ -30,14 +33,14 @@ fn paint_beat(scene: HeroScene<'_>) -> impl FnOnce(&mut ratatui::Frame<'_>) + '_ move |frame| { let area = frame.area(); frame.render_widget(Clear, area); - lock_boards::paint_hero(area, frame.buffer_mut(), scene); + readme_hero_boards::paint_hero(area, frame.buffer_mut(), scene); } } -/// Storyboard: idle splash, type the rate-limit prompt, hold the working lock. +/// Storyboard: splash, type, working, slash / model, a tool row, composer. pub fn storyboard_beats() -> Vec<(String, u32, String)> { let mut beats = Vec::new(); - beats.push(("splash".into(), 18, String::new())); + beats.push(("splash".into(), 12, String::new())); let chars: Vec