1243 lines
49 KiB
Python
1243 lines
49 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import base64
|
|
import copy
|
|
import json
|
|
import mimetypes
|
|
import os
|
|
import re
|
|
import sys
|
|
import time
|
|
from collections.abc import Iterable, Mapping, Sequence
|
|
from dataclasses import dataclass
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from urllib.parse import quote, unquote, urlparse
|
|
|
|
|
|
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
|
PACKAGES_DIR = SCRIPTS_DIR / "packages"
|
|
CADPY_SRC_DIR = PACKAGES_DIR / "cadpy" / "src"
|
|
for runtime_path in (SCRIPTS_DIR, PACKAGES_DIR, CADPY_SRC_DIR):
|
|
runtime_path_text = str(runtime_path)
|
|
if runtime_path_text not in sys.path:
|
|
sys.path.insert(0, runtime_path_text)
|
|
|
|
import cadpy.cad_ref_syntax as cad_ref_syntax
|
|
import cadpy.lookup as lookup
|
|
from cadpy.render import existing_part_glb_path, part_glb_path
|
|
from cadpy.step_targets import ResolvedStepTarget, StepTopologyArtifact, StepTopologyArtifactError
|
|
|
|
|
|
SNAPSHOT_ORIGIN = "http://snapshot.local"
|
|
SNAPSHOT_RENDER_URL = f"{SNAPSHOT_ORIGIN}/render.html"
|
|
SNAPSHOT_ROUTE_GLOB = f"{SNAPSHOT_ORIGIN}/**"
|
|
RUNTIME_DIR = Path(__file__).resolve().parent / "runtime"
|
|
RENDER_HTML_PATH = RUNTIME_DIR / "render.html"
|
|
DEFAULT_RENDER_THEME_ID = "workbench"
|
|
DEFAULT_TIMEOUT_SECONDS = 300
|
|
RENDER_BROWSER_STARTUP_TIMEOUT_MS = 15_000
|
|
SUPPORTED_RENDER_MODES = {"view", "orbit", "section", "list"}
|
|
WORKBENCH_RENDER_THEME_IDS = {DEFAULT_RENDER_THEME_ID}
|
|
|
|
SIMPLE_RENDER_WIDTH = 1200
|
|
SIMPLE_RENDER_HEIGHT = 900
|
|
SIMPLE_SQUARE_RENDER_WIDTH = 1024
|
|
SIMPLE_SQUARE_RENDER_HEIGHT = 1024
|
|
DIAGNOSTIC_RENDER_WIDTH = 1600
|
|
DIAGNOSTIC_RENDER_HEIGHT = 1200
|
|
COMPLEX_ASSEMBLY_RENDER_WIDTH = 1800
|
|
COMPLEX_ASSEMBLY_RENDER_HEIGHT = 1200
|
|
COMPLEX_ASSEMBLY_LARGE_RENDER_WIDTH = 1920
|
|
COMPLEX_ASSEMBLY_LARGE_RENDER_HEIGHT = 1440
|
|
PRESENTATION_RENDER_WIDTH = 2400
|
|
PRESENTATION_RENDER_HEIGHT = 1600
|
|
PRESENTATION_LARGE_RENDER_WIDTH = 2800
|
|
PRESENTATION_LARGE_RENDER_HEIGHT = 1800
|
|
ORBIT_RENDER_WIDTH = 960
|
|
ORBIT_RENDER_HEIGHT = 640
|
|
CONTACT_SHEET_RENDER_WIDTH = 2400
|
|
CONTACT_SHEET_RENDER_HEIGHT = 1600
|
|
|
|
DISPLAY_OPTION_KEYS = {"projection", "mode", "clip", "exploded", "edges"}
|
|
DISPLAY_MODE_ALIASES = {
|
|
"solid": "solid",
|
|
"edges": "solid",
|
|
"edge": "solid",
|
|
"shaded_edges": "solid",
|
|
"shaded_with_edges": "solid",
|
|
"with_edges": "solid",
|
|
"shaded": "rendered",
|
|
"shaded_without_edges": "rendered",
|
|
"without_edges": "rendered",
|
|
"transparent": "transparent",
|
|
"translucent": "transparent",
|
|
"xray": "transparent",
|
|
"x_ray": "transparent",
|
|
"see_through": "transparent",
|
|
"hidden_edges": "hidden_edges",
|
|
"hidden_edge": "hidden_edges",
|
|
"hidden_edges_visible": "hidden_edges",
|
|
"hidden_edge_display": "hidden_edges",
|
|
"shaded_hidden_edges": "hidden_edges",
|
|
"hidden_lines_removed": "hidden_lines_removed",
|
|
"hidden_line_removed": "hidden_lines_removed",
|
|
"hidden_lines": "hidden_lines_removed",
|
|
"hidden_edges_removed": "hidden_lines_removed",
|
|
"visible_edges": "hidden_lines_removed",
|
|
"visible_edges_only": "hidden_lines_removed",
|
|
"unshaded": "unshaded",
|
|
"flat": "unshaded",
|
|
"rendered": "rendered",
|
|
"appearance": "rendered",
|
|
"material": "rendered",
|
|
"materials": "rendered",
|
|
"wireframe": "wireframe",
|
|
"wire_frame": "wireframe",
|
|
"wire": "wireframe",
|
|
}
|
|
APPEARANCE_OPTION_KEYS = {
|
|
"materials",
|
|
"background",
|
|
"floor",
|
|
"environment",
|
|
"lighting",
|
|
}
|
|
|
|
|
|
ensure_step_topology_artifact = None
|
|
|
|
|
|
@dataclass
|
|
class SnapshotOptions:
|
|
job: str = ""
|
|
input: str = ""
|
|
output: str = ""
|
|
mode: str = "view"
|
|
appearance: object = DEFAULT_RENDER_THEME_ID
|
|
appearance_specified: bool = False
|
|
display: object = ""
|
|
display_specified: bool = False
|
|
camera: object = "iso"
|
|
camera_specified: bool = False
|
|
width: int | None = None
|
|
height: int | None = None
|
|
size_profile: str = ""
|
|
params: object = None
|
|
params_specified: bool = False
|
|
focus: list[str] | None = None
|
|
hide: list[str] | None = None
|
|
view_labels: bool = False
|
|
json: bool = False
|
|
help: bool = False
|
|
|
|
|
|
class SnapshotError(RuntimeError):
|
|
pass
|
|
|
|
|
|
class RouteFileError(SnapshotError):
|
|
def __init__(self, message: str, *, status: int = 404) -> None:
|
|
super().__init__(message)
|
|
self.status = status
|
|
|
|
|
|
def help_text() -> str:
|
|
return """Usage:
|
|
python scripts/snapshot --job render-job.json
|
|
python scripts/snapshot --job -
|
|
python scripts/snapshot --input models/part.step --output /tmp/part.png --appearance workbench
|
|
|
|
Shortcut flags are for common STEP/STP snapshots. --job accepts one render job, an array of render jobs, or { "jobs": [...] }. Every job input must be a relative or absolute .step/.stp path, or a same-stem Python generator; direct GLB/STL/3MF/DXF/G-code/robot-description inputs are unsupported. The default appearance is the workbench saved theme. --appearance accepts a saved theme name, an inline JSON appearance settings object, or a JSON appearance settings file path. --display accepts solid, rendered, transparent, hidden_edges, hidden_lines_removed, unshaded, wireframe, an inline JSON display settings object, or a JSON display settings file path. Projection, exploded view, and edge styling belong in display JSON, for example {"projection":"orthographic","mode":"rendered","exploded":{"enabled":true,"axis":"z","spacing":1.6},"edges":{"color":"#132232"}}. Use {"axis":"radial"} for outward radial disassembly. --camera accepts a preset, azimuth:elevation pair, or JSON object with preset, position, target, up, and zoom fields. --focus and --hide accept one or more selector refs such as #o1.2 for parts or subassemblies; pass the flag repeatedly or list refs after the flag. Option JSON is direct settings JSON, not a wrapped job fragment. Full JSON jobs use top-level appearance and display. Use --view-labels to burn the camera/view label into shortcut outputs. Use --params with STEP parameter sidecar JSON values, and --size-profile for default dimensions such as simple, diagnostic, labeled, assembly, presentation, orbit, or contact-sheet. Output file names are saved with a shared UTC seconds timestamp before the extension.
|
|
"""
|
|
|
|
|
|
def positive_integer(value: object, label: str) -> int:
|
|
try:
|
|
parsed = int(str(value or ""), 10)
|
|
except ValueError as exc:
|
|
raise SnapshotError(f"{label} must be a positive integer") from exc
|
|
if parsed <= 0:
|
|
raise SnapshotError(f"{label} must be a positive integer")
|
|
return parsed
|
|
|
|
|
|
def parse_required_value(argv: Sequence[str], index: int, flag: str) -> str:
|
|
try:
|
|
value = argv[index + 1]
|
|
except IndexError as exc:
|
|
raise SnapshotError(f"{flag} requires a value") from exc
|
|
if not value or value.startswith("--"):
|
|
raise SnapshotError(f"{flag} requires a value")
|
|
return value
|
|
|
|
|
|
def parse_required_values(argv: Sequence[str], index: int, flag: str) -> tuple[list[str], int]:
|
|
values: list[str] = []
|
|
cursor = index + 1
|
|
while cursor < len(argv):
|
|
value = argv[cursor]
|
|
if value.startswith("--"):
|
|
break
|
|
if value:
|
|
values.append(value)
|
|
cursor += 1
|
|
if not values:
|
|
raise SnapshotError(f"{flag} requires at least one value")
|
|
return values, cursor - index - 1
|
|
|
|
|
|
def parse_snapshot_args(argv: Sequence[str]) -> SnapshotOptions:
|
|
if argv and argv[0] == "daemon":
|
|
raise SnapshotError("snapshot daemon commands have been removed; use a batch --job snapshot instead")
|
|
|
|
options = SnapshotOptions()
|
|
index = 0
|
|
while index < len(argv):
|
|
arg = argv[index]
|
|
if arg in {"--help", "-h"}:
|
|
options.help = True
|
|
elif arg == "--json":
|
|
options.json = True
|
|
elif arg == "--no-daemon":
|
|
raise SnapshotError("--no-daemon has been removed; snapshot no longer uses a daemon")
|
|
elif arg == "--socket" or arg.startswith("--socket="):
|
|
raise SnapshotError("--socket has been removed; snapshot no longer uses a daemon")
|
|
elif arg == "--view-labels":
|
|
options.view_labels = True
|
|
elif arg == "--job":
|
|
options.job = parse_required_value(argv, index, arg)
|
|
index += 1
|
|
elif arg.startswith("--job="):
|
|
options.job = arg[len("--job=") :]
|
|
elif arg == "--input":
|
|
options.input = parse_required_value(argv, index, arg)
|
|
index += 1
|
|
elif arg.startswith("--input="):
|
|
options.input = arg[len("--input=") :]
|
|
elif arg in {"--output", "-o"}:
|
|
options.output = parse_required_value(argv, index, arg)
|
|
index += 1
|
|
elif arg.startswith("--output="):
|
|
options.output = arg[len("--output=") :]
|
|
elif arg == "--mode":
|
|
options.mode = parse_required_value(argv, index, arg)
|
|
index += 1
|
|
elif arg.startswith("--mode="):
|
|
options.mode = arg[len("--mode=") :]
|
|
elif arg == "--appearance":
|
|
options.appearance = parse_required_value(argv, index, arg)
|
|
options.appearance_specified = True
|
|
index += 1
|
|
elif arg.startswith("--appearance="):
|
|
options.appearance = arg[len("--appearance=") :]
|
|
options.appearance_specified = True
|
|
elif arg == "--display":
|
|
options.display = parse_required_value(argv, index, arg)
|
|
options.display_specified = True
|
|
index += 1
|
|
elif arg.startswith("--display="):
|
|
options.display = arg[len("--display=") :]
|
|
options.display_specified = True
|
|
elif arg == "--params":
|
|
options.params = parse_required_value(argv, index, arg)
|
|
options.params_specified = True
|
|
index += 1
|
|
elif arg.startswith("--params="):
|
|
options.params = arg[len("--params=") :]
|
|
options.params_specified = True
|
|
elif arg == "--focus":
|
|
values, consumed = parse_required_values(argv, index, arg)
|
|
options.focus = [*(options.focus or []), *values]
|
|
index += consumed
|
|
elif arg.startswith("--focus="):
|
|
value = arg[len("--focus=") :]
|
|
if not value:
|
|
raise SnapshotError("--focus requires at least one value")
|
|
options.focus = [*(options.focus or []), value]
|
|
elif arg == "--hide":
|
|
values, consumed = parse_required_values(argv, index, arg)
|
|
options.hide = [*(options.hide or []), *values]
|
|
index += consumed
|
|
elif arg.startswith("--hide="):
|
|
value = arg[len("--hide=") :]
|
|
if not value:
|
|
raise SnapshotError("--hide requires at least one value")
|
|
options.hide = [*(options.hide or []), value]
|
|
elif arg == "--size-profile":
|
|
options.size_profile = parse_required_value(argv, index, arg)
|
|
index += 1
|
|
elif arg.startswith("--size-profile="):
|
|
options.size_profile = arg[len("--size-profile=") :]
|
|
elif arg == "--camera":
|
|
options.camera = parse_required_value(argv, index, arg)
|
|
options.camera_specified = True
|
|
index += 1
|
|
elif arg.startswith("--camera="):
|
|
options.camera = arg[len("--camera=") :]
|
|
options.camera_specified = True
|
|
elif arg == "--width":
|
|
options.width = positive_integer(parse_required_value(argv, index, arg), arg)
|
|
index += 1
|
|
elif arg.startswith("--width="):
|
|
options.width = positive_integer(arg[len("--width=") :], "--width")
|
|
elif arg == "--height":
|
|
options.height = positive_integer(parse_required_value(argv, index, arg), arg)
|
|
index += 1
|
|
elif arg.startswith("--height="):
|
|
options.height = positive_integer(arg[len("--height=") :], "--height")
|
|
else:
|
|
raise SnapshotError(f"Unknown argument: {arg}")
|
|
index += 1
|
|
if options.focus and options.hide:
|
|
raise SnapshotError("--focus and --hide cannot be used in the same snapshot command")
|
|
return options
|
|
|
|
|
|
def is_plain_object(value: object) -> bool:
|
|
return isinstance(value, dict)
|
|
|
|
|
|
def load_json_text(text: str, source_label: str) -> object:
|
|
try:
|
|
return json.loads(text)
|
|
except json.JSONDecodeError as exc:
|
|
raise SnapshotError(f"Failed to parse JSON from {source_label}: {exc}") from exc
|
|
|
|
|
|
def parse_camera_option(raw_camera: object) -> object:
|
|
camera = str(raw_camera or "").strip()
|
|
if not camera:
|
|
raise SnapshotError("--camera requires a preset, azimuth:elevation pair, or JSON camera object")
|
|
if not camera.startswith("{"):
|
|
return camera
|
|
parsed = load_json_text(camera, "--camera")
|
|
if not is_plain_object(parsed):
|
|
raise SnapshotError("--camera must be a preset, azimuth:elevation pair, or JSON object")
|
|
return parsed
|
|
|
|
|
|
def parse_params_option(raw_params: object) -> dict[str, object]:
|
|
parsed = load_json_text(str(raw_params or ""), "--params")
|
|
if not is_plain_object(parsed):
|
|
raise SnapshotError("--params must be a STEP parameter JSON object")
|
|
return parsed
|
|
|
|
|
|
def option_focus_hide_specified(options: SnapshotOptions) -> bool:
|
|
return bool(options.focus or options.hide)
|
|
|
|
|
|
def merge_focus_hide_options(job: dict[str, object], options: SnapshotOptions) -> None:
|
|
if not option_focus_hide_specified(options):
|
|
return
|
|
if options.focus and options.hide:
|
|
raise SnapshotError("--focus and --hide cannot be used in the same snapshot command")
|
|
selection = dict(job.get("selection") if is_plain_object(job.get("selection")) else {})
|
|
if options.focus:
|
|
selection["focus"] = list(options.focus)
|
|
if options.hide:
|
|
selection["hide"] = list(options.hide)
|
|
job["selection"] = selection
|
|
|
|
|
|
def validate_direct_settings_payload(
|
|
parsed: object,
|
|
*,
|
|
option_name: str,
|
|
source_label: str,
|
|
allowed_keys: set[str],
|
|
setting_label: str,
|
|
) -> dict[str, object]:
|
|
if not is_plain_object(parsed):
|
|
raise SnapshotError(f"{option_name} JSON must be a {setting_label} object: {source_label}")
|
|
unknown_keys = [key for key in parsed if key not in allowed_keys]
|
|
if unknown_keys:
|
|
raise SnapshotError(
|
|
f"{option_name} JSON must be the {setting_label} object directly; "
|
|
f"unsupported keys: {', '.join(unknown_keys)}"
|
|
)
|
|
if not parsed:
|
|
raise SnapshotError(f"{option_name} JSON must include at least one {setting_label} field: {source_label}")
|
|
return dict(parsed)
|
|
|
|
|
|
def load_display_option(raw_display: object, *, cwd: Path) -> dict[str, object]:
|
|
display = str(raw_display or "").strip()
|
|
if not display:
|
|
raise SnapshotError("--display requires a JSON object, JSON file path, or display mode")
|
|
if display.startswith("{"):
|
|
return validate_direct_settings_payload(
|
|
load_json_text(display, "--display"),
|
|
option_name="--display",
|
|
source_label="--display",
|
|
allowed_keys=DISPLAY_OPTION_KEYS,
|
|
setting_label="display settings",
|
|
)
|
|
|
|
display_path = Path(display) if Path(display).is_absolute() else cwd / display
|
|
looks_like_file = display.lower().endswith(".json") or "/" in display or "\\" in display
|
|
if not looks_like_file and not display_path.exists():
|
|
normalized_mode = re.sub(r"[\s-]+", "_", display.lower())
|
|
if normalized_mode not in DISPLAY_MODE_ALIASES:
|
|
supported = ", ".join(sorted(set(DISPLAY_MODE_ALIASES.values())))
|
|
raise SnapshotError(f"Unsupported display mode: {display}. Supported modes: {supported}")
|
|
return {"mode": DISPLAY_MODE_ALIASES[normalized_mode]}
|
|
if not display_path.exists():
|
|
raise SnapshotError(f"Display JSON file does not exist: {display}")
|
|
return validate_direct_settings_payload(
|
|
load_json_text(display_path.read_text(encoding="utf-8"), str(display_path)),
|
|
option_name="--display",
|
|
source_label=str(display_path),
|
|
allowed_keys=DISPLAY_OPTION_KEYS,
|
|
setting_label="display settings",
|
|
)
|
|
|
|
|
|
def load_appearance_option(raw_appearance: object, *, cwd: Path) -> object:
|
|
appearance = str(raw_appearance or DEFAULT_RENDER_THEME_ID).strip() or DEFAULT_RENDER_THEME_ID
|
|
if appearance.startswith("{"):
|
|
return validate_direct_settings_payload(
|
|
load_json_text(appearance, "--appearance"),
|
|
option_name="--appearance",
|
|
source_label="--appearance",
|
|
allowed_keys=APPEARANCE_OPTION_KEYS,
|
|
setting_label="appearance settings",
|
|
)
|
|
|
|
appearance_path = Path(appearance) if Path(appearance).is_absolute() else cwd / appearance
|
|
looks_like_file = appearance.lower().endswith(".json") or "/" in appearance or "\\" in appearance
|
|
if not looks_like_file and not appearance_path.exists():
|
|
return appearance
|
|
if not appearance_path.exists():
|
|
raise SnapshotError(f"Appearance JSON file does not exist: {appearance}")
|
|
return validate_direct_settings_payload(
|
|
load_json_text(appearance_path.read_text(encoding="utf-8"), str(appearance_path)),
|
|
option_name="--appearance",
|
|
source_label=str(appearance_path),
|
|
allowed_keys=APPEARANCE_OPTION_KEYS,
|
|
setting_label="appearance settings",
|
|
)
|
|
|
|
|
|
def apply_option_overrides_to_job(job: object, options: SnapshotOptions, *, cwd: Path) -> object:
|
|
if not is_plain_object(job):
|
|
return job
|
|
if not any(
|
|
[
|
|
options.view_labels,
|
|
options.size_profile,
|
|
options.params_specified,
|
|
options.display_specified,
|
|
options.appearance_specified,
|
|
options.camera_specified,
|
|
option_focus_hide_specified(options),
|
|
]
|
|
):
|
|
return job
|
|
next_job = copy.deepcopy(job)
|
|
merge_focus_hide_options(next_job, options)
|
|
if options.appearance_specified:
|
|
next_job["appearance"] = load_appearance_option(options.appearance, cwd=cwd)
|
|
if options.params_specified:
|
|
next_job["stepParameters"] = parse_params_option(options.params)
|
|
if options.display_specified:
|
|
next_job["display"] = load_display_option(options.display, cwd=cwd)
|
|
if options.camera_specified:
|
|
next_job["camera"] = parse_camera_option(options.camera)
|
|
render = dict(next_job.get("render") if is_plain_object(next_job.get("render")) else {})
|
|
if options.view_labels:
|
|
render["viewLabels"] = True
|
|
if options.size_profile:
|
|
render["sizeProfile"] = options.size_profile
|
|
next_job["render"] = render
|
|
return next_job
|
|
|
|
|
|
def apply_option_overrides_to_payload(payload: object, options: SnapshotOptions, *, cwd: Path) -> object:
|
|
if isinstance(payload, list):
|
|
return [apply_option_overrides_to_job(job, options, cwd=cwd) for job in payload]
|
|
if is_plain_object(payload) and isinstance(payload.get("jobs"), list):
|
|
next_payload = copy.deepcopy(payload)
|
|
next_payload["jobs"] = [apply_option_overrides_to_job(job, options, cwd=cwd) for job in payload["jobs"]]
|
|
return next_payload
|
|
return apply_option_overrides_to_job(payload, options, cwd=cwd)
|
|
|
|
|
|
def load_job_from_options(
|
|
options: SnapshotOptions,
|
|
*,
|
|
stdin: Any = sys.stdin,
|
|
cwd: Path | None = None,
|
|
) -> object:
|
|
resolved_cwd = (cwd or Path.cwd()).resolve()
|
|
if options.job:
|
|
if options.job == "-":
|
|
text = stdin.read()
|
|
source_label = "stdin"
|
|
else:
|
|
job_path = (resolved_cwd / options.job).resolve()
|
|
text = job_path.read_text(encoding="utf-8")
|
|
source_label = str(job_path)
|
|
return apply_option_overrides_to_payload(load_json_text(text, source_label), options, cwd=resolved_cwd)
|
|
|
|
if not stdin.isatty() and not options.input:
|
|
text = stdin.read()
|
|
if text.strip():
|
|
return apply_option_overrides_to_payload(load_json_text(text, "stdin"), options, cwd=resolved_cwd)
|
|
|
|
if not options.input:
|
|
raise SnapshotError("render requires --job, stdin JSON, or --input")
|
|
if options.mode != "list" and not options.output:
|
|
raise SnapshotError("render shortcut requires --output for non-list modes")
|
|
|
|
output: dict[str, object] = {
|
|
"path": options.output,
|
|
"camera": parse_camera_option(options.camera),
|
|
}
|
|
if options.width:
|
|
output["width"] = options.width
|
|
if options.height:
|
|
output["height"] = options.height
|
|
|
|
job: dict[str, object] = {
|
|
"input": options.input,
|
|
"mode": options.mode,
|
|
"outputs": [] if options.mode == "list" else [output],
|
|
"appearance": load_appearance_option(options.appearance, cwd=resolved_cwd),
|
|
"render": {"viewLabels": options.view_labels},
|
|
}
|
|
if options.size_profile:
|
|
job["render"]["sizeProfile"] = options.size_profile
|
|
if options.display_specified:
|
|
job["display"] = load_display_option(options.display, cwd=resolved_cwd)
|
|
if options.params_specified:
|
|
job["stepParameters"] = parse_params_option(options.params)
|
|
merge_focus_hide_options(job, options)
|
|
return job
|
|
|
|
|
|
def path_is_inside_or_equal(child: Path, parent: Path) -> bool:
|
|
resolved_child = child.resolve()
|
|
resolved_parent = parent.resolve()
|
|
try:
|
|
resolved_child.relative_to(resolved_parent)
|
|
return True
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
def input_kind(file_path: Path) -> str:
|
|
suffix = file_path.suffix.lower()
|
|
if suffix == ".step":
|
|
return "step"
|
|
if suffix == ".stp":
|
|
return "stp"
|
|
if suffix == ".py":
|
|
return "python"
|
|
return ""
|
|
|
|
|
|
def logical_step_path_for_python_source(source_path: Path) -> Path:
|
|
return source_path.with_suffix(".step")
|
|
|
|
|
|
def same_stem_python_generator_path(step_path: Path) -> Path | None:
|
|
candidate = step_path.with_suffix(".py")
|
|
try:
|
|
return candidate if re.search(r"\bgen_step\s*\(", candidate.read_text(encoding="utf-8")) else None
|
|
except OSError:
|
|
return None
|
|
|
|
|
|
def resolve_input_path(raw_input: object, *, cwd: Path) -> Path:
|
|
input_text = str(raw_input or "").strip()
|
|
if not input_text:
|
|
raise SnapshotError("render job is missing input")
|
|
raw_path = Path(input_text)
|
|
selected = raw_path.resolve() if raw_path.is_absolute() else (cwd / raw_path).resolve()
|
|
if not selected.exists():
|
|
if selected.suffix.lower() in {".step", ".stp"} and same_stem_python_generator_path(selected):
|
|
return selected
|
|
raise SnapshotError(f"Render input does not exist: {input_text}")
|
|
return selected
|
|
|
|
|
|
def encode_path_param(value: str) -> str:
|
|
return "/".join(quote(part) for part in value.replace(os.sep, "/").split("/"))
|
|
|
|
|
|
def asset_url_for_path(file_path: Path, root_path: Path) -> str:
|
|
if not path_is_inside_or_equal(file_path, root_path):
|
|
raise SnapshotError(f"Render asset must be inside the snapshot render root: {file_path}")
|
|
return f"/__render_asset/{encode_path_param(file_path.resolve().relative_to(root_path.resolve()).as_posix())}"
|
|
|
|
|
|
def step_parameter_path_for_step_source(source_path: Path) -> Path:
|
|
return source_path.with_name(f".{source_path.stem}.step.js")
|
|
|
|
|
|
def has_step_parameter_render_values(value: object) -> bool:
|
|
return value is not None
|
|
|
|
|
|
def step_parameter_render_values_are_animated(value: object) -> bool:
|
|
return is_plain_object(value) and is_plain_object(value.get("animate")) and bool(value["animate"])
|
|
|
|
|
|
def appearance_theme_id_for_job(job: Mapping[str, object]) -> str:
|
|
appearance = job.get("appearance")
|
|
if isinstance(appearance, str):
|
|
return appearance.strip().lower() or DEFAULT_RENDER_THEME_ID
|
|
return DEFAULT_RENDER_THEME_ID
|
|
|
|
|
|
def normalize_size_profile(value: object) -> str:
|
|
return str(value or "").strip().lower().replace("_", "-")
|
|
|
|
|
|
def explicit_size_profile(job: Mapping[str, object], output: Mapping[str, object]) -> str:
|
|
render = job.get("render") if is_plain_object(job.get("render")) else {}
|
|
return normalize_size_profile(output.get("sizeProfile") or render.get("sizeProfile") or job.get("sizeProfile") or "")
|
|
|
|
|
|
def default_render_size(job: Mapping[str, object], output: Mapping[str, object]) -> tuple[int, int]:
|
|
mode = str(job.get("mode") or "view").strip().lower()
|
|
profile = explicit_size_profile(job, output)
|
|
if profile in {"simple-square", "square"}:
|
|
return SIMPLE_SQUARE_RENDER_WIDTH, SIMPLE_SQUARE_RENDER_HEIGHT
|
|
if profile in {"simple", "simple-part", "unlabeled"}:
|
|
return SIMPLE_RENDER_WIDTH, SIMPLE_RENDER_HEIGHT
|
|
if profile in {"presentation-large", "hero", "large-presentation"}:
|
|
return PRESENTATION_LARGE_RENDER_WIDTH, PRESENTATION_LARGE_RENDER_HEIGHT
|
|
if profile == "presentation":
|
|
return PRESENTATION_RENDER_WIDTH, PRESENTATION_RENDER_HEIGHT
|
|
if profile in {"complex-assembly-large", "assembly-large"}:
|
|
return COMPLEX_ASSEMBLY_LARGE_RENDER_WIDTH, COMPLEX_ASSEMBLY_LARGE_RENDER_HEIGHT
|
|
if profile in {"complex-assembly", "assembly"}:
|
|
return COMPLEX_ASSEMBLY_RENDER_WIDTH, COMPLEX_ASSEMBLY_RENDER_HEIGHT
|
|
if profile in {"contact-sheet", "contactsheet"}:
|
|
return CONTACT_SHEET_RENDER_WIDTH, CONTACT_SHEET_RENDER_HEIGHT
|
|
if profile == "orbit" or mode == "orbit" or step_parameter_render_values_are_animated(job.get("stepParameters")):
|
|
return ORBIT_RENDER_WIDTH, ORBIT_RENDER_HEIGHT
|
|
render = job.get("render") if is_plain_object(job.get("render")) else {}
|
|
if (
|
|
profile in {"dimensioned", "section", "labeled"}
|
|
or mode == "section"
|
|
or render.get("viewLabels") is True
|
|
or output.get("viewLabel")
|
|
or output.get("label")
|
|
):
|
|
return DIAGNOSTIC_RENDER_WIDTH, DIAGNOSTIC_RENDER_HEIGHT
|
|
if profile == "diagnostic" or appearance_theme_id_for_job(job) in WORKBENCH_RENDER_THEME_IDS:
|
|
return DIAGNOSTIC_RENDER_WIDTH, DIAGNOSTIC_RENDER_HEIGHT
|
|
return SIMPLE_RENDER_WIDTH, SIMPLE_RENDER_HEIGHT
|
|
|
|
|
|
def resolve_output_size(job: Mapping[str, object], output: Mapping[str, object]) -> tuple[int, int]:
|
|
default_width, default_height = default_render_size(job, output)
|
|
return (
|
|
positive_integer(output.get("width") or job.get("width") or default_width, "output width"),
|
|
positive_integer(output.get("height") or job.get("height") or default_height, "output height"),
|
|
)
|
|
|
|
|
|
def snapshot_timestamp() -> str:
|
|
return datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
|
|
|
|
|
def timestamp_output_path(output_path: str, timestamp: str) -> str:
|
|
if not output_path:
|
|
return ""
|
|
path = Path(output_path)
|
|
return str(path.with_name(f"{path.stem}_{timestamp}{path.suffix}"))
|
|
|
|
|
|
def normalize_snapshot_job_packet(raw_payload: object) -> tuple[bool, list[object]]:
|
|
if isinstance(raw_payload, list):
|
|
return False, raw_payload
|
|
if is_plain_object(raw_payload) and isinstance(raw_payload.get("jobs"), list):
|
|
return False, list(raw_payload["jobs"])
|
|
return True, [raw_payload]
|
|
|
|
|
|
def reference_root_for_input(input_path: Path, cwd: Path) -> Path:
|
|
return cwd if path_is_inside_or_equal(input_path, cwd) else input_path.parent
|
|
|
|
|
|
def cad_ref_for_step_path(repo_root: Path, step_path: Path) -> str:
|
|
try:
|
|
relative = step_path.resolve().relative_to(repo_root.resolve()).as_posix()
|
|
except ValueError:
|
|
relative = step_path.resolve().as_posix()
|
|
suffix = step_path.suffix
|
|
return relative[: -len(suffix)] if suffix else relative
|
|
|
|
|
|
def load_ensure_step_topology_artifact():
|
|
global ensure_step_topology_artifact
|
|
if ensure_step_topology_artifact is None:
|
|
from cadpy.step_artifacts import ensure_step_topology_artifact as imported_ensure
|
|
|
|
ensure_step_topology_artifact = imported_ensure
|
|
return ensure_step_topology_artifact
|
|
|
|
|
|
def selection_value_list(value: object) -> list[str]:
|
|
if isinstance(value, list):
|
|
values: list[str] = []
|
|
for item in value:
|
|
values.extend(selection_value_list(item))
|
|
return values
|
|
text = str(value or "").strip()
|
|
if not text:
|
|
return []
|
|
return [entry.strip() for entry in text.split(",") if entry.strip()]
|
|
|
|
|
|
def selection_filter_values(job: Mapping[str, object]) -> list[str]:
|
|
selection = job.get("selection") if is_plain_object(job.get("selection")) else {}
|
|
values: list[str] = []
|
|
for key in ("focus", "refs", "hide"):
|
|
values.extend(selection_value_list(selection.get(key)))
|
|
return values
|
|
|
|
|
|
def selector_value_requires_topology(value: str) -> bool:
|
|
text = str(value or "").strip()
|
|
if not text:
|
|
return False
|
|
parsed = cad_ref_syntax.parse_selector(text)
|
|
return parsed is not None and parsed.selector_type != "opaque"
|
|
|
|
|
|
def selection_requires_selector_topology(job: Mapping[str, object]) -> bool:
|
|
return any(selector_value_requires_topology(value) for value in selection_filter_values(job))
|
|
|
|
|
|
def ensure_render_job_step_artifact(
|
|
job: Mapping[str, object],
|
|
*,
|
|
reference_root: Path,
|
|
input_path: Path,
|
|
step_path: Path,
|
|
require_selector: bool = False,
|
|
) -> StepTopologyArtifact:
|
|
target = ResolvedStepTarget(
|
|
cad_path=cad_ref_for_step_path(reference_root, step_path),
|
|
kind="part",
|
|
source_path=input_path,
|
|
step_path=step_path,
|
|
)
|
|
try:
|
|
ensure_artifact = load_ensure_step_topology_artifact()
|
|
return ensure_artifact(target, owner="cad-snapshot", require_selector=require_selector)
|
|
except StepTopologyArtifactError as exc:
|
|
raise SnapshotError(str(exc)) from exc
|
|
|
|
|
|
def artifact_selector_index(artifact: StepTopologyArtifact | None) -> lookup.SelectorIndex | None:
|
|
selector_bundle = artifact.selector_bundle if artifact is not None else None
|
|
if selector_bundle is None:
|
|
return None
|
|
manifest = selector_bundle.manifest if isinstance(selector_bundle.manifest, dict) else None
|
|
if manifest is None:
|
|
return None
|
|
buffers = selector_bundle.buffers if isinstance(selector_bundle.buffers, Mapping) else None
|
|
return lookup.build_selector_index(manifest, buffers=buffers)
|
|
|
|
|
|
def validate_occurrence_selector(selector: str, *, selector_index: lookup.SelectorIndex | None, source_label: str) -> None:
|
|
if selector_index is None:
|
|
return
|
|
if selector not in selector_index.occurrence_by_id:
|
|
raise SnapshotError(f"{source_label} references unknown part/subassembly occurrence selector: {selector}")
|
|
|
|
|
|
def normalize_selection_selector(
|
|
raw_value: str,
|
|
*,
|
|
selector_index: lookup.SelectorIndex | None,
|
|
source_label: str,
|
|
) -> list[str]:
|
|
text = str(raw_value or "").strip()
|
|
if not text:
|
|
return []
|
|
parsed = cad_ref_syntax.parse_selector(text)
|
|
if parsed is None:
|
|
return []
|
|
if parsed.selector_type == "opaque":
|
|
return [parsed.canonical]
|
|
if parsed.selector_type != "occurrence":
|
|
raise SnapshotError(
|
|
f"{source_label} supports only part/subassembly occurrence refs; "
|
|
f"got {parsed.selector_type} selector {text!r}"
|
|
)
|
|
validate_occurrence_selector(parsed.canonical, selector_index=selector_index, source_label=source_label)
|
|
return [parsed.canonical]
|
|
|
|
|
|
def normalize_selection_filter_values(
|
|
value: object,
|
|
*,
|
|
expected_cad_path: str,
|
|
selector_index: lookup.SelectorIndex | None,
|
|
source_label: str,
|
|
) -> list[str]:
|
|
_ = expected_cad_path
|
|
selectors: list[str] = []
|
|
for raw_value in selection_value_list(value):
|
|
selectors.extend(
|
|
normalize_selection_selector(raw_value, selector_index=selector_index, source_label=source_label)
|
|
)
|
|
return selectors
|
|
|
|
|
|
def normalize_render_job_selection(
|
|
job: Mapping[str, object],
|
|
*,
|
|
expected_cad_path: str,
|
|
selector_index: lookup.SelectorIndex | None,
|
|
) -> dict[str, object] | None:
|
|
selection = job.get("selection") if is_plain_object(job.get("selection")) else None
|
|
if selection is None:
|
|
return None
|
|
if any(selection_value_list(selection.get(key)) for key in ("focus", "refs")) and selection_value_list(
|
|
selection.get("hide")
|
|
):
|
|
raise SnapshotError("selection.focus/refs and selection.hide cannot be used in the same snapshot job")
|
|
normalized = dict(selection)
|
|
for key in ("focus", "refs", "hide"):
|
|
if key not in selection:
|
|
continue
|
|
normalized[key] = normalize_selection_filter_values(
|
|
selection.get(key),
|
|
expected_cad_path=expected_cad_path,
|
|
selector_index=selector_index,
|
|
source_label=f"selection.{key}",
|
|
)
|
|
return normalized
|
|
|
|
|
|
def resolve_render_job(
|
|
raw_job: object,
|
|
*,
|
|
cwd: Path | None = None,
|
|
timestamp: str | None = None,
|
|
) -> dict[str, object]:
|
|
if not is_plain_object(raw_job):
|
|
raise SnapshotError("render job must be an object")
|
|
job = copy.deepcopy(raw_job)
|
|
if "theme" in job:
|
|
raise SnapshotError("render jobs use appearance; theme is reserved for saved appearance settings")
|
|
if "params" in job:
|
|
raise SnapshotError("render jobs use stepParameters; params is reserved for shortcut --params parsing")
|
|
forbidden_root_fields = [field for field in ("workspaceRoot", "rootDir") if field in job]
|
|
if forbidden_root_fields:
|
|
raise SnapshotError(
|
|
"snapshot jobs no longer accept workspaceRoot or rootDir; pass a relative or absolute input path instead"
|
|
)
|
|
|
|
resolved_cwd = (cwd or Path.cwd()).resolve()
|
|
raw_input = str(job.get("input") or "").strip()
|
|
if not raw_input:
|
|
raise SnapshotError("render job is missing input")
|
|
|
|
input_path = resolve_input_path(raw_input, cwd=resolved_cwd)
|
|
root_path = input_path.parent.resolve()
|
|
reference_root = reference_root_for_input(input_path, resolved_cwd)
|
|
kind = input_kind(input_path)
|
|
source_path = input_path
|
|
if kind == "python":
|
|
input_path = logical_step_path_for_python_source(input_path)
|
|
root_path = input_path.parent.resolve()
|
|
kind = "step"
|
|
if kind not in {"step", "stp"}:
|
|
raise SnapshotError("Snapshot supports only STEP/STP inputs or same-stem Python generators")
|
|
|
|
artifact = ensure_render_job_step_artifact(
|
|
job,
|
|
reference_root=reference_root,
|
|
input_path=source_path,
|
|
step_path=input_path,
|
|
require_selector=selection_requires_selector_topology(job),
|
|
)
|
|
expected_cad_path = cad_ref_for_step_path(reference_root, input_path)
|
|
normalized_selection = normalize_render_job_selection(
|
|
job,
|
|
expected_cad_path=expected_cad_path,
|
|
selector_index=artifact_selector_index(artifact),
|
|
)
|
|
|
|
glb_path = existing_part_glb_path(input_path) or part_glb_path(input_path)
|
|
if not glb_path.exists():
|
|
raise SnapshotError(f"STEP/STP render input is missing its CAD Viewer GLB artifact: {glb_path}")
|
|
|
|
has_param_render = has_step_parameter_render_values(job.get("stepParameters"))
|
|
animated_params = step_parameter_render_values_are_animated(job.get("stepParameters"))
|
|
step_parameter_path = step_parameter_path_for_step_source(input_path)
|
|
|
|
mode = str(job.get("mode") or "view").strip().lower()
|
|
if mode not in SUPPORTED_RENDER_MODES:
|
|
raise SnapshotError(f"Unsupported render mode: {mode or '(missing)'}")
|
|
if has_param_render and mode != "view":
|
|
raise SnapshotError("stepParameters support only view mode; set display.mode for display-style changes")
|
|
if has_param_render and not step_parameter_path.exists():
|
|
raise SnapshotError(
|
|
f"STEP/STP render stepParameters require a CAD Viewer STEP parameter sidecar: {step_parameter_path}"
|
|
)
|
|
|
|
outputs = job.get("outputs") if isinstance(job.get("outputs"), list) else []
|
|
if mode != "list" and not outputs:
|
|
raise SnapshotError("render job must include outputs for non-list modes")
|
|
if animated_params and len(outputs) != 1:
|
|
raise SnapshotError("animated stepParameters require exactly one output")
|
|
|
|
normalized_render = dict(job.get("render") if is_plain_object(job.get("render")) else {})
|
|
normalized_render.pop("clip", None)
|
|
normalized_render.pop("clipSettings", None)
|
|
raw_scale = str(
|
|
normalized_render.get("scale")
|
|
or normalized_render.get("sceneScale")
|
|
or normalized_render.get("sceneScaleMode")
|
|
or job.get("scale")
|
|
or job.get("sceneScale")
|
|
or ""
|
|
).strip().lower()
|
|
if raw_scale:
|
|
normalized_render["scale"] = "cad"
|
|
|
|
normalized_outputs: list[dict[str, object]] = []
|
|
resolved_timestamp = timestamp or snapshot_timestamp()
|
|
for output in outputs:
|
|
output_object = dict(output if is_plain_object(output) else {})
|
|
width, height = resolve_output_size({**job, "mode": mode}, output_object)
|
|
output_path = str(output_object.get("path") or "")
|
|
timestamped_output_path = timestamp_output_path(output_path, resolved_timestamp)
|
|
normalized_outputs.append(
|
|
{
|
|
**output_object,
|
|
"path": str((resolved_cwd / timestamped_output_path).resolve()) if timestamped_output_path else "",
|
|
"width": width,
|
|
"height": height,
|
|
"camera": output_object.get("camera") or job.get("camera") or "iso",
|
|
}
|
|
)
|
|
|
|
job.pop("clip", None)
|
|
job.pop("clipSettings", None)
|
|
resolved: dict[str, object] = {
|
|
"rootPath": str(root_path),
|
|
"inputPath": str(input_path),
|
|
"inputUrl": asset_url_for_path(input_path, root_path),
|
|
"kind": kind,
|
|
"glbPath": str(glb_path),
|
|
"glbUrl": asset_url_for_path(glb_path, root_path),
|
|
}
|
|
if step_parameter_path.exists():
|
|
resolved["stepParameterPath"] = str(step_parameter_path)
|
|
resolved["stepParameterUrl"] = asset_url_for_path(step_parameter_path, root_path)
|
|
|
|
if normalized_selection is not None:
|
|
job["selection"] = normalized_selection
|
|
|
|
return {
|
|
**job,
|
|
"mode": mode,
|
|
"display": job.get("display") if is_plain_object(job.get("display")) else {"mode": "solid"},
|
|
"render": normalized_render,
|
|
"outputs": normalized_outputs,
|
|
"resolved": resolved,
|
|
}
|
|
|
|
|
|
def resolve_render_job_packet(raw_payload: object, *, cwd: Path | None = None) -> dict[str, object]:
|
|
single, jobs = normalize_snapshot_job_packet(raw_payload)
|
|
timestamp = snapshot_timestamp()
|
|
return {
|
|
"single": single,
|
|
"jobs": [resolve_render_job(job, cwd=cwd, timestamp=timestamp) for job in jobs],
|
|
}
|
|
|
|
|
|
def content_type_for_path(path: Path) -> str:
|
|
if path.suffix.lower() == ".mjs":
|
|
return "text/javascript; charset=utf-8"
|
|
if path.suffix.lower() == ".js":
|
|
return "text/javascript; charset=utf-8"
|
|
if path.suffix.lower() == ".html":
|
|
return "text/html; charset=utf-8"
|
|
if path.suffix.lower() == ".wasm":
|
|
return "application/wasm"
|
|
if path.suffix.lower() == ".glb":
|
|
return "model/gltf-binary"
|
|
guessed, _ = mimetypes.guess_type(path)
|
|
return guessed or "application/octet-stream"
|
|
|
|
|
|
def route_file(pathname: str, prefix: str, root: Path) -> Path:
|
|
relative_path = unquote(pathname[len(prefix) :])
|
|
file_path = (root / relative_path.lstrip("/")).resolve()
|
|
if not path_is_inside_or_equal(file_path, root):
|
|
raise RouteFileError(f"forbidden route path: {pathname}", status=403)
|
|
return file_path
|
|
|
|
|
|
def resolve_snapshot_route_file(raw_url: str, *, active_root_path: Path | None = None) -> Path:
|
|
parsed = urlparse(raw_url)
|
|
origin = f"{parsed.scheme}://{parsed.netloc}"
|
|
if origin != SNAPSHOT_ORIGIN:
|
|
raise RouteFileError(f"unsupported snapshot origin: {origin}", status=403)
|
|
if parsed.path == "/render.html":
|
|
return RENDER_HTML_PATH
|
|
if parsed.path.startswith("/__render_asset/"):
|
|
if active_root_path is None:
|
|
raise RouteFileError("snapshot render asset requested without an active render root")
|
|
return route_file(parsed.path, "/__render_asset/", active_root_path)
|
|
if parsed.path == "/snapshot-render.js":
|
|
return RUNTIME_DIR / "snapshot-render.js"
|
|
raise RouteFileError(f"snapshot route not found: {parsed.path}")
|
|
|
|
|
|
def max_output_size(job: Mapping[str, object]) -> tuple[int, int]:
|
|
outputs = job.get("outputs") if isinstance(job.get("outputs"), list) and job.get("outputs") else []
|
|
if not outputs:
|
|
return SIMPLE_RENDER_WIDTH, SIMPLE_RENDER_HEIGHT
|
|
widths = [int(output.get("width") or SIMPLE_RENDER_WIDTH) for output in outputs if is_plain_object(output)]
|
|
heights = [int(output.get("height") or SIMPLE_RENDER_HEIGHT) for output in outputs if is_plain_object(output)]
|
|
return max(widths or [SIMPLE_RENDER_WIDTH], default=SIMPLE_RENDER_WIDTH), max(heights or [SIMPLE_RENDER_HEIGHT], default=SIMPLE_RENDER_HEIGHT)
|
|
|
|
|
|
async def with_snapshot_timeout(awaitable: Any, timeout_seconds: object, label: str = "snapshot") -> object:
|
|
timeout = max(1, float(timeout_seconds or DEFAULT_TIMEOUT_SECONDS))
|
|
try:
|
|
return await asyncio.wait_for(awaitable, timeout=timeout)
|
|
except asyncio.TimeoutError as exc:
|
|
raise SnapshotError(f"{label} timed out after {timeout_seconds}s") from exc
|
|
|
|
|
|
class BatchSnapshotRenderer:
|
|
def __init__(self) -> None:
|
|
self.playwright = None
|
|
self.browser = None
|
|
self.context = None
|
|
self.page = None
|
|
self.active_root_path: Path | None = None
|
|
self.started = False
|
|
|
|
async def start(self) -> None:
|
|
if self.started:
|
|
return
|
|
try:
|
|
try:
|
|
from playwright.async_api import async_playwright
|
|
except ImportError as exc:
|
|
raise SnapshotError(
|
|
"CAD snapshot requires the Python playwright package. "
|
|
"Install the CAD skill requirements, then run `python -m playwright install chromium` if needed."
|
|
) from exc
|
|
self.playwright = await async_playwright().start()
|
|
self.browser = await self.playwright.chromium.launch(
|
|
headless=True,
|
|
timeout=RENDER_BROWSER_STARTUP_TIMEOUT_MS,
|
|
)
|
|
self.context = await self.browser.new_context(
|
|
viewport={"width": SIMPLE_RENDER_WIDTH, "height": SIMPLE_RENDER_HEIGHT},
|
|
device_scale_factor=1,
|
|
)
|
|
self.page = await self.context.new_page()
|
|
await self.page.route(SNAPSHOT_ROUTE_GLOB, self.handle_route)
|
|
await self.page.goto(SNAPSHOT_RENDER_URL, wait_until="load", timeout=DEFAULT_TIMEOUT_SECONDS * 1000)
|
|
await self.page.wait_for_function(
|
|
"typeof window.__snapshotRender === 'function'",
|
|
timeout=DEFAULT_TIMEOUT_SECONDS * 1000,
|
|
)
|
|
self.started = True
|
|
except Exception:
|
|
await self.close()
|
|
raise
|
|
|
|
async def handle_route(self, route: Any) -> None:
|
|
request = route.request
|
|
if request.method != "GET":
|
|
await route.fulfill(status=405, content_type="text/plain; charset=utf-8", body="method not allowed")
|
|
return
|
|
try:
|
|
file_path = resolve_snapshot_route_file(request.url, active_root_path=self.active_root_path)
|
|
except RouteFileError as exc:
|
|
await route.fulfill(status=exc.status, content_type="text/plain; charset=utf-8", body=str(exc))
|
|
return
|
|
except Exception as exc:
|
|
await route.fulfill(status=500, content_type="text/plain; charset=utf-8", body=str(exc))
|
|
return
|
|
if not file_path.is_file():
|
|
await route.fulfill(status=404, content_type="text/plain; charset=utf-8", body="not found")
|
|
return
|
|
await route.fulfill(
|
|
status=200,
|
|
content_type=content_type_for_path(file_path),
|
|
headers={"cache-control": "no-store"},
|
|
body=file_path.read_bytes(),
|
|
)
|
|
|
|
async def render(self, job: Mapping[str, object]) -> dict[str, object]:
|
|
await self.start()
|
|
resolved = job.get("resolved") if is_plain_object(job.get("resolved")) else {}
|
|
self.active_root_path = Path(str(resolved.get("rootPath") or "")).resolve()
|
|
width, height = max_output_size(job)
|
|
await self.page.set_viewport_size({"width": width, "height": height})
|
|
timeout_seconds = job.get("timeoutSeconds") or DEFAULT_TIMEOUT_SECONDS
|
|
result = await with_snapshot_timeout(
|
|
self.page.evaluate("(renderJob) => window.__snapshotRender(renderJob)", dict(job)),
|
|
timeout_seconds,
|
|
)
|
|
if not is_plain_object(result) or not result.get("ok"):
|
|
message = result.get("error") if is_plain_object(result) else ""
|
|
raise SnapshotError(str(message or "unknown browser snapshot failure"))
|
|
return result
|
|
|
|
async def close(self) -> None:
|
|
if self.context is not None:
|
|
try:
|
|
await self.context.close()
|
|
except Exception:
|
|
pass
|
|
self.context = None
|
|
if self.browser is not None:
|
|
try:
|
|
await self.browser.close()
|
|
except Exception:
|
|
pass
|
|
self.browser = None
|
|
if self.playwright is not None:
|
|
try:
|
|
await self.playwright.stop()
|
|
except Exception:
|
|
pass
|
|
self.playwright = None
|
|
self.page = None
|
|
self.started = False
|
|
|
|
|
|
async def render_resolved_job_packet(packet: Mapping[str, object], *, renderer: BatchSnapshotRenderer | None = None) -> dict[str, object]:
|
|
snapshot_renderer = renderer or BatchSnapshotRenderer()
|
|
started = time.perf_counter()
|
|
results: list[dict[str, object]] = []
|
|
try:
|
|
for job in packet["jobs"]:
|
|
result = await snapshot_renderer.render(job)
|
|
results.append(result if packet["single"] else {"input": job.get("input"), **result})
|
|
finally:
|
|
await snapshot_renderer.close()
|
|
if packet["single"]:
|
|
return results[0]
|
|
return {
|
|
"ok": all(result.get("ok") is not False for result in results),
|
|
"jobs": results,
|
|
"timings": {
|
|
"jobCount": len(results),
|
|
"totalMs": (time.perf_counter() - started) * 1000,
|
|
},
|
|
}
|
|
|
|
|
|
def write_output_payload(output: Mapping[str, object]) -> None:
|
|
output_path = str(output.get("path") or "")
|
|
if not output_path:
|
|
return
|
|
path = Path(output_path)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
text = output.get("text")
|
|
if isinstance(text, str):
|
|
path.write_text(text, encoding="utf-8")
|
|
return
|
|
data_url = str(output.get("dataUrl") or "")
|
|
match = re.match(r"^data:([^;]+);base64,(.+)$", data_url)
|
|
if not match:
|
|
raise SnapshotError(f"Snapshot output did not include a base64 data URL: {output_path}")
|
|
path.write_bytes(base64.b64decode(match.group(2)))
|
|
|
|
|
|
def write_render_outputs(result: Mapping[str, object]) -> None:
|
|
if isinstance(result.get("jobs"), list):
|
|
for job_result in result["jobs"]:
|
|
if is_plain_object(job_result):
|
|
write_render_outputs(job_result)
|
|
return
|
|
outputs = result.get("outputs") if isinstance(result.get("outputs"), list) else []
|
|
for output in outputs:
|
|
if is_plain_object(output):
|
|
write_output_payload(output)
|
|
|
|
|
|
def print_render_result(result: Mapping[str, object], *, json_output: bool = False, stdout: Any = sys.stdout) -> None:
|
|
if json_output:
|
|
stdout.write(f"{json.dumps(result, indent=2)}\n")
|
|
return
|
|
if isinstance(result.get("jobs"), list):
|
|
for job_result in result["jobs"]:
|
|
if is_plain_object(job_result):
|
|
print_render_result(job_result, json_output=False, stdout=stdout)
|
|
for warning in result.get("warnings") or []:
|
|
stdout.write(f"warning: {warning}\n")
|
|
return
|
|
outputs = result.get("outputs")
|
|
if not isinstance(outputs, list):
|
|
stdout.write(f"{json.dumps(result, indent=2)}\n")
|
|
return
|
|
if result.get("mode") == "list":
|
|
stdout.write(f"{json.dumps(result.get('parts') or [], indent=2)}\n")
|
|
return
|
|
for output in outputs:
|
|
if is_plain_object(output) and output.get("path"):
|
|
stdout.write(f"saved snapshot: {output['path']}\n")
|
|
for warning in result.get("warnings") or []:
|
|
stdout.write(f"warning: {warning}\n")
|
|
|
|
|
|
async def run_render_cli_async(
|
|
argv: Sequence[str],
|
|
*,
|
|
cwd: Path | None = None,
|
|
stdout: Any = sys.stdout,
|
|
stdin: Any = sys.stdin,
|
|
) -> int:
|
|
options = parse_snapshot_args(argv)
|
|
if options.help:
|
|
stdout.write(help_text())
|
|
return 0
|
|
raw_payload = load_job_from_options(options, stdin=stdin, cwd=cwd)
|
|
packet = resolve_render_job_packet(raw_payload, cwd=cwd)
|
|
result = await render_resolved_job_packet(packet)
|
|
write_render_outputs(result)
|
|
print_render_result(result, json_output=options.json, stdout=stdout)
|
|
return 0
|
|
|
|
|
|
def run_render_cli(
|
|
argv: Sequence[str],
|
|
*,
|
|
cwd: Path | None = None,
|
|
stdout: Any = sys.stdout,
|
|
stderr: Any = sys.stderr,
|
|
stdin: Any = sys.stdin,
|
|
) -> int:
|
|
try:
|
|
return asyncio.run(run_render_cli_async(argv, cwd=cwd, stdout=stdout, stdin=stdin))
|
|
except SnapshotError as exc:
|
|
stderr.write(f"{exc}\n")
|
|
return 1
|
|
except Exception as exc:
|
|
stderr.write(f"{exc}\n")
|
|
return 1
|
|
|
|
|
|
def main(argv: Sequence[str] | None = None) -> int:
|
|
return run_render_cli(list(sys.argv[1:] if argv is None else argv))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|