diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index aac9852..2288f46 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -23,9 +23,11 @@ jobs: python-version: "3.13" cache: pip - name: Install project - run: python -m pip install --upgrade pip && python -m pip install -e ".[dev]" + run: python -m pip install --upgrade pip && python -m pip install -e ".[dev,demo]" - name: Lint and format run: ruff check . && ruff format --check . + - name: Rebuild README demo + run: python scripts/render_readme_demo.py --output .test-tmp-ci-demo/creator-toolkit-demo.gif - name: Audit dependencies run: python -m pip_audit - name: Build and inspect distributions diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e0cfa5..e9f505f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ All notable changes to this project are documented in this file. The format is b - Safe rename previews, confirmation, recovery manifests, and an `undo` command. - Machine-readable JSON output for every scriptable subcommand. - Installed CLI smoke coverage on Windows, macOS, and Linux. +- Reproducible animated README demo with real title, tag, and rename-preview workflows. - Python 3.11–3.13 test matrix, linting, coverage, and distribution checks. ### Changed diff --git a/MANIFEST.in b/MANIFEST.in index fc2bedf..9a29abd 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1 +1,2 @@ -recursive-include docs *.md +include scripts/render_readme_demo.py +recursive-include docs *.gif *.md diff --git a/README.md b/README.md index c90f469..ac30425 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,27 @@ A lightweight Python CLI for repeatable creator workflows. Generate content ideas and tags, or normalize a folder of image names from an interactive menu or scriptable subcommands. +![Animated terminal demo showing title generation, tag selection, and a dry-run image rename preview](docs/assets/creator-toolkit-demo.gif) + +The demo uses disposable sample files. Title and tag suggestions vary during normal use. + +## Why Creator Toolkit? + +- Preview filename changes before touching a directory, then undo applied renames from a manifest. +- Automate every scriptable workflow with stable JSON schemas and meaningful exit codes. +- Get the same CLI behavior on current Windows, macOS, and Linux systems. +- Keep small creator tasks local, inspectable, and easy to compose in scripts. + +## Real workflows + +| Goal | Before | Example result | +| --- | --- | --- | +| Draft a title | `creator workflow` | `10 Creator Workflow Tips Every Beginner Should Know` | +| Pick three tags | `--count 3` | `automation`, `productivity`, `tutorial` | +| Preview image renames | `cover.jpg`, `thumbnail.png` | `image_1.jpg`, `image_2.png` — no files changed | + +See the [demo transcript and reproduction steps](docs/demo.md). + ## Features - Generate title ideas from a keyword. diff --git a/docs/assets/creator-toolkit-demo.gif b/docs/assets/creator-toolkit-demo.gif new file mode 100644 index 0000000..fe239ad Binary files /dev/null and b/docs/assets/creator-toolkit-demo.gif differ diff --git a/docs/demo.md b/docs/demo.md new file mode 100644 index 0000000..fd38559 --- /dev/null +++ b/docs/demo.md @@ -0,0 +1,37 @@ +# README demo + +The animated README demo is generated from the current Creator Toolkit source. It calls the same +CLI entry logic shown to users, creates two non-sensitive sample image files in a temporary +directory, and removes that directory when rendering finishes. + +## Rebuild the asset + +From the repository root: + +```bash +python -m pip install -e ".[demo]" +python scripts/render_readme_demo.py +``` + +The renderer writes `docs/assets/creator-toolkit-demo.gif`. Title and tag examples use a fixed +seed in the renderer so reviews get a stable visual diff; normal CLI suggestions still vary +between runs. + +## Demo transcript + +```console +$ creator-toolkit title "creator workflow" +10 Creator Workflow Tips Every Beginner Should Know + +$ creator-toolkit tags --count 3 +automation +productivity +tutorial + +$ creator-toolkit rename demo-images --dry-run +Planned 2 image(s). +cover.jpg -> image_1.jpg +thumbnail.png -> image_2.png +``` + +The final command is a preview. It does not change either sample filename. diff --git a/pyproject.toml b/pyproject.toml index cfed180..96f3125 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,9 @@ classifiers = [ ] [project.optional-dependencies] +demo = [ + "Pillow>=12.0", +] dev = [ "build>=1.2.2", "pip-audit>=2.9", diff --git a/scripts/render_readme_demo.py b/scripts/render_readme_demo.py new file mode 100644 index 0000000..11af689 --- /dev/null +++ b/scripts/render_readme_demo.py @@ -0,0 +1,195 @@ +"""Render the animated README demo from real Creator Toolkit CLI output.""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / "src" +DEFAULT_OUTPUT = ROOT / "docs" / "assets" / "creator-toolkit-demo.gif" +WIDTH = 900 +HEIGHT = 506 +SEED = 2026 + +BACKGROUND = "#0d1117" +PANEL = "#161b22" +BORDER = "#30363d" +TEXT = "#e6edf3" +MUTED = "#8b949e" +GREEN = "#3fb950" +BLUE = "#58a6ff" +PURPLE = "#a371f7" + +Line = tuple[str, str] + + +def _font(size: int, *, bold: bool = False) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: + """Load a readable system monospace font on the supported platforms.""" + filename = "consolab.ttf" if bold else "consola.ttf" + candidates = [ + Path(os.environ.get("WINDIR", "C:/Windows")) / "Fonts" / filename, + Path( + "/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf" + if bold + else "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf" + ), + Path("/System/Library/Fonts/Menlo.ttc"), + ] + for candidate in candidates: + if candidate.is_file(): + return ImageFont.truetype(str(candidate), size=size) + return ImageFont.load_default(size=size) + + +def _run_cli(arguments: list[str], *, cwd: Path) -> list[str]: + """Run the current source tree with a stable seed and return its output lines.""" + environment = os.environ.copy() + current_pythonpath = environment.get("PYTHONPATH") + environment["PYTHONPATH"] = os.pathsep.join( + value for value in (str(SOURCE), current_pythonpath) if value + ) + runner = ( + "import random, sys; " + f"random.seed({SEED}); " + "from creator_toolkit.main import main; " + "raise SystemExit(main(sys.argv[1:]))" + ) + result = subprocess.run( + [sys.executable, "-c", runner, *arguments], + cwd=cwd, + env=environment, + check=True, + capture_output=True, + text=True, + ) + return result.stdout.strip().splitlines() + + +def _capture_scenes() -> list[tuple[str, list[Line]]]: + """Capture title, tag, and safe-rename scenes from non-sensitive sample data.""" + with tempfile.TemporaryDirectory(prefix="creator-toolkit-demo-") as temporary_directory: + workspace = Path(temporary_directory) + images = workspace / "demo-images" + images.mkdir() + (images / "cover.jpg").write_bytes(b"demo") + (images / "thumbnail.png").write_bytes(b"demo") + + title = _run_cli(["title", "creator workflow"], cwd=workspace) + tags = _run_cli(["tags", "--count", "3"], cwd=workspace) + rename = _run_cli(["rename", "demo-images", "--dry-run"], cwd=workspace) + + return [ + ( + "TITLE IDEAS", + [ + ("prompt", '$ creator-toolkit title "creator workflow"'), + *(("output", line) for line in title), + ("blank", ""), + ("note", "Turn one keyword into a ready-to-refine headline."), + ], + ), + ( + "CURATED TAGS", + [ + ("prompt", "$ creator-toolkit tags --count 3"), + *(("output", line) for line in tags), + ("blank", ""), + ("note", "Unique suggestions, sorted for predictable output."), + ], + ), + ( + "SAFE RENAME PREVIEW", + [ + ("muted", "demo-images/"), + ("muted", " cover.jpg"), + ("muted", " thumbnail.png"), + ("blank", ""), + ("prompt", "$ creator-toolkit rename demo-images --dry-run"), + *(("output", line) for line in rename), + ("blank", ""), + ("success", "Preview only - no files changed."), + ], + ), + ] + + +def _draw_frame(label: str, lines: list[Line], step: int, total: int) -> Image.Image: + """Draw one terminal-style frame.""" + image = Image.new("RGB", (WIDTH, HEIGHT), BACKGROUND) + draw = ImageDraw.Draw(image) + body_font = _font(22) + body_bold = _font(22, bold=True) + small_font = _font(16) + + draw.rounded_rectangle((18, 18, WIDTH - 18, HEIGHT - 18), radius=14, fill=PANEL, outline=BORDER) + for x, color in ((42, "#ff5f56"), (66, "#ffbd2e"), (90, "#27c93f")): + draw.ellipse((x - 6, 38, x + 6, 50), fill=color) + draw.text((WIDTH // 2, 44), "creator-toolkit - demo", font=small_font, fill=MUTED, anchor="mm") + draw.line((19, 68, WIDTH - 19, 68), fill=BORDER, width=1) + + draw.rounded_rectangle((42, 86, 42 + 20 + len(label) * 10, 118), radius=16, fill="#21262d") + draw.text((54, 102), label, font=small_font, fill=PURPLE, anchor="lm") + + colors = { + "prompt": GREEN, + "output": TEXT, + "muted": MUTED, + "note": BLUE, + "success": GREEN, + "blank": TEXT, + } + y = 142 + for kind, line in lines: + font = body_bold if kind in {"prompt", "success"} else body_font + prefix = "[ok] " if kind == "success" else "" + draw.text((48, y), prefix + line, font=font, fill=colors[kind]) + y += 31 + + footer = "safe previews | JSON output | undo manifests" + draw.text((44, HEIGHT - 41), footer, font=small_font, fill=MUTED, anchor="lm") + draw.text( + (WIDTH - 44, HEIGHT - 41), f"{step}/{total}", font=small_font, fill=MUTED, anchor="rm" + ) + return image + + +def render(output: Path) -> None: + """Capture CLI output and write the optimized looping GIF.""" + scenes = _capture_scenes() + frames = [ + _draw_frame(label, lines, step=index, total=len(scenes)) + for index, (label, lines) in enumerate(scenes, start=1) + ] + palette_frames = [ + frame.quantize(colors=64, method=Image.Quantize.MEDIANCUT) for frame in frames + ] + output.parent.mkdir(parents=True, exist_ok=True) + palette_frames[0].save( + output, + save_all=True, + append_images=palette_frames[1:], + duration=[2200, 2200, 3200], + loop=0, + optimize=True, + disposal=2, + ) + + +def main() -> None: + """Parse arguments and render the README demo.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) + arguments = parser.parse_args() + render(arguments.output.resolve()) + print(f"Rendered {arguments.output.resolve()}") + + +if __name__ == "__main__": + main()