返回 ppt-master
video_motion_plan.py
根目录 / skills / ppt-master / scripts / video_motion_plan.py
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
696 lines PYTHON