stlthumb/stlthumb.py
2025-08-29 20:33:52 -05:00

700 lines
24 KiB
Python
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env -S uv run --quiet --script
# /// script
# requires-python = ">=3.12"
# dependencies = [
# "typer",
# "fastapi",
# "uvicorn",
# "python-multipart",
# "jinja2",
# "numpy",
# "numpy-stl",
# "matplotlib",
# "pillow",
# ]
# ///
"""
stlthumb.py — Generate PNG thumbnails for STL files.
Features
- CLI (Typer): render thumbnails with size, angles, bg/transparent
- Web (FastAPI): HTMX+Tailwind UI to upload STL and get a thumbnail
- SQLite: records every render (inputs, size, angles, bytes) and serves by id
- Shading modes: lambert (default), unlit/flat, toon (cel), depth
Usage
CLI:
stlthumb.py render model.stl --output thumb.png --size 512 --elev 20 --azim 45
stlthumb.py render model.stl --transparent --color "#f59e0b" --shading toon --toon-levels 4
stlthumb.py render model.stl --bg '#0f172a' --edge-alpha 0.15 --proj ortho
stlthumb.py render model.stl --light 1 1 1 --ambient 0.2 --diffuse 0.8
Server:
stlthumb.py serve --host 0.0.0.0 --port 8037
Notes
* "Lambert" is a diffuse-only shading model: brightness ∝ cos(θ) between face normal and light direction.
* For toon, intensity is quantized into N bands.
* For depth, brightness comes from camera-space Z (near = bright, far = dark).
"""
from __future__ import annotations
import base64
import io
import os
import sqlite3
import time
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Optional, Tuple, Literal
import numpy as np
# Use non-interactive backend for headless rendering
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402
from mpl_toolkits.mplot3d.art3d import Poly3DCollection # noqa: E402
from fastapi import FastAPI, File, Form, UploadFile # noqa: E402
from fastapi.responses import HTMLResponse, StreamingResponse, PlainTextResponse # noqa: E402
from stl import mesh as npstl # noqa: E402
import typer # noqa: E402
# ---------- Configuration ----------
APP_NAME = "STL Thumb"
DB_PATH = Path("stlthumbs.db")
cli = typer.Typer(add_completion=False, no_args_is_help=True)
app = FastAPI(title=APP_NAME, version="1.1")
# ---------- Utilities ----------
def ensure_db() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS thumbnails (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL,
stl_name TEXT NOT NULL,
width INTEGER NOT NULL,
height INTEGER NOT NULL,
elev REAL NOT NULL,
azim REAL NOT NULL,
bg TEXT NOT NULL,
transparent INTEGER NOT NULL,
edge_alpha REAL NOT NULL,
bytes_size INTEGER NOT NULL,
png_bytes BLOB NOT NULL
)
"""
)
conn.commit()
return conn
@dataclass
class RenderParams:
width: int = 512
height: int = 512
elev: float = 20.0
azim: float = 45.0
bg: str = "#111827" # Tailwind slate-900-ish
transparent: bool = False
edge_alpha: float = 0.12 # subtle edges help readability
color: str = "#38bdf8" # base material color
shading: Literal["lambert", "unlit", "toon", "depth"] = "lambert"
ambient: float = 0.20
diffuse: float = 0.80
light_dir: Tuple[float, float, float] = (0.577, 0.577, 0.577)
toon_levels: int = 4
proj: Literal["persp", "ortho"] = "persp"
def _hex_to_rgb01(h: str) -> Tuple[float, float, float]:
s = h.strip().lstrip("#")
if len(s) == 3:
s = "".join([c * 2 for c in s])
if len(s) != 6:
raise ValueError(f"Invalid hex color: {h!r}")
r = int(s[0:2], 16) / 255.0
g = int(s[2:4], 16) / 255.0
b = int(s[4:6], 16) / 255.0
return r, g, b
def _normalize(v: np.ndarray) -> np.ndarray:
n = np.linalg.norm(v, axis=-1, keepdims=True)
return np.divide(v, np.where(n == 0, 1, n))
def _rotate_vectors(
vectors: np.ndarray, elev_deg: float, azim_deg: float
) -> np.ndarray:
"""Rotate vectors by azimuth (Z) then elevation (X) to approximate camera view."""
az = np.deg2rad(azim_deg)
el = np.deg2rad(elev_deg)
Rz = np.array(
[
[np.cos(az), -np.sin(az), 0],
[np.sin(az), np.cos(az), 0],
[0, 0, 1],
]
)
Rx = np.array(
[
[1, 0, 0],
[0, np.cos(el), -np.sin(el)],
[0, np.sin(el), np.cos(el)],
]
)
R = Rx @ Rz
v = vectors.reshape(-1, 3)
v_rot = (R @ v.T).T
return v_rot.reshape(-1, 3, 3)
def _shade_colors(
normals: np.ndarray,
base_rgb: Tuple[float, float, float],
params: RenderParams,
vectors: np.ndarray,
) -> np.ndarray:
mode = params.shading
if mode == "unlit":
N = normals.shape[0]
return np.tile(np.array([*base_rgb, 1.0])[None, :], (N, 1))
if mode == "toon":
L = _normalize(np.array(params.light_dir, dtype=float))
dots = (normals @ L).clip(0, 1)
intensity = params.ambient + params.diffuse * dots
# quantize
levels = max(1, int(params.toon_levels))
q = np.floor(intensity * levels) / max(1, (levels - 1))
q = q.clip(0, 1)
r, g, b = base_rgb
return np.stack([r * q, g * q, b * q, np.ones_like(q)], axis=1)
if mode == "depth":
# rotate to camera space and use triangle centroid Z for intensity
v_cam = _rotate_vectors(vectors, params.elev, params.azim)
centroids = v_cam.mean(axis=1)
z = centroids[:, 2]
# map z from [zmin,zmax] -> [1,0] (near bright)
zmin, zmax = float(z.min()), float(z.max())
if zmax - zmin == 0:
t = np.ones_like(z)
else:
t = 1.0 - (z - zmin) / (zmax - zmin)
t = (params.ambient + (1 - params.ambient) * t).clip(0, 1)
r, g, b = base_rgb
return np.stack([r * t, g * t, b * t, np.ones_like(t)], axis=1)
# default: lambert
L = _normalize(np.array(params.light_dir, dtype=float))
dots = (normals @ L).clip(0, 1)
intensity = params.ambient + params.diffuse * dots
r, g, b = base_rgb
cols = np.stack(
[r * intensity, g * intensity, b * intensity, np.ones_like(intensity)], axis=1
)
return cols
def _center_and_scale(vectors: np.ndarray) -> np.ndarray:
"""
Center mesh at origin and scale uniformly so the largest extent fits [-0.5, 0.5].
vectors: (N,3,3) triangles
"""
all_pts = vectors.reshape(-1, 3)
center = all_pts.mean(axis=0)
centered = all_pts - center
mins = centered.min(axis=0)
maxs = centered.max(axis=0)
extents = maxs - mins
max_extent = float(extents.max()) or 1.0
scale = 1.0 / max_extent
scaled = centered * scale
return scaled.reshape(-1, 3, 3)
def render_stl_to_png_bytes(
stl_path: Path,
params: RenderParams = RenderParams(),
dpi: int = 200,
) -> bytes:
"""
Load an STL and render a PNG thumbnail into bytes using matplotlib (headless).
"""
# Load STL (handles ASCII & binary)
stl_mesh: npstl.Mesh = npstl.Mesh.from_file(str(stl_path))
# Prepare geometry
vectors = _center_and_scale(stl_mesh.vectors.copy()) # (N,3,3)
normals = stl_mesh.normals.copy()
normals = _normalize(normals)
# Shading colors
base_rgb = _hex_to_rgb01(params.color)
face_colors = _shade_colors(normals, base_rgb, params, vectors)
# Matplotlib figure
fig = plt.figure(figsize=(params.width / dpi, params.height / dpi), dpi=dpi)
# Background
if params.transparent:
fig.patch.set_alpha(0.0)
fig.patch.set_facecolor("none")
bg_color = (0, 0, 0, 0)
else:
fig.patch.set_facecolor(params.bg)
bg_color = _hex_to_rgb01(params.bg) + (1.0,)
ax = fig.add_subplot(111, projection="3d")
ax.set_axis_off()
ax.set_facecolor(bg_color)
try:
ax.set_proj_type("ortho" if params.proj == "ortho" else "persp")
except Exception:
pass # older Matplotlib
# Equal aspect & limits
ax.set_box_aspect((1, 1, 1))
lim = 0.6 # a bit of padding beyond [-0.5, 0.5]
ax.set_xlim3d(-lim, lim)
ax.set_ylim3d(-lim, lim)
ax.set_zlim3d(-lim, lim)
# View
ax.view_init(elev=params.elev, azim=params.azim)
# Triangles
tri_collection = Poly3DCollection(vectors, linewidths=0.2)
tri_collection.set_facecolor(face_colors)
tri_collection.set_edgecolor((0, 0, 0, float(params.edge_alpha)))
ax.add_collection3d(tri_collection)
# Render to bytes
buf = io.BytesIO()
plt.subplots_adjust(0, 0, 1, 1)
ax.margins(0, 0, 0)
plt.savefig(
buf,
format="png",
dpi=dpi,
bbox_inches="tight",
pad_inches=0,
transparent=params.transparent,
facecolor=fig.get_facecolor(),
)
plt.close(fig)
buf.seek(0)
return buf.read()
def _record_png(
conn: sqlite3.Connection,
stl_name: str,
png_bytes: bytes,
params: RenderParams,
) -> int:
cur = conn.cursor()
cur.execute(
"""
INSERT INTO thumbnails (created_at, stl_name, width, height, elev, azim, bg, transparent, edge_alpha, bytes_size, png_bytes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
datetime.utcnow().isoformat(timespec="seconds") + "Z",
stl_name,
params.width,
params.height,
params.elev,
params.azim,
params.bg,
int(params.transparent),
float(params.edge_alpha),
len(png_bytes),
sqlite3.Binary(png_bytes),
),
)
conn.commit()
return int(cur.lastrowid)
def _row_to_info(row: tuple) -> dict:
return {
"id": row[0],
"created_at": row[1],
"stl_name": row[2],
"width": row[3],
"height": row[4],
"elev": row[5],
"azim": row[6],
"bg": row[7],
"transparent": bool(row[8]),
"edge_alpha": row[9],
"bytes_size": row[10],
}
# ---------- CLI ----------
@cli.command("render")
def cli_render(
stl: Path = typer.Argument(..., exists=True, readable=True, help="Input .stl file"),
output: Optional[Path] = typer.Option(
None,
"--output",
"-o",
help="Output PNG path (default: <stl>.png next to input)",
),
size: int = typer.Option(
512, "--size", "-s", min=64, max=4096, help="Square size in px"
),
width: Optional[int] = typer.Option(
None, help="PNG width in px (overrides --size)"
),
height: Optional[int] = typer.Option(
None, help="PNG height in px (overrides --size)"
),
elev: float = typer.Option(20.0, help="Elevation angle"),
azim: float = typer.Option(45.0, help="Azimuth angle"),
bg: str = typer.Option("#111827", help="Background color (hex)"),
transparent: bool = typer.Option(
False, "--transparent/--opaque", help="Transparent background"
),
edge_alpha: float = typer.Option(
0.12, min=0.0, max=1.0, help="Edge alpha (0 disables edges)"
),
color: str = typer.Option("#38bdf8", help="Base material color (hex)"),
shading: str = typer.Option(
"lambert", case_sensitive=False, help="Shading: lambert|unlit|toon|depth"
),
ambient: float = typer.Option(
0.20, min=0.0, max=1.0, help="Ambient term for lambert/toon"
),
diffuse: float = typer.Option(
0.80, min=0.0, max=1.0, help="Diffuse term for lambert/toon"
),
light: Tuple[float, float, float] = typer.Option(
(0.577, 0.577, 0.577), help="Light direction x y z"
),
toon_levels: int = typer.Option(4, min=1, max=16, help="Toon levels (bands)"),
proj: str = typer.Option("persp", help="Projection: persp|ortho"),
):
"""Render STL to a PNG thumbnail."""
w = width or size
h = height or size
params = RenderParams(
width=w,
height=h,
elev=elev,
azim=azim,
bg=bg,
transparent=transparent,
edge_alpha=edge_alpha,
color=color,
shading=shading.lower(),
ambient=ambient,
diffuse=diffuse,
light_dir=light,
toon_levels=toon_levels,
proj=proj.lower(),
)
typer.echo(f"[{APP_NAME}] Rendering: {stl}{output or (stl.with_suffix('.png'))}")
png = render_stl_to_png_bytes(stl, params)
out = output or stl.with_suffix(".png")
out.parent.mkdir(parents=True, exist_ok=True)
out.write_bytes(png)
typer.echo(f"Saved: {out} ({len(png)} bytes)")
conn = ensure_db()
rec_id = _record_png(conn, stl.name, png, params)
typer.echo(f"Recorded in DB: {DB_PATH} (id={rec_id})")
@cli.command("history")
def cli_history(
limit: int = typer.Option(25, min=1, max=500),
):
"""Show recent renders stored in the SQLite database."""
if not DB_PATH.exists():
typer.echo("No history yet.")
raise typer.Exit(code=0)
conn = ensure_db()
rows = conn.execute(
"SELECT id, created_at, stl_name, width, height, elev, azim, bg, transparent, edge_alpha, bytes_size FROM thumbnails ORDER BY id DESC LIMIT ?",
(limit,),
).fetchall()
if not rows:
typer.echo("No history.")
return
for r in rows:
info = _row_to_info(r)
tflag = "T" if info["transparent"] else "B"
typer.echo(
f"#{info['id']:>4} {info['created_at']} {info['stl_name']:<25} "
f"{info['width']}x{info['height']} elev={info['elev']:>4.0f} azim={info['azim']:>4.0f} "
f"{tflag}G={info['bg']} α={info['edge_alpha']:.2f} {info['bytes_size']}B"
)
@cli.command("serve")
def cli_serve(
host: str = typer.Option("127.0.0.1", help="Bind host"),
port: int = typer.Option(8037, help="Port"),
reload: bool = typer.Option(False, help="Auto-reload on code changes"),
):
"""Run the FastAPI server with the HTMX+Tailwind UI."""
import uvicorn
ensure_db()
uvicorn.run(app, host=host, port=port, reload=reload)
# ---------- Web (FastAPI + HTMX + Tailwind) ----------
HOME_HTML = """<!doctype html>
<html lang=\"en\" class=\"h-full\">
<head>
<meta charset=\"utf-8\" />
<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"/>
<title>STL → PNG Thumbnail</title>
<script src=\"https://unpkg.com/htmx.org@1.9.12\"></script>
<script src=\"https://cdn.tailwindcss.com\"></script>
</head>
<body class=\"h-full bg-slate-950 text-slate-100\">
<main class=\"mx-auto max-w-3xl p-6\">
<h1 class=\"text-3xl font-bold tracking-tight mb-4\">STL → PNG Thumbnail</h1>
<p class=\"text-slate-300 mb-6\">Upload an <code>.stl</code> and get a shaded PNG thumbnail. Everything renders locally.</p>
<form hx-post=\"/api/thumbnail\" hx-target=\"#result\" hx-swap=\"innerHTML\" enctype=\"multipart/form-data\"
class=\"grid gap-4 bg-slate-900/60 p-4 rounded-2xl border border-slate-800\">
<div>
<label class=\"block text-sm mb-1\">STL file</label>
<input type=\"file\" name=\"stl\" accept=\".stl\" required
class=\"file:mr-4 file:py-2 file:px-4 file:rounded-xl file:border-0
file:text-sm file:bg-sky-500 file:text-white hover:file:bg-sky-400
w-full text-sm text-slate-200\"/>
</div>
<div class=\"grid grid-cols-2 md:grid-cols-4 gap-3\">
<div>
<label class=\"block text-sm mb-1\">Width</label>
<input type=\"number\" name=\"width\" value=\"512\" min=\"64\" max=\"4096\"
class=\"w-full rounded-lg bg-slate-800/70 border border-slate-700 px-3 py-2\"/>
</div>
<div>
<label class=\"block text-sm mb-1\">Height</label>
<input type=\"number\" name=\"height\" value=\"512\" min=\"64\" max=\"4096\"
class=\"w-full rounded-lg bg-slate-800/70 border border-slate-700 px-3 py-2\"/>
</div>
<div>
<label class=\"block text-sm mb-1\">Elev</label>
<input type=\"number\" step=\"1\" name=\"elev\" value=\"20\"
class=\"w-full rounded-lg bg-slate-800/70 border border-slate-700 px-3 py-2\"/>
</div>
<div>
<label class=\"block text-sm mb-1\">Azim</label>
<input type=\"number\" step=\"1\" name=\"azim\" value=\"45\"
class=\"w-full rounded-lg bg-slate-800/70 border border-slate-700 px-3 py-2\"/>
</div>
</div>
<div class=\"grid grid-cols-2 md:grid-cols-3 gap-3 items-end\">
<div>
<label class=\"block text-sm mb-1\">Background</label>
<input type=\"text\" name=\"bg\" value=\"#111827\"
class=\"w-full rounded-lg bg-slate-800/70 border border-slate-700 px-3 py-2\" />
</div>
<div>
<label class=\"block text-sm mb-1\">Edge α</label>
<input type=\"number\" name=\"edge_alpha\" value=\"0.12\" step=\"0.01\" min=\"0\" max=\"1\"
class=\"w-full rounded-lg bg-slate-800/70 border border-slate-700 px-3 py-2\"/>
</div>
<label class=\"inline-flex items-center gap-2\">
<input type=\"checkbox\" name=\"transparent\" class=\"size-5 rounded border-slate-600 bg-slate-800\" />
<span class=\"text-sm\">Transparent</span>
</label>
</div>
<div class=\"grid grid-cols-2 md:grid-cols-4 gap-3\">
<div>
<label class=\"block text-sm mb-1\">Color</label>
<input type=\"text\" name=\"color\" value=\"#38bdf8\" class=\"w-full rounded-lg bg-slate-800/70 border border-slate-700 px-3 py-2\" />
</div>
<div>
<label class=\"block text-sm mb-1\">Shading</label>
<select name=\"shading\" class=\"w-full rounded-lg bg-slate-800/70 border border-slate-700 px-3 py-2\">
<option value=\"lambert\" selected>lambert</option>
<option value=\"unlit\">unlit</option>
<option value=\"toon\">toon</option>
<option value=\"depth\">depth</option>
</select>
</div>
<div>
<label class=\"block text-sm mb-1\">Ambient</label>
<input type=\"number\" step=\"0.01\" min=\"0\" max=\"1\" name=\"ambient\" value=\"0.2\" class=\"w-full rounded-lg bg-slate-800/70 border border-slate-700 px-3 py-2\" />
</div>
<div>
<label class=\"block text-sm mb-1\">Diffuse</label>
<input type=\"number\" step=\"0.01\" min=\"0\" max=\"1\" name=\"diffuse\" value=\"0.8\" class=\"w-full rounded-lg bg-slate-800/70 border border-slate-700 px-3 py-2\" />
</div>
</div>
<div class=\"grid grid-cols-3 md:grid-cols-6 gap-3\">
<div>
<label class=\"block text-sm mb-1\">Light X</label>
<input type=\"number\" step=\"0.01\" name=\"lx\" value=\"0.577\" class=\"w-full rounded-lg bg-slate-800/70 border border-slate-700 px-3 py-2\" />
</div>
<div>
<label class=\"block text-sm mb-1\">Light Y</label>
<input type=\"number\" step=\"0.01\" name=\"ly\" value=\"0.577\" class=\"w-full rounded-lg bg-slate-800/70 border border-slate-700 px-3 py-2\" />
</div>
<div>
<label class=\"block text-sm mb-1\">Light Z</label>
<input type=\"number\" step=\"0.01\" name=\"lz\" value=\"0.577\" class=\"w-full rounded-lg bg-slate-800/70 border border-slate-700 px-3 py-2\" />
</div>
<div>
<label class=\"block text-sm mb-1\">Toon Levels</label>
<input type=\"number\" min=\"1\" max=\"16\" name=\"toon_levels\" value=\"4\" class=\"w-full rounded-lg bg-slate-800/70 border border-slate-700 px-3 py-2\" />
</div>
<div>
<label class=\"block text-sm mb-1\">Projection</label>
<select name=\"proj\" class=\"w-full rounded-lg bg-slate-800/70 border border-slate-700 px-3 py-2\">
<option value=\"persp\" selected>perspective</option>
<option value=\"ortho\">orthographic</option>
</select>
</div>
</div>
<button class=\"inline-flex items-center gap-2 bg-sky-500 hover:bg-sky-400 text-white px-4 py-2 rounded-xl text-sm font-semibold\">
<span>Generate</span>
</button>
</form>
<div id=\"result\" class=\"mt-6\"></div>
<div class=\"mt-10 text-xs text-slate-500\">
<p>Tip: Default material color is <span class=\"text-sky-400\">sky-400</span>. Try <code>toon</code> or <code>depth</code> for stylized thumbs.</p>
</div>
</main>
</body>
</html>
"""
@app.get("/", response_class=HTMLResponse)
def home() -> str:
return HOME_HTML
@app.get("/healthz", response_class=PlainTextResponse)
def healthz() -> str:
return "ok"
@app.get("/thumb/{thumb_id}.png")
def get_thumbnail(thumb_id: int):
conn = ensure_db()
row = conn.execute(
"SELECT png_bytes FROM thumbnails WHERE id= ?", (thumb_id,)
).fetchone()
if not row:
return PlainTextResponse("Not Found", status_code=404)
data = row[0]
return StreamingResponse(io.BytesIO(data), media_type="image/png")
@app.post("/api/thumbnail", response_class=HTMLResponse)
async def api_thumbnail(
stl: UploadFile = File(...),
width: int = Form(512),
height: int = Form(512),
elev: float = Form(20.0),
azim: float = Form(45.0),
bg: str = Form("#111827"),
edge_alpha: float = Form(0.12),
transparent: Optional[str] = Form(None),
color: str = Form("#38bdf8"),
shading: str = Form("lambert"),
ambient: float = Form(0.20),
diffuse: float = Form(0.80),
lx: float = Form(0.577),
ly: float = Form(0.577),
lz: float = Form(0.577),
toon_levels: int = Form(4),
proj: str = Form("persp"),
):
# Persist incoming file to temp
data = await stl.read()
tmp_path = Path(f"/tmp/{int(time.time() * 1000)}_{stl.filename}")
tmp_path.write_bytes(data)
params = RenderParams(
width=width,
height=height,
elev=elev,
azim=azim,
bg=bg,
transparent=bool(transparent),
edge_alpha=edge_alpha,
color=color,
shading=shading,
ambient=ambient,
diffuse=diffuse,
light_dir=(lx, ly, lz),
toon_levels=toon_levels,
proj=proj,
)
try:
png = render_stl_to_png_bytes(tmp_path, params)
except Exception as e:
return HTMLResponse(
f'<div class="p-4 rounded-2xl bg-red-900/40 border border-red-800">'
f"<p class='font-semibold text-red-200'>Render failed</p>"
f"<pre class='text-red-300 text-xs mt-2'>{e}</pre>"
f"</div>",
status_code=400,
)
finally:
# cleanup temp
try:
tmp_path.unlink(missing_ok=True)
except Exception:
pass
conn = ensure_db()
rec_id = _record_png(conn, stl.filename, png, params)
b64 = base64.b64encode(png).decode("ascii")
html = f"""
<div class=\"grid gap-3\">
<div class=\"rounded-2xl overflow-hidden ring-1 ring-slate-800 bg-slate-900/50\">
<img src=\"data:image/png;base64,{b64}\" class=\"w-full h-auto block\" alt=\"thumbnail\"/>
</div>
<div class=\"text-xs text-slate-400\">
<a class=\"underline hover:text-slate-200\" href=\"/thumb/{rec_id}.png\" download>Download PNG (#{rec_id})</a>
<span class=\"ml-2\">|</span>
<span class=\"ml-2\">Size: {len(png)} bytes</span>
</div>
</div>
"""
return HTMLResponse(html)
# ---------- Entrypoint ----------
def _maybe_run_cli_or_server():
# If uvicorn is exec-ing this, avoid double-running CLI
if os.environ.get("RUN_FROM_UVICORN", "") == "1":
return
cli()
if __name__ == "__main__":
_maybe_run_cli_or_server()