mirror of
https://github.com/HKUDS/CLI-Anything.git
synced 2026-09-18 23:42:43 +08:00
757 lines
30 KiB
Python
757 lines
30 KiB
Python
#!/usr/bin/env python3
|
|
"""Render a polished Blender build-story video from a real preview demo run.
|
|
|
|
The source build run must already contain real preview bundles, a persisted live
|
|
session, and a `trajectory.json`. This script turns that trajectory into a
|
|
split-screen story video, then appends the real turntable ending from the
|
|
source run.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import importlib.util
|
|
import json
|
|
import math
|
|
import shutil
|
|
import subprocess
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
FREECAD_DEMO_SCRIPT = Path(__file__).with_name("freecad_live_preview_demo.py")
|
|
|
|
|
|
def _load_style_module():
|
|
spec = importlib.util.spec_from_file_location("freecad_live_preview_demo", FREECAD_DEMO_SCRIPT)
|
|
module = importlib.util.module_from_spec(spec)
|
|
assert spec.loader is not None
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
STYLE = _load_style_module()
|
|
VIDEO_W = STYLE.VIDEO_W
|
|
VIDEO_H = STYLE.VIDEO_H
|
|
LEFT_W = STYLE.LEFT_W
|
|
FPS = STYLE.FPS
|
|
COLORS = STYLE.COLORS
|
|
HOLD_TAIL_S = 1.4
|
|
BASELINE_START_S = 0.55
|
|
PREVIEW_SWITCH_LATENCY_S = 0.0
|
|
FINAL_STILL_STEP_S = 0.85
|
|
TURNTABLE_STEP_S = 0.7
|
|
|
|
DISPLAY_FONT_PATH = STYLE.DISPLAY_FONT_PATH
|
|
SANS_FONT_PATH = STYLE.SANS_FONT_PATH
|
|
MONO_FONT_PATH = STYLE.MONO_FONT_PATH
|
|
MONO_BOLD_FONT_PATH = STYLE.MONO_BOLD_FONT_PATH
|
|
|
|
|
|
def load_json(path: Path) -> Dict[str, Any]:
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
|
|
|
|
def write_json(path: Path, payload: Dict[str, Any]) -> Path:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
return path
|
|
|
|
|
|
def _stage_title(stage: Dict[str, Any]) -> str:
|
|
stage_id = str(stage.get("stage") or "stage").strip()
|
|
label = str(stage.get("label") or stage_id.replace("_", " ")).strip()
|
|
prefix = stage_id.split("_", 1)[0]
|
|
if prefix.isdigit():
|
|
return f"Stage {prefix} · {label}"
|
|
return label
|
|
|
|
|
|
def _stage_story(stage: Dict[str, Any]) -> str:
|
|
return str(stage.get("story") or stage.get("label") or stage.get("stage") or "Build stage").strip()
|
|
|
|
|
|
def _stage_display_cmd(stage: Dict[str, Any]) -> str:
|
|
return str(stage.get("display_cmd") or f"publish {stage.get('stage') or 'stage'}").strip()
|
|
|
|
|
|
def build_trajectory(build_manifest_path: Path, output_dir: Path) -> Dict[str, Any]:
|
|
build_manifest = load_json(build_manifest_path)
|
|
project_path = Path(build_manifest["project_path"]).expanduser().resolve()
|
|
live_root = Path(build_manifest["preview_root"]).expanduser().resolve()
|
|
final_render_path = Path(build_manifest["final_render"]["output"]).expanduser().resolve()
|
|
turntable_path = Path(build_manifest["motion"]["video"]["video_path"]).expanduser().resolve()
|
|
live_session = build_manifest.get("live_session") or {}
|
|
stage_log = build_manifest.get("stage_log") or []
|
|
if not stage_log:
|
|
raise RuntimeError(f"No stage_log entries found in {build_manifest_path}")
|
|
|
|
commands: List[Dict[str, Any]] = []
|
|
t = 0.0
|
|
|
|
def add_command(cmd_id: str, label: str, display_cmd: str, duration_s: float) -> Dict[str, Any]:
|
|
nonlocal t
|
|
command = {
|
|
"index": len(commands),
|
|
"id": cmd_id,
|
|
"label": label,
|
|
"display_cmd": display_cmd,
|
|
"duration_s": float(duration_s),
|
|
"timeline_start_s": round(t, 3),
|
|
"timeline_end_s": round(t + float(duration_s), 3),
|
|
"stdout": "",
|
|
"stderr": "",
|
|
"returncode": 0,
|
|
}
|
|
commands.append(command)
|
|
t += float(duration_s)
|
|
return command
|
|
|
|
def bundle_payload(stage: Dict[str, Any]) -> Dict[str, Any]:
|
|
bundle_dir = Path(stage["current_bundle_dir"]).expanduser().resolve()
|
|
manifest_path = Path(stage["current_manifest_path"]).expanduser().resolve()
|
|
summary_path = bundle_dir / "summary.json"
|
|
manifest = load_json(manifest_path)
|
|
summary = load_json(summary_path)
|
|
artifacts = {
|
|
item["artifact_id"]: str((bundle_dir / item["path"]).resolve())
|
|
for item in manifest.get("artifacts", [])
|
|
}
|
|
return {
|
|
"bundle_id": manifest["bundle_id"],
|
|
"bundle_dir": str(bundle_dir),
|
|
"manifest_path": str(manifest_path),
|
|
"summary_path": str(summary_path),
|
|
"artifacts": artifacts,
|
|
"summary": summary,
|
|
}
|
|
|
|
baseline_command = add_command(
|
|
"live-baseline",
|
|
"Bring live preview online from the launch-platform baseline",
|
|
str(
|
|
live_session.get("start_command")
|
|
or f"cli-anything-blender --project {project_path} preview live start --recipe quick --mode manual --root-dir {live_root}"
|
|
),
|
|
BASELINE_START_S,
|
|
)
|
|
|
|
preview_events: List[Dict[str, Any]] = []
|
|
baseline_stage = stage_log[0]
|
|
preview_events.append(
|
|
{
|
|
"sequence_index": 1,
|
|
"step_index": baseline_command["index"],
|
|
"step_id": baseline_command["id"],
|
|
"step_label": baseline_command["label"],
|
|
"stage_id": baseline_stage["stage"],
|
|
"stage_title": _stage_title(baseline_stage),
|
|
"stage_story": _stage_story(baseline_stage),
|
|
"timeline_ready_s": 0.0,
|
|
"latency_s": 0.0,
|
|
"bundle_count": int(baseline_stage.get("bundle_count") or 1),
|
|
"publish_reason": f"stage:{baseline_stage['stage']}",
|
|
"session_path": baseline_stage["session_path"],
|
|
"session_dir": str(Path(baseline_stage["session_path"]).expanduser().resolve().parent),
|
|
"copied_bundle": bundle_payload(baseline_stage),
|
|
}
|
|
)
|
|
|
|
for sequence_index, stage in enumerate(stage_log[1:], start=2):
|
|
command = add_command(
|
|
f"stage-{stage['stage']}",
|
|
str(stage.get("label") or stage["stage"]).strip(),
|
|
_stage_display_cmd(stage),
|
|
float(stage.get("duration_s") or 0.9),
|
|
)
|
|
ready_t = round(float(command["timeline_end_s"]) + PREVIEW_SWITCH_LATENCY_S, 3)
|
|
preview_events.append(
|
|
{
|
|
"sequence_index": sequence_index,
|
|
"step_index": command["index"],
|
|
"step_id": command["id"],
|
|
"step_label": command["label"],
|
|
"stage_id": stage["stage"],
|
|
"stage_title": _stage_title(stage),
|
|
"stage_story": _stage_story(stage),
|
|
"timeline_ready_s": ready_t,
|
|
"latency_s": PREVIEW_SWITCH_LATENCY_S,
|
|
"bundle_count": int(stage["bundle_count"]),
|
|
"publish_reason": f"stage:{stage['stage']}",
|
|
"session_path": stage["session_path"],
|
|
"session_dir": str(Path(stage["session_path"]).expanduser().resolve().parent),
|
|
"copied_bundle": bundle_payload(stage),
|
|
}
|
|
)
|
|
|
|
add_command(
|
|
"final-still",
|
|
"Render final hero still",
|
|
f"render_scene(..., output='{final_render_path}')",
|
|
FINAL_STILL_STEP_S,
|
|
)
|
|
add_command(
|
|
"turntable",
|
|
"Package the real turntable ending",
|
|
f"ffmpeg -> {turntable_path}",
|
|
TURNTABLE_STEP_S,
|
|
)
|
|
|
|
trajectory = {
|
|
"protocol": "blender-preview-trajectory/v1",
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
"scenario": "orbital-relay-drone",
|
|
"scenario_title": "Orbital Relay Drone",
|
|
"scenario_subtitle": "launch-platform baseline, step-aligned real preview checkpoints, then a real turntable ending",
|
|
"build_manifest_path": str(build_manifest_path.resolve()),
|
|
"project_path": str(project_path),
|
|
"live_root": str(live_root),
|
|
"final_render": str(final_render_path),
|
|
"turntable_video": str(turntable_path),
|
|
"commands": commands,
|
|
"preview_events": preview_events,
|
|
"notes": [
|
|
"The first preview shown on the right is the real stage-00 launch-platform bundle, used as a live baseline instead of leaving the monitor empty.",
|
|
"Every later preview switch is keyed from the matching real stage_log entry, so command completion and preview refresh stay aligned.",
|
|
"The ending appends the existing real turntable video from the same run.",
|
|
],
|
|
}
|
|
write_json(output_dir / "trajectory.json", trajectory)
|
|
return trajectory
|
|
|
|
|
|
def _fonts() -> Dict[str, ImageFont.FreeTypeFont]:
|
|
return {
|
|
"display": STYLE.load_font(DISPLAY_FONT_PATH, 38),
|
|
"title": STYLE.load_font(DISPLAY_FONT_PATH, 24),
|
|
"body": STYLE.load_font(SANS_FONT_PATH, 17),
|
|
"small": STYLE.load_font(SANS_FONT_PATH, 13),
|
|
"mono": STYLE.load_font(MONO_FONT_PATH, 15),
|
|
"mono_small": STYLE.load_font(MONO_BOLD_FONT_PATH, 12),
|
|
}
|
|
|
|
|
|
def _draw_text_right(draw: ImageDraw.ImageDraw, x: int, y: int, text: str, *, font: ImageFont.FreeTypeFont, fill: str) -> None:
|
|
STYLE._draw_text_right(draw, x, y, text, font=font, fill=fill)
|
|
|
|
|
|
def progress_snapshot(trajectory: Dict[str, Any], t_real: float) -> Dict[str, Any]:
|
|
return STYLE.progress_snapshot(trajectory, t_real)
|
|
|
|
|
|
def pick_preview_event(trajectory: Dict[str, Any], t_real: float) -> Optional[Dict[str, Any]]:
|
|
return STYLE.pick_preview_event(trajectory, t_real)
|
|
|
|
|
|
def build_command_cards(trajectory: Dict[str, Any], t_real: float, *, max_cards: int = 6) -> List[Dict[str, Any]]:
|
|
return STYLE.build_command_cards(trajectory, t_real, max_cards=max_cards)
|
|
|
|
|
|
def draw_global_header(
|
|
canvas: Image.Image,
|
|
trajectory: Dict[str, Any],
|
|
t_real: float,
|
|
fonts: Dict[str, ImageFont.FreeTypeFont],
|
|
) -> None:
|
|
draw = ImageDraw.Draw(canvas)
|
|
snapshot = progress_snapshot(trajectory, t_real)
|
|
title = trajectory.get("scenario_title", "Blender Live Demo").upper()
|
|
subtitle = trajectory.get("scenario_subtitle", "scripted build trace + real preview bundles")
|
|
|
|
draw.text((34, 20), "CLI-ANYTHING / BLENDER / LIVE PREVIEW PROTOCOL", fill="#88a9c8", font=fonts["small"])
|
|
draw.text((34, 36), title, fill=COLORS["white"], font=fonts["display"])
|
|
draw.text((34, 68), subtitle, fill="#97abc2", font=fonts["body"])
|
|
|
|
chip_y = 20
|
|
chips = [
|
|
f"T+ {t_real:05.1f}s",
|
|
f"{snapshot['completed_cmds']:02d}/{snapshot['total_cmds']:02d} steps",
|
|
f"{snapshot['completed_previews']:02d}/{snapshot['total_previews']:02d} bundles",
|
|
]
|
|
x = VIDEO_W - 34
|
|
for text in reversed(chips):
|
|
bbox = draw.textbbox((0, 0), text, font=fonts["mono_small"])
|
|
chip_w = (bbox[2] - bbox[0]) + 26
|
|
STYLE._draw_chip(
|
|
canvas,
|
|
(x - chip_w, chip_y, x, chip_y + 26),
|
|
text=text,
|
|
font=fonts["mono_small"],
|
|
fill=COLORS["chip_bg"],
|
|
text_fill=COLORS["chip_text"],
|
|
outline=COLORS["panel_line"],
|
|
)
|
|
x -= chip_w + 10
|
|
|
|
draw.line((30, 98, VIDEO_W - 30, 98), fill=STYLE._rgba(COLORS["grid"], 120), width=1)
|
|
|
|
|
|
def draw_trace_panel(
|
|
canvas: Image.Image,
|
|
area: tuple[int, int, int, int],
|
|
trajectory: Dict[str, Any],
|
|
t_real: float,
|
|
fonts: Dict[str, ImageFont.FreeTypeFont],
|
|
) -> None:
|
|
x0, y0, x1, y1 = area
|
|
draw = ImageDraw.Draw(canvas)
|
|
snapshot = progress_snapshot(trajectory, t_real)
|
|
STYLE._draw_panel(canvas, area, radius=30, fill=COLORS["panel"], outline=COLORS["panel_line"], accent=COLORS["accent"])
|
|
|
|
draw.text((x0 + 24, y0 + 24), "Agent Build Trace", fill=COLORS["white"], font=fonts["title"])
|
|
STYLE._draw_chip(
|
|
canvas,
|
|
(x1 - 178, y0 + 24, x1 - 24, y0 + 50),
|
|
text="REAL SCRIPT TRACE",
|
|
font=fonts["mono_small"],
|
|
fill=COLORS["accent_soft"],
|
|
text_fill=COLORS["accent"],
|
|
outline=COLORS["accent"],
|
|
)
|
|
|
|
active_label = snapshot["active_cmd"]["label"] if snapshot["active_cmd"] else (
|
|
snapshot["current_event"]["step_label"] if snapshot["current_event"] else "waiting for first build step"
|
|
)
|
|
draw.text((x0 + 24, y0 + 58), STYLE._trim_middle(active_label, 46), fill="#9bb4ce", font=fonts["body"])
|
|
STYLE._draw_segment_bar(
|
|
canvas,
|
|
(x0 + 24, y0 + 90, x1 - 24, y0 + 101),
|
|
done=snapshot["completed_cmds"],
|
|
total=max(1, snapshot["total_cmds"]),
|
|
fill=COLORS["accent"],
|
|
empty=COLORS["panel_line"],
|
|
)
|
|
|
|
chip_y = y0 + 116
|
|
chip_specs = [
|
|
(f"step {snapshot['completed_cmds']:02d}/{snapshot['total_cmds']:02d}", COLORS["chip_bg"], COLORS["chip_text"]),
|
|
(f"preview {snapshot['completed_previews']:02d}/{snapshot['total_previews']:02d}", COLORS["chip_bg"], COLORS["chip_text"]),
|
|
("manual live preview", COLORS["accent_soft"], COLORS["accent"]),
|
|
]
|
|
chip_x = x0 + 24
|
|
for text, fill, text_fill in chip_specs:
|
|
bbox = draw.textbbox((0, 0), text, font=fonts["mono_small"])
|
|
chip_w = (bbox[2] - bbox[0]) + 24
|
|
STYLE._draw_chip(
|
|
canvas,
|
|
(chip_x, chip_y, chip_x + chip_w, chip_y + 24),
|
|
text=text,
|
|
font=fonts["mono_small"],
|
|
fill=fill,
|
|
text_fill=text_fill,
|
|
outline=COLORS["panel_line"],
|
|
)
|
|
chip_x += chip_w + 10
|
|
|
|
body_area = (x0 + 16, y0 + 154, x1 - 16, y1 - 44)
|
|
STYLE._alpha_box(canvas, body_area, radius=22, fill=STYLE._rgba(COLORS["terminal_bg"], 246), outline=STYLE._rgba(COLORS["panel_line"], 255), width=1)
|
|
|
|
draw.text((body_area[0] + 18, body_area[1] + 16), "Recent steps", fill=COLORS["white"], font=fonts["body"])
|
|
draw.text((body_area[2] - 166, body_area[1] + 16), "scripted build flow", fill=COLORS["terminal_muted"], font=fonts["small"])
|
|
|
|
cards = build_command_cards(trajectory, t_real, max_cards=6)
|
|
card_gap = 10
|
|
card_height = 82
|
|
card_x0 = body_area[0] + 14
|
|
card_x1 = body_area[2] - 14
|
|
card_y = body_area[1] + 48
|
|
for card in cards:
|
|
status = card["status"]
|
|
if status == "live":
|
|
fill = "#0d2630"
|
|
outline = COLORS["accent"]
|
|
status_fill = COLORS["accent_soft"]
|
|
status_text = COLORS["accent"]
|
|
label_fill = COLORS["white"]
|
|
elif status == "done":
|
|
fill = "#0e1826"
|
|
outline = COLORS["panel_line"]
|
|
status_fill = "#123648"
|
|
status_text = "#9ddfff"
|
|
label_fill = "#dbe6f3"
|
|
else:
|
|
fill = "#0a111a"
|
|
outline = "#173049"
|
|
status_fill = "#111f30"
|
|
status_text = "#6f87a2"
|
|
label_fill = "#9bb0c7"
|
|
|
|
box = (card_x0, card_y, card_x1, card_y + card_height)
|
|
STYLE._alpha_box(canvas, box, radius=18, fill=STYLE._rgba(fill, 248), outline=STYLE._rgba(outline, 255), width=2 if status == "live" else 1)
|
|
STYLE._alpha_box(canvas, (box[0] + 10, box[1] + 10, box[0] + 14, box[3] - 10), radius=2, fill=STYLE._rgba(outline, 255))
|
|
if status == "live":
|
|
STYLE._draw_soft_glow(canvas, center=(box[2] - 34, box[1] + 24), radius=26, color=COLORS["accent"], strength=34)
|
|
|
|
STYLE._draw_chip(
|
|
canvas,
|
|
(box[0] + 22, box[1] + 12, box[0] + 74, box[1] + 34),
|
|
text=f"{card['index'] + 1:02d}",
|
|
font=fonts["mono_small"],
|
|
fill=COLORS["chip_bg"],
|
|
text_fill=COLORS["chip_text"],
|
|
outline=COLORS["panel_line"],
|
|
)
|
|
STYLE._draw_chip(
|
|
canvas,
|
|
(box[2] - 114, box[1] + 12, box[2] - 18, box[1] + 34),
|
|
text=status.upper(),
|
|
font=fonts["mono_small"],
|
|
fill=status_fill,
|
|
text_fill=status_text,
|
|
outline=outline,
|
|
)
|
|
draw.text((box[0] + 84, box[1] + 10), STYLE._trim_middle(card["label"], 38), fill=label_fill, font=fonts["body"])
|
|
draw.text((box[2] - 182, box[3] - 26), f"{card['duration_s']:.2f}s", fill=COLORS["terminal_muted"], font=fonts["mono_small"])
|
|
|
|
command_lines = STYLE._wrap_trimmed(STYLE._readable_command_text(card["command"]), width_chars=54, max_lines=2)
|
|
cmd_y = box[1] + 38
|
|
for line in command_lines:
|
|
draw.text((box[0] + 22, cmd_y), line, fill=COLORS["terminal_cmd"] if status != "queued" else "#6e8aa6", font=fonts["mono_small"])
|
|
cmd_y += 17
|
|
|
|
card_y += card_height + card_gap
|
|
|
|
footer = "Step-aligned build trace driven by the real Blender stage_log; preview captures remain real artifacts."
|
|
draw.text((x0 + 24, y1 - 28), footer, fill=COLORS["terminal_muted"], font=fonts["small"])
|
|
|
|
|
|
def _paste_preview_card(
|
|
canvas: Image.Image,
|
|
box: tuple[int, int, int, int],
|
|
*,
|
|
img_path: Optional[str],
|
|
label: str,
|
|
fonts: Dict[str, ImageFont.FreeTypeFont],
|
|
main: bool = False,
|
|
) -> None:
|
|
x0, y0, x1, y1 = box
|
|
STYLE._alpha_box(canvas, box, radius=18 if main else 14, fill=STYLE._rgba(COLORS["paper"], 255), outline=STYLE._rgba(COLORS["paper_line"], 255), width=2)
|
|
if img_path and Path(img_path).is_file():
|
|
fit = STYLE.fit_image(Image.open(img_path), (max(1, x1 - x0 - 18), max(1, y1 - y0 - 18)), background=COLORS["paper"])
|
|
canvas.paste(fit.convert("RGBA"), (x0 + 9, y0 + 9))
|
|
STYLE._draw_chip(
|
|
canvas,
|
|
(x0 + 12, y0 + 12, x0 + 112, y0 + 36),
|
|
text=label,
|
|
font=fonts["mono_small"],
|
|
fill="#fffaf3",
|
|
text_fill="#5b5145",
|
|
outline=COLORS["paper_line"],
|
|
)
|
|
overlay = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
|
|
draw = ImageDraw.Draw(overlay)
|
|
draw.line((x0 + 10, y0 + 10, x0 + 30, y0 + 10), fill=STYLE._rgba(COLORS["accent_warm"], 160), width=2)
|
|
draw.line((x0 + 10, y0 + 10, x0 + 10, y0 + 30), fill=STYLE._rgba(COLORS["accent_warm"], 160), width=2)
|
|
draw.line((x1 - 10, y1 - 10, x1 - 30, y1 - 10), fill=STYLE._rgba(COLORS["accent"], 160), width=2)
|
|
draw.line((x1 - 10, y1 - 10, x1 - 10, y1 - 30), fill=STYLE._rgba(COLORS["accent"], 160), width=2)
|
|
canvas.alpha_composite(overlay)
|
|
|
|
|
|
def draw_preview_panel(
|
|
canvas: Image.Image,
|
|
area: tuple[int, int, int, int],
|
|
trajectory: Dict[str, Any],
|
|
t_real: float,
|
|
fonts: Dict[str, ImageFont.FreeTypeFont],
|
|
image_cache: Dict[str, Image.Image],
|
|
) -> None:
|
|
x0, y0, x1, y1 = area
|
|
draw = ImageDraw.Draw(canvas)
|
|
snapshot = progress_snapshot(trajectory, t_real)
|
|
STYLE._draw_panel(canvas, area, radius=30, fill=COLORS["panel"], outline=COLORS["panel_line"], accent=COLORS["accent_warm"])
|
|
draw.text((x0 + 24, y0 + 24), "Preview Monitor", fill=COLORS["white"], font=fonts["title"])
|
|
STYLE._draw_chip(
|
|
canvas,
|
|
(x1 - 152, y0 + 24, x1 - 24, y0 + 50),
|
|
text="LIVE BUNDLES",
|
|
font=fonts["mono_small"],
|
|
fill="#351d17",
|
|
text_fill=COLORS["accent_warm"],
|
|
outline=COLORS["accent_warm"],
|
|
)
|
|
draw.text((x0 + 24, y0 + 58), "Real Blender quick-preview bundles from the active build run", fill=COLORS["preview_muted"], font=fonts["body"])
|
|
|
|
event = pick_preview_event(trajectory, t_real)
|
|
if event is None:
|
|
draw.text((x0 + 28, y0 + 96), "Waiting for first Blender preview bundle...", fill=COLORS["preview_muted"], font=fonts["body"])
|
|
return
|
|
|
|
current = event["copied_bundle"]
|
|
summary = current.get("summary") or {}
|
|
facts = summary.get("facts") or {}
|
|
previous_event = None
|
|
if event["sequence_index"] > 1:
|
|
previous_event = trajectory["preview_events"][event["sequence_index"] - 2]
|
|
|
|
stage_area = (x0 + 20, y0 + 96, x1 - 260, y1 - 20)
|
|
info_area = (x1 - 244, y0 + 96, x1 - 20, y1 - 20)
|
|
STYLE._alpha_box(canvas, info_area, radius=20, fill=STYLE._rgba(COLORS["panel_soft"], 252), outline=STYLE._rgba(COLORS["panel_line"], 255), width=1)
|
|
|
|
main_box = (stage_area[0], stage_area[1], stage_area[2], int(stage_area[1] + (stage_area[3] - stage_area[1]) * 0.73))
|
|
_paste_preview_card(canvas, main_box, img_path=current["artifacts"].get("hero"), label="HERO", fonts=fonts, main=True)
|
|
|
|
thumb_y = main_box[3] + 10
|
|
thumb_h = stage_area[3] - thumb_y
|
|
thumb_w = (stage_area[2] - stage_area[0] - 12) // 2
|
|
_paste_preview_card(
|
|
canvas,
|
|
(stage_area[0], thumb_y, stage_area[0] + thumb_w, thumb_y + thumb_h),
|
|
img_path=current["artifacts"].get("workbench"),
|
|
label="WORKBENCH",
|
|
fonts=fonts,
|
|
)
|
|
secondary_label = "FINAL STILL"
|
|
secondary_img = trajectory.get("final_render")
|
|
if previous_event is not None:
|
|
secondary_label = "PREV HERO"
|
|
secondary_img = previous_event["copied_bundle"]["artifacts"].get("hero")
|
|
_paste_preview_card(
|
|
canvas,
|
|
(stage_area[0] + thumb_w + 12, thumb_y, stage_area[2], thumb_y + thumb_h),
|
|
img_path=secondary_img,
|
|
label=secondary_label,
|
|
fonts=fonts,
|
|
)
|
|
|
|
draw.text((info_area[0] + 16, info_area[1] + 16), "Telemetry", fill=COLORS["white"], font=fonts["body"])
|
|
meta_lines = [
|
|
("STAGE", event["stage_title"]),
|
|
("BUNDLE", STYLE._trim_middle(current["bundle_id"], 18)),
|
|
("CAUSE", STYLE._trim_middle(str(event.get("publish_reason") or "n/a"), 18)),
|
|
("LATENCY", f"{event.get('latency_s', 0.0):.2f}s"),
|
|
("FRAME", str(facts.get("frame_current", "n/a"))),
|
|
("STREAM", f"{event['sequence_index']:02d}/{len(trajectory['preview_events']):02d}"),
|
|
]
|
|
meta_y = info_area[1] + 46
|
|
for label, value in meta_lines:
|
|
STYLE._draw_chip(
|
|
canvas,
|
|
(info_area[0] + 16, meta_y, info_area[2] - 16, meta_y + 28),
|
|
text=label,
|
|
font=fonts["mono_small"],
|
|
fill=COLORS["chip_bg"],
|
|
text_fill=COLORS["chip_text"],
|
|
outline=COLORS["panel_line"],
|
|
)
|
|
draw.text((info_area[0] + 18, meta_y + 36), STYLE._trim_middle(str(value), 26), fill=COLORS["white"], font=fonts["body"])
|
|
meta_y += 74
|
|
|
|
story_lines = STYLE._wrap_trimmed(event["stage_story"], width_chars=24, max_lines=5)
|
|
note_title = "Stage note"
|
|
if snapshot["active_cmd"] and snapshot["active_cmd"]["id"] in {"final-still", "turntable"}:
|
|
note_title = "Handoff note"
|
|
draw.text((info_area[0] + 16, info_area[3] - 176), note_title, fill=COLORS["white"], font=fonts["body"])
|
|
story_y = info_area[3] - 148
|
|
for line in story_lines:
|
|
draw.text((info_area[0] + 16, story_y), line, fill=COLORS["preview_muted"], font=fonts["small"])
|
|
story_y += 18
|
|
|
|
STYLE._draw_segment_bar(
|
|
canvas,
|
|
(info_area[0] + 16, info_area[3] - 32, info_area[2] - 16, info_area[3] - 18),
|
|
done=max(1, event["sequence_index"]),
|
|
total=max(1, len(trajectory["preview_events"])),
|
|
fill=COLORS["accent_warm"],
|
|
empty=COLORS["panel_line"],
|
|
)
|
|
|
|
|
|
def render_process_video(
|
|
trajectory: Dict[str, Any],
|
|
*,
|
|
output_dir: Path,
|
|
fps: int,
|
|
keep_frames: bool,
|
|
) -> Dict[str, Any]:
|
|
frames_dir = STYLE.ensure_clean_dir(output_dir / "process-frames")
|
|
stills_dir = STYLE.ensure_clean_dir(output_dir / "stills")
|
|
process_video_path = output_dir / "process.mp4"
|
|
|
|
fonts = _fonts()
|
|
backdrop = STYLE.build_static_backdrop()
|
|
image_cache: Dict[str, Image.Image] = {}
|
|
|
|
last_t = max(cmd["timeline_end_s"] for cmd in trajectory["commands"])
|
|
duration_s = last_t + HOLD_TAIL_S
|
|
frame_count = int(math.ceil(duration_s * fps))
|
|
|
|
early_idx = max(0, min(frame_count - 1, fps * 2))
|
|
mid_idx = max(0, min(frame_count - 1, frame_count // 2))
|
|
late_idx = max(0, frame_count - fps)
|
|
still_targets = {
|
|
early_idx: stills_dir / "early-command-stream.png",
|
|
mid_idx: stills_dir / "mid-preview-monitor.png",
|
|
late_idx: stills_dir / "late-build-state.png",
|
|
}
|
|
|
|
for frame_idx in range(frame_count):
|
|
t_real = frame_idx / fps
|
|
image = backdrop.copy()
|
|
draw_global_header(image, trajectory, t_real, fonts)
|
|
draw_trace_panel(image, (26, 116, LEFT_W - 14, VIDEO_H - 34), trajectory, t_real, fonts)
|
|
draw_preview_panel(image, (LEFT_W + 10, 116, VIDEO_W - 26, VIDEO_H - 34), trajectory, t_real, fonts, image_cache)
|
|
|
|
draw = ImageDraw.Draw(image)
|
|
footer_left = "STEP-ALIGNED build trace · REAL Blender preview bundles · real turntable ending appended"
|
|
footer_right = STYLE._trim_middle(str(output_dir / "trajectory.json"), 64)
|
|
draw.text((34, VIDEO_H - 26), footer_left, fill="#7891ab", font=fonts["small"])
|
|
_draw_text_right(draw, VIDEO_W - 34, VIDEO_H - 26, footer_right, font=fonts["mono_small"], fill="#7891ab")
|
|
|
|
frame_path = frames_dir / f"frame_{frame_idx:05d}.png"
|
|
rgb = image.convert("RGB")
|
|
rgb.save(frame_path)
|
|
target = still_targets.get(frame_idx)
|
|
if target is not None:
|
|
rgb.save(target)
|
|
|
|
ffmpeg = shutil.which("ffmpeg") or "ffmpeg"
|
|
process_cmd = [
|
|
ffmpeg,
|
|
"-y",
|
|
"-framerate",
|
|
str(fps),
|
|
"-i",
|
|
str(frames_dir / "frame_%05d.png"),
|
|
"-c:v",
|
|
"libx264",
|
|
"-pix_fmt",
|
|
"yuv420p",
|
|
str(process_video_path),
|
|
]
|
|
subprocess.run(process_cmd, cwd=output_dir, capture_output=True, text=True, timeout=600, check=True)
|
|
|
|
if not keep_frames:
|
|
shutil.rmtree(frames_dir)
|
|
|
|
return {
|
|
"process_video": str(process_video_path),
|
|
"frames_dir": str(frames_dir) if keep_frames else None,
|
|
"duration_s": duration_s,
|
|
"frame_count": frame_count,
|
|
"stills": {key.stem: str(key) for key in sorted(stills_dir.glob("*.png"))},
|
|
}
|
|
|
|
|
|
def transform_turntable(turntable_video: Path, output_dir: Path, fps: int) -> Path:
|
|
ffmpeg = shutil.which("ffmpeg") or "ffmpeg"
|
|
transformed = output_dir / "turntable-ending.mp4"
|
|
vf = (
|
|
f"fps={fps},"
|
|
f"scale={VIDEO_W}:{VIDEO_H}:force_original_aspect_ratio=decrease,"
|
|
f"pad={VIDEO_W}:{VIDEO_H}:(ow-iw)/2:(oh-ih)/2:color=0x030b16"
|
|
)
|
|
cmd = [
|
|
ffmpeg,
|
|
"-y",
|
|
"-i",
|
|
str(turntable_video),
|
|
"-vf",
|
|
vf,
|
|
"-c:v",
|
|
"libx264",
|
|
"-pix_fmt",
|
|
"yuv420p",
|
|
str(transformed),
|
|
]
|
|
subprocess.run(cmd, cwd=output_dir, capture_output=True, text=True, timeout=600, check=True)
|
|
return transformed
|
|
|
|
|
|
def concat_videos(process_video: Path, turntable_video: Path, output_path: Path) -> None:
|
|
ffmpeg = shutil.which("ffmpeg") or "ffmpeg"
|
|
concat_list = output_path.parent / "concat.txt"
|
|
concat_list.write_text(
|
|
f"file '{process_video.as_posix()}'\nfile '{turntable_video.as_posix()}'\n",
|
|
encoding="utf-8",
|
|
)
|
|
cmd = [
|
|
ffmpeg,
|
|
"-y",
|
|
"-f",
|
|
"concat",
|
|
"-safe",
|
|
"0",
|
|
"-i",
|
|
str(concat_list),
|
|
"-c:v",
|
|
"libx264",
|
|
"-pix_fmt",
|
|
"yuv420p",
|
|
str(output_path),
|
|
]
|
|
subprocess.run(cmd, cwd=output_path.parent, capture_output=True, text=True, timeout=600, check=True)
|
|
|
|
|
|
def render_story(build_manifest_path: Path, output_dir: Path, *, fps: int = FPS, keep_frames: bool = True) -> Dict[str, Any]:
|
|
build_manifest = load_json(build_manifest_path)
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
trajectory = build_trajectory(build_manifest_path, output_dir)
|
|
process_payload = render_process_video(trajectory, output_dir=output_dir, fps=fps, keep_frames=keep_frames)
|
|
turntable_src = Path(trajectory["turntable_video"]).expanduser().resolve()
|
|
transformed_turntable = transform_turntable(turntable_src, output_dir, fps)
|
|
final_video = output_dir / "demo-polished.mp4"
|
|
concat_videos(Path(process_payload["process_video"]), transformed_turntable, final_video)
|
|
|
|
showcase_turntable = Path(build_manifest["motion"]["stills"]["mid"]).expanduser().resolve()
|
|
showcase_turntable_copy = output_dir / "stills" / "showcase-turntable.png"
|
|
shutil.copy2(showcase_turntable, showcase_turntable_copy)
|
|
process_payload["stills"]["showcase-turntable"] = str(showcase_turntable_copy)
|
|
|
|
manifest = {
|
|
"protocol": "blender-preview-story/v1",
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
"build_manifest_path": str(build_manifest_path.resolve()),
|
|
"trajectory_path": str((output_dir / "trajectory.json").resolve()),
|
|
"process": process_payload,
|
|
"turntable_source": str(turntable_src),
|
|
"turntable_transformed": str(transformed_turntable),
|
|
"final_video": str(final_video),
|
|
"notes": [
|
|
"The process section is a programmatic composition of a scripted agent build trace and real Blender preview bundles.",
|
|
"The ending is the existing real turntable video from the same Blender run, transformed only for resolution consistency before concatenation.",
|
|
],
|
|
}
|
|
write_json(output_dir / "story_manifest.json", manifest)
|
|
return manifest
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
today = datetime.now(timezone.utc).strftime("%Y%m%d")
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument(
|
|
"--build-manifest",
|
|
default=f"/root/preview-artifacts/{today}/blender-orbital-relay-drone-v6/build_manifest.json",
|
|
help="Existing Blender build_manifest.json path from the orbital relay drone demo.",
|
|
)
|
|
parser.add_argument(
|
|
"--output-dir",
|
|
default=f"/root/preview-artifacts/{today}/blender-orbital-relay-drone-story-v5",
|
|
help="Directory for trajectory.json, process frames, stills, and the final polished video assembled from real preview bundles.",
|
|
)
|
|
parser.add_argument("--fps", type=int, default=FPS, help="Output video FPS.")
|
|
parser.add_argument(
|
|
"--discard-frames",
|
|
action="store_true",
|
|
help="Delete intermediate composed process frames after MP4 encoding.",
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
def main() -> None:
|
|
args = parse_args()
|
|
manifest = render_story(
|
|
Path(args.build_manifest).expanduser().resolve(),
|
|
Path(args.output_dir).expanduser().resolve(),
|
|
fps=int(args.fps),
|
|
keep_frames=not args.discard_frames,
|
|
)
|
|
print(json.dumps(manifest, indent=2, ensure_ascii=False))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|