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.
- {mover-0.3.2 → mover-0.3.4}/PKG-INFO +1 -4
- {mover-0.3.2 → mover-0.3.4}/pyproject.toml +1 -6
- {mover-0.3.2 → mover-0.3.4}/src/mover/converter/assets/convert.js +9 -6
- {mover-0.3.2 → mover-0.3.4}/src/mover/converter/mover_converter.py +115 -229
- {mover-0.3.2 → mover-0.3.4}/src/mover/converter/mover_querier.py +1 -29
- {mover-0.3.2 → mover-0.3.4}/src/mover/converter/raster_capture.py +20 -63
- {mover-0.3.2 → mover-0.3.4}/src/mover/converter/render_session.py +0 -2
- {mover-0.3.2 → mover-0.3.4}/src/mover.egg-info/PKG-INFO +1 -4
- {mover-0.3.2 → mover-0.3.4}/src/mover.egg-info/requires.txt +0 -4
- {mover-0.3.2 → mover-0.3.4}/tests/test_converter_capture.py +2 -201
- {mover-0.3.2 → mover-0.3.4}/tests/test_converter_dom.py +26 -116
- {mover-0.3.2 → mover-0.3.4}/tests/test_converter_timeline.py +27 -0
- {mover-0.3.2 → mover-0.3.4}/LICENSE +0 -0
- {mover-0.3.2 → mover-0.3.4}/README.md +0 -0
- {mover-0.3.2 → mover-0.3.4}/setup.cfg +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/__init__.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/_optional.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/__init__.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/LICENSE +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/VENDORED.md +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/__init__.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/dsl/__init__.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/dsl/all.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/dsl/constraint.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/dsl/dsl_domain.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/dsl/dsl_functions.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/dsl/dsl_types.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/dsl/executors/__init__.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/dsl/executors/executor_base.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/dsl/executors/function_domain_executor.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/dsl/executors/tensor_value_eager_executor.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/dsl/executors/tensor_value_executor.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/dsl/executors/value_quantizers.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/dsl/expression.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/dsl/expression_utils.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/dsl/expression_visitor.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/dsl/function_domain.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/dsl/learning/__init__.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/dsl/learning/function_domain_search.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/dsl/parsers/__init__.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/dsl/parsers/fol_python_parser.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/dsl/parsers/function_expression_parser.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/dsl/parsers/parser_base.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/dsl/tensor_state.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/dsl/tensor_value.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/dsl/tensor_value_utils.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/concepts/dsl/value.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/torch_index/LICENSE +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/torch_index/VENDORED.md +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/torch_index/__init__.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/torch_index/basic_functional.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/torch_index/batched_functional.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/torch_index/torch_utils.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/_vendor/torch_index/utils.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/config.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/converter/__init__.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/converter/assets/EasePack.min.js +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/converter/assets/MotionPathPlugin.min.js +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/converter/assets/api.js +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/converter/assets/grid.svg +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/converter/assets/gsap.min.js +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/converter/assets/index.css +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/converter/assets/property_registry.json +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/converter/assets/vis.js +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/dsl/__init__.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/dsl/assets/correction_msg_template.md +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/dsl/fol_domain.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/dsl/fol_executor.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/dsl/fol_parser.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/dsl/mover_verifier.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/dsl/transform_types.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/dsl/utils.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/nlg/__init__.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/nlg/assets/sentence_patterns.json +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/nlg/assets/vocab.json +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/nlg/data_classes.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/nlg/mover_fol_generator.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/nlg/prompt_generator.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/nlg/sentence_generation/__init__.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/nlg/sentence_generation/sentence_composer.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/nlg/sentence_generation/sentence_unit.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/nlg/sentence_generation/vocab.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/nlg/utils.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/pipeline.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/stats.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/synthesizers/__init__.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/synthesizers/animation_synthesizer.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/synthesizers/assets/sys_msg_animation_synthesizer.md +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/synthesizers/assets/sys_msg_animation_synthesizer_with_implementation.md +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/synthesizers/assets/sys_msg_mover_synthesizer.md +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/synthesizers/assets/sys_msg_prompt_rewriter.md +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/synthesizers/assets/template.html +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/synthesizers/base_synthesizer.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/synthesizers/llm_client.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/synthesizers/mover_synthesizer.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/synthesizers/prompt_rewriter.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover/synthesizers/utils.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover.egg-info/SOURCES.txt +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover.egg-info/dependency_links.txt +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover.egg-info/entry_points.txt +0 -0
- {mover-0.3.2 → mover-0.3.4}/src/mover.egg-info/top_level.txt +0 -0
- {mover-0.3.2 → mover-0.3.4}/tests/test_animation_prompt_assets.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/tests/test_mover_fol_generator.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/tests/test_mover_querier.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/tests/test_mover_synthesizer.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/tests/test_prompt_generation.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/tests/test_render_session.py +0 -0
- {mover-0.3.2 → mover-0.3.4}/tests/test_svgpt_keyframes.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
//
|
|
187
|
-
//
|
|
188
|
-
function
|
|
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.
|
|
192
|
-
&& animation.
|
|
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
|
-
|
|
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
|
|
124
|
-
"""Raise
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
149
|
+
output_path.unlink(missing_ok=True)
|
|
129
150
|
try:
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
"
|
|
181
|
-
"-
|
|
182
|
-
"-
|
|
183
|
-
"
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
|
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.
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
264
|
+
output_target = Path(output_path)
|
|
315
265
|
frames_dir: Path | None = None
|
|
316
|
-
cleanup_temp_dir =
|
|
317
|
-
if
|
|
318
|
-
output_target =
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
319
|
+
".ffmpeg.gif" if output_format == "gif" else ".ffmpeg.mp4"
|
|
384
320
|
)
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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.
|