Fix CPU HLS streaming (yuv420p) and opt-in middleware for fragments

- Add -pix_fmt yuv420p to multi_res_output.py libx264 path so browsers
  can decode CPU-encoded segments (was producing yuv444p / High 4:4:4).
- Switch silent auth check and coop fragment middlewares from opt-out
  blocklists to opt-in: only run for GET requests with Accept: text/html.
  Prevents unnecessary nav-tree/auth-menu HTTP calls on every HLS segment,
  IPFS proxy, and API request.
- Add opaque grant token verification to L1/L2 dependencies.
- Migrate client CLI to device authorization flow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
giles
2026-02-25 18:33:53 +00:00
parent 4f49985cd5
commit b788f1f778
5 changed files with 252 additions and 188 deletions

View File

@@ -21,11 +21,11 @@ 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"
_DEFAULT_ACCOUNT_SERVER = "https://account.rose-ash.com"
# Active server URLs (set during CLI init)
DEFAULT_SERVER = None
DEFAULT_L2_SERVER = None
DEFAULT_ACCOUNT_SERVER = None
def load_config() -> dict:
@@ -51,9 +51,9 @@ def get_server():
return DEFAULT_SERVER
def get_l2_server():
"""Get L2 server URL."""
return DEFAULT_L2_SERVER
def get_account_server():
"""Get account server URL."""
return DEFAULT_ACCOUNT_SERVER
def load_token() -> dict:
@@ -115,24 +115,24 @@ def _get_default_server():
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"]
def _get_default_account():
"""Get default account server from env, config, or builtin default."""
if os.environ.get("ARTDAG_ACCOUNT"):
return os.environ["ARTDAG_ACCOUNT"]
config = load_config()
return config.get("l2", _DEFAULT_L2_SERVER)
return config.get("account", _DEFAULT_ACCOUNT_SERVER)
@click.group()
@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.option("--account", default=None,
help="Account server URL (saved for future use)")
@click.pass_context
def cli(ctx, server, l2):
def cli(ctx, server, account):
"""Art DAG Client - interact with L1 rendering server."""
ctx.ensure_object(dict)
global DEFAULT_SERVER, DEFAULT_L2_SERVER
global DEFAULT_SERVER, DEFAULT_ACCOUNT_SERVER
config = load_config()
config_changed = False
@@ -146,134 +146,106 @@ def cli(ctx, server, l2):
else:
DEFAULT_SERVER = _get_default_server()
if l2:
DEFAULT_L2_SERVER = l2
if config.get("l2") != l2:
config["l2"] = l2
if account:
DEFAULT_ACCOUNT_SERVER = account
if config.get("account") != account:
config["account"] = account
config_changed = True
else:
DEFAULT_L2_SERVER = _get_default_l2()
DEFAULT_ACCOUNT_SERVER = _get_default_account()
# Save config if changed
if config_changed:
save_config(config)
ctx.obj["server"] = DEFAULT_SERVER
ctx.obj["l2"] = DEFAULT_L2_SERVER
ctx.obj["account"] = DEFAULT_ACCOUNT_SERVER
# ============ Auth Commands ============
@cli.command()
@click.argument("username")
@click.option("--password", "-p", prompt=True, hide_input=True)
def login(username, password):
"""Login to get access token."""
def login():
"""Login via device authorization flow."""
import webbrowser
account = get_account_server()
# Request device code
try:
# Server expects form data, not JSON
resp = requests.post(
f"{get_l2_server()}/auth/login",
data={"username": username, "password": password}
f"{account}/auth/device/authorize",
json={"client_id": "artdag"},
)
if resp.status_code == 200:
# Check if we got a token back in a cookie
if "auth_token" in resp.cookies:
token = resp.cookies["auth_token"]
# Decode token to get username and expiry
import base64
try:
# JWT format: header.payload.signature
payload = token.split(".")[1]
# Add padding if needed
payload += "=" * (4 - len(payload) % 4)
decoded = json.loads(base64.urlsafe_b64decode(payload))
token_data = {
"access_token": token,
"username": decoded.get("username", username),
"expires_at": decoded.get("exp", "")
}
save_token(token_data)
click.echo(f"Logged in as {token_data['username']}")
if token_data.get("expires_at"):
click.echo(f"Token expires: {token_data['expires_at']}")
except Exception:
# If we can't decode, just save the token
save_token({"access_token": token, "username": username})
click.echo(f"Logged in as {username}")
else:
# HTML response - check for success/error
if "successful" in resp.text.lower():
click.echo(f"Login successful but no token received. Try logging in via web browser.")
elif "invalid" in resp.text.lower():
click.echo(f"Login failed: Invalid username or password", err=True)
sys.exit(1)
else:
click.echo(f"Login failed: {resp.text}", err=True)
sys.exit(1)
else:
click.echo(f"Login failed: {resp.text}", err=True)
sys.exit(1)
resp.raise_for_status()
data = resp.json()
except requests.RequestException as e:
click.echo(f"Login failed: {e}", err=True)
sys.exit(1)
device_code = data["device_code"]
user_code = data["user_code"]
verification_uri = data["verification_uri"]
expires_in = data.get("expires_in", 900)
interval = data.get("interval", 5)
@cli.command()
@click.argument("username")
@click.option("--password", "-p", prompt=True, hide_input=True, confirmation_prompt=True)
@click.option("--email", "-e", default=None, help="Email (optional)")
def register(username, password, email):
"""Register a new account."""
click.echo("To sign in, open this URL in your browser:")
click.echo(f" {verification_uri}")
click.echo(f" and enter code: {user_code}")
click.echo()
# Try to open browser automatically
try:
# Server expects form data, not JSON
form_data = {
"username": username,
"password": password,
"password2": password,
}
if email:
form_data["email"] = email
webbrowser.open(verification_uri)
except Exception:
pass
resp = requests.post(
f"{get_l2_server()}/auth/register",
data=form_data
)
if resp.status_code == 200:
# Check if we got a token back in a cookie
if "auth_token" in resp.cookies:
token = resp.cookies["auth_token"]
# Decode token to get username and expiry
import base64
try:
# JWT format: header.payload.signature
payload = token.split(".")[1]
# Add padding if needed
payload += "=" * (4 - len(payload) % 4)
decoded = json.loads(base64.urlsafe_b64decode(payload))
token_data = {
"access_token": token,
"username": decoded.get("username", username),
"expires_at": decoded.get("exp", "")
}
save_token(token_data)
click.echo(f"Registered and logged in as {token_data['username']}")
except Exception:
# If we can't decode, just save the token
save_token({"access_token": token, "username": username})
click.echo(f"Registered and logged in as {username}")
else:
# HTML response - registration successful
if "successful" in resp.text.lower():
click.echo(f"Registered as {username}. Please login to get a token.")
else:
click.echo(f"Registration failed: {resp.text}", err=True)
sys.exit(1)
else:
click.echo(f"Registration failed: {resp.text}", err=True)
# Poll for approval
click.echo("Waiting for authorization", nl=False)
deadline = time.time() + expires_in
while time.time() < deadline:
time.sleep(interval)
click.echo(".", nl=False)
try:
resp = requests.post(
f"{account}/auth/device/token",
json={"device_code": device_code, "client_id": "artdag"},
)
data = resp.json()
except requests.RequestException:
continue
error = data.get("error")
if error == "authorization_pending":
continue
elif error == "expired_token":
click.echo()
click.echo("Code expired. Please try again.", err=True)
sys.exit(1)
except requests.RequestException as e:
click.echo(f"Registration failed: {e}", err=True)
sys.exit(1)
elif error == "access_denied":
click.echo()
click.echo("Authorization denied.", err=True)
sys.exit(1)
elif error:
click.echo()
click.echo(f"Login failed: {error}", err=True)
sys.exit(1)
# Success
token_data = {
"access_token": data["access_token"],
"username": data.get("username", ""),
"display_name": data.get("display_name", ""),
}
save_token(token_data)
click.echo()
click.echo(f"Logged in as {token_data['username'] or token_data['display_name']}")
return
click.echo()
click.echo("Timed out waiting for authorization.", err=True)
sys.exit(1)
@cli.command()
@@ -291,22 +263,12 @@ def whoami():
click.echo("Not logged in")
return
try:
resp = requests.get(
f"{get_l2_server()}/auth/me",
headers={"Authorization": f"Bearer {token_data['access_token']}"}
)
if resp.status_code == 200:
user = resp.json()
click.echo(f"Username: {user['username']}")
click.echo(f"Created: {user['created_at']}")
if user.get('email'):
click.echo(f"Email: {user['email']}")
else:
click.echo("Token invalid or expired. Please login again.", err=True)
clear_token()
except requests.RequestException as e:
click.echo(f"Error: {e}", err=True)
username = token_data.get("username", "")
display_name = token_data.get("display_name", "")
if username:
click.echo(f"Username: {username}")
if display_name:
click.echo(f"Name: {display_name}")
@cli.command("config")
@@ -332,11 +294,11 @@ def show_config(clear):
else:
click.echo(f" (default)")
click.echo(f"L2 Server: {DEFAULT_L2_SERVER}")
if config.get("l2"):
click.echo(f"Account Server: {DEFAULT_ACCOUNT_SERVER}")
if config.get("account"):
click.echo(f" (saved)")
elif os.environ.get("ARTDAG_L2"):
click.echo(f" (from ARTDAG_L2 env)")
elif os.environ.get("ARTDAG_ACCOUNT"):
click.echo(f" (from ARTDAG_ACCOUNT env)")
else:
click.echo(f" (default)")
@@ -459,7 +421,7 @@ def run(recipe, input_hash, name, wait):
# Check auth
token_data = load_token()
if not token_data.get("access_token"):
click.echo("Not logged in. Please run: artdag login <username>", err=True)
click.echo("Not logged in. Please run: artdag login", err=True)
sys.exit(1)
# Resolve named assets
@@ -691,7 +653,7 @@ def delete_run(run_id, force):
"""
token_data = load_token()
if not token_data.get("access_token"):
click.echo("Not logged in. Please run: artdag login <username>", err=True)
click.echo("Not logged in. Please run: artdag login", err=True)
sys.exit(1)
# Get run info first
@@ -741,7 +703,7 @@ def delete_cache(cid, force):
"""
token_data = load_token()
if not token_data.get("access_token"):
click.echo("Not logged in. Please run: artdag login <username>", err=True)
click.echo("Not logged in. Please run: artdag login", err=True)
sys.exit(1)
if not force:
@@ -927,7 +889,7 @@ def upload(filepath, name):
# Check auth
token_data = load_token()
if not token_data.get("access_token"):
click.echo("Not logged in. Please run: artdag login <username>", err=True)
click.echo("Not logged in. Please run: artdag login", err=True)
sys.exit(1)
try:
@@ -980,13 +942,13 @@ def publish(run_id, output_name):
# Check auth
token_data = load_token()
if not token_data.get("access_token"):
click.echo("Not logged in. Please run: artdag login <username>", err=True)
click.echo("Not logged in. Please run: artdag login", err=True)
sys.exit(1)
# Post to L2 server with auth, including which L1 server has the run
try:
resp = requests.post(
f"{get_l2_server()}/registry/record-run",
f"{get_server()}/registry/record-run",
json={"run_id": run_id, "output_name": output_name, "l1_server": get_server()},
headers={"Authorization": f"Bearer {token_data['access_token']}"}
)
@@ -1031,7 +993,7 @@ def meta(cid, origin, origin_url, origin_note, description, tags, folder, add_co
"""
token_data = load_token()
if not token_data.get("access_token"):
click.echo("Not logged in. Please run: artdag login <username>", err=True)
click.echo("Not logged in. Please run: artdag login", err=True)
sys.exit(1)
headers = get_auth_header(require_token=True)
@@ -1200,7 +1162,7 @@ def folder_list():
"""List all folders."""
token_data = load_token()
if not token_data.get("access_token"):
click.echo("Not logged in. Please run: artdag login <username>", err=True)
click.echo("Not logged in. Please run: artdag login", err=True)
sys.exit(1)
try:
@@ -1225,7 +1187,7 @@ def folder_create(path):
"""Create a new folder."""
token_data = load_token()
if not token_data.get("access_token"):
click.echo("Not logged in. Please run: artdag login <username>", err=True)
click.echo("Not logged in. Please run: artdag login", err=True)
sys.exit(1)
try:
@@ -1251,7 +1213,7 @@ def folder_delete(path):
"""Delete a folder (must be empty)."""
token_data = load_token()
if not token_data.get("access_token"):
click.echo("Not logged in. Please run: artdag login <username>", err=True)
click.echo("Not logged in. Please run: artdag login", err=True)
sys.exit(1)
try:
@@ -1287,7 +1249,7 @@ def collection_list():
"""List all collections."""
token_data = load_token()
if not token_data.get("access_token"):
click.echo("Not logged in. Please run: artdag login <username>", err=True)
click.echo("Not logged in. Please run: artdag login", err=True)
sys.exit(1)
try:
@@ -1312,7 +1274,7 @@ def collection_create(name):
"""Create a new collection."""
token_data = load_token()
if not token_data.get("access_token"):
click.echo("Not logged in. Please run: artdag login <username>", err=True)
click.echo("Not logged in. Please run: artdag login", err=True)
sys.exit(1)
try:
@@ -1338,7 +1300,7 @@ def collection_delete(name):
"""Delete a collection."""
token_data = load_token()
if not token_data.get("access_token"):
click.echo("Not logged in. Please run: artdag login <username>", err=True)
click.echo("Not logged in. Please run: artdag login", err=True)
sys.exit(1)
try:
@@ -1546,7 +1508,7 @@ def upload_recipe(filepath):
"""Upload a recipe file (YAML or S-expression). Requires login."""
token_data = load_token()
if not token_data.get("access_token"):
click.echo("Not logged in. Please run: artdag login <username>", err=True)
click.echo("Not logged in. Please run: artdag login", err=True)
sys.exit(1)
# Read content
@@ -1607,7 +1569,7 @@ def upload_effect(filepath, name):
"""
token_data = load_token()
if not token_data.get("access_token"):
click.echo("Not logged in. Please run: artdag login <username>", err=True)
click.echo("Not logged in. Please run: artdag login", err=True)
sys.exit(1)
# Check it's a sexp or py file
@@ -1854,7 +1816,7 @@ def run_recipe(recipe_id, inputs, wait):
"""
token_data = load_token()
if not token_data.get("access_token"):
click.echo("Not logged in. Please run: artdag login <username>", err=True)
click.echo("Not logged in. Please run: artdag login", err=True)
sys.exit(1)
# Parse inputs
@@ -1922,7 +1884,7 @@ def delete_recipe(recipe_id, force):
"""Delete a recipe. Requires login."""
token_data = load_token()
if not token_data.get("access_token"):
click.echo("Not logged in. Please run: artdag login <username>", err=True)
click.echo("Not logged in. Please run: artdag login", err=True)
sys.exit(1)
if not force:
@@ -1969,7 +1931,7 @@ def generate_plan(recipe_file, inputs, features, output):
"""
token_data = load_token()
if not token_data.get("access_token"):
click.echo("Not logged in. Please run: artdag login <username>", err=True)
click.echo("Not logged in. Please run: artdag login", err=True)
sys.exit(1)
# Read recipe YAML
@@ -2047,7 +2009,7 @@ def execute_plan(plan_file, wait):
"""
token_data = load_token()
if not token_data.get("access_token"):
click.echo("Not logged in. Please run: artdag login <username>", err=True)
click.echo("Not logged in. Please run: artdag login", err=True)
sys.exit(1)
# Read plan JSON
@@ -2098,7 +2060,7 @@ def run_recipe_v2(recipe_file, inputs, features, wait):
"""
token_data = load_token()
if not token_data.get("access_token"):
click.echo("Not logged in. Please run: artdag login <username>", err=True)
click.echo("Not logged in. Please run: artdag login", err=True)
sys.exit(1)
# Read recipe YAML
@@ -2213,7 +2175,7 @@ def run_status_v2(run_id):
"""
token_data = load_token()
if not token_data.get("access_token"):
click.echo("Not logged in. Please run: artdag login <username>", err=True)
click.echo("Not logged in. Please run: artdag login", err=True)
sys.exit(1)
try:
@@ -2267,7 +2229,7 @@ def run_stream(recipe_file, output, duration, fps, sources, audio, wait):
"""
token_data = load_token()
if not token_data.get("access_token"):
click.echo("Not logged in. Please run: artdag login <username>", err=True)
click.echo("Not logged in. Please run: artdag login", err=True)
sys.exit(1)
# Read recipe file