Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
41bab1a
feat: add WebpDataUrlOutputProvider class with basic file creation
czl9707 Feb 11, 2026
2cd5877
fix: resolve code quality issues in webp_dataurl_provider.py
czl9707 Feb 11, 2026
8972561
test: add injection mode test for WebpDataUrlOutputProvider
czl9707 Feb 11, 2026
d4f4fb6
test: add append mode test for WebpDataUrlOutputProvider
czl9707 Feb 11, 2026
fd5a5df
test: add empty frames test for WebpDataUrlOutputProvider
czl9707 Feb 11, 2026
7b3efe2
test: add multiple markers test for WebpDataUrlOutputProvider
czl9707 Feb 11, 2026
72d2c67
feat: export WebpDataUrlOutputProvider from output module
czl9707 Feb 12, 2026
24b4f9d
feat: add --write-dataurl-to CLI option with mutual exclusivity valid…
czl9707 Feb 12, 2026
cc0247a
test: add integration test for --write-dataurl-to flag
czl9707 Feb 12, 2026
886780b
simplify cli
czl9707 Feb 12, 2026
f6208b9
refactor: add write() method to OutputProvider to eliminate duplication
czl9707 Feb 12, 2026
0c52569
refactor: unify output generation in single _generate_output function
czl9707 Feb 12, 2026
af0d87c
refractor
czl9707 Feb 12, 2026
7d08457
refactor: resolve provider in main(), pass to _generate_output()
czl9707 Feb 12, 2026
fdf6f10
clean up
czl9707 Feb 12, 2026
e4a6371
feat: output HTML img tag instead of raw data URL
czl9707 Feb 12, 2026
99a67d5
docs: update CLAUDE.md with --write-dataurl-to flag and output providers
czl9707 Feb 12, 2026
e0bd4d7
docs: add --write-dataurl-to flag documentation
czl9707 Feb 12, 2026
2addcb2
docs: add --write-dataurl-to flag to README
czl9707 Feb 12, 2026
fb19305
update action.yml
czl9707 Feb 12, 2026
9d927d6
fix dataurl behavior
czl9707 Feb 12, 2026
008aa25
fix minor README
czl9707 Feb 12, 2026
a214426
minor default bugs
czl9707 Feb 12, 2026
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
15 changes: 15 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ This is a CLI tool that transforms GitHub contribution graphs into animated spac
### Core Flow

1. **CLI (`cli.py`)** - Typer-based entry point that orchestrates the pipeline
- Options:
- `--write-dataurl-to` / `-wdt` - Generate WebP as data URL in HTML `<img>` tag and write to text file
- `--output` / `-o` - Generate animated visualization (GIF or WebP)
- `--write-dataurl-to` and `--output` are mutually exclusive
2. **GitHubClient (`github_client.py`)** - Fetches contribution data via GitHub GraphQL API, returns typed `ContributionData` dict
3. **Animator (`game/animator.py`)** - Main game loop that coordinates strategy execution and frame generation
4. **GameState (`game/game_state.py`)** - Central state container holding ship, enemies, bullets, explosions
Expand All @@ -73,6 +77,17 @@ Drawables: `Ship`, `Enemy`, `Bullet`, `Explosion`, `Starfield`

The `RenderContext` (`game/render_context.py`) holds theming (colors, cell sizes, padding) and coordinate conversion helpers.

### Output Providers

Output providers encode frames to different formats for writing:
- **`GifOutputProvider`** - Animated GIF format
- **`WebPOutputProvider`** - Animated WebP format
- **`WebpDataUrlOutputProvider`** - HTML `<img>` tag with WebP data URL for direct embedding

Each provider implements:
- `encode(frames, frame_duration) -> bytes` - Encode frames to output format
- `write(path, data) -> None` - Write encoded data to file (providers store path from constructor)

### Animation Loop

In `Animator._generate_frames()`:
Expand Down
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ jobs:
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
output-path: 'game.gif'
# write-dataurl-to: 'README.md' # for dataurl generation.
strategy: 'random'
```

Expand All @@ -49,6 +50,7 @@ Then display it in your README:
- `output-path` (optional): Where to save the animation, supports `.gif` or `.webp` (default: `gh-space-shooter.gif`)
- `strategy` (optional): Attack pattern - `column`, `row`, or `random` (default: `random`)
- `fps` (optional): Frames per second for the animation (default: `40`)
- `write-dataurl-to` (optional): Write WebP as HTML `<img>` data URL to text file
- `commit-message` (optional): Commit message for the update

### From PyPI
Expand Down Expand Up @@ -128,6 +130,34 @@ This creates an animated GIF showing:
- Smooth animations with randomized particle effects
- Your contribution stats displayed in the console

### Generate Data URL (for embedding in HTML/Markdown)

For direct embedding in READMEs or HTML files, use `--write-dataurl-to` to generate a WebP data URL wrapped in an HTML `<img>` tag:

```bash
# Generate data URL and write to README.md
gh-space-shooter torvalds --write-dataurl-to README.md

# Short form
gh-space-shooter torvalds -wdt README.md
```

**Using section markers:** To use this feature, add section markers to your file where you want the game to appear:

```markdown
# My Profile

<!--START_SECTION:space-shooter-->
<!--END_SECTION:space-shooter-->

## About Me
```

This will:
- **Create new files** with content wrapped in section markers, if file not present.
- **Replace content** between existing markers (preserving surrounding content)
- **Raise error** if markers are missing or in wrong order (no silent fallback)

### Advanced Options

```bash
Expand All @@ -139,6 +169,9 @@ gh-space-shooter --raw-input data.json --output game.webp

# Combine options
gh-space-shooter torvalds -o game.webp -ro data.json -s column

# Generate data URL with custom strategy
gh-space-shooter torvalds -wdt README.md -s column
```

### Data Format
Expand Down
38 changes: 27 additions & 11 deletions action.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: 'GitHub Space Shooter'
description: 'Transform your GitHub contribution graph into an space shooter game GIF'
description: 'Transform your GitHub contribution graph into a space shooter game GIF or embeddable HTML'
author: 'zane'

branding:
Expand All @@ -26,15 +26,18 @@ inputs:
description: 'Frames per second for the animation (default: 40)'
required: false
default: '40'
write-dataurl-to:
description: 'Write WebP as HTML <img> data URL to text file (mutually exclusive with output-path)'
required: false
commit-message:
description: 'Commit message for the GIF update'
required: false
default: 'Update space shooter game GIF'

outputs:
gif-path:
description: 'Path to the generated GIF file'
value: ${{ steps.generate.outputs.gif-path }}
output-file:
description: 'Path to the generated output file (GIF, WebP, or HTML data URL file)'
value: ${{ steps.generate.outputs.output-file }}

runs:
using: 'composite'
Expand All @@ -50,22 +53,35 @@ runs:
python -m pip install --upgrade pip
pip install gh-space-shooter

- name: Generate game GIF
- name: Generate game
id: generate
shell: bash
env:
GH_TOKEN: ${{ inputs.github-token }}
run: |
gh-space-shooter ${{ inputs.username }} \
--output ${{ inputs.output-path }} \
--strategy ${{ inputs.strategy }} \
--fps ${{ inputs.fps }}
if [ -n "${{ inputs.write-dataurl-to }}" ]; then
gh-space-shooter ${{ inputs.username }} \
--write-dataurl-to ${{ inputs.write-dataurl-to }} \
--strategy ${{ inputs.strategy }} \
--fps ${{ inputs.fps }}
echo "output-file=${{ inputs.write-dataurl-to }}" >> $GITHUB_OUTPUT
else
gh-space-shooter ${{ inputs.username }} \
--output ${{ inputs.output-path }} \
--strategy ${{ inputs.strategy }} \
--fps ${{ inputs.fps }}
echo "output-file=${{ inputs.output-path }}" >> $GITHUB_OUTPUT
fi

- name: Commit and push GIF
- name: Commit and push
shell: bash
run: |
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git add ${{ inputs.output-path }}
if [ -n "${{ inputs.write-dataurl-to }}" ]; then
git add ${{ inputs.write-dataurl-to }}
else
git add ${{ inputs.output-path }}
fi
git diff --staged --quiet || git commit -m "${{ inputs.commit-message }}"
git push
120 changes: 83 additions & 37 deletions src/gh_space_shooter/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@
from rich.console import Console

from .constants import DEFAULT_FPS
from .game.strategies.base_strategy import BaseStrategy
from .console_printer import ContributionConsolePrinter
from .game import Animator, ColumnStrategy, RandomStrategy, RowStrategy
from .game import Animator, ColumnStrategy, RandomStrategy, RowStrategy, BaseStrategy
from .github_client import ContributionData, GitHubAPIError, GitHubClient
from .output import resolve_output_provider
from .output import OutputProvider, WebpDataUrlOutputProvider

# Load environment variables from .env file
load_dotenv()
Expand Down Expand Up @@ -51,6 +51,11 @@ def main(
"-o",
help="Generate animated visualization (GIF or WebP)",
),
write_dataurl_to: str = typer.Option(
None,
"--write-dataurl-to",
help="Generate WebP as data URL and write to text file",
),
strategy: str = typer.Option(
"random",
"--strategy",
Expand Down Expand Up @@ -89,8 +94,15 @@ def main(
try:
if not username:
raise CLIError("Username is required")
if not out:

# Validate mutual exclusivity of output options
if out and write_dataurl_to:
raise CLIError(
"Cannot specify both --output and --write-dataurl-to. Choose one."
)
if not out and not write_dataurl_to:
out = f"{username}-gh-space-shooter.gif"

# Load data from file or GitHub
if raw_input:
data = _load_data_from_file(raw_input)
Expand All @@ -107,7 +119,10 @@ def main(
_save_data_to_file(data, raw_output)

# Generate output if requested
_generate_output(data, out, strategy, fps, watermark, max_frames)
if write_dataurl_to or out:
output_path = write_dataurl_to or out
provider = _resolve_provider(output_path, bool(write_dataurl_to))
_generate_output(data, provider, strategy, fps, watermark, max_frames)

except CLIError as e:
err_console.print(f"[bold red]Error:[/bold red] {e}")
Expand Down Expand Up @@ -163,58 +178,89 @@ def _save_data_to_file(data: ContributionData, file_path: str) -> None:
raise CLIError(f"Failed to save file '{file_path}': {e}")


def _resolve_provider(file_path: str, is_dataurl: bool) -> OutputProvider:
"""
Resolve the appropriate output provider based on file path and mode.
"""
try:
if is_dataurl:
return WebpDataUrlOutputProvider(file_path)
else:
return resolve_output_provider(file_path)
except ValueError as e:
raise CLIError(str(e))


def _setup_animator(strategy_name: str, data: ContributionData, fps: int, watermark: bool) -> Animator:
"""
Set up strategy and animator.
"""
if strategy_name == "column":
strategy: BaseStrategy = ColumnStrategy()
elif strategy_name == "row":
strategy = RowStrategy()
elif strategy_name == "random":
strategy = RandomStrategy()
else:
raise CLIError(
f"Unknown strategy '{strategy_name}'. Available: column, row, random"
)

return Animator(data, strategy, fps=fps, watermark=watermark)


def _generate_output(
data: ContributionData,
file_path: str,
provider: OutputProvider,
strategy_name: str,
fps: int,
watermark: bool,
max_frames: int | None,
) -> None:
"""Generate animation in the format specified by file_path extension."""

"""
Generate output using the provided provider.

Args:
data: Contribution data from GitHub
provider: Output provider (already resolved with path)
strategy_name: Name of the strategy (column, row, random)
fps: Frames per second
watermark: Whether to add watermark
max_frames: Maximum number of frames to generate

Raises:
CLIError: If output generation fails
"""
# Warn about GIF FPS limitation
if file_path.endswith(".gif") and fps > 50:
if provider.path.endswith(".gif") and fps > 50:
console.print(
f"[yellow]Warning:[/yellow] FPS > 50 may not display correctly in browsers "
f"(GIF delay will be {1000 // fps}ms, but browsers clamp delays < 20ms to ~100ms)"
)

ext = Path(file_path).suffix[1:].upper() # Remove dot and uppercase
console.print(f"\n[bold blue]Generating {ext} animation...[/bold blue]")

# Resolve strategy
if strategy_name == "column":
strategy: BaseStrategy = ColumnStrategy()
elif strategy_name == "row":
strategy = RowStrategy()
elif strategy_name == "random":
strategy = RandomStrategy()
# Print generation message
if isinstance(provider, WebpDataUrlOutputProvider):
console.print("\n[bold blue]Generating WebP data URL...[/bold blue]")
else:
raise CLIError(
f"Unknown strategy '{strategy_name}'. Available: column, row, random"
)
ext = Path(provider.path).suffix[1:].upper()
console.print(f"\n[bold blue]Generating {ext} animation...[/bold blue]")

# Resolve output provider
try:
provider = resolve_output_provider(file_path)
except ValueError as e:
raise CLIError(str(e))
# Setup strategy and animator
animator = _setup_animator(strategy_name, data, fps, watermark)

# Generate animation
# Encode and write
try:
animator = Animator(data, strategy, fps=fps, watermark=watermark)
encoded = provider.encode(
animator.generate_frames(max_frames),
frame_duration=1000 // fps)
encoded = provider.encode(animator.generate_frames(max_frames), 1000 // fps)
provider.write(encoded)

console.print(f"[bold blue]Saving to {file_path}...[/bold blue]")
with open(file_path, "wb") as f:
f.write(encoded)

console.print(f"[green]✓[/green] {ext} saved to {file_path}")
# Console output based on provider type
if isinstance(provider, WebpDataUrlOutputProvider):
console.print(f"[green]✓[/green] Data URL written to {provider.path}")
else:
ext = Path(provider.path).suffix[1:].upper()
console.print(f"[green]✓[/green] {ext} saved to {provider.path}")
except Exception as e:
raise CLIError(f"Failed to generate animation: {e}")
raise CLIError(f"Failed to generate output: {e}")


app = typer.Typer()
Expand Down
13 changes: 9 additions & 4 deletions src/gh_space_shooter/output/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from .base import OutputProvider
from .gif_provider import GifOutputProvider
from .webp_provider import WebPOutputProvider
from .webp_dataurl_provider import WebpDataUrlOutputProvider


# Extension -> Provider class mapping
Expand All @@ -21,8 +22,6 @@ def resolve_output_provider(

Args:
file_path: Output file path (extension determines format)
fps: Frames per second
watermark: Whether to add watermark

Returns:
An OutputProvider instance
Expand All @@ -39,7 +38,13 @@ def resolve_output_provider(
)

provider_class = _PROVIDER_MAP[ext]
return provider_class()
return provider_class(file_path)


__all__ = ["OutputProvider", "GifOutputProvider", "WebPOutputProvider", "resolve_output_provider"]
__all__ = [
"OutputProvider",
"GifOutputProvider",
"WebPOutputProvider",
"WebpDataUrlOutputProvider",
"resolve_output_provider",
]
Loading