| 1 | #!/usr/bin/env python3 |
| 2 | """ |
| 3 | PPT Master - Video Motion Plan |
| 4 | |
| 5 | Derive an effect-aware video motion plan from one resolved SVG-to-PPTX |
| 6 | conversion trace. The plan preserves animation order, direction, duration, |
| 7 | and timing anchors while adding deterministic video-only motion parameters. |
| 8 | |
| 9 | Usage: |
| 10 | python3 scripts/video_motion_plan.py <conversion_trace.json> [options] |
| 11 | |
| 12 | Examples: |
| 13 | python3 scripts/video_motion_plan.py validation/deck.trace.json --force |
| 14 | python3 scripts/video_motion_plan.py validation/deck.trace.json \ |
| 15 | --style dynamic -o validation/video_motion_plan.json --force |
| 16 | |
| 17 | Dependencies: |
| 18 | None (standard library only) |
| 19 | |
| 20 | See scripts/docs/video-motion-plan.md for the downstream renderer contract. |
| 21 | """ |
| 22 | |
| 23 | from __future__ import annotations |
| 24 | |
| 25 | import argparse |
| 26 | import json |
| 27 | import math |
| 28 | import re |
| 29 | import sys |
| 30 | from pathlib import Path |
| 31 | from typing import Any |
| 32 | from xml.etree import ElementTree as ET |
| 33 | |
| 34 | _SCRIPTS_DIR = Path(__file__).resolve().parent |
| 35 | if str(_SCRIPTS_DIR) not in sys.path: |
| 36 | sys.path.insert(0, str(_SCRIPTS_DIR)) |
| 37 | |
| 38 | from console_encoding import configure_utf8_stdio # noqa: E402 |
| 39 | |
| 40 | configure_utf8_stdio() |
| 41 | |
| 42 | |
| 43 | VIDEO_MOTION_SCHEMA = "ppt-master.video-motion-plan.v1" |
| 44 | VIDEO_MOTION_STYLES = ("adaptive", "restrained", "dynamic") |
| 45 | _STYLE_MULTIPLIERS = { |
| 46 | "adaptive": 1.0, |
| 47 | "restrained": 0.72, |
| 48 | "dynamic": 1.28, |
| 49 | } |
| 50 | _VIDEO_EFFECT_ALIASES = { |
| 51 | "entrance_appear": "appear", |
| 52 | "entrance_fade": "fade", |
| 53 | "entrance_fly": "fly", |
| 54 | "entrance_zoom": "zoom", |
| 55 | "entrance_wipe": "wipe_down", |
| 56 | "entrance_split": "split", |
| 57 | "entrance_blinds": "blinds", |
| 58 | "entrance_checkerboard": "checkerboard", |
| 59 | "entrance_dissolve": "dissolve", |
| 60 | "entrance_random_bars": "random_bars", |
| 61 | "entrance_peek": "wipe_up", |
| 62 | "entrance_wheel": "wheel", |
| 63 | "entrance_box": "box", |
| 64 | "entrance_circle": "circle", |
| 65 | "entrance_diamond": "diamond", |
| 66 | "entrance_plus": "plus", |
| 67 | "entrance_strips": "strips", |
| 68 | "entrance_wedge": "wedge", |
| 69 | "entrance_stretch": "stretch", |
| 70 | "entrance_expand": "expand", |
| 71 | "entrance_swivel": "swivel", |
| 72 | "entrance_ascend": "fly_top", |
| 73 | } |
| 74 | _SVG_NS = "http://www.w3.org/2000/svg" |
| 75 | _EMU_PER_PX = 9525 |
| 76 | _NUMBER_RE = re.compile(r"[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?") |
| 77 | |
| 78 | |
| 79 | def _read_json_object(path: Path) -> dict[str, Any]: |
| 80 | try: |
| 81 | value = json.loads(path.read_text(encoding="utf-8")) |
| 82 | except FileNotFoundError as exc: |
| 83 | raise ValueError(f"conversion trace not found: {path}") from exc |
| 84 | except json.JSONDecodeError as exc: |
| 85 | raise ValueError(f"invalid conversion trace JSON: {path}: {exc}") from exc |
| 86 | if not isinstance(value, dict): |
| 87 | raise ValueError(f"conversion trace must be a JSON object: {path}") |
| 88 | return value |
| 89 | |
| 90 | |
| 91 | def _positive_int(value: object, field: str) -> int: |
| 92 | if isinstance(value, bool) or not isinstance(value, int) or value <= 0: |
| 93 | raise ValueError(f"{field} must be a positive integer: {value!r}") |
| 94 | return value |
| 95 | |
| 96 | |
| 97 | def _non_negative_int(value: object, field: str) -> int: |
| 98 | if isinstance(value, bool) or not isinstance(value, int) or value < 0: |
| 99 | raise ValueError(f"{field} must be a non-negative integer: {value!r}") |
| 100 | return value |
| 101 | |
| 102 | |
| 103 | def _finite_positive_float(value: object, field: str) -> float: |
| 104 | if isinstance(value, bool) or not isinstance(value, (int, float)): |
| 105 | raise ValueError(f"{field} must be a finite positive number: {value!r}") |
| 106 | number = float(value) |
| 107 | if not math.isfinite(number) or number <= 0: |
| 108 | raise ValueError(f"{field} must be a finite positive number: {value!r}") |
| 109 | return number |
| 110 | |
| 111 | |
| 112 | def _resolve_svg_path(raw: object, trace_path: Path) -> Path: |
| 113 | if not isinstance(raw, str) or not raw.strip(): |
| 114 | raise ValueError("conversion trace slide is missing its SVG path") |
| 115 | path = Path(raw) |
| 116 | if path.is_absolute() and path.is_file(): |
| 117 | return path |
| 118 | |
| 119 | candidates = [Path.cwd() / path, trace_path.parent / path] |
| 120 | candidates.extend(parent / path for parent in trace_path.parents) |
| 121 | for candidate in candidates: |
| 122 | if candidate.is_file(): |
| 123 | return candidate.resolve() |
| 124 | raise ValueError( |
| 125 | f"SVG referenced by conversion trace was not found: {raw}; " |
| 126 | "run the command from the repository root or regenerate the trace" |
| 127 | ) |
| 128 | |
| 129 | |
| 130 | def _parse_canvas_emu(svg_path: Path) -> tuple[int, int]: |
| 131 | try: |
| 132 | root = ET.parse(svg_path).getroot() |
| 133 | except (OSError, ET.ParseError) as exc: |
| 134 | raise ValueError(f"cannot read SVG canvas: {svg_path}: {exc}") from exc |
| 135 | |
| 136 | values = [ |
| 137 | float(match) |
| 138 | for match in _NUMBER_RE.findall(root.get("viewBox") or "") |
| 139 | ] |
| 140 | if len(values) != 4 or values[2] <= 0 or values[3] <= 0: |
| 141 | raise ValueError(f"SVG must have a positive four-number viewBox: {svg_path}") |
| 142 | return ( |
| 143 | max(1, round(values[2] * _EMU_PER_PX)), |
| 144 | max(1, round(values[3] * _EMU_PER_PX)), |
| 145 | ) |
| 146 | |
| 147 | |
| 148 | def _event_score(event: dict[str, Any]) -> int: |
| 149 | score = 0 |
| 150 | if event.get("tag") == "g": |
| 151 | score += 4 |
| 152 | if isinstance(event.get("id"), str) and event["id"].strip(): |
| 153 | score += 2 |
| 154 | if event.get("decision") == "native": |
| 155 | score += 1 |
| 156 | return score |
| 157 | |
| 158 | |
| 159 | def _shape_events(slide: dict[str, Any]) -> dict[int, dict[str, Any]]: |
| 160 | events = slide.get("events", []) |
| 161 | if not isinstance(events, list): |
| 162 | raise ValueError("conversion trace slide events must be a list") |
| 163 | selected: dict[int, dict[str, Any]] = {} |
| 164 | for raw_event in events: |
| 165 | if not isinstance(raw_event, dict): |
| 166 | continue |
| 167 | shape_id = raw_event.get("shape_id") |
| 168 | bounds = raw_event.get("bounds_emu") |
| 169 | if ( |
| 170 | isinstance(shape_id, int) |
| 171 | and shape_id > 0 |
| 172 | and isinstance(bounds, list) |
| 173 | and len(bounds) == 4 |
| 174 | and all(isinstance(value, int) for value in bounds) |
| 175 | ): |
| 176 | current = selected.get(shape_id) |
| 177 | if current is None or _event_score(raw_event) >= _event_score(current): |
| 178 | selected[shape_id] = raw_event |
| 179 | return selected |
| 180 | |
| 181 | |
| 182 | def _direction_for_effect( |
| 183 | effect: str, |
| 184 | filter_name: object, |
| 185 | effect_options: object, |
| 186 | ) -> str | None: |
| 187 | if isinstance(effect_options, dict): |
| 188 | raw_direction = effect_options.get("direction") |
| 189 | if isinstance(raw_direction, str) and raw_direction: |
| 190 | return raw_direction.replace("_", "-") |
| 191 | effect = _VIDEO_EFFECT_ALIASES.get(effect, effect) |
| 192 | explicit = { |
| 193 | "fly": "down", |
| 194 | "fly_left": "left", |
| 195 | "fly_right": "right", |
| 196 | "fly_top": "up", |
| 197 | "cut": "left", |
| 198 | "wipe": "left", |
| 199 | "wipe_left": "left", |
| 200 | "wipe_right": "right", |
| 201 | "wipe_up": "up", |
| 202 | "wipe_down": "down", |
| 203 | "peek": "down", |
| 204 | } |
| 205 | if effect in explicit: |
| 206 | return explicit[effect] |
| 207 | if isinstance(filter_name, str): |
| 208 | match = re.search( |
| 209 | r"\((?:from)?(TopLeft|TopRight|BottomLeft|BottomRight|" |
| 210 | r"UpLeft|UpRight|DownLeft|DownRight|Top|Bottom|Left|Right|Up|Down)\)", |
| 211 | filter_name, |
| 212 | re.IGNORECASE, |
| 213 | ) |
| 214 | if match: |
| 215 | value = match.group(1).lower() |
| 216 | return { |
| 217 | "top": "up", |
| 218 | "bottom": "down", |
| 219 | "topleft": "up-left", |
| 220 | "topright": "up-right", |
| 221 | "bottomleft": "down-left", |
| 222 | "bottomright": "down-right", |
| 223 | "upleft": "up-left", |
| 224 | "upright": "up-right", |
| 225 | "downleft": "down-left", |
| 226 | "downright": "down-right", |
| 227 | }.get(value, value) |
| 228 | return None |
| 229 | |
| 230 | |
| 231 | def _area_ratio(bounds: list[int], canvas_emu: tuple[int, int]) -> float: |
| 232 | width = max(0, bounds[2] - bounds[0]) |
| 233 | height = max(0, bounds[3] - bounds[1]) |
| 234 | canvas_area = canvas_emu[0] * canvas_emu[1] |
| 235 | return (width * height / canvas_area) if canvas_area else 0.0 |
| 236 | |
| 237 | |
| 238 | def _adaptive_multiplier( |
| 239 | style: str, |
| 240 | page_role: object, |
| 241 | object_count: int, |
| 242 | area_ratio: float, |
| 243 | ) -> float: |
| 244 | multiplier = _STYLE_MULTIPLIERS[style] |
| 245 | if style == "adaptive": |
| 246 | if page_role in {"cover", "hero", "closing", "section"}: |
| 247 | multiplier *= 1.08 |
| 248 | if object_count >= 5: |
| 249 | multiplier *= 0.82 |
| 250 | elif object_count == 1: |
| 251 | multiplier *= 1.06 |
| 252 | if area_ratio >= 0.42: |
| 253 | multiplier *= 0.78 |
| 254 | elif 0 < area_ratio <= 0.10: |
| 255 | multiplier *= 1.08 |
| 256 | return max(0.55, min(1.45, multiplier)) |
| 257 | |
| 258 | |
| 259 | def _travel_vector(direction: str | None, magnitude: float) -> list[float]: |
| 260 | vectors = { |
| 261 | "left": [-magnitude, 0.0], |
| 262 | "right": [magnitude, 0.0], |
| 263 | "up": [0.0, -magnitude], |
| 264 | "down": [0.0, magnitude], |
| 265 | "up-left": [-magnitude * 0.72, -magnitude * 0.72], |
| 266 | "up-right": [magnitude * 0.72, -magnitude * 0.72], |
| 267 | "down-left": [-magnitude * 0.72, magnitude * 0.72], |
| 268 | "down-right": [magnitude * 0.72, magnitude * 0.72], |
| 269 | } |
| 270 | return [round(value, 4) for value in vectors.get(direction, [0.0, 0.0])] |
| 271 | |
| 272 | |
| 273 | def _video_effect( |
| 274 | effect: str, |
| 275 | direction: str | None, |
| 276 | multiplier: float, |
| 277 | ) -> dict[str, Any]: |
| 278 | effect = _VIDEO_EFFECT_ALIASES.get(effect, effect) |
| 279 | common: dict[str, Any] = { |
| 280 | "easing": "ease_out_cubic", |
| 281 | "opacity_from": 0.0, |
| 282 | "scale_from": 1.0, |
| 283 | "travel_canvas_ratio": [0.0, 0.0], |
| 284 | "blur_px": 0.0, |
| 285 | "overshoot": 0.0, |
| 286 | "mask_feather_px": 0.0, |
| 287 | "motion_blur": 0.0, |
| 288 | } |
| 289 | |
| 290 | if effect == "appear": |
| 291 | common.update({ |
| 292 | "family": "hard_reveal", |
| 293 | "opacity_from": 1.0, |
| 294 | "easing": "step_end", |
| 295 | }) |
| 296 | elif effect == "fade": |
| 297 | common.update({ |
| 298 | "family": "soft_fade", |
| 299 | "scale_from": round(1.0 - 0.008 * multiplier, 4), |
| 300 | "blur_px": round(3.0 * multiplier, 2), |
| 301 | }) |
| 302 | elif effect == "dissolve": |
| 303 | common.update({ |
| 304 | "family": "grain_dissolve", |
| 305 | "scale_from": round(1.0 - 0.006 * multiplier, 4), |
| 306 | "blur_px": round(2.0 * multiplier, 2), |
| 307 | "grain": round(0.26 * multiplier, 3), |
| 308 | }) |
| 309 | elif effect in {"fly", "fly_left", "fly_right", "fly_top", "cut"}: |
| 310 | magnitude = 0.045 * multiplier |
| 311 | common.update({ |
| 312 | "family": "directional_slide", |
| 313 | "direction": direction, |
| 314 | "travel_canvas_ratio": _travel_vector(direction, magnitude), |
| 315 | "blur_px": round(4.5 * multiplier, 2), |
| 316 | "overshoot": round(0.012 * multiplier, 4), |
| 317 | "motion_blur": round(0.22 * multiplier, 3), |
| 318 | }) |
| 319 | elif effect in { |
| 320 | "wipe", |
| 321 | "wipe_left", |
| 322 | "wipe_right", |
| 323 | "wipe_up", |
| 324 | "wipe_down", |
| 325 | "peek", |
| 326 | }: |
| 327 | common.update({ |
| 328 | "family": "soft_mask_reveal", |
| 329 | "direction": direction, |
| 330 | "travel_canvas_ratio": _travel_vector( |
| 331 | direction, |
| 332 | 0.012 * multiplier, |
| 333 | ), |
| 334 | "mask_feather_px": round(18.0 * multiplier, 2), |
| 335 | "blur_px": round(1.5 * multiplier, 2), |
| 336 | }) |
| 337 | elif effect in {"zoom", "expand", "stretch"}: |
| 338 | common.update({ |
| 339 | "family": "focus_scale", |
| 340 | "scale_from": round(1.0 - 0.055 * multiplier, 4), |
| 341 | "blur_px": round(3.5 * multiplier, 2), |
| 342 | "overshoot": round(0.008 * multiplier, 4), |
| 343 | }) |
| 344 | elif effect == "split": |
| 345 | common.update({ |
| 346 | "family": "split_mask", |
| 347 | "mask_axis": "vertical", |
| 348 | "mask_feather_px": round(12.0 * multiplier, 2), |
| 349 | }) |
| 350 | elif effect in {"box", "circle", "diamond", "plus"}: |
| 351 | common.update({ |
| 352 | "family": "shape_mask", |
| 353 | "pattern": effect, |
| 354 | "scale_from": round(1.0 - 0.025 * multiplier, 4), |
| 355 | "mask_feather_px": round(10.0 * multiplier, 2), |
| 356 | }) |
| 357 | elif effect in { |
| 358 | "blinds", |
| 359 | "checkerboard", |
| 360 | "random_bars", |
| 361 | "strips", |
| 362 | "wedge", |
| 363 | "wheel", |
| 364 | }: |
| 365 | common.update({ |
| 366 | "family": "pattern_reveal", |
| 367 | "pattern": effect, |
| 368 | "mask_feather_px": round(8.0 * multiplier, 2), |
| 369 | }) |
| 370 | elif effect == "swivel": |
| 371 | common.update({ |
| 372 | "family": "soft_swivel", |
| 373 | "scale_from": round(1.0 - 0.025 * multiplier, 4), |
| 374 | "rotation_from_deg": round(-4.0 * multiplier, 2), |
| 375 | "blur_px": round(3.0 * multiplier, 2), |
| 376 | }) |
| 377 | else: |
| 378 | raise ValueError(f"unsupported resolved animation effect for video: {effect}") |
| 379 | return common |
| 380 | |
| 381 | |
| 382 | def _transition_plan(raw_motion: object) -> dict[str, Any]: |
| 383 | if not isinstance(raw_motion, dict): |
| 384 | return { |
| 385 | "source_effect": None, |
| 386 | "video_effect": "cut", |
| 387 | "duration_ms": 0, |
| 388 | "easing": "linear", |
| 389 | } |
| 390 | effect = raw_motion.get("effect") |
| 391 | duration = raw_motion.get("duration_ms") |
| 392 | if effect is None: |
| 393 | video_effect = "cut" |
| 394 | elif effect == "fade": |
| 395 | video_effect = "crossfade" |
| 396 | elif effect in {"push", "cover"}: |
| 397 | video_effect = "directional_push" |
| 398 | elif effect in {"wipe", "split", "strips"}: |
| 399 | video_effect = f"soft_{effect}" |
| 400 | else: |
| 401 | video_effect = "adaptive_crossfade" |
| 402 | return { |
| 403 | "source_effect": effect, |
| 404 | "video_effect": video_effect, |
| 405 | "duration_ms": duration if isinstance(duration, int) else 0, |
| 406 | "easing": "ease_in_out_cubic", |
| 407 | } |
| 408 | |
| 409 | |
| 410 | def build_video_motion_plan( |
| 411 | trace_path: str | Path, |
| 412 | *, |
| 413 | style: str = "adaptive", |
| 414 | default_slide_duration: float = 5.0, |
| 415 | ) -> dict[str, Any]: |
| 416 | """Build one renderer-neutral, effect-aware video motion plan.""" |
| 417 | path = Path(trace_path).resolve() |
| 418 | if style not in VIDEO_MOTION_STYLES: |
| 419 | raise ValueError( |
| 420 | f"unknown video motion style {style!r}; valid styles: " |
| 421 | f"{', '.join(VIDEO_MOTION_STYLES)}" |
| 422 | ) |
| 423 | default_duration_ms = round( |
| 424 | _finite_positive_float( |
| 425 | default_slide_duration, |
| 426 | "default slide duration", |
| 427 | ) |
| 428 | * 1000 |
| 429 | ) |
| 430 | trace = _read_json_object(path) |
| 431 | raw_slides = trace.get("slides") |
| 432 | if not isinstance(raw_slides, list) or not raw_slides: |
| 433 | raise ValueError("conversion trace must contain a non-empty slides list") |
| 434 | |
| 435 | slides: list[dict[str, Any]] = [] |
| 436 | total_objects = 0 |
| 437 | enhanced_objects = 0 |
| 438 | for raw_slide in raw_slides: |
| 439 | if not isinstance(raw_slide, dict): |
| 440 | raise ValueError("conversion trace slide entries must be objects") |
| 441 | slide_num = _positive_int(raw_slide.get("slide_num"), "slide_num") |
| 442 | svg_path = _resolve_svg_path(raw_slide.get("svg"), path) |
| 443 | canvas_emu = _parse_canvas_emu(svg_path) |
| 444 | event_index = _shape_events(raw_slide) |
| 445 | |
| 446 | animation = raw_slide.get("animation", {}) |
| 447 | if not isinstance(animation, dict): |
| 448 | raise ValueError(f"slide {slide_num} animation summary must be an object") |
| 449 | rows = animation.get("rows", []) |
| 450 | if not isinstance(rows, list): |
| 451 | raise ValueError(f"slide {slide_num} animation rows must be a list") |
| 452 | |
| 453 | objects: list[dict[str, Any]] = [] |
| 454 | for order, raw_row in enumerate(rows, 1): |
| 455 | if not isinstance(raw_row, dict): |
| 456 | raise ValueError(f"slide {slide_num} animation row must be an object") |
| 457 | shape_id = _positive_int( |
| 458 | raw_row.get("shape_id"), |
| 459 | f"slide {slide_num} animation shape_id", |
| 460 | ) |
| 461 | trigger = raw_row.get("trigger") |
| 462 | if trigger == "on-click": |
| 463 | raise ValueError( |
| 464 | f"slide {slide_num} uses on-click animation; video motion " |
| 465 | "requires click-free after-previous or with-previous timing" |
| 466 | ) |
| 467 | if trigger not in {"after-previous", "with-previous"}: |
| 468 | raise ValueError( |
| 469 | f"slide {slide_num} has unsupported video trigger: {trigger!r}" |
| 470 | ) |
| 471 | effect = raw_row.get("effect") |
| 472 | if not isinstance(effect, str) or not effect: |
| 473 | raise ValueError( |
| 474 | f"slide {slide_num} animation row has no resolved effect" |
| 475 | ) |
| 476 | start_ms = _non_negative_int( |
| 477 | raw_row.get("offset_ms"), |
| 478 | f"slide {slide_num} animation offset_ms", |
| 479 | ) |
| 480 | duration_ms = _positive_int( |
| 481 | raw_row.get("duration_ms"), |
| 482 | f"slide {slide_num} animation duration_ms", |
| 483 | ) |
| 484 | playback_duration_ms = raw_row.get( |
| 485 | "playback_duration_ms", |
| 486 | duration_ms, |
| 487 | ) |
| 488 | playback_duration_ms = _positive_int( |
| 489 | playback_duration_ms, |
| 490 | f"slide {slide_num} animation playback_duration_ms", |
| 491 | ) |
| 492 | event = event_index.get(shape_id) |
| 493 | if event is None: |
| 494 | raise ValueError( |
| 495 | f"slide {slide_num} animation shape {shape_id} has no " |
| 496 | "conversion-trace bounds" |
| 497 | ) |
| 498 | bounds = list(event["bounds_emu"]) |
| 499 | area_ratio = _area_ratio(bounds, canvas_emu) |
| 500 | multiplier = _adaptive_multiplier( |
| 501 | style, |
| 502 | raw_slide.get("page_role"), |
| 503 | len(rows), |
| 504 | area_ratio, |
| 505 | ) |
| 506 | direction = _direction_for_effect( |
| 507 | effect, |
| 508 | raw_row.get("filter_name"), |
| 509 | raw_row.get("effect_options"), |
| 510 | ) |
| 511 | video = _video_effect(effect, direction, multiplier) |
| 512 | video["duration_ms"] = duration_ms |
| 513 | |
| 514 | group_id = event.get("id") |
| 515 | if not isinstance(group_id, str) or not group_id.strip(): |
| 516 | group_id = f"shape-{shape_id}" |
| 517 | objects.append({ |
| 518 | "group_id": group_id, |
| 519 | "shape_id": shape_id, |
| 520 | "order": order, |
| 521 | "source_effect": effect, |
| 522 | "trigger": trigger, |
| 523 | "start_ms": start_ms, |
| 524 | "duration_ms": duration_ms, |
| 525 | "playback_duration_ms": playback_duration_ms, |
| 526 | "effect_options": raw_row.get("effect_options", {}), |
| 527 | "repeat_count": raw_row.get("repeat_count"), |
| 528 | "repeat_duration_ms": raw_row.get("repeat_duration_ms"), |
| 529 | "auto_reverse": raw_row.get("auto_reverse"), |
| 530 | "bounds_emu": bounds, |
| 531 | "area_ratio": round(area_ratio, 6), |
| 532 | "video": video, |
| 533 | }) |
| 534 | total_objects += 1 |
| 535 | if video["family"] != "hard_reveal": |
| 536 | enhanced_objects += 1 |
| 537 | |
| 538 | motion = raw_slide.get("motion") |
| 539 | advance_after_ms = ( |
| 540 | motion.get("advance_after_ms") |
| 541 | if isinstance(motion, dict) |
| 542 | else None |
| 543 | ) |
| 544 | content_end_ms = max( |
| 545 | ( |
| 546 | item["start_ms"] + item["playback_duration_ms"] |
| 547 | for item in objects |
| 548 | ), |
| 549 | default=0, |
| 550 | ) |
| 551 | if isinstance(advance_after_ms, int) and advance_after_ms > 0: |
| 552 | slide_duration_ms = max(advance_after_ms, content_end_ms) |
| 553 | duration_source = "recorded-advance" |
| 554 | else: |
| 555 | slide_duration_ms = max(default_duration_ms, content_end_ms + 750) |
| 556 | duration_source = "default-hold" |
| 557 | |
| 558 | slides.append({ |
| 559 | "slide_num": slide_num, |
| 560 | "svg": str(svg_path), |
| 561 | "page_role": raw_slide.get("page_role"), |
| 562 | "canvas_emu": list(canvas_emu), |
| 563 | "duration_ms": slide_duration_ms, |
| 564 | "duration_source": duration_source, |
| 565 | "transition": _transition_plan(motion), |
| 566 | "objects": objects, |
| 567 | }) |
| 568 | |
| 569 | return { |
| 570 | "schema": VIDEO_MOTION_SCHEMA, |
| 571 | "source_trace": str(path), |
| 572 | "source_pptx": trace.get("output"), |
| 573 | "style": style, |
| 574 | "locks": { |
| 575 | "object_identity": True, |
| 576 | "object_order": True, |
| 577 | "semantic_direction": True, |
| 578 | "timing_anchor": True, |
| 579 | "source_effect": True, |
| 580 | }, |
| 581 | "optimizer_scope": [ |
| 582 | "easing", |
| 583 | "travel_distance", |
| 584 | "opacity", |
| 585 | "scale", |
| 586 | "mask_feather", |
| 587 | "blur", |
| 588 | "motion_blur", |
| 589 | "overshoot", |
| 590 | ], |
| 591 | "slide_count": len(slides), |
| 592 | "object_count": total_objects, |
| 593 | "enhanced_object_count": enhanced_objects, |
| 594 | "slides": slides, |
| 595 | } |
| 596 | |
| 597 | |
| 598 | def write_video_motion_plan( |
| 599 | trace_path: str | Path, |
| 600 | output_path: str | Path, |
| 601 | *, |
| 602 | style: str = "adaptive", |
| 603 | default_slide_duration: float = 5.0, |
| 604 | force: bool = False, |
| 605 | ) -> Path: |
| 606 | """Build and write one video motion plan.""" |
| 607 | output = Path(output_path) |
| 608 | if output.exists() and not force: |
| 609 | raise FileExistsError( |
| 610 | f"output already exists: {output}; pass --force to overwrite" |
| 611 | ) |
| 612 | plan = build_video_motion_plan( |
| 613 | trace_path, |
| 614 | style=style, |
| 615 | default_slide_duration=default_slide_duration, |
| 616 | ) |
| 617 | output.parent.mkdir(parents=True, exist_ok=True) |
| 618 | output.write_text( |
| 619 | json.dumps(plan, ensure_ascii=False, indent=2) + "\n", |
| 620 | encoding="utf-8", |
| 621 | ) |
| 622 | return output.resolve() |
| 623 | |
| 624 | |
| 625 | def build_parser() -> argparse.ArgumentParser: |
| 626 | parser = argparse.ArgumentParser( |
| 627 | description=( |
| 628 | "Derive an effect-aware video motion plan from a resolved " |
| 629 | "SVG-to-PPTX conversion trace." |
| 630 | ), |
| 631 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 632 | ) |
| 633 | parser.add_argument( |
| 634 | "trace", |
| 635 | help="Path to validation/<output_stem>.trace.json", |
| 636 | ) |
| 637 | parser.add_argument( |
| 638 | "-o", |
| 639 | "--output", |
| 640 | default=None, |
| 641 | help="Output JSON path; default: <trace>.video-motion.json", |
| 642 | ) |
| 643 | parser.add_argument( |
| 644 | "--style", |
| 645 | choices=VIDEO_MOTION_STYLES, |
| 646 | default="adaptive", |
| 647 | help="Video-only enhancement intensity; default: adaptive", |
| 648 | ) |
| 649 | parser.add_argument( |
| 650 | "--default-slide-duration", |
| 651 | type=float, |
| 652 | default=5.0, |
| 653 | help="Fallback seconds for slides without recorded advance timing", |
| 654 | ) |
| 655 | parser.add_argument( |
| 656 | "--force", |
| 657 | action="store_true", |
| 658 | help="Overwrite an existing output plan", |
| 659 | ) |
| 660 | return parser |
| 661 | |
| 662 | |
| 663 | def main(argv: list[str] | None = None) -> int: |
| 664 | parser = build_parser() |
| 665 | args = parser.parse_args(argv) |
| 666 | trace_path = Path(args.trace) |
| 667 | output_path = ( |
| 668 | Path(args.output) |
| 669 | if args.output |
| 670 | else trace_path.with_suffix(".video-motion.json") |
| 671 | ) |
| 672 | try: |
| 673 | written = write_video_motion_plan( |
| 674 | trace_path, |
| 675 | output_path, |
| 676 | style=args.style, |
| 677 | default_slide_duration=args.default_slide_duration, |
| 678 | force=args.force, |
| 679 | ) |
| 680 | except (FileExistsError, ValueError) as exc: |
| 681 | print(f"Error: {exc}", file=sys.stderr) |
| 682 | return 1 |
| 683 | |
| 684 | plan = _read_json_object(written) |
| 685 | print(written) |
| 686 | print( |
| 687 | f"Slides: {plan['slide_count']}; objects: {plan['object_count']}; " |
| 688 | f"video-enhanced: {plan['enhanced_object_count']}", |
| 689 | file=sys.stderr, |
| 690 | ) |
| 691 | return 0 |
| 692 | |
| 693 | |
| 694 | if __name__ == "__main__": |
| 695 | raise SystemExit(main()) |
| 696 |