Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/python-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion MANIFEST.in
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
recursive-include docs *.md
include scripts/render_readme_demo.py
recursive-include docs *.gif *.md
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

<sub>The demo uses disposable sample files. Title and tag suggestions vary during normal use.</sub>

## 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.
Expand Down
Binary file added docs/assets/creator-toolkit-demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
37 changes: 37 additions & 0 deletions docs/demo.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ classifiers = [
]

[project.optional-dependencies]
demo = [
"Pillow>=12.0",
]
dev = [
"build>=1.2.2",
"pip-audit>=2.9",
Expand Down
195 changes: 195 additions & 0 deletions scripts/render_readme_demo.py
Original file line number Diff line number Diff line change
@@ -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()