diff --git a/common/README.md b/common/README.md
new file mode 100644
index 0000000..73d1dd5
--- /dev/null
+++ b/common/README.md
@@ -0,0 +1,293 @@
+# artdag-common
+
+Shared components for Art-DAG L1 (celery) and L2 (activity-pub) servers.
+
+## Features
+
+- **Jinja2 Templating**: Unified template environment with shared base templates
+- **Reusable Components**: Cards, tables, pagination, DAG visualization, media preview
+- **Authentication Middleware**: Cookie and JWT token parsing
+- **Content Negotiation**: HTML/JSON/ActivityPub format detection
+- **Utility Functions**: Hash truncation, file size formatting, status colors
+
+## Installation
+
+```bash
+pip install -e /path/to/artdag-common
+
+# Or add to requirements.txt
+-e file:../common
+```
+
+## Quick Start
+
+```python
+from fastapi import FastAPI, Request
+from artdag_common import create_jinja_env, render
+
+app = FastAPI()
+
+# Initialize templates with app-specific directory
+templates = create_jinja_env("app/templates")
+
+@app.get("/")
+async def home(request: Request):
+ return render(templates, "home.html", request, title="Home")
+```
+
+## Package Structure
+
+```
+artdag_common/
+├── __init__.py # Package exports
+├── constants.py # CDN URLs, colors, configs
+├── rendering.py # Jinja2 environment and helpers
+├── middleware/
+│ ├── auth.py # Authentication utilities
+│ └── content_negotiation.py # Accept header parsing
+├── models/
+│ ├── requests.py # Shared request models
+│ └── responses.py # Shared response models
+├── utils/
+│ ├── formatting.py # Text/date formatting
+│ ├── media.py # Media type detection
+│ └── pagination.py # Pagination helpers
+└── templates/
+ ├── base.html # Base layout template
+ └── components/
+ ├── badge.html # Status/type badges
+ ├── card.html # Info cards
+ ├── dag.html # Cytoscape DAG visualization
+ ├── media_preview.html # Video/image/audio preview
+ ├── pagination.html # HTMX pagination
+ └── table.html # Styled tables
+```
+
+## Jinja2 Templates
+
+### Base Template
+
+The `base.html` template provides:
+- Dark theme with Tailwind CSS
+- HTMX integration
+- Navigation slot
+- Content block
+- Optional Cytoscape.js block
+
+```html
+{% extends "base.html" %}
+
+{% block title %}My Page{% endblock %}
+
+{% block content %}
+
Hello World
+{% endblock %}
+```
+
+### Reusable Components
+
+#### Card
+
+```html
+{% include "components/card.html" %}
+```
+
+```html
+
+
+ {% block card_title %}Title{% endblock %}
+ {% block card_content %}Content{% endblock %}
+
+```
+
+#### Badge
+
+Status and type badges with appropriate colors:
+
+```html
+{% from "components/badge.html" import status_badge, type_badge %}
+
+{{ status_badge("completed") }}
+{{ status_badge("failed") }}
+{{ type_badge("video") }}
+```
+
+#### DAG Visualization
+
+Interactive Cytoscape.js graph:
+
+```html
+{% include "components/dag.html" %}
+```
+
+Requires passing `nodes` and `edges` data to template context.
+
+#### Media Preview
+
+Responsive media preview with format detection:
+
+```html
+{% include "components/media_preview.html" %}
+```
+
+Supports video, audio, and image formats.
+
+#### Pagination
+
+HTMX-powered infinite scroll pagination:
+
+```html
+{% include "components/pagination.html" %}
+```
+
+## Template Rendering
+
+### Full Page Render
+
+```python
+from artdag_common import render
+
+@app.get("/runs/{run_id}")
+async def run_detail(run_id: str, request: Request):
+ run = get_run(run_id)
+ return render(templates, "runs/detail.html", request, run=run)
+```
+
+### Fragment Render (HTMX)
+
+```python
+from artdag_common import render_fragment
+
+@app.get("/runs/{run_id}/status")
+async def run_status_fragment(run_id: str):
+ run = get_run(run_id)
+ html = render_fragment(templates, "components/status.html", status=run.status)
+ return HTMLResponse(html)
+```
+
+## Authentication Middleware
+
+### UserContext
+
+```python
+from artdag_common.middleware.auth import UserContext, get_user_from_cookie
+
+@app.get("/profile")
+async def profile(request: Request):
+ user = get_user_from_cookie(request)
+ if not user:
+ return RedirectResponse("/login")
+ return {"username": user.username, "actor_id": user.actor_id}
+```
+
+### Token Parsing
+
+```python
+from artdag_common.middleware.auth import get_user_from_header, decode_jwt_claims
+
+@app.get("/api/me")
+async def api_me(request: Request):
+ user = get_user_from_header(request)
+ if not user:
+ raise HTTPException(401, "Not authenticated")
+ return {"user": user.username}
+```
+
+## Content Negotiation
+
+Detect what response format the client wants:
+
+```python
+from artdag_common.middleware.content_negotiation import wants_html, wants_json, wants_activity_json
+
+@app.get("/users/{username}")
+async def user_profile(username: str, request: Request):
+ user = get_user(username)
+
+ if wants_activity_json(request):
+ return ActivityPubActor(user)
+ elif wants_json(request):
+ return user.dict()
+ else:
+ return render(templates, "users/profile.html", request, user=user)
+```
+
+## Constants
+
+### CDN URLs
+
+```python
+from artdag_common import TAILWIND_CDN, HTMX_CDN, CYTOSCAPE_CDN
+
+# Available in templates as globals:
+# {{ TAILWIND_CDN }}
+# {{ HTMX_CDN }}
+# {{ CYTOSCAPE_CDN }}
+```
+
+### Node Colors
+
+```python
+from artdag_common import NODE_COLORS
+
+# {
+# "SOURCE": "#3b82f6", # Blue
+# "EFFECT": "#22c55e", # Green
+# "OUTPUT": "#a855f7", # Purple
+# "ANALYSIS": "#f59e0b", # Amber
+# "_LIST": "#6366f1", # Indigo
+# "default": "#6b7280", # Gray
+# }
+```
+
+### Status Colors
+
+```python
+STATUS_COLORS = {
+ "completed": "bg-green-600",
+ "cached": "bg-blue-600",
+ "running": "bg-yellow-600",
+ "pending": "bg-gray-600",
+ "failed": "bg-red-600",
+}
+```
+
+## Custom Jinja2 Filters
+
+The following filters are available in all templates:
+
+| Filter | Usage | Description |
+|--------|-------|-------------|
+| `truncate_hash` | `{{ hash\|truncate_hash }}` | Shorten hash to 16 chars with ellipsis |
+| `format_size` | `{{ bytes\|format_size }}` | Format bytes as KB/MB/GB |
+| `status_color` | `{{ status\|status_color }}` | Get Tailwind class for status |
+
+Example:
+
+```html
+
+ {{ run.status }}
+
+
+{{ content_hash|truncate_hash }}
+
+{{ file_size|format_size }}
+```
+
+## Development
+
+```bash
+cd /root/art-dag/common
+
+# Install in development mode
+pip install -e .
+
+# Run tests
+pytest
+```
+
+## Dependencies
+
+- `fastapi>=0.100.0` - Web framework
+- `jinja2>=3.1.0` - Templating engine
+- `pydantic>=2.0.0` - Data validation
diff --git a/common/artdag_common/__init__.py b/common/artdag_common/__init__.py
new file mode 100644
index 0000000..e7fd6e5
--- /dev/null
+++ b/common/artdag_common/__init__.py
@@ -0,0 +1,18 @@
+"""
+Art-DAG Common Library
+
+Shared components for L1 (celery) and L2 (activity-pub) servers.
+"""
+
+from .constants import NODE_COLORS, TAILWIND_CDN, HTMX_CDN, CYTOSCAPE_CDN
+from .rendering import create_jinja_env, render, render_fragment
+
+__all__ = [
+ "NODE_COLORS",
+ "TAILWIND_CDN",
+ "HTMX_CDN",
+ "CYTOSCAPE_CDN",
+ "create_jinja_env",
+ "render",
+ "render_fragment",
+]
diff --git a/common/artdag_common/constants.py b/common/artdag_common/constants.py
new file mode 100644
index 0000000..ee8862d
--- /dev/null
+++ b/common/artdag_common/constants.py
@@ -0,0 +1,76 @@
+"""
+Shared constants for Art-DAG servers.
+"""
+
+# CDN URLs
+TAILWIND_CDN = "https://cdn.tailwindcss.com?plugins=typography"
+HTMX_CDN = "https://unpkg.com/htmx.org@1.9.10"
+CYTOSCAPE_CDN = "https://cdnjs.cloudflare.com/ajax/libs/cytoscape/3.28.1/cytoscape.min.js"
+DAGRE_CDN = "https://cdnjs.cloudflare.com/ajax/libs/dagre/0.8.5/dagre.min.js"
+CYTOSCAPE_DAGRE_CDN = "https://cdn.jsdelivr.net/npm/cytoscape-dagre@2.5.0/cytoscape-dagre.min.js"
+
+# Node colors for DAG visualization
+NODE_COLORS = {
+ "SOURCE": "#3b82f6", # Blue - input sources
+ "EFFECT": "#22c55e", # Green - processing effects
+ "OUTPUT": "#a855f7", # Purple - final outputs
+ "ANALYSIS": "#f59e0b", # Amber - analysis nodes
+ "_LIST": "#6366f1", # Indigo - list aggregation
+ "default": "#6b7280", # Gray - unknown types
+}
+
+# Status colors
+STATUS_COLORS = {
+ "completed": "bg-green-600",
+ "cached": "bg-blue-600",
+ "running": "bg-yellow-600",
+ "pending": "bg-gray-600",
+ "failed": "bg-red-600",
+}
+
+# Tailwind dark theme configuration
+TAILWIND_CONFIG = """
+
+
+"""
+
+# Default pagination settings
+DEFAULT_PAGE_SIZE = 20
+MAX_PAGE_SIZE = 100
diff --git a/common/artdag_common/fragments.py b/common/artdag_common/fragments.py
new file mode 100644
index 0000000..321949b
--- /dev/null
+++ b/common/artdag_common/fragments.py
@@ -0,0 +1,91 @@
+"""Fragment client for fetching HTML fragments from coop apps.
+
+Lightweight httpx-based client (no Quart dependency) for Art-DAG to consume
+coop app fragments like nav-tree, auth-menu, and cart-mini.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import os
+from typing import Sequence
+
+import httpx
+
+log = logging.getLogger(__name__)
+
+FRAGMENT_HEADER = "X-Fragment-Request"
+
+_client: httpx.AsyncClient | None = None
+_DEFAULT_TIMEOUT = 2.0
+
+
+def _get_client() -> httpx.AsyncClient:
+ global _client
+ if _client is None or _client.is_closed:
+ _client = httpx.AsyncClient(
+ timeout=httpx.Timeout(_DEFAULT_TIMEOUT),
+ follow_redirects=False,
+ )
+ return _client
+
+
+def _internal_url(app_name: str) -> str:
+ """Resolve internal base URL for a coop app.
+
+ Looks up ``INTERNAL_URL_{APP}`` first, falls back to ``http://{app}:8000``.
+ """
+ env_key = f"INTERNAL_URL_{app_name.upper()}"
+ return os.getenv(env_key, f"http://{app_name}:8000").rstrip("/")
+
+
+async def fetch_fragment(
+ app_name: str,
+ fragment_type: str,
+ *,
+ params: dict | None = None,
+ timeout: float = _DEFAULT_TIMEOUT,
+ required: bool = False,
+) -> str:
+ """Fetch an HTML fragment from a coop app.
+
+ Returns empty string on failure by default (required=False).
+ """
+ base = _internal_url(app_name)
+ url = f"{base}/internal/fragments/{fragment_type}"
+ try:
+ resp = await _get_client().get(
+ url,
+ params=params,
+ headers={FRAGMENT_HEADER: "1"},
+ timeout=timeout,
+ )
+ if resp.status_code == 200:
+ return resp.text
+ msg = f"Fragment {app_name}/{fragment_type} returned {resp.status_code}"
+ log.warning(msg)
+ if required:
+ raise RuntimeError(msg)
+ return ""
+ except RuntimeError:
+ raise
+ except Exception as exc:
+ msg = f"Fragment {app_name}/{fragment_type} failed: {exc}"
+ log.warning(msg)
+ if required:
+ raise RuntimeError(msg) from exc
+ return ""
+
+
+async def fetch_fragments(
+ requests: Sequence[tuple[str, str, dict | None]],
+ *,
+ timeout: float = _DEFAULT_TIMEOUT,
+ required: bool = False,
+) -> list[str]:
+ """Fetch multiple fragments concurrently."""
+ return list(await asyncio.gather(*(
+ fetch_fragment(app, ftype, params=params, timeout=timeout, required=required)
+ for app, ftype, params in requests
+ )))
diff --git a/common/artdag_common/middleware/__init__.py b/common/artdag_common/middleware/__init__.py
new file mode 100644
index 0000000..185158d
--- /dev/null
+++ b/common/artdag_common/middleware/__init__.py
@@ -0,0 +1,16 @@
+"""
+Middleware and FastAPI dependencies for Art-DAG servers.
+"""
+
+from .auth import UserContext, get_user_from_cookie, get_user_from_header, require_auth
+from .content_negotiation import wants_html, wants_json, ContentType
+
+__all__ = [
+ "UserContext",
+ "get_user_from_cookie",
+ "get_user_from_header",
+ "require_auth",
+ "wants_html",
+ "wants_json",
+ "ContentType",
+]
diff --git a/common/artdag_common/middleware/__pycache__/auth.cpython-310.pyc b/common/artdag_common/middleware/__pycache__/auth.cpython-310.pyc
new file mode 100644
index 0000000..0a26285
Binary files /dev/null and b/common/artdag_common/middleware/__pycache__/auth.cpython-310.pyc differ
diff --git a/common/artdag_common/middleware/auth.py b/common/artdag_common/middleware/auth.py
new file mode 100644
index 0000000..b227894
--- /dev/null
+++ b/common/artdag_common/middleware/auth.py
@@ -0,0 +1,276 @@
+"""
+Authentication middleware and dependencies.
+
+Provides common authentication patterns for L1 and L2 servers.
+Each server can extend or customize these as needed.
+"""
+
+from dataclasses import dataclass
+from typing import Callable, Optional, Awaitable, Any
+import base64
+import json
+
+from fastapi import Request, HTTPException, Depends
+from fastapi.responses import RedirectResponse
+
+
+@dataclass
+class UserContext:
+ """User context extracted from authentication."""
+ username: str
+ actor_id: str # Full actor ID like "@user@server.com"
+ token: Optional[str] = None
+ l2_server: Optional[str] = None # L2 server URL for this user
+ email: Optional[str] = None # User's email address
+
+ @property
+ def display_name(self) -> str:
+ """Get display name (username without @ prefix)."""
+ return self.username.lstrip("@")
+
+
+def get_user_from_cookie(request: Request) -> Optional[UserContext]:
+ """
+ Extract user context from session cookie.
+
+ Supports two cookie formats:
+ 1. artdag_session: base64-encoded JSON {"username": "user", "actor_id": "@user@server.com"}
+ 2. auth_token: raw JWT token (used by L1 servers)
+
+ Args:
+ request: FastAPI request
+
+ Returns:
+ UserContext if valid cookie found, None otherwise
+ """
+ # Try artdag_session cookie first (base64-encoded JSON)
+ cookie = request.cookies.get("artdag_session")
+ if cookie:
+ try:
+ data = json.loads(base64.b64decode(cookie))
+ username = data.get("username", "")
+ actor_id = data.get("actor_id", "")
+ if not actor_id and username:
+ actor_id = f"@{username}"
+ return UserContext(
+ username=username,
+ actor_id=actor_id,
+ email=data.get("email", ""),
+ )
+ except (json.JSONDecodeError, ValueError, KeyError):
+ pass
+
+ # Try auth_token cookie (raw JWT, used by L1)
+ token = request.cookies.get("auth_token")
+ if token:
+ claims = decode_jwt_claims(token)
+ if claims:
+ username = claims.get("username") or claims.get("sub", "")
+ actor_id = claims.get("actor_id") or claims.get("actor")
+ if not actor_id and username:
+ actor_id = f"@{username}"
+ return UserContext(
+ username=username,
+ actor_id=actor_id or "",
+ token=token,
+ email=claims.get("email", ""),
+ )
+
+ return None
+
+
+def get_user_from_header(request: Request) -> Optional[UserContext]:
+ """
+ Extract user context from Authorization header.
+
+ Supports:
+ - Bearer format (JWT or opaque token)
+ - Basic format
+
+ Args:
+ request: FastAPI request
+
+ Returns:
+ UserContext if valid header found, None otherwise
+ """
+ auth_header = request.headers.get("Authorization", "")
+
+ if auth_header.startswith("Bearer "):
+ token = auth_header[7:]
+ # Attempt to decode JWT claims
+ claims = decode_jwt_claims(token)
+ if claims:
+ username = claims.get("username") or claims.get("sub", "")
+ actor_id = claims.get("actor_id") or claims.get("actor")
+ # Default actor_id to @username if not provided
+ if not actor_id and username:
+ actor_id = f"@{username}"
+ return UserContext(
+ username=username,
+ actor_id=actor_id or "",
+ token=token,
+ )
+
+ return None
+
+
+def decode_jwt_claims(token: str) -> Optional[dict]:
+ """
+ Decode JWT claims without verification.
+
+ This is useful for extracting user info from a token
+ when full verification is handled elsewhere.
+
+ Args:
+ token: JWT token string
+
+ Returns:
+ Claims dict if valid JWT format, None otherwise
+ """
+ try:
+ parts = token.split(".")
+ if len(parts) != 3:
+ return None
+
+ # Decode payload (second part)
+ payload = parts[1]
+ # Add padding if needed
+ padding = 4 - len(payload) % 4
+ if padding != 4:
+ payload += "=" * padding
+
+ return json.loads(base64.urlsafe_b64decode(payload))
+ except (json.JSONDecodeError, ValueError):
+ return None
+
+
+def create_auth_dependency(
+ token_validator: Optional[Callable[[str], Awaitable[Optional[dict]]]] = None,
+ allow_cookie: bool = True,
+ allow_header: bool = True,
+):
+ """
+ Create a customized auth dependency for a specific server.
+
+ Args:
+ token_validator: Optional async function to validate tokens with backend
+ allow_cookie: Whether to check cookies for auth
+ allow_header: Whether to check Authorization header
+
+ Returns:
+ FastAPI dependency function
+ """
+ async def get_current_user(request: Request) -> Optional[UserContext]:
+ ctx = None
+
+ # Try header first (API clients)
+ if allow_header:
+ ctx = get_user_from_header(request)
+ if ctx and token_validator:
+ # Validate token with backend
+ validated = await token_validator(ctx.token)
+ if not validated:
+ ctx = None
+
+ # Fall back to cookie (browser)
+ if ctx is None and allow_cookie:
+ ctx = get_user_from_cookie(request)
+
+ return ctx
+
+ return get_current_user
+
+
+async def require_auth(request: Request) -> UserContext:
+ """
+ Dependency that requires authentication.
+
+ Raises HTTPException 401 if not authenticated.
+ Use with Depends() in route handlers.
+
+ Example:
+ @app.get("/protected")
+ async def protected_route(user: UserContext = Depends(require_auth)):
+ return {"user": user.username}
+ """
+ # Try header first
+ ctx = get_user_from_header(request)
+ if ctx is None:
+ ctx = get_user_from_cookie(request)
+
+ if ctx is None:
+ # Check Accept header to determine response type
+ accept = request.headers.get("accept", "")
+ if "text/html" in accept:
+ raise HTTPException(
+ status_code=302,
+ headers={"Location": "/login"}
+ )
+ raise HTTPException(
+ status_code=401,
+ detail="Authentication required"
+ )
+
+ return ctx
+
+
+def require_owner(resource_owner_field: str = "username"):
+ """
+ Dependency factory that requires the user to own the resource.
+
+ Args:
+ resource_owner_field: Field name on the resource that contains owner username
+
+ Returns:
+ Dependency function
+
+ Example:
+ @app.delete("/items/{item_id}")
+ async def delete_item(
+ item: Item = Depends(get_item),
+ user: UserContext = Depends(require_owner("created_by"))
+ ):
+ ...
+ """
+ async def check_ownership(
+ request: Request,
+ user: UserContext = Depends(require_auth),
+ ) -> UserContext:
+ # The actual ownership check must be done in the route
+ # after fetching the resource
+ return user
+
+ return check_ownership
+
+
+def set_auth_cookie(response: Any, user: UserContext, max_age: int = 86400 * 30) -> None:
+ """
+ Set authentication cookie on response.
+
+ Args:
+ response: FastAPI response object
+ user: User context to store
+ max_age: Cookie max age in seconds (default 30 days)
+ """
+ cookie_data = {
+ "username": user.username,
+ "actor_id": user.actor_id,
+ }
+ if user.email:
+ cookie_data["email"] = user.email
+ data = json.dumps(cookie_data)
+ cookie_value = base64.b64encode(data.encode()).decode()
+
+ response.set_cookie(
+ key="artdag_session",
+ value=cookie_value,
+ max_age=max_age,
+ httponly=True,
+ samesite="lax",
+ secure=True, # Require HTTPS in production
+ )
+
+
+def clear_auth_cookie(response: Any) -> None:
+ """Clear authentication cookie."""
+ response.delete_cookie(key="artdag_session")
diff --git a/common/artdag_common/middleware/content_negotiation.py b/common/artdag_common/middleware/content_negotiation.py
new file mode 100644
index 0000000..aaa47c8
--- /dev/null
+++ b/common/artdag_common/middleware/content_negotiation.py
@@ -0,0 +1,174 @@
+"""
+Content negotiation utilities.
+
+Helps determine what response format the client wants.
+"""
+
+from enum import Enum
+from typing import Optional
+
+from fastapi import Request
+
+
+class ContentType(Enum):
+ """Response content types."""
+ HTML = "text/html"
+ JSON = "application/json"
+ ACTIVITY_JSON = "application/activity+json"
+ XML = "application/xml"
+
+
+def wants_html(request: Request) -> bool:
+ """
+ Check if the client wants HTML response.
+
+ Returns True if:
+ - Accept header contains text/html
+ - Accept header contains application/xhtml+xml
+ - No Accept header (browser default)
+
+ Args:
+ request: FastAPI request
+
+ Returns:
+ True if HTML is preferred
+ """
+ accept = request.headers.get("accept", "")
+
+ # No accept header usually means browser
+ if not accept:
+ return True
+
+ # Check for HTML preferences
+ if "text/html" in accept:
+ return True
+ if "application/xhtml" in accept:
+ return True
+
+ return False
+
+
+def wants_json(request: Request) -> bool:
+ """
+ Check if the client wants JSON response.
+
+ Returns True if:
+ - Accept header contains application/json
+ - Accept header does NOT contain text/html
+ - Request has .json suffix (convention)
+
+ Args:
+ request: FastAPI request
+
+ Returns:
+ True if JSON is preferred
+ """
+ accept = request.headers.get("accept", "")
+
+ # Explicit JSON preference
+ if "application/json" in accept:
+ # But not if HTML is also requested (browsers often send both)
+ if "text/html" not in accept:
+ return True
+
+ # Check URL suffix convention
+ if request.url.path.endswith(".json"):
+ return True
+
+ return False
+
+
+def wants_activity_json(request: Request) -> bool:
+ """
+ Check if the client wants ActivityPub JSON-LD response.
+
+ Used for federation with other ActivityPub servers.
+
+ Args:
+ request: FastAPI request
+
+ Returns:
+ True if ActivityPub format is preferred
+ """
+ accept = request.headers.get("accept", "")
+
+ if "application/activity+json" in accept:
+ return True
+ if "application/ld+json" in accept:
+ return True
+
+ return False
+
+
+def get_preferred_type(request: Request) -> ContentType:
+ """
+ Determine the preferred content type from Accept header.
+
+ Args:
+ request: FastAPI request
+
+ Returns:
+ ContentType enum value
+ """
+ if wants_activity_json(request):
+ return ContentType.ACTIVITY_JSON
+ if wants_json(request):
+ return ContentType.JSON
+ return ContentType.HTML
+
+
+def is_htmx_request(request: Request) -> bool:
+ """
+ Check if this is an HTMX request (partial page update).
+
+ HTMX requests set the HX-Request header.
+
+ Args:
+ request: FastAPI request
+
+ Returns:
+ True if this is an HTMX request
+ """
+ return request.headers.get("HX-Request") == "true"
+
+
+def get_htmx_target(request: Request) -> Optional[str]:
+ """
+ Get the HTMX target element ID.
+
+ Args:
+ request: FastAPI request
+
+ Returns:
+ Target element ID or None
+ """
+ return request.headers.get("HX-Target")
+
+
+def get_htmx_trigger(request: Request) -> Optional[str]:
+ """
+ Get the HTMX trigger element ID.
+
+ Args:
+ request: FastAPI request
+
+ Returns:
+ Trigger element ID or None
+ """
+ return request.headers.get("HX-Trigger")
+
+
+def is_ios_request(request: Request) -> bool:
+ """
+ Check if request is from iOS device.
+
+ Useful for video format selection (iOS prefers MP4).
+
+ Args:
+ request: FastAPI request
+
+ Returns:
+ True if iOS user agent detected
+ """
+ user_agent = request.headers.get("user-agent", "").lower()
+ return "iphone" in user_agent or "ipad" in user_agent
diff --git a/common/artdag_common/models/__init__.py b/common/artdag_common/models/__init__.py
new file mode 100644
index 0000000..d0d43c7
--- /dev/null
+++ b/common/artdag_common/models/__init__.py
@@ -0,0 +1,25 @@
+"""
+Shared Pydantic models for Art-DAG servers.
+"""
+
+from .requests import (
+ PaginationParams,
+ PublishRequest,
+ StorageConfigRequest,
+ MetadataUpdateRequest,
+)
+from .responses import (
+ PaginatedResponse,
+ ErrorResponse,
+ SuccessResponse,
+)
+
+__all__ = [
+ "PaginationParams",
+ "PublishRequest",
+ "StorageConfigRequest",
+ "MetadataUpdateRequest",
+ "PaginatedResponse",
+ "ErrorResponse",
+ "SuccessResponse",
+]
diff --git a/common/artdag_common/models/requests.py b/common/artdag_common/models/requests.py
new file mode 100644
index 0000000..1c34d45
--- /dev/null
+++ b/common/artdag_common/models/requests.py
@@ -0,0 +1,74 @@
+"""
+Request models shared across L1 and L2 servers.
+"""
+
+from typing import Optional, List, Dict, Any
+from pydantic import BaseModel, Field
+
+from ..constants import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE
+
+
+class PaginationParams(BaseModel):
+ """Common pagination parameters."""
+ page: int = Field(default=1, ge=1, description="Page number (1-indexed)")
+ limit: int = Field(
+ default=DEFAULT_PAGE_SIZE,
+ ge=1,
+ le=MAX_PAGE_SIZE,
+ description="Items per page"
+ )
+
+ @property
+ def offset(self) -> int:
+ """Calculate offset for database queries."""
+ return (self.page - 1) * self.limit
+
+
+class PublishRequest(BaseModel):
+ """Request to publish content to L2/storage."""
+ name: str = Field(..., min_length=1, max_length=255)
+ description: Optional[str] = Field(default=None, max_length=2000)
+ tags: List[str] = Field(default_factory=list)
+ storage_id: Optional[str] = Field(default=None, description="Target storage provider")
+
+
+class MetadataUpdateRequest(BaseModel):
+ """Request to update content metadata."""
+ name: Optional[str] = Field(default=None, max_length=255)
+ description: Optional[str] = Field(default=None, max_length=2000)
+ tags: Optional[List[str]] = Field(default=None)
+ metadata: Optional[Dict[str, Any]] = Field(default=None)
+
+
+class StorageConfigRequest(BaseModel):
+ """Request to configure a storage provider."""
+ provider_type: str = Field(..., description="Provider type (pinata, web3storage, local, etc.)")
+ name: str = Field(..., min_length=1, max_length=100)
+ api_key: Optional[str] = Field(default=None)
+ api_secret: Optional[str] = Field(default=None)
+ endpoint: Optional[str] = Field(default=None)
+ config: Optional[Dict[str, Any]] = Field(default_factory=dict)
+ is_default: bool = Field(default=False)
+
+
+class RecipeRunRequest(BaseModel):
+ """Request to run a recipe."""
+ recipe_id: str = Field(..., description="Recipe content hash or ID")
+ inputs: Dict[str, str] = Field(..., description="Map of input name to content hash")
+ features: List[str] = Field(
+ default=["beats", "energy"],
+ description="Analysis features to extract"
+ )
+
+
+class PlanRequest(BaseModel):
+ """Request to generate an execution plan."""
+ recipe_yaml: str = Field(..., description="Recipe YAML content")
+ input_hashes: Dict[str, str] = Field(..., description="Map of input name to content hash")
+ features: List[str] = Field(default=["beats", "energy"])
+
+
+class ExecutePlanRequest(BaseModel):
+ """Request to execute a generated plan."""
+ plan_json: str = Field(..., description="JSON-serialized execution plan")
+ run_id: Optional[str] = Field(default=None, description="Optional run ID for tracking")
diff --git a/common/artdag_common/models/responses.py b/common/artdag_common/models/responses.py
new file mode 100644
index 0000000..447e70c
--- /dev/null
+++ b/common/artdag_common/models/responses.py
@@ -0,0 +1,96 @@
+"""
+Response models shared across L1 and L2 servers.
+"""
+
+from typing import Optional, List, Dict, Any, Generic, TypeVar
+from pydantic import BaseModel, Field
+
+T = TypeVar("T")
+
+
+class PaginatedResponse(BaseModel, Generic[T]):
+ """Generic paginated response."""
+ data: List[Any] = Field(default_factory=list)
+ pagination: Dict[str, Any] = Field(default_factory=dict)
+
+ @classmethod
+ def create(
+ cls,
+ items: List[Any],
+ page: int,
+ limit: int,
+ total: int,
+ ) -> "PaginatedResponse":
+ """Create a paginated response."""
+ return cls(
+ data=items,
+ pagination={
+ "page": page,
+ "limit": limit,
+ "total": total,
+ "has_more": page * limit < total,
+ "total_pages": (total + limit - 1) // limit,
+ }
+ )
+
+
+class ErrorResponse(BaseModel):
+ """Standard error response."""
+ error: str = Field(..., description="Error message")
+ detail: Optional[str] = Field(default=None, description="Detailed error info")
+ code: Optional[str] = Field(default=None, description="Error code")
+
+
+class SuccessResponse(BaseModel):
+ """Standard success response."""
+ success: bool = Field(default=True)
+ message: Optional[str] = Field(default=None)
+ data: Optional[Dict[str, Any]] = Field(default=None)
+
+
+class RunStatus(BaseModel):
+ """Run execution status."""
+ run_id: str
+ status: str = Field(..., description="pending, running, completed, failed")
+ recipe: Optional[str] = None
+ plan_id: Optional[str] = None
+ output_hash: Optional[str] = None
+ output_ipfs_cid: Optional[str] = None
+ total_steps: int = 0
+ cached_steps: int = 0
+ completed_steps: int = 0
+ error: Optional[str] = None
+
+
+class CacheItemResponse(BaseModel):
+ """Cached content item response."""
+ content_hash: str
+ media_type: Optional[str] = None
+ size: Optional[int] = None
+ name: Optional[str] = None
+ description: Optional[str] = None
+ tags: List[str] = Field(default_factory=list)
+ ipfs_cid: Optional[str] = None
+ created_at: Optional[str] = None
+
+
+class RecipeResponse(BaseModel):
+ """Recipe response."""
+ recipe_id: str
+ name: str
+ description: Optional[str] = None
+ inputs: List[Dict[str, Any]] = Field(default_factory=list)
+ outputs: List[str] = Field(default_factory=list)
+ node_count: int = 0
+ created_at: Optional[str] = None
+
+
+class StorageProviderResponse(BaseModel):
+ """Storage provider configuration response."""
+ storage_id: str
+ provider_type: str
+ name: str
+ is_default: bool = False
+ is_connected: bool = False
+ usage_bytes: Optional[int] = None
+ pin_count: int = 0
diff --git a/common/artdag_common/rendering.py b/common/artdag_common/rendering.py
new file mode 100644
index 0000000..e5edacf
--- /dev/null
+++ b/common/artdag_common/rendering.py
@@ -0,0 +1,160 @@
+"""
+Jinja2 template rendering system for Art-DAG servers.
+
+Provides a unified template environment that can load from:
+1. The shared artdag_common/templates directory
+2. App-specific template directories
+
+Usage:
+ from artdag_common import create_jinja_env, render
+
+ # In app initialization
+ templates = create_jinja_env("app/templates")
+
+ # In route handler
+ return render(templates, "runs/detail.html", request, run=run, user=user)
+"""
+
+from pathlib import Path
+from typing import Any, Optional, Union
+
+from fastapi import Request
+from fastapi.responses import HTMLResponse
+from jinja2 import Environment, ChoiceLoader, FileSystemLoader, PackageLoader, select_autoescape
+
+from .constants import (
+ TAILWIND_CDN,
+ HTMX_CDN,
+ CYTOSCAPE_CDN,
+ DAGRE_CDN,
+ CYTOSCAPE_DAGRE_CDN,
+ TAILWIND_CONFIG,
+ NODE_COLORS,
+ STATUS_COLORS,
+)
+
+
+def create_jinja_env(*template_dirs: Union[str, Path]) -> Environment:
+ """
+ Create a Jinja2 environment with the shared templates and optional app-specific dirs.
+
+ Args:
+ *template_dirs: Additional template directories to search (app-specific)
+
+ Returns:
+ Configured Jinja2 Environment
+
+ Example:
+ env = create_jinja_env("/app/templates", "/app/custom")
+ """
+ loaders = []
+
+ # Add app-specific directories first (higher priority)
+ for template_dir in template_dirs:
+ path = Path(template_dir)
+ if path.exists():
+ loaders.append(FileSystemLoader(str(path)))
+
+ # Add shared templates from this package (lower priority, fallback)
+ loaders.append(PackageLoader("artdag_common", "templates"))
+
+ env = Environment(
+ loader=ChoiceLoader(loaders),
+ autoescape=select_autoescape(["html", "xml"]),
+ trim_blocks=True,
+ lstrip_blocks=True,
+ )
+
+ # Add global context available to all templates
+ env.globals.update({
+ "TAILWIND_CDN": TAILWIND_CDN,
+ "HTMX_CDN": HTMX_CDN,
+ "CYTOSCAPE_CDN": CYTOSCAPE_CDN,
+ "DAGRE_CDN": DAGRE_CDN,
+ "CYTOSCAPE_DAGRE_CDN": CYTOSCAPE_DAGRE_CDN,
+ "TAILWIND_CONFIG": TAILWIND_CONFIG,
+ "NODE_COLORS": NODE_COLORS,
+ "STATUS_COLORS": STATUS_COLORS,
+ })
+
+ # Add custom filters
+ env.filters["truncate_hash"] = truncate_hash
+ env.filters["format_size"] = format_size
+ env.filters["status_color"] = status_color
+
+ return env
+
+
+def render(
+ env: Environment,
+ template_name: str,
+ request: Request,
+ status_code: int = 200,
+ **context: Any,
+) -> HTMLResponse:
+ """
+ Render a template to an HTMLResponse.
+
+ Args:
+ env: Jinja2 environment
+ template_name: Template file path (e.g., "runs/detail.html")
+ request: FastAPI request object
+ status_code: HTTP status code (default 200)
+ **context: Template context variables
+
+ Returns:
+ HTMLResponse with rendered content
+ """
+ template = env.get_template(template_name)
+ html = template.render(request=request, **context)
+ return HTMLResponse(html, status_code=status_code)
+
+
+def render_fragment(
+ env: Environment,
+ template_name: str,
+ **context: Any,
+) -> str:
+ """
+ Render a template fragment to a string (for HTMX partial updates).
+
+ Args:
+ env: Jinja2 environment
+ template_name: Template file path
+ **context: Template context variables
+
+ Returns:
+ Rendered HTML string
+ """
+ template = env.get_template(template_name)
+ return template.render(**context)
+
+
+# Custom Jinja2 filters
+
+def truncate_hash(value: str, length: int = 16) -> str:
+ """Truncate a hash to specified length with ellipsis."""
+ if not value:
+ return ""
+ if len(value) <= length:
+ return value
+ return f"{value[:length]}..."
+
+
+def format_size(size_bytes: Optional[int]) -> str:
+ """Format file size in human-readable form."""
+ if size_bytes is None:
+ return "Unknown"
+ if size_bytes < 1024:
+ return f"{size_bytes} B"
+ elif size_bytes < 1024 * 1024:
+ return f"{size_bytes / 1024:.1f} KB"
+ elif size_bytes < 1024 * 1024 * 1024:
+ return f"{size_bytes / (1024 * 1024):.1f} MB"
+ else:
+ return f"{size_bytes / (1024 * 1024 * 1024):.1f} GB"
+
+
+def status_color(status: str) -> str:
+ """Get Tailwind CSS class for a status."""
+ return STATUS_COLORS.get(status, STATUS_COLORS["pending"])
diff --git a/common/artdag_common/templates/_base.html b/common/artdag_common/templates/_base.html
new file mode 100644
index 0000000..deeb67b
--- /dev/null
+++ b/common/artdag_common/templates/_base.html
@@ -0,0 +1,91 @@
+
+
+
+
+
+ {% block title %}Art-DAG{% endblock %}
+
+
+
+
+
+
+
+
+
+
+
+ {% block head %}{% endblock %}
+
+
+
+
+
+ {% block header %}
+ {# Coop-style header: sky banner with title, nav-tree, auth-menu, cart-mini #}
+
+
+
+ {# Cart mini #}
+ {% block cart_mini %}{% endblock %}
+
+ {# Site title #}
+
+
+ {# Desktop nav: nav-tree + auth-menu #}
+
+
+
+ {# Mobile auth #}
+
+ {% block auth_menu_mobile %}{% endblock %}
+
+
+ {% endblock %}
+
+ {# App-specific sub-nav (Runs, Recipes, Effects, etc.) #}
+ {% block sub_nav %}{% endblock %}
+
+
+ {% block content %}{% endblock %}
+
+
+ {% block footer %}{% endblock %}
+ {% block scripts %}{% endblock %}
+
+
diff --git a/common/artdag_common/templates/components/badge.html b/common/artdag_common/templates/components/badge.html
new file mode 100644
index 0000000..8c9f484
--- /dev/null
+++ b/common/artdag_common/templates/components/badge.html
@@ -0,0 +1,64 @@
+{#
+Badge component for status and type indicators.
+
+Usage:
+ {% from "components/badge.html" import badge, status_badge, type_badge %}
+
+ {{ badge("Active", "green") }}
+ {{ status_badge("completed") }}
+ {{ type_badge("EFFECT") }}
+#}
+
+{% macro badge(text, color="gray", class="") %}
+
+ {{ text }}
+
+{% endmacro %}
+
+{% macro status_badge(status, class="") %}
+{% set colors = {
+ "completed": "green",
+ "cached": "blue",
+ "running": "yellow",
+ "pending": "gray",
+ "failed": "red",
+ "active": "green",
+ "inactive": "gray",
+} %}
+{% set color = colors.get(status, "gray") %}
+
+ {% if status == "running" %}
+
+ {% endif %}
+ {{ status | capitalize }}
+
+{% endmacro %}
+
+{% macro type_badge(node_type, class="") %}
+{% set colors = {
+ "SOURCE": "blue",
+ "EFFECT": "green",
+ "OUTPUT": "purple",
+ "ANALYSIS": "amber",
+ "_LIST": "indigo",
+} %}
+{% set color = colors.get(node_type, "gray") %}
+
+ {{ node_type }}
+
+{% endmacro %}
+
+{% macro role_badge(role, class="") %}
+{% set colors = {
+ "input": "blue",
+ "output": "purple",
+ "intermediate": "gray",
+} %}
+{% set color = colors.get(role, "gray") %}
+
+ {{ role | capitalize }}
+
+{% endmacro %}
diff --git a/common/artdag_common/templates/components/card.html b/common/artdag_common/templates/components/card.html
new file mode 100644
index 0000000..04f5c54
--- /dev/null
+++ b/common/artdag_common/templates/components/card.html
@@ -0,0 +1,45 @@
+{#
+Card component for displaying information.
+
+Usage:
+ {% include "components/card.html" with title="Status", content="Active", class="col-span-2" %}
+
+Or as a block:
+ {% call card(title="Details") %}
+ Card content here
+ {% endcall %}
+#}
+
+{% macro card(title=None, class="") %}
+
+ {% if title %}
+
{{ title }}
+ {% endif %}
+
+ {{ caller() if caller else "" }}
+
+
+{% endmacro %}
+
+{% macro stat_card(title, value, color="white", class="") %}
+
+
{{ value }}
+
{{ title }}
+
+{% endmacro %}
+
+{% macro info_card(title, items, class="") %}
+
+ {% if title %}
+
{{ title }}
+ {% endif %}
+
+ {% for label, value in items %}
+
+
- {{ label }}
+ - {{ value }}
+
+ {% endfor %}
+
+
+{% endmacro %}
diff --git a/common/artdag_common/templates/components/dag.html b/common/artdag_common/templates/components/dag.html
new file mode 100644
index 0000000..fa17fdc
--- /dev/null
+++ b/common/artdag_common/templates/components/dag.html
@@ -0,0 +1,176 @@
+{#
+Cytoscape.js DAG visualization component.
+
+Usage:
+ {% from "components/dag.html" import dag_container, dag_scripts, dag_legend %}
+
+ {# In head block #}
+ {{ dag_scripts() }}
+
+ {# In content #}
+ {{ dag_container(id="plan-dag", height="400px") }}
+ {{ dag_legend() }}
+
+ {# In scripts block #}
+
+#}
+
+{% macro dag_scripts() %}
+
+
+
+
+{% endmacro %}
+
+{% macro dag_container(id="dag-container", height="400px", class="") %}
+
+
+{% endmacro %}
+
+{% macro dag_legend(node_types=None) %}
+{% set types = node_types or ["SOURCE", "EFFECT", "_LIST"] %}
+
+ {% for type in types %}
+
+
+ {{ type }}
+
+ {% endfor %}
+
+
+ Cached
+
+
+{% endmacro %}
diff --git a/common/artdag_common/templates/components/media_preview.html b/common/artdag_common/templates/components/media_preview.html
new file mode 100644
index 0000000..ec810ae
--- /dev/null
+++ b/common/artdag_common/templates/components/media_preview.html
@@ -0,0 +1,98 @@
+{#
+Media preview component for videos, images, and audio.
+
+Usage:
+ {% from "components/media_preview.html" import media_preview, video_player, image_preview, audio_player %}
+
+ {{ media_preview(content_hash, media_type, title="Preview") }}
+ {{ video_player(src="/cache/abc123/mp4", poster="/cache/abc123/thumb") }}
+#}
+
+{% macro media_preview(content_hash, media_type, title=None, class="", show_download=True) %}
+
+ {% if title %}
+
+
{{ title }}
+
+ {% endif %}
+
+
+ {% if media_type == "video" %}
+ {{ video_player("/cache/" + content_hash + "/mp4") }}
+ {% elif media_type == "image" %}
+ {{ image_preview("/cache/" + content_hash + "/raw") }}
+ {% elif media_type == "audio" %}
+ {{ audio_player("/cache/" + content_hash + "/raw") }}
+ {% else %}
+
+
+
Preview not available
+
+ {% endif %}
+
+
+ {% if show_download %}
+
+ {% endif %}
+
+{% endmacro %}
+
+{% macro video_player(src, poster=None, autoplay=False, muted=True, loop=False, class="") %}
+
+{% endmacro %}
+
+{% macro image_preview(src, alt="", class="") %}
+
+{% endmacro %}
+
+{% macro audio_player(src, class="") %}
+
+
+
+{% endmacro %}
+
+{% macro thumbnail(content_hash, media_type, size="w-24 h-24", class="") %}
+
+ {% if media_type == "image" %}
+

+ {% elif media_type == "video" %}
+
+ {% elif media_type == "audio" %}
+
+ {% else %}
+
+ {% endif %}
+
+{% endmacro %}
diff --git a/common/artdag_common/templates/components/pagination.html b/common/artdag_common/templates/components/pagination.html
new file mode 100644
index 0000000..ec1b4a5
--- /dev/null
+++ b/common/artdag_common/templates/components/pagination.html
@@ -0,0 +1,82 @@
+{#
+Pagination component with HTMX infinite scroll support.
+
+Usage:
+ {% from "components/pagination.html" import infinite_scroll_trigger, page_links %}
+
+ {# Infinite scroll (HTMX) #}
+ {{ infinite_scroll_trigger(url="/items?page=2", colspan=3, has_more=True) }}
+
+ {# Traditional pagination #}
+ {{ page_links(current_page=1, total_pages=5, base_url="/items") }}
+#}
+
+{% macro infinite_scroll_trigger(url, colspan=1, has_more=True, target=None) %}
+{% if has_more %}
+
+ |
+
+
+ Loading more...
+
+ |
+
+{% endif %}
+{% endmacro %}
+
+{% macro page_links(current_page, total_pages, base_url, class="") %}
+
+{% endmacro %}
+
+{% macro page_info(page, limit, total) %}
+
+ Showing {{ (page - 1) * limit + 1 }}-{{ [page * limit, total] | min }} of {{ total }}
+
+{% endmacro %}
diff --git a/common/artdag_common/templates/components/table.html b/common/artdag_common/templates/components/table.html
new file mode 100644
index 0000000..1c00fc4
--- /dev/null
+++ b/common/artdag_common/templates/components/table.html
@@ -0,0 +1,51 @@
+{#
+Table component with dark theme styling.
+
+Usage:
+ {% from "components/table.html" import table, table_row %}
+
+ {% call table(columns=["Name", "Status", "Actions"]) %}
+ {% for item in items %}
+ {{ table_row([item.name, item.status, actions_html]) }}
+ {% endfor %}
+ {% endcall %}
+#}
+
+{% macro table(columns, class="", id="") %}
+
+
+
+
+ {% for col in columns %}
+ | {{ col }} |
+ {% endfor %}
+
+
+
+ {{ caller() }}
+
+
+
+{% endmacro %}
+
+{% macro table_row(cells, class="", href=None) %}
+
+ {% for cell in cells %}
+ |
+ {% if href and loop.first %}
+ {{ cell }}
+ {% else %}
+ {{ cell | safe }}
+ {% endif %}
+ |
+ {% endfor %}
+
+{% endmacro %}
+
+{% macro empty_row(colspan, message="No items found") %}
+
+ |
+ {{ message }}
+ |
+
+{% endmacro %}
diff --git a/common/artdag_common/utils/__init__.py b/common/artdag_common/utils/__init__.py
new file mode 100644
index 0000000..192edfa
--- /dev/null
+++ b/common/artdag_common/utils/__init__.py
@@ -0,0 +1,19 @@
+"""
+Utility functions shared across Art-DAG servers.
+"""
+
+from .pagination import paginate, get_pagination_params
+from .media import detect_media_type, get_media_extension, is_streamable
+from .formatting import format_date, format_size, truncate_hash, format_duration
+
+__all__ = [
+ "paginate",
+ "get_pagination_params",
+ "detect_media_type",
+ "get_media_extension",
+ "is_streamable",
+ "format_date",
+ "format_size",
+ "truncate_hash",
+ "format_duration",
+]
diff --git a/common/artdag_common/utils/formatting.py b/common/artdag_common/utils/formatting.py
new file mode 100644
index 0000000..3dcc3a8
--- /dev/null
+++ b/common/artdag_common/utils/formatting.py
@@ -0,0 +1,165 @@
+"""
+Formatting utilities for display.
+"""
+
+from datetime import datetime
+from typing import Optional, Union
+
+
+def format_date(
+ value: Optional[Union[str, datetime]],
+ length: int = 10,
+ include_time: bool = False,
+) -> str:
+ """
+ Format a date/datetime for display.
+
+ Args:
+ value: Date string or datetime object
+ length: Length to truncate to (default 10 for YYYY-MM-DD)
+ include_time: Whether to include time portion
+
+ Returns:
+ Formatted date string
+ """
+ if value is None:
+ return ""
+
+ if isinstance(value, str):
+ # Parse ISO format string
+ try:
+ if "T" in value:
+ dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
+ else:
+ return value[:length]
+ except ValueError:
+ return value[:length]
+ else:
+ dt = value
+
+ if include_time:
+ return dt.strftime("%Y-%m-%d %H:%M")
+ return dt.strftime("%Y-%m-%d")
+
+
+def format_size(size_bytes: Optional[int]) -> str:
+ """
+ Format file size in human-readable form.
+
+ Args:
+ size_bytes: Size in bytes
+
+ Returns:
+ Human-readable size string (e.g., "1.5 MB")
+ """
+ if size_bytes is None:
+ return "Unknown"
+ if size_bytes < 0:
+ return "Unknown"
+ if size_bytes == 0:
+ return "0 B"
+
+ units = ["B", "KB", "MB", "GB", "TB"]
+ unit_index = 0
+ size = float(size_bytes)
+
+ while size >= 1024 and unit_index < len(units) - 1:
+ size /= 1024
+ unit_index += 1
+
+ if unit_index == 0:
+ return f"{int(size)} {units[unit_index]}"
+ return f"{size:.1f} {units[unit_index]}"
+
+
+def truncate_hash(value: str, length: int = 16, suffix: str = "...") -> str:
+ """
+ Truncate a hash or long string with ellipsis.
+
+ Args:
+ value: String to truncate
+ length: Maximum length before truncation
+ suffix: Suffix to add when truncated
+
+ Returns:
+ Truncated string
+ """
+ if not value:
+ return ""
+ if len(value) <= length:
+ return value
+ return f"{value[:length]}{suffix}"
+
+
+def format_duration(seconds: Optional[float]) -> str:
+ """
+ Format duration in human-readable form.
+
+ Args:
+ seconds: Duration in seconds
+
+ Returns:
+ Human-readable duration string (e.g., "2m 30s")
+ """
+ if seconds is None or seconds < 0:
+ return "Unknown"
+
+ if seconds < 1:
+ return f"{int(seconds * 1000)}ms"
+
+ if seconds < 60:
+ return f"{seconds:.1f}s"
+
+ minutes = int(seconds // 60)
+ remaining_seconds = int(seconds % 60)
+
+ if minutes < 60:
+ if remaining_seconds:
+ return f"{minutes}m {remaining_seconds}s"
+ return f"{minutes}m"
+
+ hours = minutes // 60
+ remaining_minutes = minutes % 60
+
+ if remaining_minutes:
+ return f"{hours}h {remaining_minutes}m"
+ return f"{hours}h"
+
+
+def format_count(count: int) -> str:
+ """
+ Format a count with abbreviation for large numbers.
+
+ Args:
+ count: Number to format
+
+ Returns:
+ Formatted string (e.g., "1.2K", "3.5M")
+ """
+ if count < 1000:
+ return str(count)
+ if count < 1000000:
+ return f"{count / 1000:.1f}K"
+ if count < 1000000000:
+ return f"{count / 1000000:.1f}M"
+ return f"{count / 1000000000:.1f}B"
+
+
+def format_percentage(value: float, decimals: int = 1) -> str:
+ """
+ Format a percentage value.
+
+ Args:
+ value: Percentage value (0-100 or 0-1)
+ decimals: Number of decimal places
+
+ Returns:
+ Formatted percentage string
+ """
+ # Assume 0-1 if less than 1
+ if value <= 1:
+ value *= 100
+
+ if decimals == 0:
+ return f"{int(value)}%"
+ return f"{value:.{decimals}f}%"
diff --git a/common/artdag_common/utils/media.py b/common/artdag_common/utils/media.py
new file mode 100644
index 0000000..ef0eaee
--- /dev/null
+++ b/common/artdag_common/utils/media.py
@@ -0,0 +1,166 @@
+"""
+Media type detection and handling utilities.
+"""
+
+from pathlib import Path
+from typing import Optional
+import mimetypes
+
+# Initialize mimetypes database
+mimetypes.init()
+
+# Media type categories
+VIDEO_TYPES = {"video/mp4", "video/webm", "video/quicktime", "video/x-msvideo", "video/avi"}
+IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml"}
+AUDIO_TYPES = {"audio/mpeg", "audio/wav", "audio/ogg", "audio/flac", "audio/aac", "audio/mp3"}
+
+# File extension mappings
+EXTENSION_TO_CATEGORY = {
+ # Video
+ ".mp4": "video",
+ ".webm": "video",
+ ".mov": "video",
+ ".avi": "video",
+ ".mkv": "video",
+ # Image
+ ".jpg": "image",
+ ".jpeg": "image",
+ ".png": "image",
+ ".gif": "image",
+ ".webp": "image",
+ ".svg": "image",
+ # Audio
+ ".mp3": "audio",
+ ".wav": "audio",
+ ".ogg": "audio",
+ ".flac": "audio",
+ ".aac": "audio",
+ ".m4a": "audio",
+}
+
+
+def detect_media_type(path: Path) -> str:
+ """
+ Detect the media category for a file.
+
+ Args:
+ path: Path to the file
+
+ Returns:
+ Category string: "video", "image", "audio", or "unknown"
+ """
+ if not path:
+ return "unknown"
+
+ # Try extension first
+ ext = path.suffix.lower()
+ if ext in EXTENSION_TO_CATEGORY:
+ return EXTENSION_TO_CATEGORY[ext]
+
+ # Try mimetypes
+ mime_type, _ = mimetypes.guess_type(str(path))
+ if mime_type:
+ if mime_type in VIDEO_TYPES or mime_type.startswith("video/"):
+ return "video"
+ if mime_type in IMAGE_TYPES or mime_type.startswith("image/"):
+ return "image"
+ if mime_type in AUDIO_TYPES or mime_type.startswith("audio/"):
+ return "audio"
+
+ return "unknown"
+
+
+def get_mime_type(path: Path) -> str:
+ """
+ Get the MIME type for a file.
+
+ Args:
+ path: Path to the file
+
+ Returns:
+ MIME type string or "application/octet-stream"
+ """
+ mime_type, _ = mimetypes.guess_type(str(path))
+ return mime_type or "application/octet-stream"
+
+
+def get_media_extension(media_type: str) -> str:
+ """
+ Get the typical file extension for a media type.
+
+ Args:
+ media_type: Media category or MIME type
+
+ Returns:
+ File extension with dot (e.g., ".mp4")
+ """
+ if media_type == "video":
+ return ".mp4"
+ if media_type == "image":
+ return ".png"
+ if media_type == "audio":
+ return ".mp3"
+
+ # Try as MIME type
+ ext = mimetypes.guess_extension(media_type)
+ return ext or ""
+
+
+def is_streamable(path: Path) -> bool:
+ """
+ Check if a file type is streamable (video/audio).
+
+ Args:
+ path: Path to the file
+
+ Returns:
+ True if the file can be streamed
+ """
+ media_type = detect_media_type(path)
+ return media_type in ("video", "audio")
+
+
+def needs_conversion(path: Path, target_format: str = "mp4") -> bool:
+ """
+ Check if a video file needs format conversion.
+
+ Args:
+ path: Path to the file
+ target_format: Target format (default mp4)
+
+ Returns:
+ True if conversion is needed
+ """
+ media_type = detect_media_type(path)
+ if media_type != "video":
+ return False
+
+ ext = path.suffix.lower().lstrip(".")
+ return ext != target_format
+
+
+def get_video_src(
+ content_hash: str,
+ original_path: Optional[Path] = None,
+ is_ios: bool = False,
+) -> str:
+ """
+ Get the appropriate video source URL.
+
+ For iOS devices, prefer MP4 format.
+
+ Args:
+ content_hash: Content hash for the video
+ original_path: Optional original file path
+ is_ios: Whether the client is iOS
+
+ Returns:
+ URL path for the video source
+ """
+ if is_ios:
+ return f"/cache/{content_hash}/mp4"
+
+ if original_path and original_path.suffix.lower() in (".mp4", ".webm"):
+ return f"/cache/{content_hash}/raw"
+
+ return f"/cache/{content_hash}/mp4"
diff --git a/common/artdag_common/utils/pagination.py b/common/artdag_common/utils/pagination.py
new file mode 100644
index 0000000..f892f95
--- /dev/null
+++ b/common/artdag_common/utils/pagination.py
@@ -0,0 +1,85 @@
+"""
+Pagination utilities.
+"""
+
+from typing import List, Any, Tuple, Optional
+
+from fastapi import Request
+
+from ..constants import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE
+
+
+def get_pagination_params(request: Request) -> Tuple[int, int]:
+ """
+ Extract pagination parameters from request query string.
+
+ Args:
+ request: FastAPI request
+
+ Returns:
+ Tuple of (page, limit)
+ """
+ try:
+ page = int(request.query_params.get("page", 1))
+ page = max(1, page)
+ except ValueError:
+ page = 1
+
+ try:
+ limit = int(request.query_params.get("limit", DEFAULT_PAGE_SIZE))
+ limit = max(1, min(limit, MAX_PAGE_SIZE))
+ except ValueError:
+ limit = DEFAULT_PAGE_SIZE
+
+ return page, limit
+
+
+def paginate(
+ items: List[Any],
+ page: int = 1,
+ limit: int = DEFAULT_PAGE_SIZE,
+) -> Tuple[List[Any], dict]:
+ """
+ Paginate a list of items.
+
+ Args:
+ items: Full list of items
+ page: Page number (1-indexed)
+ limit: Items per page
+
+ Returns:
+ Tuple of (paginated items, pagination info dict)
+ """
+ total = len(items)
+ start = (page - 1) * limit
+ end = start + limit
+
+ paginated = items[start:end]
+
+ return paginated, {
+ "page": page,
+ "limit": limit,
+ "total": total,
+ "has_more": end < total,
+ "total_pages": (total + limit - 1) // limit if total > 0 else 1,
+ }
+
+
+def calculate_offset(page: int, limit: int) -> int:
+ """Calculate database offset from page and limit."""
+ return (page - 1) * limit
+
+
+def build_pagination_info(
+ page: int,
+ limit: int,
+ total: int,
+) -> dict:
+ """Build pagination info dictionary."""
+ return {
+ "page": page,
+ "limit": limit,
+ "total": total,
+ "has_more": page * limit < total,
+ "total_pages": (total + limit - 1) // limit if total > 0 else 1,
+ }
diff --git a/common/pyproject.toml b/common/pyproject.toml
new file mode 100644
index 0000000..8205b9b
--- /dev/null
+++ b/common/pyproject.toml
@@ -0,0 +1,22 @@
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[project]
+name = "artdag-common"
+version = "0.1.3"
+description = "Shared components for Art-DAG L1 and L2 servers"
+requires-python = ">=3.10"
+dependencies = [
+ "fastapi>=0.100.0",
+ "jinja2>=3.1.0",
+ "pydantic>=2.0.0",
+]
+
+[project.optional-dependencies]
+dev = [
+ "pytest>=7.0.0",
+]
+
+[tool.hatch.build.targets.wheel]
+packages = ["artdag_common"]