#!/usr/bin/env python3 """ PPT Master - Video Motion Plan Derive an effect-aware video motion plan from one resolved SVG-to-PPTX conversion trace. The plan preserves animation order, direction, duration, and timing anchors while adding deterministic video-only motion parameters. Usage: python3 scripts/video_motion_plan.py [options] Examples: python3 scripts/video_motion_plan.py validation/deck.trace.json --force python3 scripts/video_motion_plan.py validation/deck.trace.json \ --style dynamic -o validation/video_motion_plan.json --force Dependencies: None (standard library only) See scripts/docs/video-motion-plan.md for the downstream renderer contract. """ from __future__ import annotations import argparse import json import math import re import sys from pathlib import Path from typing import Any from xml.etree import ElementTree as ET _SCRIPTS_DIR = Path(__file__).resolve().parent if str(_SCRIPTS_DIR) not in sys.path: sys.path.insert(0, str(_SCRIPTS_DIR)) from console_encoding import configure_utf8_stdio # noqa: E402 configure_utf8_stdio() VIDEO_MOTION_SCHEMA = "ppt-master.video-motion-plan.v1" VIDEO_MOTION_STYLES = ("adaptive", "restrained", "dynamic") _STYLE_MULTIPLIERS = { "adaptive": 1.0, "restrained": 0.72, "dynamic": 1.28, } _VIDEO_EFFECT_ALIASES = { "entrance_appear": "appear", "entrance_fade": "fade", "entrance_fly": "fly", "entrance_zoom": "zoom", "entrance_wipe": "wipe_down", "entrance_split": "split", "entrance_blinds": "blinds", "entrance_checkerboard": "checkerboard", "entrance_dissolve": "dissolve", "entrance_random_bars": "random_bars", "entrance_peek": "wipe_up", "entrance_wheel": "wheel", "entrance_box": "box", "entrance_circle": "circle", "entrance_diamond": "diamond", "entrance_plus": "plus", "entrance_strips": "strips", "entrance_wedge": "wedge", "entrance_stretch": "stretch", "entrance_expand": "expand", "entrance_swivel": "swivel", "entrance_ascend": "fly_top", } _SVG_NS = "http://www.w3.org/2000/svg" _EMU_PER_PX = 9525 _NUMBER_RE = re.compile(r"[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?") def _read_json_object(path: Path) -> dict[str, Any]: try: value = json.loads(path.read_text(encoding="utf-8")) except FileNotFoundError as exc: raise ValueError(f"conversion trace not found: {path}") from exc except json.JSONDecodeError as exc: raise ValueError(f"invalid conversion trace JSON: {path}: {exc}") from exc if not isinstance(value, dict): raise ValueError(f"conversion trace must be a JSON object: {path}") return value def _positive_int(value: object, field: str) -> int: if isinstance(value, bool) or not isinstance(value, int) or value <= 0: raise ValueError(f"{field} must be a positive integer: {value!r}") return value def _non_negative_int(value: object, field: str) -> int: if isinstance(value, bool) or not isinstance(value, int) or value < 0: raise ValueError(f"{field} must be a non-negative integer: {value!r}") return value def _finite_positive_float(value: object, field: str) -> float: if isinstance(value, bool) or not isinstance(value, (int, float)): raise ValueError(f"{field} must be a finite positive number: {value!r}") number = float(value) if not math.isfinite(number) or number <= 0: raise ValueError(f"{field} must be a finite positive number: {value!r}") return number def _resolve_svg_path(raw: object, trace_path: Path) -> Path: if not isinstance(raw, str) or not raw.strip(): raise ValueError("conversion trace slide is missing its SVG path") path = Path(raw) if path.is_absolute() and path.is_file(): return path candidates = [Path.cwd() / path, trace_path.parent / path] candidates.extend(parent / path for parent in trace_path.parents) for candidate in candidates: if candidate.is_file(): return candidate.resolve() raise ValueError( f"SVG referenced by conversion trace was not found: {raw}; " "run the command from the repository root or regenerate the trace" ) def _parse_canvas_emu(svg_path: Path) -> tuple[int, int]: try: root = ET.parse(svg_path).getroot() except (OSError, ET.ParseError) as exc: raise ValueError(f"cannot read SVG canvas: {svg_path}: {exc}") from exc values = [ float(match) for match in _NUMBER_RE.findall(root.get("viewBox") or "") ] if len(values) != 4 or values[2] <= 0 or values[3] <= 0: raise ValueError(f"SVG must have a positive four-number viewBox: {svg_path}") return ( max(1, round(values[2] * _EMU_PER_PX)), max(1, round(values[3] * _EMU_PER_PX)), ) def _event_score(event: dict[str, Any]) -> int: score = 0 if event.get("tag") == "g": score += 4 if isinstance(event.get("id"), str) and event["id"].strip(): score += 2 if event.get("decision") == "native": score += 1 return score def _shape_events(slide: dict[str, Any]) -> dict[int, dict[str, Any]]: events = slide.get("events", []) if not isinstance(events, list): raise ValueError("conversion trace slide events must be a list") selected: dict[int, dict[str, Any]] = {} for raw_event in events: if not isinstance(raw_event, dict): continue shape_id = raw_event.get("shape_id") bounds = raw_event.get("bounds_emu") if ( isinstance(shape_id, int) and shape_id > 0 and isinstance(bounds, list) and len(bounds) == 4 and all(isinstance(value, int) for value in bounds) ): current = selected.get(shape_id) if current is None or _event_score(raw_event) >= _event_score(current): selected[shape_id] = raw_event return selected def _direction_for_effect( effect: str, filter_name: object, effect_options: object, ) -> str | None: if isinstance(effect_options, dict): raw_direction = effect_options.get("direction") if isinstance(raw_direction, str) and raw_direction: return raw_direction.replace("_", "-") effect = _VIDEO_EFFECT_ALIASES.get(effect, effect) explicit = { "fly": "down", "fly_left": "left", "fly_right": "right", "fly_top": "up", "cut": "left", "wipe": "left", "wipe_left": "left", "wipe_right": "right", "wipe_up": "up", "wipe_down": "down", "peek": "down", } if effect in explicit: return explicit[effect] if isinstance(filter_name, str): match = re.search( r"\((?:from)?(TopLeft|TopRight|BottomLeft|BottomRight|" r"UpLeft|UpRight|DownLeft|DownRight|Top|Bottom|Left|Right|Up|Down)\)", filter_name, re.IGNORECASE, ) if match: value = match.group(1).lower() return { "top": "up", "bottom": "down", "topleft": "up-left", "topright": "up-right", "bottomleft": "down-left", "bottomright": "down-right", "upleft": "up-left", "upright": "up-right", "downleft": "down-left", "downright": "down-right", }.get(value, value) return None def _area_ratio(bounds: list[int], canvas_emu: tuple[int, int]) -> float: width = max(0, bounds[2] - bounds[0]) height = max(0, bounds[3] - bounds[1]) canvas_area = canvas_emu[0] * canvas_emu[1] return (width * height / canvas_area) if canvas_area else 0.0 def _adaptive_multiplier( style: str, page_role: object, object_count: int, area_ratio: float, ) -> float: multiplier = _STYLE_MULTIPLIERS[style] if style == "adaptive": if page_role in {"cover", "hero", "closing", "section"}: multiplier *= 1.08 if object_count >= 5: multiplier *= 0.82 elif object_count == 1: multiplier *= 1.06 if area_ratio >= 0.42: multiplier *= 0.78 elif 0 < area_ratio <= 0.10: multiplier *= 1.08 return max(0.55, min(1.45, multiplier)) def _travel_vector(direction: str | None, magnitude: float) -> list[float]: vectors = { "left": [-magnitude, 0.0], "right": [magnitude, 0.0], "up": [0.0, -magnitude], "down": [0.0, magnitude], "up-left": [-magnitude * 0.72, -magnitude * 0.72], "up-right": [magnitude * 0.72, -magnitude * 0.72], "down-left": [-magnitude * 0.72, magnitude * 0.72], "down-right": [magnitude * 0.72, magnitude * 0.72], } return [round(value, 4) for value in vectors.get(direction, [0.0, 0.0])] def _video_effect( effect: str, direction: str | None, multiplier: float, ) -> dict[str, Any]: effect = _VIDEO_EFFECT_ALIASES.get(effect, effect) common: dict[str, Any] = { "easing": "ease_out_cubic", "opacity_from": 0.0, "scale_from": 1.0, "travel_canvas_ratio": [0.0, 0.0], "blur_px": 0.0, "overshoot": 0.0, "mask_feather_px": 0.0, "motion_blur": 0.0, } if effect == "appear": common.update({ "family": "hard_reveal", "opacity_from": 1.0, "easing": "step_end", }) elif effect == "fade": common.update({ "family": "soft_fade", "scale_from": round(1.0 - 0.008 * multiplier, 4), "blur_px": round(3.0 * multiplier, 2), }) elif effect == "dissolve": common.update({ "family": "grain_dissolve", "scale_from": round(1.0 - 0.006 * multiplier, 4), "blur_px": round(2.0 * multiplier, 2), "grain": round(0.26 * multiplier, 3), }) elif effect in {"fly", "fly_left", "fly_right", "fly_top", "cut"}: magnitude = 0.045 * multiplier common.update({ "family": "directional_slide", "direction": direction, "travel_canvas_ratio": _travel_vector(direction, magnitude), "blur_px": round(4.5 * multiplier, 2), "overshoot": round(0.012 * multiplier, 4), "motion_blur": round(0.22 * multiplier, 3), }) elif effect in { "wipe", "wipe_left", "wipe_right", "wipe_up", "wipe_down", "peek", }: common.update({ "family": "soft_mask_reveal", "direction": direction, "travel_canvas_ratio": _travel_vector( direction, 0.012 * multiplier, ), "mask_feather_px": round(18.0 * multiplier, 2), "blur_px": round(1.5 * multiplier, 2), }) elif effect in {"zoom", "expand", "stretch"}: common.update({ "family": "focus_scale", "scale_from": round(1.0 - 0.055 * multiplier, 4), "blur_px": round(3.5 * multiplier, 2), "overshoot": round(0.008 * multiplier, 4), }) elif effect == "split": common.update({ "family": "split_mask", "mask_axis": "vertical", "mask_feather_px": round(12.0 * multiplier, 2), }) elif effect in {"box", "circle", "diamond", "plus"}: common.update({ "family": "shape_mask", "pattern": effect, "scale_from": round(1.0 - 0.025 * multiplier, 4), "mask_feather_px": round(10.0 * multiplier, 2), }) elif effect in { "blinds", "checkerboard", "random_bars", "strips", "wedge", "wheel", }: common.update({ "family": "pattern_reveal", "pattern": effect, "mask_feather_px": round(8.0 * multiplier, 2), }) elif effect == "swivel": common.update({ "family": "soft_swivel", "scale_from": round(1.0 - 0.025 * multiplier, 4), "rotation_from_deg": round(-4.0 * multiplier, 2), "blur_px": round(3.0 * multiplier, 2), }) else: raise ValueError(f"unsupported resolved animation effect for video: {effect}") return common def _transition_plan(raw_motion: object) -> dict[str, Any]: if not isinstance(raw_motion, dict): return { "source_effect": None, "video_effect": "cut", "duration_ms": 0, "easing": "linear", } effect = raw_motion.get("effect") duration = raw_motion.get("duration_ms") if effect is None: video_effect = "cut" elif effect == "fade": video_effect = "crossfade" elif effect in {"push", "cover"}: video_effect = "directional_push" elif effect in {"wipe", "split", "strips"}: video_effect = f"soft_{effect}" else: video_effect = "adaptive_crossfade" return { "source_effect": effect, "video_effect": video_effect, "duration_ms": duration if isinstance(duration, int) else 0, "easing": "ease_in_out_cubic", } def build_video_motion_plan( trace_path: str | Path, *, style: str = "adaptive", default_slide_duration: float = 5.0, ) -> dict[str, Any]: """Build one renderer-neutral, effect-aware video motion plan.""" path = Path(trace_path).resolve() if style not in VIDEO_MOTION_STYLES: raise ValueError( f"unknown video motion style {style!r}; valid styles: " f"{', '.join(VIDEO_MOTION_STYLES)}" ) default_duration_ms = round( _finite_positive_float( default_slide_duration, "default slide duration", ) * 1000 ) trace = _read_json_object(path) raw_slides = trace.get("slides") if not isinstance(raw_slides, list) or not raw_slides: raise ValueError("conversion trace must contain a non-empty slides list") slides: list[dict[str, Any]] = [] total_objects = 0 enhanced_objects = 0 for raw_slide in raw_slides: if not isinstance(raw_slide, dict): raise ValueError("conversion trace slide entries must be objects") slide_num = _positive_int(raw_slide.get("slide_num"), "slide_num") svg_path = _resolve_svg_path(raw_slide.get("svg"), path) canvas_emu = _parse_canvas_emu(svg_path) event_index = _shape_events(raw_slide) animation = raw_slide.get("animation", {}) if not isinstance(animation, dict): raise ValueError(f"slide {slide_num} animation summary must be an object") rows = animation.get("rows", []) if not isinstance(rows, list): raise ValueError(f"slide {slide_num} animation rows must be a list") objects: list[dict[str, Any]] = [] for order, raw_row in enumerate(rows, 1): if not isinstance(raw_row, dict): raise ValueError(f"slide {slide_num} animation row must be an object") shape_id = _positive_int( raw_row.get("shape_id"), f"slide {slide_num} animation shape_id", ) trigger = raw_row.get("trigger") if trigger == "on-click": raise ValueError( f"slide {slide_num} uses on-click animation; video motion " "requires click-free after-previous or with-previous timing" ) if trigger not in {"after-previous", "with-previous"}: raise ValueError( f"slide {slide_num} has unsupported video trigger: {trigger!r}" ) effect = raw_row.get("effect") if not isinstance(effect, str) or not effect: raise ValueError( f"slide {slide_num} animation row has no resolved effect" ) start_ms = _non_negative_int( raw_row.get("offset_ms"), f"slide {slide_num} animation offset_ms", ) duration_ms = _positive_int( raw_row.get("duration_ms"), f"slide {slide_num} animation duration_ms", ) playback_duration_ms = raw_row.get( "playback_duration_ms", duration_ms, ) playback_duration_ms = _positive_int( playback_duration_ms, f"slide {slide_num} animation playback_duration_ms", ) event = event_index.get(shape_id) if event is None: raise ValueError( f"slide {slide_num} animation shape {shape_id} has no " "conversion-trace bounds" ) bounds = list(event["bounds_emu"]) area_ratio = _area_ratio(bounds, canvas_emu) multiplier = _adaptive_multiplier( style, raw_slide.get("page_role"), len(rows), area_ratio, ) direction = _direction_for_effect( effect, raw_row.get("filter_name"), raw_row.get("effect_options"), ) video = _video_effect(effect, direction, multiplier) video["duration_ms"] = duration_ms group_id = event.get("id") if not isinstance(group_id, str) or not group_id.strip(): group_id = f"shape-{shape_id}" objects.append({ "group_id": group_id, "shape_id": shape_id, "order": order, "source_effect": effect, "trigger": trigger, "start_ms": start_ms, "duration_ms": duration_ms, "playback_duration_ms": playback_duration_ms, "effect_options": raw_row.get("effect_options", {}), "repeat_count": raw_row.get("repeat_count"), "repeat_duration_ms": raw_row.get("repeat_duration_ms"), "auto_reverse": raw_row.get("auto_reverse"), "bounds_emu": bounds, "area_ratio": round(area_ratio, 6), "video": video, }) total_objects += 1 if video["family"] != "hard_reveal": enhanced_objects += 1 motion = raw_slide.get("motion") advance_after_ms = ( motion.get("advance_after_ms") if isinstance(motion, dict) else None ) content_end_ms = max( ( item["start_ms"] + item["playback_duration_ms"] for item in objects ), default=0, ) if isinstance(advance_after_ms, int) and advance_after_ms > 0: slide_duration_ms = max(advance_after_ms, content_end_ms) duration_source = "recorded-advance" else: slide_duration_ms = max(default_duration_ms, content_end_ms + 750) duration_source = "default-hold" slides.append({ "slide_num": slide_num, "svg": str(svg_path), "page_role": raw_slide.get("page_role"), "canvas_emu": list(canvas_emu), "duration_ms": slide_duration_ms, "duration_source": duration_source, "transition": _transition_plan(motion), "objects": objects, }) return { "schema": VIDEO_MOTION_SCHEMA, "source_trace": str(path), "source_pptx": trace.get("output"), "style": style, "locks": { "object_identity": True, "object_order": True, "semantic_direction": True, "timing_anchor": True, "source_effect": True, }, "optimizer_scope": [ "easing", "travel_distance", "opacity", "scale", "mask_feather", "blur", "motion_blur", "overshoot", ], "slide_count": len(slides), "object_count": total_objects, "enhanced_object_count": enhanced_objects, "slides": slides, } def write_video_motion_plan( trace_path: str | Path, output_path: str | Path, *, style: str = "adaptive", default_slide_duration: float = 5.0, force: bool = False, ) -> Path: """Build and write one video motion plan.""" output = Path(output_path) if output.exists() and not force: raise FileExistsError( f"output already exists: {output}; pass --force to overwrite" ) plan = build_video_motion_plan( trace_path, style=style, default_slide_duration=default_slide_duration, ) output.parent.mkdir(parents=True, exist_ok=True) output.write_text( json.dumps(plan, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", ) return output.resolve() def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description=( "Derive an effect-aware video motion plan from a resolved " "SVG-to-PPTX conversion trace." ), formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "trace", help="Path to validation/.trace.json", ) parser.add_argument( "-o", "--output", default=None, help="Output JSON path; default: .video-motion.json", ) parser.add_argument( "--style", choices=VIDEO_MOTION_STYLES, default="adaptive", help="Video-only enhancement intensity; default: adaptive", ) parser.add_argument( "--default-slide-duration", type=float, default=5.0, help="Fallback seconds for slides without recorded advance timing", ) parser.add_argument( "--force", action="store_true", help="Overwrite an existing output plan", ) return parser def main(argv: list[str] | None = None) -> int: parser = build_parser() args = parser.parse_args(argv) trace_path = Path(args.trace) output_path = ( Path(args.output) if args.output else trace_path.with_suffix(".video-motion.json") ) try: written = write_video_motion_plan( trace_path, output_path, style=args.style, default_slide_duration=args.default_slide_duration, force=args.force, ) except (FileExistsError, ValueError) as exc: print(f"Error: {exc}", file=sys.stderr) return 1 plan = _read_json_object(written) print(written) print( f"Slides: {plan['slide_count']}; objects: {plan['object_count']}; " f"video-enhanced: {plan['enhanced_object_count']}", file=sys.stderr, ) return 0 if __name__ == "__main__": raise SystemExit(main())