mover 0.3.2__tar.gz → 0.3.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. {mover-0.3.2 → mover-0.3.4}/PKG-INFO +1 -4
  2. {mover-0.3.2 → mover-0.3.4}/pyproject.toml +1 -6
  3. {mover-0.3.2 → mover-0.3.4}/src/mover/converter/assets/convert.js +9 -6
  4. {mover-0.3.2 → mover-0.3.4}/src/mover/converter/mover_converter.py +115 -229
  5. {mover-0.3.2 → mover-0.3.4}/src/mover/converter/mover_querier.py +1 -29
  6. {mover-0.3.2 → mover-0.3.4}/src/mover/converter/raster_capture.py +20 -63
  7. {mover-0.3.2 → mover-0.3.4}/src/mover/converter/render_session.py +0 -2
  8. {mover-0.3.2 → mover-0.3.4}/src/mover.egg-info/PKG-INFO +1 -4
  9. {mover-0.3.2 → mover-0.3.4}/src/mover.egg-info/requires.txt +0 -4
  10. {mover-0.3.2 → mover-0.3.4}/tests/test_converter_capture.py +2 -201
  11. {mover-0.3.2 → mover-0.3.4}/tests/test_converter_dom.py +26 -116
  12. {mover-0.3.2 → mover-0.3.4}/tests/test_converter_timeline.py +27 -0
  13. {mover-0.3.2 → mover-0.3.4}/LICENSE +0 -0
  14. {mover-0.3.2 → mover-0.3.4}/README.md +0 -0
  15. {mover-0.3.2 → mover-0.3.4}/setup.cfg +0 -0
  16. {mover-0.3.2 → mover-0.3.4}/src/mover/__init__.py +0 -0
  17. {mover-0.3.2 → mover-0.3.4}/src/mover/_optional.py +0 -0
  18. {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/__init__.py +0 -0
  19. {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/LICENSE +0 -0
  20. {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/VENDORED.md +0 -0
  21. {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/__init__.py +0 -0
  22. {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/dsl/__init__.py +0 -0
  23. {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/dsl/all.py +0 -0
  24. {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/dsl/constraint.py +0 -0
  25. {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/dsl/dsl_domain.py +0 -0
  26. {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/dsl/dsl_functions.py +0 -0
  27. {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/dsl/dsl_types.py +0 -0
  28. {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/dsl/executors/__init__.py +0 -0
  29. {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/dsl/executors/executor_base.py +0 -0
  30. {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/dsl/executors/function_domain_executor.py +0 -0
  31. {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/dsl/executors/tensor_value_eager_executor.py +0 -0
  32. {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/dsl/executors/tensor_value_executor.py +0 -0
  33. {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/dsl/executors/value_quantizers.py +0 -0
  34. {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/dsl/expression.py +0 -0
  35. {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/dsl/expression_utils.py +0 -0
  36. {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/dsl/expression_visitor.py +0 -0
  37. {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/dsl/function_domain.py +0 -0
  38. {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/dsl/learning/__init__.py +0 -0
  39. {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/dsl/learning/function_domain_search.py +0 -0
  40. {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/dsl/parsers/__init__.py +0 -0
  41. {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/dsl/parsers/fol_python_parser.py +0 -0
  42. {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/dsl/parsers/function_expression_parser.py +0 -0
  43. {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/dsl/parsers/parser_base.py +0 -0
  44. {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/dsl/tensor_state.py +0 -0
  45. {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/dsl/tensor_value.py +0 -0
  46. {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/dsl/tensor_value_utils.py +0 -0
  47. {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/dsl/value.py +0 -0
  48. {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/torch_index/LICENSE +0 -0
  49. {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/torch_index/VENDORED.md +0 -0
  50. {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/torch_index/__init__.py +0 -0
  51. {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/torch_index/basic_functional.py +0 -0
  52. {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/torch_index/batched_functional.py +0 -0
  53. {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/torch_index/torch_utils.py +0 -0
  54. {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/torch_index/utils.py +0 -0
  55. {mover-0.3.2 → mover-0.3.4}/src/mover/config.py +0 -0
  56. {mover-0.3.2 → mover-0.3.4}/src/mover/converter/__init__.py +0 -0
  57. {mover-0.3.2 → mover-0.3.4}/src/mover/converter/assets/EasePack.min.js +0 -0
  58. {mover-0.3.2 → mover-0.3.4}/src/mover/converter/assets/MotionPathPlugin.min.js +0 -0
  59. {mover-0.3.2 → mover-0.3.4}/src/mover/converter/assets/api.js +0 -0
  60. {mover-0.3.2 → mover-0.3.4}/src/mover/converter/assets/grid.svg +0 -0
  61. {mover-0.3.2 → mover-0.3.4}/src/mover/converter/assets/gsap.min.js +0 -0
  62. {mover-0.3.2 → mover-0.3.4}/src/mover/converter/assets/index.css +0 -0
  63. {mover-0.3.2 → mover-0.3.4}/src/mover/converter/assets/property_registry.json +0 -0
  64. {mover-0.3.2 → mover-0.3.4}/src/mover/converter/assets/vis.js +0 -0
  65. {mover-0.3.2 → mover-0.3.4}/src/mover/dsl/__init__.py +0 -0
  66. {mover-0.3.2 → mover-0.3.4}/src/mover/dsl/assets/correction_msg_template.md +0 -0
  67. {mover-0.3.2 → mover-0.3.4}/src/mover/dsl/fol_domain.py +0 -0
  68. {mover-0.3.2 → mover-0.3.4}/src/mover/dsl/fol_executor.py +0 -0
  69. {mover-0.3.2 → mover-0.3.4}/src/mover/dsl/fol_parser.py +0 -0
  70. {mover-0.3.2 → mover-0.3.4}/src/mover/dsl/mover_verifier.py +0 -0
  71. {mover-0.3.2 → mover-0.3.4}/src/mover/dsl/transform_types.py +0 -0
  72. {mover-0.3.2 → mover-0.3.4}/src/mover/dsl/utils.py +0 -0
  73. {mover-0.3.2 → mover-0.3.4}/src/mover/nlg/__init__.py +0 -0
  74. {mover-0.3.2 → mover-0.3.4}/src/mover/nlg/assets/sentence_patterns.json +0 -0
  75. {mover-0.3.2 → mover-0.3.4}/src/mover/nlg/assets/vocab.json +0 -0
  76. {mover-0.3.2 → mover-0.3.4}/src/mover/nlg/data_classes.py +0 -0
  77. {mover-0.3.2 → mover-0.3.4}/src/mover/nlg/mover_fol_generator.py +0 -0
  78. {mover-0.3.2 → mover-0.3.4}/src/mover/nlg/prompt_generator.py +0 -0
  79. {mover-0.3.2 → mover-0.3.4}/src/mover/nlg/sentence_generation/__init__.py +0 -0
  80. {mover-0.3.2 → mover-0.3.4}/src/mover/nlg/sentence_generation/sentence_composer.py +0 -0
  81. {mover-0.3.2 → mover-0.3.4}/src/mover/nlg/sentence_generation/sentence_unit.py +0 -0
  82. {mover-0.3.2 → mover-0.3.4}/src/mover/nlg/sentence_generation/vocab.py +0 -0
  83. {mover-0.3.2 → mover-0.3.4}/src/mover/nlg/utils.py +0 -0
  84. {mover-0.3.2 → mover-0.3.4}/src/mover/pipeline.py +0 -0
  85. {mover-0.3.2 → mover-0.3.4}/src/mover/stats.py +0 -0
  86. {mover-0.3.2 → mover-0.3.4}/src/mover/synthesizers/__init__.py +0 -0
  87. {mover-0.3.2 → mover-0.3.4}/src/mover/synthesizers/animation_synthesizer.py +0 -0
  88. {mover-0.3.2 → mover-0.3.4}/src/mover/synthesizers/assets/sys_msg_animation_synthesizer.md +0 -0
  89. {mover-0.3.2 → mover-0.3.4}/src/mover/synthesizers/assets/sys_msg_animation_synthesizer_with_implementation.md +0 -0
  90. {mover-0.3.2 → mover-0.3.4}/src/mover/synthesizers/assets/sys_msg_mover_synthesizer.md +0 -0
  91. {mover-0.3.2 → mover-0.3.4}/src/mover/synthesizers/assets/sys_msg_prompt_rewriter.md +0 -0
  92. {mover-0.3.2 → mover-0.3.4}/src/mover/synthesizers/assets/template.html +0 -0
  93. {mover-0.3.2 → mover-0.3.4}/src/mover/synthesizers/base_synthesizer.py +0 -0
  94. {mover-0.3.2 → mover-0.3.4}/src/mover/synthesizers/llm_client.py +0 -0
  95. {mover-0.3.2 → mover-0.3.4}/src/mover/synthesizers/mover_synthesizer.py +0 -0
  96. {mover-0.3.2 → mover-0.3.4}/src/mover/synthesizers/prompt_rewriter.py +0 -0
  97. {mover-0.3.2 → mover-0.3.4}/src/mover/synthesizers/utils.py +0 -0
  98. {mover-0.3.2 → mover-0.3.4}/src/mover.egg-info/SOURCES.txt +0 -0
  99. {mover-0.3.2 → mover-0.3.4}/src/mover.egg-info/dependency_links.txt +0 -0
  100. {mover-0.3.2 → mover-0.3.4}/src/mover.egg-info/entry_points.txt +0 -0
  101. {mover-0.3.2 → mover-0.3.4}/src/mover.egg-info/top_level.txt +0 -0
  102. {mover-0.3.2 → mover-0.3.4}/tests/test_animation_prompt_assets.py +0 -0
  103. {mover-0.3.2 → mover-0.3.4}/tests/test_mover_fol_generator.py +0 -0
  104. {mover-0.3.2 → mover-0.3.4}/tests/test_mover_querier.py +0 -0
  105. {mover-0.3.2 → mover-0.3.4}/tests/test_mover_synthesizer.py +0 -0
  106. {mover-0.3.2 → mover-0.3.4}/tests/test_prompt_generation.py +0 -0
  107. {mover-0.3.2 → mover-0.3.4}/tests/test_render_session.py +0 -0
  108. {mover-0.3.2 → mover-0.3.4}/tests/test_svgpt_keyframes.py +0 -0
  109. {mover-0.3.2 → mover-0.3.4}/tests/test_transform_types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mover
3
- Version: 0.3.2
3
+ Version: 0.3.4
4
4
  Summary: Official implementation of MoVer: Motion Verification for Motion Graphics Animations
5
5
  Author-email: Jiaju Ma <jiajuma@stanford.edu>
6
6
  License-Expression: Apache-2.0
@@ -29,13 +29,10 @@ Requires-Dist: jacinle<2,>=1.0.4; extra == "full"
29
29
  Requires-Dist: Jinja2<4,>=3.1.6; extra == "full"
30
30
  Requires-Dist: lark<2,>=1.3.1; extra == "full"
31
31
  Requires-Dist: openai<3,>=2.45; extra == "full"
32
- Requires-Dist: opencv-python-headless<6,>=4.11; extra == "full"
33
32
  Requires-Dist: pyrealb<4,>=3.2.4; extra == "full"
34
33
  Requires-Dist: PyYAML<7,>=6.0.2; extra == "full"
35
34
  Requires-Dist: torch<3,>=2.7.1; extra == "full"
36
35
  Requires-Dist: treelib<2,>=1.8; extra == "full"
37
- Provides-Extra: media
38
- Requires-Dist: opencv-python-headless<6,>=4.11; extra == "media"
39
36
  Provides-Extra: openai
40
37
  Requires-Dist: openai<3,>=2.45; extra == "openai"
41
38
  Provides-Extra: groq
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "mover"
7
- version = "0.3.2"
7
+ version = "0.3.4"
8
8
  description = "Official implementation of MoVer: Motion Verification for Motion Graphics Animations"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10,<3.13"
@@ -90,15 +90,11 @@ full = [
90
90
  "Jinja2>=3.1.6,<4",
91
91
  "lark>=1.3.1,<2",
92
92
  "openai>=2.45,<3",
93
- "opencv-python-headless>=4.11,<6",
94
93
  "pyrealb>=3.2.4,<4",
95
94
  "PyYAML>=6.0.2,<7",
96
95
  "torch>=2.7.1,<3",
97
96
  "treelib>=1.8,<2",
98
97
  ]
99
- media = [
100
- "opencv-python-headless>=4.11,<6",
101
- ]
102
98
  openai = [
103
99
  "openai>=2.45,<3",
104
100
  ]
@@ -123,7 +119,6 @@ dev = [
123
119
  "Jinja2>=3.1.6,<4",
124
120
  "lark>=1.3.1,<2",
125
121
  "openai>=2.45,<3",
126
- "opencv-python-headless>=4.11,<6",
127
122
  "pyrealb>=3.2.4,<4",
128
123
  "PyYAML>=6.0.2,<7",
129
124
  "torch>=2.7.1,<3",
@@ -183,13 +183,14 @@ function initializeControlledTimelineAtZero(timeline) {
183
183
  return timeline;
184
184
  }
185
185
 
186
- // Zero-duration root animations hold no time-varying state, so they are not unsupported.
187
- // They do not necessarily apply instantly -- see unfreezeAuthoredGsapRootAfterSnapshot.
188
- function isInstantRootAnimation(animation) {
186
+ // Settled means already applied: no time left and no delay. Zero duration alone is not
187
+ // enough -- {delay} applies later, {repeat} reports a non-zero totalDuration.
188
+ function isSettledInstantRootAnimation(animation) {
189
189
  return Boolean(
190
190
  animation
191
- && typeof animation.duration === "function"
192
- && animation.duration() === 0
191
+ && typeof animation.totalDuration === "function"
192
+ && animation.totalDuration() === 0
193
+ && getAnimationDelay(animation) === 0
193
194
  );
194
195
  }
195
196
 
@@ -206,7 +207,9 @@ function getUnexpectedRootAnimations() {
206
207
  .getChildren(false, true, true)
207
208
  .filter(animation => (
208
209
  animation !== tl_to_use
209
- && !isInstantRootAnimation(animation)
210
+ // getInitialRootRecords leaves a delayedCall on the root by design.
211
+ && !isGsapDelayedCall(animation)
212
+ && !isSettledInstantRootAnimation(animation)
210
213
  ));
211
214
  }
212
215
 
@@ -2,7 +2,6 @@ import io
2
2
  import json
3
3
  import asyncio
4
4
  import argparse
5
- import importlib
6
5
  import math
7
6
  import tempfile
8
7
  import shutil
@@ -18,6 +17,8 @@ import subprocess
18
17
  from PIL import Image
19
18
  import uvicorn
20
19
 
20
+ from mover.converter.raster_capture import AWAIT_PAINT_JS
21
+
21
22
 
22
23
  VIDEO_OUTPUT_FORMATS = {"mp4", "gif"}
23
24
  FRAME_OUTPUT_FORMATS = {"png", "svg"}
@@ -77,19 +78,6 @@ async def _capture_svg_png(svg_element, hide_grid: bool) -> bytes:
77
78
  )
78
79
 
79
80
 
80
- def _load_opencv():
81
- """Load the optional OpenCV MP4 fallback with an actionable error."""
82
- try:
83
- return importlib.import_module("cv2")
84
- except ModuleNotFoundError as error:
85
- if error.name != "cv2":
86
- raise
87
- raise RuntimeError(
88
- "MP4 output requires FFmpeg or the optional OpenCV fallback. "
89
- 'Install FFmpeg or run: pip install "mover[media]"'
90
- ) from error
91
-
92
-
93
81
  def _validate_nonempty_output(path: Path, output_format: str) -> None:
94
82
  if not path.is_file() or path.stat().st_size == 0:
95
83
  raise RuntimeError(
@@ -120,20 +108,66 @@ def _validate_ffmpeg_mp4(path: Path) -> None:
120
108
  raise RuntimeError(f"MP4 output could not be decoded: {path}") from error
121
109
 
122
110
 
123
- def _validate_mp4(path: Path, cv2_module=None) -> None:
124
- """Raise when ``path`` is not a nonempty, decodable MP4."""
125
- _validate_nonempty_output(path, "mp4")
126
- cv2_module = cv2_module or _load_opencv()
111
+ def _require_ffmpeg(output_format: str) -> None:
112
+ """Raise an actionable error when FFmpeg is unavailable."""
113
+ try:
114
+ subprocess.run(["ffmpeg", "-version"], capture_output=True, check=True)
115
+ except (subprocess.SubprocessError, FileNotFoundError) as error:
116
+ raise RuntimeError(
117
+ f"{output_format.upper()} output requires a working FFmpeg "
118
+ "installation. Install it with your system package manager, e.g. "
119
+ "`apt install ffmpeg` or `brew install ffmpeg`."
120
+ ) from error
121
+
122
+
123
+ def _ffmpeg_encode(
124
+ input_args: list[str],
125
+ output_path: Path,
126
+ fps: int,
127
+ output_format: str,
128
+ stdin_bytes: bytes | None = None,
129
+ ) -> None:
130
+ """Encode one video with FFmpeg, whatever the frame source."""
131
+ if output_format == "gif":
132
+ codec_args = [
133
+ "-vf",
134
+ (
135
+ f"fps={fps},split[s0][s1];"
136
+ "[s0]palettegen=stats_mode=diff[p];"
137
+ "[s1][p]paletteuse=dither=bayer:bayer_scale=5"
138
+ ),
139
+ ]
140
+ else:
141
+ codec_args = [
142
+ "-c:v", "libx264",
143
+ "-preset", "ultrafast",
144
+ "-crf", "23",
145
+ "-pix_fmt", "yuv420p",
146
+ "-movflags", "+faststart",
147
+ ]
127
148
 
128
- capture = cv2_module.VideoCapture(str(path))
149
+ output_path.unlink(missing_ok=True)
129
150
  try:
130
- if not capture.isOpened():
131
- raise RuntimeError(f"MP4 output could not be opened: {path}")
132
- decoded, frame = capture.read()
133
- if not decoded or frame is None:
134
- raise RuntimeError(f"MP4 output contains no decodable frame: {path}")
135
- finally:
136
- capture.release()
151
+ subprocess.run(
152
+ ["ffmpeg", "-y", *input_args, *codec_args, str(output_path)],
153
+ input=stdin_bytes,
154
+ stdout=subprocess.DEVNULL,
155
+ stderr=subprocess.DEVNULL,
156
+ check=True,
157
+ )
158
+ if output_format == "gif":
159
+ _validate_nonempty_output(output_path, "gif")
160
+ else:
161
+ _validate_ffmpeg_mp4(output_path)
162
+ except (subprocess.SubprocessError, FileNotFoundError, RuntimeError) as error:
163
+ output_path.unlink(missing_ok=True)
164
+ if output_format == "gif":
165
+ raise RuntimeError(
166
+ "GIF output requires FFmpeg with GIF palette support"
167
+ ) from error
168
+ raise RuntimeError(
169
+ f"FFmpeg failed to encode {output_format.upper()} output"
170
+ ) from error
137
171
 
138
172
 
139
173
  def create_video_from_frames(
@@ -142,20 +176,12 @@ def create_video_from_frames(
142
176
  fps: int = DEFAULT_FPS,
143
177
  output_format: str = "mp4",
144
178
  ) -> None:
145
- """Create MP4/GIF output; GIF requires FFmpeg, MP4 can fall back to OpenCV."""
179
+ """Create MP4/GIF output. Both formats require FFmpeg."""
146
180
  if not frames:
147
181
  raise ValueError("No frames provided")
148
182
  if output_format not in VIDEO_OUTPUT_FORMATS:
149
183
  raise ValueError(f"Unsupported video output format: {output_format}")
150
-
151
- try:
152
- subprocess.run(['ffmpeg', '-version'], capture_output=True, check=True)
153
- ffmpeg_available = True
154
- except (subprocess.SubprocessError, FileNotFoundError):
155
- ffmpeg_available = False
156
-
157
- if output_format == "gif" and not ffmpeg_available:
158
- raise RuntimeError("GIF output requires a working FFmpeg installation")
184
+ _require_ffmpeg(output_format)
159
185
 
160
186
  final_output_path = Path(output_path)
161
187
  final_output_path.parent.mkdir(parents=True, exist_ok=True)
@@ -171,94 +197,23 @@ def create_video_from_frames(
171
197
  ):
172
198
  raise ValueError("All video frames must have the same HxWx3 shape")
173
199
 
174
- if ffmpeg_available:
175
- ffmpeg_output_path = final_output_path.with_suffix(
176
- ".ffmpeg.gif" if output_format == "gif" else ".ffmpeg.mp4"
177
- )
178
- ffmpeg_output_path.unlink(missing_ok=True)
179
- command = [
180
- "ffmpeg",
181
- "-y",
182
- "-f",
183
- "rawvideo",
184
- "-pix_fmt",
185
- "bgr24",
186
- "-s",
187
- f"{width}x{height}",
188
- "-r",
189
- str(fps),
190
- "-i",
191
- "-",
192
- ]
193
- if output_format == "gif":
194
- command.extend(
195
- [
196
- "-vf",
197
- (
198
- f"fps={fps},split[s0][s1];"
199
- "[s0]palettegen=stats_mode=diff[p];"
200
- "[s1][p]paletteuse=dither=bayer:bayer_scale=5"
201
- ),
202
- ]
203
- )
204
- else:
205
- command.extend(
206
- [
207
- "-an",
208
- "-c:v",
209
- "libx264",
210
- "-preset",
211
- "ultrafast",
212
- "-crf",
213
- "23",
214
- "-pix_fmt",
215
- "yuv420p",
216
- "-movflags",
217
- "+faststart",
218
- ]
219
- )
220
- command.append(str(ffmpeg_output_path))
221
-
222
- try:
223
- subprocess.run(
224
- command,
225
- input=b"".join(frame.tobytes() for frame in normalized_frames),
226
- stdout=subprocess.DEVNULL,
227
- stderr=subprocess.DEVNULL,
228
- check=True,
229
- )
230
- if output_format == "gif":
231
- _validate_nonempty_output(ffmpeg_output_path, "gif")
232
- else:
233
- _validate_ffmpeg_mp4(ffmpeg_output_path)
234
- ffmpeg_output_path.replace(final_output_path)
235
- return
236
- except (subprocess.SubprocessError, FileNotFoundError, RuntimeError):
237
- ffmpeg_output_path.unlink(missing_ok=True)
238
- if output_format == "gif":
239
- raise RuntimeError(
240
- "GIF output requires FFmpeg with GIF palette support"
241
- )
242
-
243
- print("Using OpenCV MP4 output because FFmpeg conversion is unavailable")
244
- cv2 = _load_opencv()
245
- fourcc = cv2.VideoWriter_fourcc(*"mp4v")
246
- temp_mp4_path = final_output_path.with_suffix(".temp.mp4")
247
- temp_mp4_path.unlink(missing_ok=True)
248
- out = cv2.VideoWriter(str(temp_mp4_path), fourcc, fps, (width, height))
249
- try:
250
- if not out.isOpened():
251
- raise RuntimeError("OpenCV could not initialize the MP4 writer")
252
- for frame in normalized_frames:
253
- out.write(frame)
254
- except Exception:
255
- temp_mp4_path.unlink(missing_ok=True)
256
- raise
257
- finally:
258
- out.release()
259
-
260
- _validate_mp4(temp_mp4_path, cv2)
261
- temp_mp4_path.replace(final_output_path)
200
+ ffmpeg_output_path = final_output_path.with_suffix(
201
+ ".ffmpeg.gif" if output_format == "gif" else ".ffmpeg.mp4"
202
+ )
203
+ _ffmpeg_encode(
204
+ [
205
+ "-f", "rawvideo",
206
+ "-pix_fmt", "bgr24",
207
+ "-s", f"{width}x{height}",
208
+ "-r", str(fps),
209
+ "-i", "-",
210
+ ],
211
+ ffmpeg_output_path,
212
+ fps,
213
+ output_format,
214
+ stdin_bytes=b"".join(frame.tobytes() for frame in normalized_frames),
215
+ )
216
+ ffmpeg_output_path.replace(final_output_path)
262
217
 
263
218
 
264
219
  async def capture_frames_server_driven(
@@ -266,18 +221,15 @@ async def capture_frames_server_driven(
266
221
  output_path: str,
267
222
  fps: int = DEFAULT_FPS,
268
223
  output_format: str = "mp4",
269
- in_memory: bool = False,
270
224
  hide_grid: bool = False,
271
225
  capture_duration: float | None = None,
272
- ) -> None | tuple[list[np.ndarray | io.StringIO], float]:
226
+ ) -> None:
273
227
  """
274
228
  Server-driven frame capture: Python controls the timeline and screenshots.
275
229
  Video/GIF frames are streamed through a temp directory; PNG/SVG outputs
276
- are saved as per-frame files in a directory. In-memory PNG/SVG capture
277
- returns ``(frames, duration)`` without creating, modifying, or deleting
278
- filesystem output. ``hide_grid`` suppresses the SVG grid only in raster
279
- screenshots and does not mutate the page's persistent styles. GIF output
280
- requires a working FFmpeg installation.
230
+ are saved as per-frame files in a directory. ``hide_grid`` suppresses the
231
+ SVG grid only in raster screenshots and does not mutate the page's
232
+ persistent styles. GIF output requires a working FFmpeg installation.
281
233
 
282
234
  Call this at most once per page load. A capture leaves element state at the
283
235
  final frame and only the timeline time is restored, so a second pass silently
@@ -288,8 +240,6 @@ async def capture_frames_server_driven(
288
240
  raise ValueError("fps must be positive")
289
241
  if output_format not in SUPPORTED_OUTPUT_FORMATS:
290
242
  raise ValueError(f"Unsupported output format: {output_format}")
291
- if in_memory and output_format not in FRAME_OUTPUT_FORMATS:
292
- raise ValueError("in_memory=True is only supported for PNG and SVG output")
293
243
  normalized_capture_duration = _normalize_capture_duration(capture_duration)
294
244
 
295
245
  ## Get animation info from the page using the same FPS used for seeking.
@@ -311,24 +261,21 @@ async def capture_frames_server_driven(
311
261
  svg_element = page.locator("svg").first
312
262
  await svg_element.wait_for(state="visible")
313
263
 
314
- in_memory_frames: list[np.ndarray | io.StringIO] = []
264
+ output_target = Path(output_path)
315
265
  frames_dir: Path | None = None
316
- cleanup_temp_dir = False
317
- if not in_memory:
318
- output_target = Path(output_path)
319
- cleanup_temp_dir = output_format in VIDEO_OUTPUT_FORMATS
320
- if cleanup_temp_dir:
321
- output_target.parent.mkdir(parents=True, exist_ok=True)
322
- frames_dir = Path(tempfile.mkdtemp(prefix="mover_frames_"))
323
- else:
324
- frames_dir = output_target
325
- if frames_dir.suffix.lower() == f".{output_format}":
326
- frames_dir = frames_dir.with_suffix("")
327
- if frames_dir.exists() and not frames_dir.is_dir():
328
- raise ValueError(f"Frame output path exists and is not a directory: {frames_dir}")
329
- frames_dir.mkdir(parents=True, exist_ok=True)
330
- for existing_frame in frames_dir.glob(f"frame_*.{output_format}"):
331
- existing_frame.unlink()
266
+ cleanup_temp_dir = output_format in VIDEO_OUTPUT_FORMATS
267
+ if cleanup_temp_dir:
268
+ output_target.parent.mkdir(parents=True, exist_ok=True)
269
+ frames_dir = Path(tempfile.mkdtemp(prefix="mover_frames_"))
270
+ else:
271
+ frames_dir = output_target
272
+ if frames_dir.suffix.lower() == f".{output_format}":
273
+ frames_dir = frames_dir.with_suffix("")
274
+ if frames_dir.exists() and not frames_dir.is_dir():
275
+ raise ValueError(f"Frame output path exists and is not a directory: {frames_dir}")
276
+ frames_dir.mkdir(parents=True, exist_ok=True)
277
+ for existing_frame in frames_dir.glob(f"frame_*.{output_format}"):
278
+ existing_frame.unlink()
332
279
 
333
280
  capture_started = False
334
281
  try:
@@ -336,9 +283,7 @@ async def capture_frames_server_driven(
336
283
  for frame_index in range(capture_frame_count):
337
284
  await page.evaluate(f"() => seekToFrame({frame_index}, {fps}, {duration})")
338
285
 
339
- await page.evaluate(
340
- "() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))"
341
- )
286
+ await page.evaluate(AWAIT_PAINT_JS)
342
287
  await page.evaluate("assertNoLateRootAnimations()")
343
288
 
344
289
  if output_format == "svg":
@@ -347,30 +292,20 @@ async def capture_frames_server_driven(
347
292
  if (!svg) throw new Error("No SVG element found");
348
293
  return new XMLSerializer().serializeToString(svg);
349
294
  }""")
350
- if in_memory:
351
- in_memory_frames.append(io.StringIO(f"{svg_markup}\n"))
352
- else:
353
- assert frames_dir is not None
354
- frame_path = frames_dir / f"frame_{frame_index:06d}.svg"
355
- frame_path.write_text(f"{svg_markup}\n", encoding="utf-8")
295
+ assert frames_dir is not None
296
+ frame_path = frames_dir / f"frame_{frame_index:06d}.svg"
297
+ frame_path.write_text(f"{svg_markup}\n", encoding="utf-8")
356
298
  else:
357
299
  png_bytes = await _capture_svg_png(svg_element, hide_grid)
358
300
 
359
- if in_memory:
360
- img = Image.open(io.BytesIO(png_bytes)).convert("RGBA")
361
- in_memory_frames.append(np.asarray(img, dtype=np.float32) / 255.0)
362
- else:
363
- assert frames_dir is not None
364
- frame_path = frames_dir / f"frame_{frame_index:06d}.png"
365
- with open(frame_path, "wb") as f:
366
- f.write(png_bytes)
301
+ assert frames_dir is not None
302
+ frame_path = frames_dir / f"frame_{frame_index:06d}.png"
303
+ with open(frame_path, "wb") as f:
304
+ f.write(png_bytes)
367
305
 
368
306
  if (frame_index + 1) % max(1, capture_frame_count // 10) == 0 or frame_index == capture_frame_count - 1:
369
307
  print(f" Captured frame {frame_index + 1}/{capture_frame_count}")
370
308
 
371
- if in_memory:
372
- return in_memory_frames, duration
373
-
374
309
  if output_format in FRAME_OUTPUT_FORMATS:
375
310
  assert frames_dir is not None
376
311
  print(f"{output_format.upper()} frames saved to {frames_dir}")
@@ -378,68 +313,19 @@ async def capture_frames_server_driven(
378
313
 
379
314
  ## Encode video using FFmpeg directly from image sequence.
380
315
  assert frames_dir is not None
316
+ _require_ffmpeg(output_format)
381
317
  final_output_path = Path(output_path)
382
318
  ffmpeg_output_path = final_output_path.with_suffix(
383
- '.ffmpeg.gif' if output_format == "gif" else '.ffmpeg.mp4'
319
+ ".ffmpeg.gif" if output_format == "gif" else ".ffmpeg.mp4"
384
320
  )
385
- ffmpeg_output_path.unlink(missing_ok=True)
386
- try:
387
- subprocess.run(['ffmpeg', '-version'], capture_output=True, check=True)
388
-
389
- if output_format == "gif":
390
- subprocess.run([
391
- 'ffmpeg', '-y',
392
- '-framerate', str(fps),
393
- '-i', str(frames_dir / 'frame_%06d.png'),
394
- '-vf', f'fps={fps},split[s0][s1];[s0]palettegen=stats_mode=diff[p];[s1][p]paletteuse=dither=bayer:bayer_scale=5',
395
- str(ffmpeg_output_path)
396
- ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
397
- if (
398
- not ffmpeg_output_path.is_file()
399
- or ffmpeg_output_path.stat().st_size == 0
400
- ):
401
- raise RuntimeError("FFmpeg produced an empty GIF")
402
- else:
403
- subprocess.run([
404
- 'ffmpeg', '-y',
405
- '-framerate', str(fps),
406
- '-i', str(frames_dir / 'frame_%06d.png'),
407
- '-c:v', 'libx264',
408
- '-preset', 'ultrafast',
409
- '-crf', '23',
410
- '-pix_fmt', 'yuv420p',
411
- '-movflags', '+faststart',
412
- str(ffmpeg_output_path)
413
- ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
414
- _validate_ffmpeg_mp4(ffmpeg_output_path)
415
-
416
- ffmpeg_output_path.replace(final_output_path)
417
- print(f"Video saved to {output_path}")
418
-
419
- except (subprocess.SubprocessError, FileNotFoundError, RuntimeError) as error:
420
- ffmpeg_output_path.unlink(missing_ok=True)
421
- if output_format == "gif":
422
- raise RuntimeError(
423
- "GIF output requires a working FFmpeg installation"
424
- ) from error
425
- print("ffmpeg not found, falling back to OpenCV encoding")
426
- # Fallback: read frames back and use OpenCV
427
- video_frames = []
428
- for frame_index in range(capture_frame_count):
429
- frame_path = frames_dir / f"frame_{frame_index:06d}.png"
430
- img = Image.open(frame_path)
431
- if img.mode != 'RGB':
432
- background = Image.new('RGB', img.size, (255, 255, 255))
433
- if img.mode == 'RGBA':
434
- background.paste(img, mask=img.split()[3])
435
- else:
436
- background.paste(img)
437
- img = background
438
- video_frames.append(
439
- np.asarray(img, dtype=np.uint8)[:, :, ::-1].copy()
440
- )
441
- create_video_from_frames(video_frames, output_path, fps, output_format)
442
- print(f"Video saved to {output_path} (OpenCV fallback)")
321
+ _ffmpeg_encode(
322
+ ["-framerate", str(fps), "-i", str(frames_dir / "frame_%06d.png")],
323
+ ffmpeg_output_path,
324
+ fps,
325
+ output_format,
326
+ )
327
+ ffmpeg_output_path.replace(final_output_path)
328
+ print(f"Video saved to {output_path}")
443
329
 
444
330
  finally:
445
331
  try:
@@ -10,10 +10,6 @@ import asyncio
10
10
  import argparse
11
11
  from typing import List
12
12
  from pathlib import Path
13
- from fastapi import FastAPI
14
- from fastapi.staticfiles import StaticFiles
15
- from fastapi.responses import FileResponse
16
- from fastapi.middleware.cors import CORSMiddleware
17
13
  from playwright.async_api import async_playwright
18
14
  import uvicorn
19
15
 
@@ -22,34 +18,10 @@ from mover.converter.mover_converter import (
22
18
  _normalize_capture_duration,
23
19
  _wait_for_server_start,
24
20
  neutralize_gsdevtools,
21
+ setup_fastapi_app,
25
22
  )
26
23
 
27
24
 
28
- def setup_fastapi_app(html_file: str, html_dir: str, base_name: str) -> FastAPI:
29
- """Set up and configure a minimal FastAPI application for position querying."""
30
- app = FastAPI()
31
-
32
- # Add CORS middleware
33
- app.add_middleware(
34
- CORSMiddleware,
35
- allow_origins=["*"],
36
- allow_credentials=True,
37
- allow_methods=["*"],
38
- allow_headers=["*"],
39
- )
40
-
41
- @app.get("/")
42
- async def serve_html():
43
- """Serve the HTML file."""
44
- return FileResponse(html_file)
45
-
46
- # Mount the assets directory at root to allow relative path access
47
- assets_path = Path(__file__).parent / "assets"
48
- app.mount("/", StaticFiles(directory=str(assets_path)), name="assets")
49
-
50
- return app
51
-
52
-
53
25
  async def run_get_position(html_file: str, target_centroids: List[dict], element_id: str, tolerance: float, port: int, capture_duration: float | None = None) -> List[List[dict]]:
54
26
  """
55
27
  Run the time analysis.