Files

696 lines
23 KiB
Python

#!/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 <conversion_trace.json> [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/<output_stem>.trace.json",
)
parser.add_argument(
"-o",
"--output",
default=None,
help="Output JSON path; default: <trace>.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())