Compare commits
11 Commits
84acfc45cf
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bb084154a | ||
|
|
656738782f | ||
|
|
0242c0bb22 | ||
|
|
3d663950f1 | ||
|
|
eb0a38b087 | ||
|
|
1a768370f6 | ||
|
|
d69ed09575 | ||
|
|
599f181f8c | ||
|
|
efca939c7d | ||
|
|
b56f4d906c | ||
|
|
9e13a4ce3d |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,3 +2,4 @@ __pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
venv/
|
||||
.scripts
|
||||
|
||||
@@ -117,9 +117,10 @@ artdag upload-effect <filepath>
|
||||
artdag cache [--limit N] [--offset N] [--type all|image|video|audio]
|
||||
|
||||
# View/download cached content
|
||||
artdag view <cid> # Show info
|
||||
artdag view <cid> -o output.mp4 # Download to file
|
||||
artdag view <cid> -o - | mpv - # Pipe to player
|
||||
artdag view <cid> # Show metadata (size, type, friendly name)
|
||||
artdag view <cid> --raw # Get raw content info
|
||||
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
|
||||
artdag upload <filepath>
|
||||
|
||||
445
artdag.py
445
artdag.py
@@ -15,14 +15,39 @@ import click
|
||||
import requests
|
||||
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"
|
||||
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():
|
||||
"""Get server URL from env or default."""
|
||||
"""Get server URL."""
|
||||
return DEFAULT_SERVER
|
||||
|
||||
|
||||
@@ -53,18 +78,22 @@ def clear_token():
|
||||
TOKEN_FILE.unlink()
|
||||
|
||||
|
||||
def get_auth_header() -> dict:
|
||||
"""Get Authorization header if token exists."""
|
||||
def get_auth_header(require_token: bool = False) -> dict:
|
||||
"""Get headers for API requests. Always includes Accept: application/json."""
|
||||
headers = {"Accept": "application/json"}
|
||||
token_data = load_token()
|
||||
token = token_data.get("access_token")
|
||||
if token:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
return {}
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
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):
|
||||
"""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.raise_for_status()
|
||||
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):
|
||||
"""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.raise_for_status()
|
||||
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.option("--server", "-s", envvar="ARTDAG_SERVER", default=DEFAULT_SERVER,
|
||||
help="L1 server URL")
|
||||
@click.option("--l2", envvar="ARTDAG_L2", default=DEFAULT_L2_SERVER,
|
||||
help="L2 server URL")
|
||||
@click.option("--server", "-s", default=None,
|
||||
help="L1 server URL (saved for future use)")
|
||||
@click.option("--l2", default=None,
|
||||
help="L2 server URL (saved for future use)")
|
||||
@click.pass_context
|
||||
def cli(ctx, server, l2):
|
||||
"""Art DAG Client - interact with L1 rendering server."""
|
||||
ctx.ensure_object(dict)
|
||||
ctx.obj["server"] = server
|
||||
ctx.obj["l2"] = l2
|
||||
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 ============
|
||||
@@ -241,6 +309,38 @@ def whoami():
|
||||
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 ============
|
||||
|
||||
@cli.command()
|
||||
@@ -263,7 +363,7 @@ def stats():
|
||||
sys.exit(1)
|
||||
|
||||
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.raise_for_status()
|
||||
stats = resp.json()
|
||||
@@ -294,7 +394,7 @@ def clear_data(force):
|
||||
|
||||
# Show current stats first
|
||||
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.raise_for_status()
|
||||
stats = resp.json()
|
||||
@@ -409,13 +509,9 @@ def run(recipe, input_hash, name, wait):
|
||||
@click.option("--offset", "-o", default=0, help="Offset for pagination")
|
||||
def list_runs(limit, offset):
|
||||
"""List all runs with pagination."""
|
||||
token_data = load_token()
|
||||
if not token_data.get("access_token"):
|
||||
click.echo("Not logged in. Use 'artdag login' first.", err=True)
|
||||
sys.exit(1)
|
||||
headers = get_auth_header(require_token=True)
|
||||
|
||||
try:
|
||||
headers = {"Authorization": f"Bearer {token_data['access_token']}"}
|
||||
resp = requests.get(f"{get_server()}/runs?offset={offset}&limit={limit}", headers=headers)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
@@ -434,12 +530,18 @@ def list_runs(limit, offset):
|
||||
end = offset + len(runs)
|
||||
click.echo(f"Showing {start}-{end}" + (" (more available)" if has_more else ""))
|
||||
click.echo()
|
||||
click.echo(f"{'ID':<36} {'Status':<10} {'Recipe':<10} {'Output Hash':<20}")
|
||||
click.echo("-" * 80)
|
||||
|
||||
for run in runs:
|
||||
output = run.get("output_cid", "")[:16] + "..." if run.get("output_cid") else "-"
|
||||
click.echo(f"{run['run_id']} {run['status']:<10} {run['recipe']:<10} {output}")
|
||||
click.echo(f"Run ID: {run['run_id']}")
|
||||
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()
|
||||
@@ -449,10 +551,7 @@ def list_runs(limit, offset):
|
||||
@click.option("--analysis", is_flag=True, help="Show audio analysis data")
|
||||
def status(run_id, plan, artifacts, analysis):
|
||||
"""Get status of a run with optional detailed views."""
|
||||
token_data = load_token()
|
||||
headers = {}
|
||||
if token_data.get("access_token"):
|
||||
headers["Authorization"] = f"Bearer {token_data['access_token']}"
|
||||
headers = get_auth_header() # Optional auth, always has Accept header
|
||||
|
||||
try:
|
||||
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']}")
|
||||
|
||||
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"):
|
||||
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_type = step.get("type", "unknown")
|
||||
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:
|
||||
click.echo(" No plan steps available.")
|
||||
else:
|
||||
@@ -533,9 +636,12 @@ def status(run_id, plan, artifacts, analysis):
|
||||
name = art.get("name", art.get("step_id", "output"))
|
||||
media_type = art.get("media_type", art.get("content_type", ""))
|
||||
size = art.get("size", "")
|
||||
size_str = f" ({size})" if size else ""
|
||||
type_str = f" [{media_type}]" if media_type else ""
|
||||
click.echo(f" {name}: {cid[:24]}...{type_str}{size_str}")
|
||||
click.echo(f" {name}:")
|
||||
click.echo(f" CID: {cid}")
|
||||
if media_type:
|
||||
click.echo(f" Type: {media_type}")
|
||||
if size:
|
||||
click.echo(f" Size: {size}")
|
||||
else:
|
||||
click.echo(" No artifacts available.")
|
||||
else:
|
||||
@@ -606,7 +712,7 @@ def delete_run(run_id, force):
|
||||
return
|
||||
|
||||
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)
|
||||
if resp.status_code == 400:
|
||||
click.echo(f"Cannot delete: {resp.json().get('detail', 'Unknown error')}", err=True)
|
||||
@@ -645,7 +751,7 @@ def delete_cache(cid, force):
|
||||
return
|
||||
|
||||
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)
|
||||
if resp.status_code == 400:
|
||||
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")
|
||||
def cache(limit, offset, media_type):
|
||||
"""List cached content with pagination and optional type filter."""
|
||||
token_data = load_token()
|
||||
if not token_data.get("access_token"):
|
||||
click.echo("Not logged in. Use 'artdag login' first.", err=True)
|
||||
sys.exit(1)
|
||||
headers = get_auth_header(require_token=True)
|
||||
|
||||
# Fetch more items if filtering to ensure we get enough results
|
||||
fetch_limit = limit * 3 if media_type != "all" else limit
|
||||
|
||||
try:
|
||||
headers = {"Authorization": f"Bearer {token_data['access_token']}"}
|
||||
resp = requests.get(f"{get_server()}/cache?offset={offset}&limit={fetch_limit}", headers=headers)
|
||||
resp.raise_for_status()
|
||||
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
|
||||
content_type = item.get("content_type", "") if isinstance(item, dict) else ""
|
||||
type_badge = f"[{content_type.split('/')[0]}]" if content_type else ""
|
||||
click.echo(f"CID: {cid}")
|
||||
if name:
|
||||
click.echo(f" {cid[:24]}... {name} {type_badge}")
|
||||
else:
|
||||
click.echo(f" {cid} {type_badge}")
|
||||
click.echo(f" Name: {name}")
|
||||
if type_badge:
|
||||
click.echo(f" Type: {type_badge}")
|
||||
click.echo()
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.argument("cid")
|
||||
@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.
|
||||
|
||||
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:
|
||||
if output == "-":
|
||||
# Stream to stdout for piping
|
||||
if output == "-" or (raw and not output):
|
||||
# Stream to stdout for piping (--raw without -o also goes to stdout)
|
||||
resp = requests.get(url, stream=True)
|
||||
resp.raise_for_status()
|
||||
for chunk in resp.iter_content(chunk_size=8192):
|
||||
@@ -778,16 +888,21 @@ def view(cid, output):
|
||||
f.write(chunk)
|
||||
click.echo(f"Saved to: {output}", err=True)
|
||||
else:
|
||||
# Get info via GET with stream to check size
|
||||
resp = requests.get(url, stream=True)
|
||||
# Get info - use JSON endpoint for metadata
|
||||
headers = {"Accept": "application/json"}
|
||||
resp = requests.get(f"{get_server()}/cache/{cid}", headers=headers)
|
||||
resp.raise_for_status()
|
||||
size = resp.headers.get("content-length", "unknown")
|
||||
content_type = resp.headers.get("content-type", "unknown")
|
||||
info = resp.json()
|
||||
click.echo(f"CID: {cid}")
|
||||
click.echo(f"Size: {size} bytes")
|
||||
click.echo(f"Type: {content_type}")
|
||||
click.echo(f"URL: {url}")
|
||||
resp.close()
|
||||
click.echo(f"Size: {info.get('size', 'unknown')} bytes")
|
||||
click.echo(f"Type: {info.get('mime_type') or info.get('media_type', 'unknown')}")
|
||||
if info.get('friendly_name'):
|
||||
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:
|
||||
if e.response.status_code == 404:
|
||||
click.echo(f"Not found: {cid}", err=True)
|
||||
@@ -806,7 +921,8 @@ def import_file(filepath):
|
||||
|
||||
@cli.command()
|
||||
@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."""
|
||||
# Check auth
|
||||
token_data = load_token()
|
||||
@@ -817,8 +933,9 @@ def upload(filepath):
|
||||
try:
|
||||
with open(filepath, "rb") as f:
|
||||
files = {"file": (Path(filepath).name, f)}
|
||||
headers = {"Authorization": f"Bearer {token_data['access_token']}"}
|
||||
resp = requests.post(f"{get_server()}/cache/upload", files=files, headers=headers)
|
||||
data = {"display_name": name} if name else {}
|
||||
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:
|
||||
click.echo("Authentication failed. Please login again.", err=True)
|
||||
sys.exit(1)
|
||||
@@ -831,10 +948,12 @@ def upload(filepath):
|
||||
sys.exit(1)
|
||||
result = resp.json()
|
||||
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()
|
||||
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:
|
||||
click.echo(f"Upload failed: {e}", err=True)
|
||||
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)
|
||||
sys.exit(1)
|
||||
|
||||
headers = {"Authorization": f"Bearer {token_data['access_token']}"}
|
||||
headers = get_auth_header(require_token=True)
|
||||
|
||||
# Handle publish action
|
||||
if publish_name:
|
||||
@@ -1269,7 +1388,7 @@ def storage_list():
|
||||
sys.exit(1)
|
||||
|
||||
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.raise_for_status()
|
||||
data = resp.json()
|
||||
@@ -1322,7 +1441,7 @@ def storage_add(provider_type, name, capacity):
|
||||
|
||||
# Send to server
|
||||
try:
|
||||
headers = {"Authorization": f"Bearer {token_data['access_token']}"}
|
||||
headers = get_auth_header(require_token=True)
|
||||
payload = {
|
||||
"provider_type": provider_type,
|
||||
"config": config,
|
||||
@@ -1355,7 +1474,7 @@ def storage_test(storage_id):
|
||||
sys.exit(1)
|
||||
|
||||
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)
|
||||
if resp.status_code == 404:
|
||||
click.echo(f"Storage provider not found: {storage_id}", err=True)
|
||||
@@ -1389,7 +1508,7 @@ def storage_delete(storage_id, force):
|
||||
return
|
||||
|
||||
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)
|
||||
if resp.status_code == 400:
|
||||
click.echo(f"Error: {resp.json().get('detail', 'Bad request')}", err=True)
|
||||
@@ -1458,7 +1577,7 @@ def upload_recipe(filepath):
|
||||
try:
|
||||
with open(filepath, "rb") as 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)
|
||||
if resp.status_code == 401:
|
||||
click.echo("Authentication failed. Please login again.", err=True)
|
||||
@@ -1479,10 +1598,11 @@ def upload_recipe(filepath):
|
||||
|
||||
@cli.command("upload-effect")
|
||||
@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.
|
||||
|
||||
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.
|
||||
"""
|
||||
token_data = load_token()
|
||||
@@ -1490,17 +1610,18 @@ def upload_effect(filepath):
|
||||
click.echo("Not logged in. Please run: artdag login <username>", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
# Check it's a Python file
|
||||
if not filepath.endswith(".py"):
|
||||
click.echo("Effect must be a Python file (.py)", err=True)
|
||||
# Check it's a sexp or py file
|
||||
if not filepath.endswith(".sexp") and not filepath.endswith(".py"):
|
||||
click.echo("Effect must be a .sexp or .py file", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
# Upload
|
||||
try:
|
||||
with open(filepath, "rb") as f:
|
||||
files = {"file": (Path(filepath).name, f)}
|
||||
headers = {"Authorization": f"Bearer {token_data['access_token']}"}
|
||||
resp = requests.post(f"{get_server()}/effects/upload", files=files, headers=headers)
|
||||
data = {"display_name": name} if name else {}
|
||||
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:
|
||||
click.echo("Authentication failed. Please login again.", err=True)
|
||||
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"CID: {result['cid']}")
|
||||
click.echo(f"Friendly name: {result.get('friendly_name', 'N/A')}")
|
||||
click.echo(f"Temporal: {result.get('temporal', False)}")
|
||||
if result.get('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("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:
|
||||
click.echo(f"Upload failed: {e}", err=True)
|
||||
sys.exit(1)
|
||||
@@ -1530,13 +1650,9 @@ def upload_effect(filepath):
|
||||
@click.option("--offset", "-o", default=0, help="Offset for pagination")
|
||||
def list_effects(limit, offset):
|
||||
"""List uploaded effects with pagination."""
|
||||
token_data = load_token()
|
||||
if not token_data.get("access_token"):
|
||||
click.echo("Not logged in. Use 'artdag login' first.", err=True)
|
||||
sys.exit(1)
|
||||
headers = get_auth_header(require_token=True)
|
||||
|
||||
try:
|
||||
headers = {"Authorization": f"Bearer {token_data['access_token']}"}
|
||||
resp = requests.get(f"{get_server()}/effects?offset={offset}&limit={limit}", headers=headers)
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
@@ -1555,11 +1671,13 @@ def list_effects(limit, offset):
|
||||
|
||||
for effect in effects:
|
||||
meta = effect.get("meta", {})
|
||||
click.echo(f" {meta.get('name', 'unknown')} v{meta.get('version', '?')}")
|
||||
click.echo(f" Hash: {effect['cid'][:32]}...")
|
||||
click.echo(f" Temporal: {meta.get('temporal', False)}")
|
||||
click.echo(f"Name: {meta.get('name', 'unknown')} v{meta.get('version', '?')}")
|
||||
click.echo(f" CID: {effect['cid']}")
|
||||
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'):
|
||||
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()
|
||||
except requests.RequestException as e:
|
||||
click.echo(f"Failed to list effects: {e}", err=True)
|
||||
@@ -1577,7 +1695,7 @@ def show_effect(cid, source):
|
||||
sys.exit(1)
|
||||
|
||||
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)
|
||||
if resp.status_code == 404:
|
||||
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")
|
||||
def list_recipes(limit, offset):
|
||||
"""List uploaded recipes for the current user with pagination."""
|
||||
token_data = load_token()
|
||||
if not token_data.get("access_token"):
|
||||
click.echo("Not logged in. Use 'artdag login' first.", err=True)
|
||||
sys.exit(1)
|
||||
headers = get_auth_header(require_token=True)
|
||||
|
||||
try:
|
||||
headers = {"Authorization": f"Bearer {token_data['access_token']}"}
|
||||
resp = requests.get(f"{get_server()}/recipes?offset={offset}&limit={limit}", headers=headers)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
@@ -1669,13 +1783,19 @@ def list_recipes(limit, offset):
|
||||
end = offset + len(recipes)
|
||||
click.echo(f"Showing {start}-{end}" + (" (more available)" if has_more else ""))
|
||||
click.echo()
|
||||
click.echo(f"{'Name':<20} {'Version':<8} {'Variables':<10} {'Recipe ID':<24}")
|
||||
click.echo("-" * 70)
|
||||
|
||||
for recipe in recipes:
|
||||
recipe_id = recipe["recipe_id"][:20] + "..."
|
||||
recipe_id = recipe["recipe_id"]
|
||||
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")
|
||||
@@ -1688,7 +1808,7 @@ def show_recipe(recipe_id):
|
||||
sys.exit(1)
|
||||
|
||||
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)
|
||||
if resp.status_code == 404:
|
||||
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)
|
||||
sys.exit(1)
|
||||
|
||||
click.echo(f"Name: {recipe['name']}")
|
||||
click.echo(f"Version: {recipe['version']}")
|
||||
click.echo(f"Name: {recipe.get('name', 'Unnamed')}")
|
||||
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"Recipe ID: {recipe['recipe_id']}")
|
||||
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"):
|
||||
click.echo("\nVariable Inputs:")
|
||||
@@ -1715,7 +1838,7 @@ def show_recipe(recipe_id):
|
||||
if recipe.get("fixed_inputs"):
|
||||
click.echo("\nFixed 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")
|
||||
@@ -1745,7 +1868,7 @@ def run_recipe(recipe_id, inputs, wait):
|
||||
|
||||
# Run
|
||||
try:
|
||||
headers = {"Authorization": f"Bearer {token_data['access_token']}"}
|
||||
headers = get_auth_header(require_token=True)
|
||||
resp = requests.post(
|
||||
f"{get_server()}/recipes/{recipe_id}/run",
|
||||
json={"inputs": input_dict},
|
||||
@@ -1808,7 +1931,7 @@ def delete_recipe(recipe_id, force):
|
||||
return
|
||||
|
||||
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)
|
||||
if resp.status_code == 401:
|
||||
click.echo("Authentication failed. Please login again.", err=True)
|
||||
@@ -1872,7 +1995,7 @@ def generate_plan(recipe_file, inputs, features, output):
|
||||
|
||||
# Submit to API
|
||||
try:
|
||||
headers = {"Authorization": f"Bearer {token_data['access_token']}"}
|
||||
headers = get_auth_header(require_token=True)
|
||||
resp = requests.post(
|
||||
f"{get_server()}/api/v2/plan",
|
||||
json=request_data,
|
||||
@@ -1933,7 +2056,7 @@ def execute_plan(plan_file, wait):
|
||||
|
||||
# Submit to API
|
||||
try:
|
||||
headers = {"Authorization": f"Bearer {token_data['access_token']}"}
|
||||
headers = get_auth_header(require_token=True)
|
||||
resp = requests.post(
|
||||
f"{get_server()}/api/v2/execute",
|
||||
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)}")
|
||||
|
||||
try:
|
||||
headers = {"Authorization": f"Bearer {token_data['access_token']}"}
|
||||
headers = get_auth_header(require_token=True)
|
||||
resp = requests.post(
|
||||
f"{get_server()}/api/v2/run-recipe",
|
||||
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):
|
||||
"""Poll v2 run status until completion."""
|
||||
click.echo("Waiting for completion...")
|
||||
headers = {"Authorization": f"Bearer {token_data['access_token']}"}
|
||||
headers = get_auth_header(require_token=True)
|
||||
|
||||
while True:
|
||||
time.sleep(2)
|
||||
@@ -2094,7 +2217,7 @@ def run_status_v2(run_id):
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
headers = {"Authorization": f"Bearer {token_data['access_token']}"}
|
||||
headers = get_auth_header(require_token=True)
|
||||
resp = requests.get(
|
||||
f"{get_server()}/api/v2/run/{run_id}",
|
||||
headers=headers
|
||||
@@ -2127,5 +2250,105 @@ def run_status_v2(run_id):
|
||||
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__":
|
||||
cli()
|
||||
|
||||
38
test_gpu_effects.sexp
Normal file
38
test_gpu_effects.sexp
Normal 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
26
test_simple.sexp
Normal 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)))
|
||||
Reference in New Issue
Block a user