{headline}
- " + warning_html + "
"""Preview bundle inspection, live session rendering, and popup helpers."""
from __future__ import annotations
import functools
import html
import json
import os
import shutil
import subprocess
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Tuple
def _read_json(path: Path) -> Dict[str, Any]:
with open(path, "r", encoding="utf-8") as fh:
return json.load(fh)
def resolve_bundle_ref(bundle_ref: str) -> Tuple[Path, Path]:
ref = Path(bundle_ref).expanduser().resolve()
if ref.is_dir():
manifest = ref / "manifest.json"
if not manifest.is_file():
raise FileNotFoundError(f"manifest.json not found in bundle directory: {ref}")
return ref, manifest
if ref.is_file():
if ref.name != "manifest.json":
raise ValueError("Bundle ref must be a bundle directory or a manifest.json path")
return ref.parent, ref
raise FileNotFoundError(f"Bundle ref not found: {bundle_ref}")
def resolve_session_ref(session_ref: str) -> Tuple[Path, Path]:
ref = Path(session_ref).expanduser().resolve()
if ref.is_dir():
session_path = ref / "session.json"
if not session_path.is_file():
raise FileNotFoundError(f"session.json not found in live session directory: {ref}")
return ref, session_path
if ref.is_file():
if ref.name != "session.json":
raise ValueError("Session ref must be a live session directory or a session.json path")
return ref.parent, ref
raise FileNotFoundError(f"Session ref not found: {session_ref}")
def is_live_session_ref(preview_ref: str) -> bool:
ref = Path(preview_ref).expanduser().resolve()
if ref.is_dir():
return (ref / "session.json").is_file()
return ref.is_file() and ref.name == "session.json"
def load_bundle(bundle_ref: str) -> Tuple[Path, Dict[str, Any], Dict[str, Any]]:
bundle_dir, manifest_path = resolve_bundle_ref(bundle_ref)
manifest = _read_json(manifest_path)
summary_rel = manifest.get("summary_path", "summary.json")
summary_path = (bundle_dir / summary_rel).resolve()
summary = _read_json(summary_path) if summary_path.is_file() else {}
return bundle_dir, manifest, summary
def load_session(session_ref: str) -> Tuple[Path, Dict[str, Any]]:
session_dir, session_path = resolve_session_ref(session_ref)
return session_dir, _read_json(session_path)
def format_bytes(size: int) -> str:
if size < 1024:
return f"{size} B"
if size < 1024 * 1024:
return f"{size / 1024:.1f} KB"
if size < 1024 * 1024 * 1024:
return f"{size / (1024 * 1024):.1f} MB"
return f"{size / (1024 * 1024 * 1024):.1f} GB"
_TRAJECTORY_FILENAMES = ("trajectory.json", "timeline.json")
_TRAJECTORY_CONTAINER_KEYS = {"trajectory", "timeline"}
_TRAJECTORY_PATH_KEYS = {
"trajectory_path",
"timeline_path",
"trajectory_file",
"timeline_file",
"trajectory_ref",
"timeline_ref",
}
def _coalesce(*values: Any) -> Any:
for value in values:
if value is None:
continue
if isinstance(value, str) and not value.strip():
continue
return value
return None
def _stringify_command(value: Any) -> Optional[str]:
if value is None:
return None
if isinstance(value, str):
value = value.strip()
return value or None
if isinstance(value, (list, tuple)):
text = " ".join(str(part) for part in value if part is not None)
return text.strip() or None
if isinstance(value, dict):
for key in ("display", "display_cmd", "command", "raw", "argv"):
if key in value:
return _stringify_command(value[key])
return json.dumps(value, ensure_ascii=False, sort_keys=True)
return str(value)
def _normalize_index(value: Any, fallback: int) -> int:
if isinstance(value, bool):
return fallback
if isinstance(value, int):
return value
if isinstance(value, float):
return int(value)
if isinstance(value, str):
stripped = value.strip()
if stripped.isdigit():
return int(stripped)
return fallback
def _resolve_ref_path(base_dir: Path, path_ref: str) -> Path:
ref = Path(path_ref).expanduser()
if not ref.is_absolute():
ref = (base_dir / ref).resolve()
else:
ref = ref.resolve()
return ref
def _iter_trajectory_hints(node: Any, *, _seen: Optional[set[int]] = None) -> Iterable[Tuple[str, Any, str]]:
if _seen is None:
_seen = set()
if not isinstance(node, (dict, list)):
return
node_id = id(node)
if node_id in _seen:
return
_seen.add(node_id)
if isinstance(node, list):
for item in node:
yield from _iter_trajectory_hints(item, _seen=_seen)
return
for key, value in node.items():
lower = str(key).lower()
if lower in _TRAJECTORY_CONTAINER_KEYS:
if isinstance(value, dict):
yield ("object", value, lower)
elif isinstance(value, str):
yield ("path", value, lower)
elif lower in _TRAJECTORY_PATH_KEYS and isinstance(value, str):
yield ("path", value, lower)
if isinstance(value, (dict, list)):
yield from _iter_trajectory_hints(value, _seen=_seen)
def _trajectory_candidate_refs(base_dir: Path, *payloads: Dict[str, Any]) -> List[str]:
refs: List[str] = []
seen = set()
for payload in payloads:
if not isinstance(payload, dict):
continue
for kind, value, _label in _iter_trajectory_hints(payload):
if kind != "path" or not isinstance(value, str):
continue
resolved = _resolve_ref_path(base_dir, value)
if not resolved.is_file():
continue
rel = os.path.relpath(resolved, base_dir)
if rel not in seen:
refs.append(rel)
seen.add(rel)
for filename in _TRAJECTORY_FILENAMES:
if filename not in seen:
refs.append(filename)
seen.add(filename)
return refs
def _extract_bundle_payload(item: Dict[str, Any]) -> Dict[str, Any]:
for key in ("copied_bundle", "bundle", "preview_bundle", "current_bundle", "published_bundle"):
value = item.get(key)
if isinstance(value, dict):
return value
return item
def _normalize_timeline_row(item: Any, index: int) -> Dict[str, Any]:
if not isinstance(item, dict):
return {
"order_index": index,
"step_index": index,
"step_id": f"step-{index:03d}",
"step_label": str(item),
"command": None,
"command_started_at": None,
"command_finished_at": None,
"timeline_ready_at": None,
"publish_reason": None,
"bundle_id": None,
"bundle_dir": None,
"manifest_path": None,
"summary_path": None,
"status": None,
"stage_label": None,
"note": None,
}
bundle = _extract_bundle_payload(item)
command = _stringify_command(
_coalesce(
item.get("command"),
item.get("display_cmd"),
item.get("display_command"),
item.get("argv"),
item.get("raw_command"),
)
)
step_index = _normalize_index(
_coalesce(item.get("step_index"), item.get("index"), item.get("sequence_index")),
index,
)
status = item.get("status")
if status is None and "returncode" in item:
status = "ok" if int(item.get("returncode", 1)) == 0 else "error"
return {
"order_index": index,
"step_index": step_index,
"step_id": str(
_coalesce(item.get("step_id"), item.get("id"), item.get("command_id"), f"step-{step_index:03d}")
),
"step_label": _coalesce(
item.get("step_label"),
item.get("label"),
item.get("title"),
item.get("name"),
item.get("stage_title"),
item.get("stage_label"),
),
"command": command,
"command_started_at": _coalesce(
item.get("command_started_at"),
item.get("started_at"),
item.get("timeline_start_s"),
item.get("start_s"),
),
"command_finished_at": _coalesce(
item.get("command_finished_at"),
item.get("finished_at"),
item.get("timeline_end_s"),
item.get("end_s"),
item.get("completed_at"),
),
"timeline_ready_at": _coalesce(
item.get("timeline_ready_s"),
item.get("ready_at"),
item.get("published_at"),
item.get("created_at"),
),
"publish_reason": _coalesce(item.get("publish_reason"), item.get("reason")),
"bundle_id": _coalesce(item.get("bundle_id"), bundle.get("bundle_id")),
"bundle_dir": _coalesce(item.get("bundle_dir"), bundle.get("bundle_dir")),
"manifest_path": _coalesce(item.get("manifest_path"), bundle.get("manifest_path")),
"summary_path": _coalesce(item.get("summary_path"), bundle.get("summary_path")),
"status": status,
"stage_label": _coalesce(
item.get("stage_title"),
item.get("stage_label"),
item.get("stage_id"),
item.get("stage"),
),
"note": _coalesce(
item.get("note"),
item.get("stage_story"),
item.get("story"),
item.get("description"),
),
}
def _merge_timeline_rows(target: Dict[str, Any], source: Dict[str, Any]) -> None:
for key in (
"step_label",
"command",
"command_started_at",
"command_finished_at",
"timeline_ready_at",
"publish_reason",
"bundle_id",
"bundle_dir",
"manifest_path",
"summary_path",
"status",
"stage_label",
"note",
):
target[key] = _coalesce(target.get(key), source.get(key))
def _pick_trajectory_events(raw: Dict[str, Any]) -> List[Dict[str, Any]]:
for key in ("preview_events", "events", "publishes", "entries", "history", "steps", "timeline"):
value = raw.get(key)
if isinstance(value, list) and value:
return value
return []
def _sort_timeline_rows(rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
def sort_key(row: Dict[str, Any]) -> Tuple[int, int, str]:
step_index = _normalize_index(row.get("step_index"), row.get("order_index", 0))
order_index = _normalize_index(row.get("order_index"), step_index)
finish = _coalesce(row.get("command_finished_at"), row.get("timeline_ready_at"), "")
return step_index, order_index, str(finish)
return sorted(rows, key=sort_key)
def _normalize_trajectory(raw: Optional[Dict[str, Any]], *, fallback_history: Optional[List[Dict[str, Any]]] = None) -> Optional[Dict[str, Any]]:
if not raw and not fallback_history:
return None
rows: List[Dict[str, Any]] = []
rows_by_id: Dict[str, Dict[str, Any]] = {}
rows_by_index: Dict[int, Dict[str, Any]] = {}
commands = raw.get("commands", []) if isinstance(raw, dict) else []
if isinstance(commands, list):
for index, item in enumerate(commands):
row = _normalize_timeline_row(item, index)
rows.append(row)
rows_by_id[row["step_id"]] = row
rows_by_index[row["step_index"]] = row
events = _pick_trajectory_events(raw) if isinstance(raw, dict) else []
if events:
for index, item in enumerate(events):
event_row = _normalize_timeline_row(item, index)
existing = rows_by_id.get(event_row["step_id"]) or rows_by_index.get(event_row["step_index"])
if existing is None:
rows.append(event_row)
rows_by_id[event_row["step_id"]] = event_row
rows_by_index[event_row["step_index"]] = event_row
continue
_merge_timeline_rows(existing, event_row)
if not rows and fallback_history:
for index, item in enumerate(fallback_history):
rows.append(_normalize_timeline_row(item, index))
rows = _sort_timeline_rows(rows)
if not rows:
return None
recent_command_row = next((row for row in reversed(rows) if row.get("command")), None)
recent_publish_row = next(
(row for row in reversed(rows) if row.get("publish_reason") or row.get("bundle_id")),
None,
)
step_count = _coalesce(
raw.get("step_count") if isinstance(raw, dict) else None,
len(commands) if isinstance(commands, list) and commands else None,
len(rows),
)
published_bundles = sum(1 for row in rows if row.get("bundle_id"))
return {
"protocol": raw.get("protocol") or raw.get("protocol_version") if isinstance(raw, dict) else None,
"step_count": step_count,
"current_step_id": _coalesce(
raw.get("current_step_id") if isinstance(raw, dict) else None,
recent_publish_row.get("step_id") if recent_publish_row else None,
recent_command_row.get("step_id") if recent_command_row else None,
),
"published_bundle_count": published_bundles,
"recent_command": _coalesce(
raw.get("latest_command") if isinstance(raw, dict) else None,
recent_command_row.get("command") if recent_command_row else None,
),
"recent_publish_reason": _coalesce(
raw.get("latest_publish_reason") if isinstance(raw, dict) else None,
recent_publish_row.get("publish_reason") if recent_publish_row else None,
),
"recent_bundle_id": recent_publish_row.get("bundle_id") if recent_publish_row else None,
"entries": rows,
}
def _load_trajectory(base_dir: Path, *payloads: Dict[str, Any]) -> Optional[Dict[str, Any]]:
for ref in _trajectory_candidate_refs(base_dir, *payloads):
candidate = _resolve_ref_path(base_dir, ref)
if candidate.is_file():
raw = _read_json(candidate)
normalized = _normalize_trajectory(raw)
if normalized is not None:
normalized["mode"] = "trajectory"
normalized["source_path"] = str(candidate)
normalized["source_label"] = os.path.relpath(candidate, base_dir)
return normalized
for payload in payloads:
if not isinstance(payload, dict):
continue
for kind, value, label in _iter_trajectory_hints(payload):
if kind != "object" or not isinstance(value, dict):
continue
normalized = _normalize_trajectory(value)
if normalized is not None:
normalized["mode"] = "trajectory"
normalized["source_path"] = None
normalized["source_label"] = label
return normalized
return None
def _history_from_session(session: Dict[str, Any]) -> Optional[Dict[str, Any]]:
history = session.get("history", [])
if not isinstance(history, list) or not history:
return None
normalized = _normalize_trajectory({}, fallback_history=history)
if normalized is None:
return None
source_state = session.get("source_state", {}) if isinstance(session.get("source_state"), dict) else {}
normalized["mode"] = "legacy-history"
normalized["source_path"] = None
normalized["source_label"] = "session.history"
normalized["current_step_id"] = _coalesce(
session.get("current_step_id"),
normalized.get("current_step_id"),
)
normalized["recent_command"] = _coalesce(
session.get("latest_command"),
normalized.get("recent_command"),
)
normalized["recent_publish_reason"] = _coalesce(
session.get("latest_publish_reason"),
normalized.get("recent_publish_reason"),
source_state.get("last_publish_reason"),
)
return normalized
def _apply_session_trajectory_metadata(trajectory: Optional[Dict[str, Any]], session: Dict[str, Any]) -> Optional[Dict[str, Any]]:
if trajectory is None:
return None
trajectory["protocol"] = _coalesce(
trajectory.get("protocol"),
session.get("trajectory_protocol_version"),
)
trajectory["step_count"] = _coalesce(
session.get("trajectory_step_count"),
trajectory.get("step_count"),
)
trajectory["current_step_id"] = _coalesce(
session.get("current_step_id"),
trajectory.get("current_step_id"),
)
trajectory["recent_command"] = _coalesce(
session.get("latest_command"),
trajectory.get("recent_command"),
)
trajectory["recent_publish_reason"] = _coalesce(
session.get("latest_publish_reason"),
trajectory.get("recent_publish_reason"),
)
return trajectory
def _render_trajectory_text_lines(title: str, trajectory: Optional[Dict[str, Any]], *, limit: int = 5) -> List[str]:
if not trajectory:
return []
lines = [
"",
title,
f" Source: {trajectory.get('source_label') or trajectory.get('mode') or 'unknown'}",
f" Steps: {trajectory.get('step_count', 0)}",
f" Published bundles: {trajectory.get('published_bundle_count', 0)}",
]
if trajectory.get("current_step_id"):
lines.append(f" Current step: {trajectory['current_step_id']}")
if trajectory.get("recent_command"):
lines.append(f" Recent command: {trajectory['recent_command']}")
if trajectory.get("recent_publish_reason"):
lines.append(f" Recent publish: {trajectory['recent_publish_reason']}")
if trajectory.get("recent_bundle_id"):
lines.append(f" Recent bundle: {trajectory['recent_bundle_id']}")
entries = trajectory.get("entries", [])
if entries:
lines.append(" Timeline")
for entry in entries[-limit:]:
label = entry.get("step_label") or entry.get("stage_label") or entry.get("step_id") or "step"
lines.append(f" - {label}")
if entry.get("command"):
lines.append(f" Command: {entry['command']}")
if entry.get("publish_reason"):
lines.append(f" Publish: {entry['publish_reason']}")
if entry.get("bundle_id"):
lines.append(f" Bundle: {entry['bundle_id']}")
return lines
def inspect_bundle(bundle_ref: str) -> Dict[str, Any]:
bundle_dir, manifest, summary = load_bundle(bundle_ref)
return {
"bundle_dir": str(bundle_dir),
"manifest": manifest,
"summary": summary,
"artifact_count": len(manifest.get("artifacts", [])),
"trajectory": _load_trajectory(bundle_dir, manifest, summary),
}
def inspect_session(session_ref: str) -> Dict[str, Any]:
session_dir, session = load_session(session_ref)
current_bundle = None
try:
current_bundle = inspect_bundle(str(session_dir / session.get("current_link", "current")))
except (FileNotFoundError, ValueError):
current_bundle = None
trajectory = _load_trajectory(session_dir, session)
if trajectory is None:
trajectory = _history_from_session(session)
trajectory = _apply_session_trajectory_metadata(trajectory, session)
return {
"session_dir": str(session_dir),
"session": session,
"current_bundle": current_bundle,
"trajectory": trajectory,
}
def render_inspect_text(bundle_ref: str) -> str:
payload = inspect_bundle(bundle_ref)
bundle_dir = Path(payload["bundle_dir"])
manifest = payload["manifest"]
summary = payload["summary"]
lines = [
f"Bundle: {bundle_dir}",
f"Protocol: {manifest.get('protocol_version', 'unknown')}",
f"Software: {manifest.get('software', 'unknown')}",
f"Recipe: {manifest.get('recipe', 'unknown')}",
f"Kind: {manifest.get('bundle_kind', 'unknown')}",
f"Status: {manifest.get('status', 'unknown')}",
f"Created: {manifest.get('created_at', 'unknown')}",
]
source = manifest.get("source", {})
if source:
lines.append(
"Source: "
+ (
source.get("project_path")
or source.get("capture_path")
or source.get("project_name")
or "n/a"
)
)
if source.get("project_fingerprint"):
lines.append(f"Fingerprint: {source['project_fingerprint']}")
elif source.get("capture_fingerprint"):
lines.append(f"Fingerprint: {source['capture_fingerprint']}")
if summary:
lines.append("")
lines.append("Summary")
lines.append(f" Headline: {summary.get('headline', '(none)')}")
facts = summary.get("facts", {})
for key, value in facts.items():
lines.append(f" {key}: {value}")
for warning in summary.get("warnings", []):
lines.append(f" Warning: {warning}")
lines.append("")
lines.append("Artifacts")
for artifact in manifest.get("artifacts", []):
desc = (
f" - {artifact.get('artifact_id', '?')} "
f"[{artifact.get('role', '?')}] {artifact.get('label', '')}"
)
desc += f" -> {artifact.get('path', '')}"
if artifact.get("bytes") is not None:
desc += f" ({format_bytes(int(artifact['bytes']))})"
lines.append(desc)
lines.extend(_render_trajectory_text_lines("Trajectory", payload.get("trajectory")))
return "\n".join(lines) + "\n"
def render_session_text(session_ref: str) -> str:
payload = inspect_session(session_ref)
session_dir = Path(payload["session_dir"])
session = payload["session"]
trajectory = payload.get("trajectory")
lines = [
f"Live Session: {session_dir}",
f"Protocol: {session.get('protocol_version', 'unknown')}",
f"Software: {session.get('software', 'unknown')}",
f"Recipe: {session.get('recipe', 'unknown')}",
f"Status: {session.get('status', 'unknown')}",
f"Updated: {session.get('updated_at', 'unknown')}",
f"Current: {session.get('current_bundle_id', 'n/a')}",
f"Project: {session.get('project_path') or session.get('project_name') or 'n/a'}",
]
if session.get("watch_command"):
lines.append(f"Watch: {session['watch_command']}")
history_title = "History" if trajectory and trajectory.get("mode") == "legacy-history" else "Trajectory"
lines.extend(_render_trajectory_text_lines(history_title, trajectory))
if trajectory is None:
history = session.get("history", [])
if history:
lines.append("")
lines.append("History")
for item in history:
lines.append(
f" - {item.get('bundle_id', '?')} "
f"[{item.get('status', 'unknown')}] {item.get('created_at', 'unknown')}"
)
return "\n".join(lines) + "\n"
def _artifact_href(output_dir: Path, bundle_dir: Path, artifact_path: str) -> str:
target = (bundle_dir / artifact_path).resolve()
return os.path.relpath(target, output_dir)
def _render_artifact_card(output_dir: Path, bundle_dir: Path, artifact: Dict[str, Any]) -> str:
role = html.escape(artifact.get("role", "artifact"))
label = html.escape(artifact.get("label", artifact.get("artifact_id", "artifact")))
path_ref = _artifact_href(output_dir, bundle_dir, artifact.get("path", ""))
media_type = artifact.get("media_type", "")
size = artifact.get("bytes")
meta = []
if artifact.get("width") and artifact.get("height"):
meta.append(f"{artifact['width']}×{artifact['height']}")
if artifact.get("duration_s") is not None:
meta.append(f"{artifact['duration_s']}s")
if size is not None:
meta.append(format_bytes(int(size)))
meta_line = " · ".join(meta)
if media_type.startswith("image/"):
body = f''
elif media_type.startswith("video/"):
body = (
f'"
)
else:
body = (
'
{html.escape(str(item["command"]))}{html.escape(session.get("publish_command", "shotcut preview live push"))} to publish or refresh the session.