Compare commits

...

11 Commits

Author SHA1 Message Date
giles
4bb084154a Add deterministic debug logging to fused pipeline 2026-02-04 12:17:12 +00:00
giles
656738782f Fix database connection pool leak in init_db()
- Check if pool already exists before creating a new one
- Set pool size limits (min=2, max=10) to prevent exhaustion
- Multiple calls to init_db() were creating new pools without
  closing old ones, leading to "too many clients" errors

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 23:00:13 +00:00
giles
0242c0bb22 Add stream command for streaming recipes
- artdag stream <recipe.sexp> runs streaming recipes
- Supports --duration, --fps, --sources, --audio options
- --wait flag polls for completion

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 19:18:57 +00:00
gilesb
3d663950f1 Add plan_cid to run status, change Output Hash to Output
- Display plan CID in run status when available
- Simplified "Output Hash:" label to "Output:"

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 18:43:47 +00:00
gilesb
eb0a38b087 Fix --raw to output raw content to stdout
--raw without -o now streams raw content to stdout instead of showing metadata

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 17:59:59 +00:00
gilesb
1a768370f6 Update README with --raw flag for view command
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 17:53:42 +00:00
gilesb
d69ed09575 Remember server settings between sessions
- Server URLs are now saved to ~/.artdag/config.json when provided
- Subsequent commands use saved settings without needing --server/--l2
- Priority: CLI args > env vars > saved config > defaults
- Added 'artdag config' command to view/clear settings

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 17:28:40 +00:00
gilesb
599f181f8c Remove all ellipsis truncation from CLI output
- Show full CIDs/hashes in runs list, cache list, effects list
- Show full output CIDs in plan steps and artifacts
- Show full CIDs in recipe fixed_inputs
- Use block format for better readability with long IDs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 17:11:50 +00:00
gilesb
efca939c7d Add --raw flag to view command and improve metadata display
- Add --raw/-r flag to fetch from /cache/{cid}/raw endpoint
- Automatically use /raw when outputting to file or stdout
- Show friendly_name, title, filename in metadata view
- Use JSON API for metadata instead of streaming response

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 17:00:53 +00:00
gilesb
b56f4d906c Fix recipe list display and handle missing uploaded_at
- Show full Recipe ID instead of truncated
- Display friendly name with version hash when available
- Make uploaded_at optional to avoid KeyError
- Improve recipe list format with one recipe per block

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 16:57:34 +00:00
gilesb
9e13a4ce3d Fix JSON decode error by adding Accept header to all API requests
The CLI was getting empty responses from the server because it wasn't
sending Accept: application/json header. The server uses content
negotiation and returns HTML for browser requests.

Changes:
- Updated get_auth_header() to always include Accept: application/json
- Simplified all commands to use get_auth_header(require_token=True)
- Removed redundant token_data checks now handled by get_auth_header

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 14:46:55 +00:00
5 changed files with 403 additions and 114 deletions

1
.gitignore vendored
View File

@@ -2,3 +2,4 @@ __pycache__/
*.py[cod] *.py[cod]
.venv/ .venv/
venv/ venv/
.scripts

View File

@@ -117,9 +117,10 @@ artdag upload-effect <filepath>
artdag cache [--limit N] [--offset N] [--type all|image|video|audio] artdag cache [--limit N] [--offset N] [--type all|image|video|audio]
# View/download cached content # View/download cached content
artdag view <cid> # Show info artdag view <cid> # Show metadata (size, type, friendly name)
artdag view <cid> -o output.mp4 # Download to file artdag view <cid> --raw # Get raw content info
artdag view <cid> -o - | mpv - # Pipe to player artdag view <cid> -o output.mp4 # Download raw file
artdag view <cid> -o - | mpv - # Pipe raw content to player
# Upload file to cache and IPFS # Upload file to cache and IPFS
artdag upload <filepath> artdag upload <filepath>

445
artdag.py
View File

@@ -15,14 +15,39 @@ import click
import requests import requests
import yaml import yaml
DEFAULT_SERVER = os.environ.get("ARTDAG_SERVER", "http://localhost:8100")
DEFAULT_L2_SERVER = os.environ.get("ARTDAG_L2", "http://localhost:8200")
CONFIG_DIR = Path.home() / ".artdag" CONFIG_DIR = Path.home() / ".artdag"
TOKEN_FILE = CONFIG_DIR / "token.json" TOKEN_FILE = CONFIG_DIR / "token.json"
CONFIG_FILE = CONFIG_DIR / "config.json"
# Defaults - can be overridden by env vars, config file, or CLI args
_DEFAULT_SERVER = "http://localhost:8100"
_DEFAULT_L2_SERVER = "http://localhost:8200"
# Active server URLs (set during CLI init)
DEFAULT_SERVER = None
DEFAULT_L2_SERVER = None
def load_config() -> dict:
"""Load saved config (server URLs, etc.)."""
if CONFIG_FILE.exists():
try:
with open(CONFIG_FILE) as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
return {}
return {}
def save_config(config: dict):
"""Save config to file."""
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
with open(CONFIG_FILE, "w") as f:
json.dump(config, f, indent=2)
def get_server(): def get_server():
"""Get server URL from env or default.""" """Get server URL."""
return DEFAULT_SERVER return DEFAULT_SERVER
@@ -53,18 +78,22 @@ def clear_token():
TOKEN_FILE.unlink() TOKEN_FILE.unlink()
def get_auth_header() -> dict: def get_auth_header(require_token: bool = False) -> dict:
"""Get Authorization header if token exists.""" """Get headers for API requests. Always includes Accept: application/json."""
headers = {"Accept": "application/json"}
token_data = load_token() token_data = load_token()
token = token_data.get("access_token") token = token_data.get("access_token")
if token: if token:
return {"Authorization": f"Bearer {token}"} headers["Authorization"] = f"Bearer {token}"
return {} elif require_token:
click.echo("Not logged in. Use 'artdag login' first.", err=True)
sys.exit(1)
return headers
def api_get(path: str, auth: bool = False): def api_get(path: str, auth: bool = False):
"""GET request to server.""" """GET request to server."""
headers = get_auth_header() if auth else {} headers = get_auth_header(require_token=auth)
resp = requests.get(f"{get_server()}{path}", headers=headers) resp = requests.get(f"{get_server()}{path}", headers=headers)
resp.raise_for_status() resp.raise_for_status()
return resp.json() return resp.json()
@@ -72,26 +101,65 @@ def api_get(path: str, auth: bool = False):
def api_post(path: str, data: dict = None, params: dict = None, auth: bool = False): def api_post(path: str, data: dict = None, params: dict = None, auth: bool = False):
"""POST request to server.""" """POST request to server."""
headers = get_auth_header() if auth else {} headers = get_auth_header(require_token=auth)
resp = requests.post(f"{get_server()}{path}", json=data, params=params, headers=headers) resp = requests.post(f"{get_server()}{path}", json=data, params=params, headers=headers)
resp.raise_for_status() resp.raise_for_status()
return resp.json() return resp.json()
def _get_default_server():
"""Get default server from env, config, or builtin default."""
if os.environ.get("ARTDAG_SERVER"):
return os.environ["ARTDAG_SERVER"]
config = load_config()
return config.get("server", _DEFAULT_SERVER)
def _get_default_l2():
"""Get default L2 server from env, config, or builtin default."""
if os.environ.get("ARTDAG_L2"):
return os.environ["ARTDAG_L2"]
config = load_config()
return config.get("l2", _DEFAULT_L2_SERVER)
@click.group() @click.group()
@click.option("--server", "-s", envvar="ARTDAG_SERVER", default=DEFAULT_SERVER, @click.option("--server", "-s", default=None,
help="L1 server URL") help="L1 server URL (saved for future use)")
@click.option("--l2", envvar="ARTDAG_L2", default=DEFAULT_L2_SERVER, @click.option("--l2", default=None,
help="L2 server URL") help="L2 server URL (saved for future use)")
@click.pass_context @click.pass_context
def cli(ctx, server, l2): def cli(ctx, server, l2):
"""Art DAG Client - interact with L1 rendering server.""" """Art DAG Client - interact with L1 rendering server."""
ctx.ensure_object(dict) ctx.ensure_object(dict)
ctx.obj["server"] = server
ctx.obj["l2"] = l2
global DEFAULT_SERVER, DEFAULT_L2_SERVER global DEFAULT_SERVER, DEFAULT_L2_SERVER
DEFAULT_SERVER = server
DEFAULT_L2_SERVER = l2 config = load_config()
config_changed = False
# Use provided value, or fall back to saved/default
if server:
DEFAULT_SERVER = server
if config.get("server") != server:
config["server"] = server
config_changed = True
else:
DEFAULT_SERVER = _get_default_server()
if l2:
DEFAULT_L2_SERVER = l2
if config.get("l2") != l2:
config["l2"] = l2
config_changed = True
else:
DEFAULT_L2_SERVER = _get_default_l2()
# Save config if changed
if config_changed:
save_config(config)
ctx.obj["server"] = DEFAULT_SERVER
ctx.obj["l2"] = DEFAULT_L2_SERVER
# ============ Auth Commands ============ # ============ Auth Commands ============
@@ -241,6 +309,38 @@ def whoami():
click.echo(f"Error: {e}", err=True) click.echo(f"Error: {e}", err=True)
@cli.command("config")
@click.option("--clear", is_flag=True, help="Clear saved server settings")
def show_config(clear):
"""Show or clear saved configuration."""
if clear:
if CONFIG_FILE.exists():
CONFIG_FILE.unlink()
click.echo("Configuration cleared")
else:
click.echo("No configuration to clear")
return
config = load_config()
click.echo(f"Config file: {CONFIG_FILE}")
click.echo()
click.echo(f"L1 Server: {DEFAULT_SERVER}")
if config.get("server"):
click.echo(f" (saved)")
elif os.environ.get("ARTDAG_SERVER"):
click.echo(f" (from ARTDAG_SERVER env)")
else:
click.echo(f" (default)")
click.echo(f"L2 Server: {DEFAULT_L2_SERVER}")
if config.get("l2"):
click.echo(f" (saved)")
elif os.environ.get("ARTDAG_L2"):
click.echo(f" (from ARTDAG_L2 env)")
else:
click.echo(f" (default)")
# ============ Server Commands ============ # ============ Server Commands ============
@cli.command() @cli.command()
@@ -263,7 +363,7 @@ def stats():
sys.exit(1) sys.exit(1)
try: try:
headers = {"Authorization": f"Bearer {token_data['access_token']}"} headers = get_auth_header(require_token=True)
resp = requests.get(f"{get_server()}/api/stats", headers=headers) resp = requests.get(f"{get_server()}/api/stats", headers=headers)
resp.raise_for_status() resp.raise_for_status()
stats = resp.json() stats = resp.json()
@@ -294,7 +394,7 @@ def clear_data(force):
# Show current stats first # Show current stats first
try: try:
headers = {"Authorization": f"Bearer {token_data['access_token']}"} headers = get_auth_header(require_token=True)
resp = requests.get(f"{get_server()}/api/stats", headers=headers) resp = requests.get(f"{get_server()}/api/stats", headers=headers)
resp.raise_for_status() resp.raise_for_status()
stats = resp.json() stats = resp.json()
@@ -409,13 +509,9 @@ def run(recipe, input_hash, name, wait):
@click.option("--offset", "-o", default=0, help="Offset for pagination") @click.option("--offset", "-o", default=0, help="Offset for pagination")
def list_runs(limit, offset): def list_runs(limit, offset):
"""List all runs with pagination.""" """List all runs with pagination."""
token_data = load_token() headers = get_auth_header(require_token=True)
if not token_data.get("access_token"):
click.echo("Not logged in. Use 'artdag login' first.", err=True)
sys.exit(1)
try: try:
headers = {"Authorization": f"Bearer {token_data['access_token']}"}
resp = requests.get(f"{get_server()}/runs?offset={offset}&limit={limit}", headers=headers) resp = requests.get(f"{get_server()}/runs?offset={offset}&limit={limit}", headers=headers)
resp.raise_for_status() resp.raise_for_status()
data = resp.json() data = resp.json()
@@ -434,12 +530,18 @@ def list_runs(limit, offset):
end = offset + len(runs) end = offset + len(runs)
click.echo(f"Showing {start}-{end}" + (" (more available)" if has_more else "")) click.echo(f"Showing {start}-{end}" + (" (more available)" if has_more else ""))
click.echo() click.echo()
click.echo(f"{'ID':<36} {'Status':<10} {'Recipe':<10} {'Output Hash':<20}")
click.echo("-" * 80)
for run in runs: for run in runs:
output = run.get("output_cid", "")[:16] + "..." if run.get("output_cid") else "-" click.echo(f"Run ID: {run['run_id']}")
click.echo(f"{run['run_id']} {run['status']:<10} {run['recipe']:<10} {output}") click.echo(f" Status: {run['status']}")
click.echo(f" Recipe: {run['recipe']}")
if run.get("recipe_name"):
click.echo(f" Recipe Name: {run['recipe_name']}")
if run.get("output_cid"):
click.echo(f" Output: {run['output_cid']}")
if run.get("created_at"):
click.echo(f" Created: {run['created_at']}")
click.echo()
@cli.command() @cli.command()
@@ -449,10 +551,7 @@ def list_runs(limit, offset):
@click.option("--analysis", is_flag=True, help="Show audio analysis data") @click.option("--analysis", is_flag=True, help="Show audio analysis data")
def status(run_id, plan, artifacts, analysis): def status(run_id, plan, artifacts, analysis):
"""Get status of a run with optional detailed views.""" """Get status of a run with optional detailed views."""
token_data = load_token() headers = get_auth_header() # Optional auth, always has Accept header
headers = {}
if token_data.get("access_token"):
headers["Authorization"] = f"Bearer {token_data['access_token']}"
try: try:
resp = requests.get(f"{get_server()}/runs/{run_id}", headers=headers) resp = requests.get(f"{get_server()}/runs/{run_id}", headers=headers)
@@ -477,7 +576,10 @@ def status(run_id, plan, artifacts, analysis):
click.echo(f"Completed: {run['completed_at']}") click.echo(f"Completed: {run['completed_at']}")
if run.get("output_cid"): if run.get("output_cid"):
click.echo(f"Output Hash: {run['output_cid']}") click.echo(f"Output: {run['output_cid']}")
if run.get("plan_cid"):
click.echo(f"Plan: {run['plan_cid']}")
if run.get("error"): if run.get("error"):
click.echo(f"Error: {run['error']}") click.echo(f"Error: {run['error']}")
@@ -507,9 +609,10 @@ def status(run_id, plan, artifacts, analysis):
step_id = step.get("id", step.get("node_id", f"step_{i}")) step_id = step.get("id", step.get("node_id", f"step_{i}"))
step_type = step.get("type", "unknown") step_type = step.get("type", "unknown")
output_cid = step.get("output_cid", "") output_cid = step.get("output_cid", "")
output_str = f"{output_cid[:16]}..." if output_cid else ""
click.echo(f" {i}. {status_badge:<10} {step_id:<20} ({step_type}) {output_str}") click.echo(f" {i}. {status_badge:<10} {step_id} ({step_type})")
if output_cid:
click.echo(f" Output: {output_cid}")
else: else:
click.echo(" No plan steps available.") click.echo(" No plan steps available.")
else: else:
@@ -533,9 +636,12 @@ def status(run_id, plan, artifacts, analysis):
name = art.get("name", art.get("step_id", "output")) name = art.get("name", art.get("step_id", "output"))
media_type = art.get("media_type", art.get("content_type", "")) media_type = art.get("media_type", art.get("content_type", ""))
size = art.get("size", "") size = art.get("size", "")
size_str = f" ({size})" if size else "" click.echo(f" {name}:")
type_str = f" [{media_type}]" if media_type else "" click.echo(f" CID: {cid}")
click.echo(f" {name}: {cid[:24]}...{type_str}{size_str}") if media_type:
click.echo(f" Type: {media_type}")
if size:
click.echo(f" Size: {size}")
else: else:
click.echo(" No artifacts available.") click.echo(" No artifacts available.")
else: else:
@@ -606,7 +712,7 @@ def delete_run(run_id, force):
return return
try: try:
headers = {"Authorization": f"Bearer {token_data['access_token']}"} headers = get_auth_header(require_token=True)
resp = requests.delete(f"{get_server()}/runs/{run_id}", headers=headers) resp = requests.delete(f"{get_server()}/runs/{run_id}", headers=headers)
if resp.status_code == 400: if resp.status_code == 400:
click.echo(f"Cannot delete: {resp.json().get('detail', 'Unknown error')}", err=True) click.echo(f"Cannot delete: {resp.json().get('detail', 'Unknown error')}", err=True)
@@ -645,7 +751,7 @@ def delete_cache(cid, force):
return return
try: try:
headers = {"Authorization": f"Bearer {token_data['access_token']}"} headers = get_auth_header(require_token=True)
resp = requests.delete(f"{get_server()}/cache/{cid}", headers=headers) resp = requests.delete(f"{get_server()}/cache/{cid}", headers=headers)
if resp.status_code == 400: if resp.status_code == 400:
click.echo(f"Cannot delete: {resp.json().get('detail', 'Unknown error')}", err=True) click.echo(f"Cannot delete: {resp.json().get('detail', 'Unknown error')}", err=True)
@@ -703,16 +809,12 @@ def matches_media_type(item: dict, media_type: str) -> bool:
default="all", help="Filter by media type") default="all", help="Filter by media type")
def cache(limit, offset, media_type): def cache(limit, offset, media_type):
"""List cached content with pagination and optional type filter.""" """List cached content with pagination and optional type filter."""
token_data = load_token() headers = get_auth_header(require_token=True)
if not token_data.get("access_token"):
click.echo("Not logged in. Use 'artdag login' first.", err=True)
sys.exit(1)
# Fetch more items if filtering to ensure we get enough results # Fetch more items if filtering to ensure we get enough results
fetch_limit = limit * 3 if media_type != "all" else limit fetch_limit = limit * 3 if media_type != "all" else limit
try: try:
headers = {"Authorization": f"Bearer {token_data['access_token']}"}
resp = requests.get(f"{get_server()}/cache?offset={offset}&limit={fetch_limit}", headers=headers) resp = requests.get(f"{get_server()}/cache?offset={offset}&limit={fetch_limit}", headers=headers)
resp.raise_for_status() resp.raise_for_status()
data = resp.json() data = resp.json()
@@ -746,25 +848,33 @@ def cache(limit, offset, media_type):
name = item.get("friendly_name") or item.get("filename") if isinstance(item, dict) else None name = item.get("friendly_name") or item.get("filename") if isinstance(item, dict) else None
content_type = item.get("content_type", "") if isinstance(item, dict) else "" content_type = item.get("content_type", "") if isinstance(item, dict) else ""
type_badge = f"[{content_type.split('/')[0]}]" if content_type else "" type_badge = f"[{content_type.split('/')[0]}]" if content_type else ""
click.echo(f"CID: {cid}")
if name: if name:
click.echo(f" {cid[:24]}... {name} {type_badge}") click.echo(f" Name: {name}")
else: if type_badge:
click.echo(f" {cid} {type_badge}") click.echo(f" Type: {type_badge}")
click.echo()
@cli.command() @cli.command()
@click.argument("cid") @click.argument("cid")
@click.option("--output", "-o", type=click.Path(), help="Save to file (use - for stdout)") @click.option("--output", "-o", type=click.Path(), help="Save to file (use - for stdout)")
def view(cid, output): @click.option("--raw", "-r", is_flag=True, help="Get raw content (use with -o to download)")
def view(cid, output, raw):
"""View or download cached content. """View or download cached content.
Use -o - to pipe to stdout, e.g.: artdag view <cid> -o - | mpv - Use -o - to pipe to stdout, e.g.: artdag view <cid> -o - | mpv -
Use --raw to get the raw file content instead of metadata.
""" """
url = f"{get_server()}/cache/{cid}" # Use /raw endpoint if --raw flag or if outputting to file/stdout
if raw or output:
url = f"{get_server()}/cache/{cid}/raw"
else:
url = f"{get_server()}/cache/{cid}"
try: try:
if output == "-": if output == "-" or (raw and not output):
# Stream to stdout for piping # Stream to stdout for piping (--raw without -o also goes to stdout)
resp = requests.get(url, stream=True) resp = requests.get(url, stream=True)
resp.raise_for_status() resp.raise_for_status()
for chunk in resp.iter_content(chunk_size=8192): for chunk in resp.iter_content(chunk_size=8192):
@@ -778,16 +888,21 @@ def view(cid, output):
f.write(chunk) f.write(chunk)
click.echo(f"Saved to: {output}", err=True) click.echo(f"Saved to: {output}", err=True)
else: else:
# Get info via GET with stream to check size # Get info - use JSON endpoint for metadata
resp = requests.get(url, stream=True) headers = {"Accept": "application/json"}
resp = requests.get(f"{get_server()}/cache/{cid}", headers=headers)
resp.raise_for_status() resp.raise_for_status()
size = resp.headers.get("content-length", "unknown") info = resp.json()
content_type = resp.headers.get("content-type", "unknown")
click.echo(f"CID: {cid}") click.echo(f"CID: {cid}")
click.echo(f"Size: {size} bytes") click.echo(f"Size: {info.get('size', 'unknown')} bytes")
click.echo(f"Type: {content_type}") click.echo(f"Type: {info.get('mime_type') or info.get('media_type', 'unknown')}")
click.echo(f"URL: {url}") if info.get('friendly_name'):
resp.close() click.echo(f"Friendly Name: {info['friendly_name']}")
if info.get('title'):
click.echo(f"Title: {info['title']}")
if info.get('filename'):
click.echo(f"Filename: {info['filename']}")
click.echo(f"Raw URL: {get_server()}/cache/{cid}/raw")
except requests.HTTPError as e: except requests.HTTPError as e:
if e.response.status_code == 404: if e.response.status_code == 404:
click.echo(f"Not found: {cid}", err=True) click.echo(f"Not found: {cid}", err=True)
@@ -806,7 +921,8 @@ def import_file(filepath):
@cli.command() @cli.command()
@click.argument("filepath", type=click.Path(exists=True)) @click.argument("filepath", type=click.Path(exists=True))
def upload(filepath): @click.option("--name", "-n", help="Friendly name for the asset")
def upload(filepath, name):
"""Upload a file to cache and IPFS. Requires login.""" """Upload a file to cache and IPFS. Requires login."""
# Check auth # Check auth
token_data = load_token() token_data = load_token()
@@ -817,8 +933,9 @@ def upload(filepath):
try: try:
with open(filepath, "rb") as f: with open(filepath, "rb") as f:
files = {"file": (Path(filepath).name, f)} files = {"file": (Path(filepath).name, f)}
headers = {"Authorization": f"Bearer {token_data['access_token']}"} data = {"display_name": name} if name else {}
resp = requests.post(f"{get_server()}/cache/upload", files=files, headers=headers) headers = get_auth_header(require_token=True)
resp = requests.post(f"{get_server()}/cache/upload", files=files, data=data, headers=headers)
if resp.status_code == 401: if resp.status_code == 401:
click.echo("Authentication failed. Please login again.", err=True) click.echo("Authentication failed. Please login again.", err=True)
sys.exit(1) sys.exit(1)
@@ -831,10 +948,12 @@ def upload(filepath):
sys.exit(1) sys.exit(1)
result = resp.json() result = resp.json()
click.echo(f"CID: {result['cid']}") click.echo(f"CID: {result['cid']}")
click.echo(f"Friendly name: {result.get('friendly_name', 'N/A')}")
click.echo(f"Size: {result['size']} bytes") click.echo(f"Size: {result['size']} bytes")
click.echo() click.echo()
click.echo("Use in recipes:") click.echo("Use in recipes:")
click.echo(f' (asset my-asset :cid "{result["cid"]}")') friendly = result.get('friendly_name', result['cid'])
click.echo(f' (streaming:make-video-source "{friendly}" 30)')
except requests.RequestException as e: except requests.RequestException as e:
click.echo(f"Upload failed: {e}", err=True) click.echo(f"Upload failed: {e}", err=True)
sys.exit(1) sys.exit(1)
@@ -915,7 +1034,7 @@ def meta(cid, origin, origin_url, origin_note, description, tags, folder, add_co
click.echo("Not logged in. Please run: artdag login <username>", err=True) click.echo("Not logged in. Please run: artdag login <username>", err=True)
sys.exit(1) sys.exit(1)
headers = {"Authorization": f"Bearer {token_data['access_token']}"} headers = get_auth_header(require_token=True)
# Handle publish action # Handle publish action
if publish_name: if publish_name:
@@ -1269,7 +1388,7 @@ def storage_list():
sys.exit(1) sys.exit(1)
try: try:
headers = {"Authorization": f"Bearer {token_data['access_token']}"} headers = get_auth_header(require_token=True)
resp = requests.get(f"{get_server()}/storage", headers=headers) resp = requests.get(f"{get_server()}/storage", headers=headers)
resp.raise_for_status() resp.raise_for_status()
data = resp.json() data = resp.json()
@@ -1322,7 +1441,7 @@ def storage_add(provider_type, name, capacity):
# Send to server # Send to server
try: try:
headers = {"Authorization": f"Bearer {token_data['access_token']}"} headers = get_auth_header(require_token=True)
payload = { payload = {
"provider_type": provider_type, "provider_type": provider_type,
"config": config, "config": config,
@@ -1355,7 +1474,7 @@ def storage_test(storage_id):
sys.exit(1) sys.exit(1)
try: try:
headers = {"Authorization": f"Bearer {token_data['access_token']}"} headers = get_auth_header(require_token=True)
resp = requests.post(f"{get_server()}/storage/{storage_id}/test", headers=headers) resp = requests.post(f"{get_server()}/storage/{storage_id}/test", headers=headers)
if resp.status_code == 404: if resp.status_code == 404:
click.echo(f"Storage provider not found: {storage_id}", err=True) click.echo(f"Storage provider not found: {storage_id}", err=True)
@@ -1389,7 +1508,7 @@ def storage_delete(storage_id, force):
return return
try: try:
headers = {"Authorization": f"Bearer {token_data['access_token']}"} headers = get_auth_header(require_token=True)
resp = requests.delete(f"{get_server()}/storage/{storage_id}", headers=headers) resp = requests.delete(f"{get_server()}/storage/{storage_id}", headers=headers)
if resp.status_code == 400: if resp.status_code == 400:
click.echo(f"Error: {resp.json().get('detail', 'Bad request')}", err=True) click.echo(f"Error: {resp.json().get('detail', 'Bad request')}", err=True)
@@ -1458,7 +1577,7 @@ def upload_recipe(filepath):
try: try:
with open(filepath, "rb") as f: with open(filepath, "rb") as f:
files = {"file": (Path(filepath).name, f)} files = {"file": (Path(filepath).name, f)}
headers = {"Authorization": f"Bearer {token_data['access_token']}"} headers = get_auth_header(require_token=True)
resp = requests.post(f"{get_server()}/recipes/upload", files=files, headers=headers) resp = requests.post(f"{get_server()}/recipes/upload", files=files, headers=headers)
if resp.status_code == 401: if resp.status_code == 401:
click.echo("Authentication failed. Please login again.", err=True) click.echo("Authentication failed. Please login again.", err=True)
@@ -1479,10 +1598,11 @@ def upload_recipe(filepath):
@cli.command("upload-effect") @cli.command("upload-effect")
@click.argument("filepath", type=click.Path(exists=True)) @click.argument("filepath", type=click.Path(exists=True))
def upload_effect(filepath): @click.option("--name", "-n", help="Friendly name for the effect")
def upload_effect(filepath, name):
"""Upload an effect file to IPFS. Requires login. """Upload an effect file to IPFS. Requires login.
Effects are Python files with PEP 723 dependencies and @-tag metadata. Effects are S-expression files (.sexp) with metadata in comments.
Returns the IPFS CID for use in recipes. Returns the IPFS CID for use in recipes.
""" """
token_data = load_token() token_data = load_token()
@@ -1490,17 +1610,18 @@ def upload_effect(filepath):
click.echo("Not logged in. Please run: artdag login <username>", err=True) click.echo("Not logged in. Please run: artdag login <username>", err=True)
sys.exit(1) sys.exit(1)
# Check it's a Python file # Check it's a sexp or py file
if not filepath.endswith(".py"): if not filepath.endswith(".sexp") and not filepath.endswith(".py"):
click.echo("Effect must be a Python file (.py)", err=True) click.echo("Effect must be a .sexp or .py file", err=True)
sys.exit(1) sys.exit(1)
# Upload # Upload
try: try:
with open(filepath, "rb") as f: with open(filepath, "rb") as f:
files = {"file": (Path(filepath).name, f)} files = {"file": (Path(filepath).name, f)}
headers = {"Authorization": f"Bearer {token_data['access_token']}"} data = {"display_name": name} if name else {}
resp = requests.post(f"{get_server()}/effects/upload", files=files, headers=headers) headers = get_auth_header(require_token=True)
resp = requests.post(f"{get_server()}/effects/upload", files=files, data=data, headers=headers)
if resp.status_code == 401: if resp.status_code == 401:
click.echo("Authentication failed. Please login again.", err=True) click.echo("Authentication failed. Please login again.", err=True)
sys.exit(1) sys.exit(1)
@@ -1512,14 +1633,13 @@ def upload_effect(filepath):
click.echo(f"Uploaded effect: {result['name']} v{result.get('version', '1.0.0')}") click.echo(f"Uploaded effect: {result['name']} v{result.get('version', '1.0.0')}")
click.echo(f"CID: {result['cid']}") click.echo(f"CID: {result['cid']}")
click.echo(f"Friendly name: {result.get('friendly_name', 'N/A')}")
click.echo(f"Temporal: {result.get('temporal', False)}") click.echo(f"Temporal: {result.get('temporal', False)}")
if result.get('params'): if result.get('params'):
click.echo(f"Parameters: {', '.join(p['name'] for p in result['params'])}") click.echo(f"Parameters: {', '.join(p['name'] for p in result['params'])}")
if result.get('dependencies'):
click.echo(f"Dependencies: {', '.join(result['dependencies'])}")
click.echo() click.echo()
click.echo("Use in recipes:") click.echo("Use in recipes:")
click.echo(f' (effect {result["name"]} :cid "{result["cid"]}")') click.echo(f' (effect {result["name"]} :name "{result.get("friendly_name", result["cid"])}")')
except requests.RequestException as e: except requests.RequestException as e:
click.echo(f"Upload failed: {e}", err=True) click.echo(f"Upload failed: {e}", err=True)
sys.exit(1) sys.exit(1)
@@ -1530,13 +1650,9 @@ def upload_effect(filepath):
@click.option("--offset", "-o", default=0, help="Offset for pagination") @click.option("--offset", "-o", default=0, help="Offset for pagination")
def list_effects(limit, offset): def list_effects(limit, offset):
"""List uploaded effects with pagination.""" """List uploaded effects with pagination."""
token_data = load_token() headers = get_auth_header(require_token=True)
if not token_data.get("access_token"):
click.echo("Not logged in. Use 'artdag login' first.", err=True)
sys.exit(1)
try: try:
headers = {"Authorization": f"Bearer {token_data['access_token']}"}
resp = requests.get(f"{get_server()}/effects?offset={offset}&limit={limit}", headers=headers) resp = requests.get(f"{get_server()}/effects?offset={offset}&limit={limit}", headers=headers)
resp.raise_for_status() resp.raise_for_status()
result = resp.json() result = resp.json()
@@ -1555,11 +1671,13 @@ def list_effects(limit, offset):
for effect in effects: for effect in effects:
meta = effect.get("meta", {}) meta = effect.get("meta", {})
click.echo(f" {meta.get('name', 'unknown')} v{meta.get('version', '?')}") click.echo(f"Name: {meta.get('name', 'unknown')} v{meta.get('version', '?')}")
click.echo(f" Hash: {effect['cid'][:32]}...") click.echo(f" CID: {effect['cid']}")
click.echo(f" Temporal: {meta.get('temporal', False)}") if effect.get('friendly_name'):
click.echo(f" Friendly Name: {effect['friendly_name']}")
click.echo(f" Temporal: {meta.get('temporal', False)}")
if meta.get('params'): if meta.get('params'):
click.echo(f" Params: {', '.join(p['name'] for p in meta['params'])}") click.echo(f" Params: {', '.join(p['name'] for p in meta['params'])}")
click.echo() click.echo()
except requests.RequestException as e: except requests.RequestException as e:
click.echo(f"Failed to list effects: {e}", err=True) click.echo(f"Failed to list effects: {e}", err=True)
@@ -1577,7 +1695,7 @@ def show_effect(cid, source):
sys.exit(1) sys.exit(1)
try: try:
headers = {"Authorization": f"Bearer {token_data['access_token']}"} headers = get_auth_header(require_token=True)
resp = requests.get(f"{get_server()}/effects/{cid}", headers=headers) resp = requests.get(f"{get_server()}/effects/{cid}", headers=headers)
if resp.status_code == 404: if resp.status_code == 404:
click.echo(f"Effect not found: {cid}", err=True) click.echo(f"Effect not found: {cid}", err=True)
@@ -1644,13 +1762,9 @@ def show_effect(cid, source):
@click.option("--offset", "-o", default=0, help="Offset for pagination") @click.option("--offset", "-o", default=0, help="Offset for pagination")
def list_recipes(limit, offset): def list_recipes(limit, offset):
"""List uploaded recipes for the current user with pagination.""" """List uploaded recipes for the current user with pagination."""
token_data = load_token() headers = get_auth_header(require_token=True)
if not token_data.get("access_token"):
click.echo("Not logged in. Use 'artdag login' first.", err=True)
sys.exit(1)
try: try:
headers = {"Authorization": f"Bearer {token_data['access_token']}"}
resp = requests.get(f"{get_server()}/recipes?offset={offset}&limit={limit}", headers=headers) resp = requests.get(f"{get_server()}/recipes?offset={offset}&limit={limit}", headers=headers)
resp.raise_for_status() resp.raise_for_status()
data = resp.json() data = resp.json()
@@ -1669,13 +1783,19 @@ def list_recipes(limit, offset):
end = offset + len(recipes) end = offset + len(recipes)
click.echo(f"Showing {start}-{end}" + (" (more available)" if has_more else "")) click.echo(f"Showing {start}-{end}" + (" (more available)" if has_more else ""))
click.echo() click.echo()
click.echo(f"{'Name':<20} {'Version':<8} {'Variables':<10} {'Recipe ID':<24}")
click.echo("-" * 70)
for recipe in recipes: for recipe in recipes:
recipe_id = recipe["recipe_id"][:20] + "..." recipe_id = recipe["recipe_id"]
var_count = len(recipe.get("variable_inputs", [])) var_count = len(recipe.get("variable_inputs", []))
click.echo(f"{recipe['name']:<20} {recipe['version']:<8} {var_count:<10} {recipe_id}") friendly_name = recipe.get("friendly_name", "")
click.echo(f"Name: {recipe['name']}")
click.echo(f" Version: {recipe.get('version', 'N/A')}")
if friendly_name:
click.echo(f" Friendly Name: {friendly_name}")
click.echo(f" Variables: {var_count}")
click.echo(f" Recipe ID: {recipe_id}")
click.echo()
@cli.command("recipe") @cli.command("recipe")
@@ -1688,7 +1808,7 @@ def show_recipe(recipe_id):
sys.exit(1) sys.exit(1)
try: try:
headers = {"Authorization": f"Bearer {token_data['access_token']}"} headers = get_auth_header(require_token=True)
resp = requests.get(f"{get_server()}/recipes/{recipe_id}", headers=headers) resp = requests.get(f"{get_server()}/recipes/{recipe_id}", headers=headers)
if resp.status_code == 404: if resp.status_code == 404:
click.echo(f"Recipe not found: {recipe_id}", err=True) click.echo(f"Recipe not found: {recipe_id}", err=True)
@@ -1699,12 +1819,15 @@ def show_recipe(recipe_id):
click.echo(f"Failed to get recipe: {e}", err=True) click.echo(f"Failed to get recipe: {e}", err=True)
sys.exit(1) sys.exit(1)
click.echo(f"Name: {recipe['name']}") click.echo(f"Name: {recipe.get('name', 'Unnamed')}")
click.echo(f"Version: {recipe['version']}") click.echo(f"Version: {recipe.get('version', 'N/A')}")
if recipe.get("friendly_name"):
click.echo(f"Friendly Name: {recipe['friendly_name']}")
click.echo(f"Description: {recipe.get('description', 'N/A')}") click.echo(f"Description: {recipe.get('description', 'N/A')}")
click.echo(f"Recipe ID: {recipe['recipe_id']}") click.echo(f"Recipe ID: {recipe['recipe_id']}")
click.echo(f"Owner: {recipe.get('owner', 'N/A')}") click.echo(f"Owner: {recipe.get('owner', 'N/A')}")
click.echo(f"Uploaded: {recipe['uploaded_at']}") if recipe.get("uploaded_at"):
click.echo(f"Uploaded: {recipe['uploaded_at']}")
if recipe.get("variable_inputs"): if recipe.get("variable_inputs"):
click.echo("\nVariable Inputs:") click.echo("\nVariable Inputs:")
@@ -1715,7 +1838,7 @@ def show_recipe(recipe_id):
if recipe.get("fixed_inputs"): if recipe.get("fixed_inputs"):
click.echo("\nFixed Inputs:") click.echo("\nFixed Inputs:")
for inp in recipe["fixed_inputs"]: for inp in recipe["fixed_inputs"]:
click.echo(f" - {inp['asset']}: {inp['cid'][:16]}...") click.echo(f" - {inp['asset']}: {inp['cid']}")
@cli.command("run-recipe") @cli.command("run-recipe")
@@ -1745,7 +1868,7 @@ def run_recipe(recipe_id, inputs, wait):
# Run # Run
try: try:
headers = {"Authorization": f"Bearer {token_data['access_token']}"} headers = get_auth_header(require_token=True)
resp = requests.post( resp = requests.post(
f"{get_server()}/recipes/{recipe_id}/run", f"{get_server()}/recipes/{recipe_id}/run",
json={"inputs": input_dict}, json={"inputs": input_dict},
@@ -1808,7 +1931,7 @@ def delete_recipe(recipe_id, force):
return return
try: try:
headers = {"Authorization": f"Bearer {token_data['access_token']}"} headers = get_auth_header(require_token=True)
resp = requests.delete(f"{get_server()}/recipes/{recipe_id}", headers=headers) resp = requests.delete(f"{get_server()}/recipes/{recipe_id}", headers=headers)
if resp.status_code == 401: if resp.status_code == 401:
click.echo("Authentication failed. Please login again.", err=True) click.echo("Authentication failed. Please login again.", err=True)
@@ -1872,7 +1995,7 @@ def generate_plan(recipe_file, inputs, features, output):
# Submit to API # Submit to API
try: try:
headers = {"Authorization": f"Bearer {token_data['access_token']}"} headers = get_auth_header(require_token=True)
resp = requests.post( resp = requests.post(
f"{get_server()}/api/v2/plan", f"{get_server()}/api/v2/plan",
json=request_data, json=request_data,
@@ -1933,7 +2056,7 @@ def execute_plan(plan_file, wait):
# Submit to API # Submit to API
try: try:
headers = {"Authorization": f"Bearer {token_data['access_token']}"} headers = get_auth_header(require_token=True)
resp = requests.post( resp = requests.post(
f"{get_server()}/api/v2/execute", f"{get_server()}/api/v2/execute",
json={"plan_json": plan_json}, json={"plan_json": plan_json},
@@ -2011,7 +2134,7 @@ def run_recipe_v2(recipe_file, inputs, features, wait):
click.echo(f"Inputs: {len(input_hashes)}") click.echo(f"Inputs: {len(input_hashes)}")
try: try:
headers = {"Authorization": f"Bearer {token_data['access_token']}"} headers = get_auth_header(require_token=True)
resp = requests.post( resp = requests.post(
f"{get_server()}/api/v2/run-recipe", f"{get_server()}/api/v2/run-recipe",
json=request_data, json=request_data,
@@ -2046,7 +2169,7 @@ def run_recipe_v2(recipe_file, inputs, features, wait):
def _wait_for_v2_run(token_data: dict, run_id: str): def _wait_for_v2_run(token_data: dict, run_id: str):
"""Poll v2 run status until completion.""" """Poll v2 run status until completion."""
click.echo("Waiting for completion...") click.echo("Waiting for completion...")
headers = {"Authorization": f"Bearer {token_data['access_token']}"} headers = get_auth_header(require_token=True)
while True: while True:
time.sleep(2) time.sleep(2)
@@ -2094,7 +2217,7 @@ def run_status_v2(run_id):
sys.exit(1) sys.exit(1)
try: try:
headers = {"Authorization": f"Bearer {token_data['access_token']}"} headers = get_auth_header(require_token=True)
resp = requests.get( resp = requests.get(
f"{get_server()}/api/v2/run/{run_id}", f"{get_server()}/api/v2/run/{run_id}",
headers=headers headers=headers
@@ -2127,5 +2250,105 @@ def run_status_v2(run_id):
click.echo(f"Error: {run['error']}") click.echo(f"Error: {run['error']}")
@cli.command("stream")
@click.argument("recipe_file", type=click.Path(exists=True))
@click.option("--output", "-o", default="output.mp4", help="Output filename")
@click.option("--duration", "-d", type=float, help="Duration in seconds")
@click.option("--fps", type=float, help="FPS override")
@click.option("--sources", type=click.Path(exists=True), help="Sources config .sexp file")
@click.option("--audio", type=click.Path(exists=True), help="Audio config .sexp file")
@click.option("--wait", "-w", is_flag=True, help="Wait for completion")
def run_stream(recipe_file, output, duration, fps, sources, audio, wait):
"""Run a streaming S-expression recipe. Requires login.
RECIPE_FILE: Path to the recipe .sexp file
Example: artdag stream effects/my_effect.sexp --duration 10 --fps 30 -w
"""
token_data = load_token()
if not token_data.get("access_token"):
click.echo("Not logged in. Please run: artdag login <username>", err=True)
sys.exit(1)
# Read recipe file
recipe_path = Path(recipe_file)
recipe_sexp = recipe_path.read_text()
# Read optional config files
sources_sexp = None
if sources:
sources_sexp = Path(sources).read_text()
audio_sexp = None
if audio:
audio_sexp = Path(audio).read_text()
# Build request
request_data = {
"recipe_sexp": recipe_sexp,
"output_name": output,
}
if duration:
request_data["duration"] = duration
if fps:
request_data["fps"] = fps
if sources_sexp:
request_data["sources_sexp"] = sources_sexp
if audio_sexp:
request_data["audio_sexp"] = audio_sexp
# Submit
try:
headers = get_auth_header(require_token=True)
resp = requests.post(
f"{get_server()}/runs/stream",
json=request_data,
headers=headers
)
if resp.status_code == 401:
click.echo("Authentication failed. Please login again.", err=True)
sys.exit(1)
if resp.status_code == 400:
error = resp.json().get("detail", "Bad request")
click.echo(f"Error: {error}", err=True)
sys.exit(1)
resp.raise_for_status()
result = resp.json()
except requests.RequestException as e:
click.echo(f"Stream failed: {e}", err=True)
sys.exit(1)
run_id = result["run_id"]
click.echo(f"Stream started: {run_id}")
click.echo(f"Task ID: {result.get('celery_task_id', 'N/A')}")
click.echo(f"Status: {result.get('status', 'pending')}")
if wait:
click.echo("Waiting for completion...")
while True:
time.sleep(2)
try:
resp = requests.get(
f"{get_server()}/runs/{run_id}",
headers=get_auth_header()
)
resp.raise_for_status()
run = resp.json()
except requests.RequestException:
continue
status = run.get("status")
if status == "completed":
click.echo(f"\nCompleted!")
if run.get("output_cid"):
click.echo(f"Output CID: {run['output_cid']}")
break
elif status == "failed":
click.echo(f"\nFailed: {run.get('error', 'Unknown error')}", err=True)
sys.exit(1)
else:
click.echo(".", nl=False)
if __name__ == "__main__": if __name__ == "__main__":
cli() cli()

38
test_gpu_effects.sexp Normal file
View File

@@ -0,0 +1,38 @@
;; GPU Effects Performance Test
;; Tests rotation, zoom, hue-shift, ripple
(stream "gpu_effects_test"
:fps 30
:width 1920
:height 1080
:seed 42
;; Load primitives
(require-primitives "geometry")
(require-primitives "core")
(require-primitives "math")
(require-primitives "image")
(require-primitives "color_ops")
;; Frame pipeline - test GPU effects
(frame
(let [;; Create a base gradient image
r (+ 0.5 (* 0.5 (math:sin (* t 1))))
g (+ 0.5 (* 0.5 (math:sin (* t 1.3))))
b (+ 0.5 (* 0.5 (math:sin (* t 1.7))))
color [(* r 255) (* g 255) (* b 255)]
base (image:make-image 1920 1080 color)
;; Apply rotation (this is the main GPU bottleneck we optimized)
angle (* t 30)
rotated (geometry:rotate base angle)
;; Apply hue shift
hue-shift (* 180 (math:sin (* t 0.5)))
hued (color_ops:hue-shift rotated hue-shift)
;; Apply brightness based on time
brightness (+ 0.8 (* 0.4 (math:sin (* t 2))))
bright (color_ops:brightness hued brightness)]
bright)))

26
test_simple.sexp Normal file
View File

@@ -0,0 +1,26 @@
;; Simple Test - No external assets required
;; Just generates a color gradient that changes over time
(stream "simple_test"
:fps 30
:width 720
:height 720
:seed 42
;; Load standard primitives
(require-primitives "geometry")
(require-primitives "core")
(require-primitives "math")
(require-primitives "image")
(require-primitives "color_ops")
;; Frame pipeline - animated gradient
(frame
(let [;; Time-based color cycling (0-1 range)
r (+ 0.5 (* 0.5 (math:sin (* t 1))))
g (+ 0.5 (* 0.5 (math:sin (* t 1.3))))
b (+ 0.5 (* 0.5 (math:sin (* t 1.7))))
;; Convert to 0-255 range and create solid color frame
color [(* r 255) (* g 255) (* b 255)]
frame (image:make-image 720 720 color)]
frame)))