motion-studio 0.1.0__py3-none-any.whl

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 (44) hide show
  1. motion_studio/__init__.py +52 -0
  2. motion_studio/__main__.py +8 -0
  3. motion_studio/bundle.py +424 -0
  4. motion_studio/cli.py +459 -0
  5. motion_studio/config.py +58 -0
  6. motion_studio/core/__init__.py +8 -0
  7. motion_studio/core/plugins.py +285 -0
  8. motion_studio/core/types.py +72 -0
  9. motion_studio/library.py +188 -0
  10. motion_studio/plugins_builtin/__init__.py +8 -0
  11. motion_studio/plugins_builtin/_convert.py +108 -0
  12. motion_studio/plugins_builtin/corrector.py +79 -0
  13. motion_studio/plugins_builtin/corrector_impl.py +992 -0
  14. motion_studio/plugins_builtin/metrics.py +62 -0
  15. motion_studio/plugins_builtin/metrics_live.py +135 -0
  16. motion_studio/plugins_builtin/utils/__init__.py +74 -0
  17. motion_studio/plugins_builtin/utils/floor_utils.py +269 -0
  18. motion_studio/plugins_builtin/utils/metrics_utils.py +599 -0
  19. motion_studio/plugins_builtin/utils/motion_utils.py +880 -0
  20. motion_studio/server/__init__.py +1 -0
  21. motion_studio/server/api_bundle.py +649 -0
  22. motion_studio/server/api_motion.py +622 -0
  23. motion_studio/server/api_video.py +182 -0
  24. motion_studio/server/app.py +185 -0
  25. motion_studio/server/common.py +119 -0
  26. motion_studio/server/loaders.py +197 -0
  27. motion_studio/server/state.py +254 -0
  28. motion_studio/server/video_cache.py +283 -0
  29. motion_studio/smpl/__init__.py +5 -0
  30. motion_studio/smpl/convert.py +363 -0
  31. motion_studio/smpl/io.py +86 -0
  32. motion_studio/smpl/refit.py +370 -0
  33. motion_studio/static/app.js +4513 -0
  34. motion_studio/static/index.html +386 -0
  35. motion_studio/static/style.css +340 -0
  36. motion_studio/static/vendor/OrbitControls.js +1417 -0
  37. motion_studio/static/vendor/TransformControls.js +1573 -0
  38. motion_studio/static/vendor/three.module.js +53044 -0
  39. motion_studio-0.1.0.dist-info/METADATA +295 -0
  40. motion_studio-0.1.0.dist-info/RECORD +44 -0
  41. motion_studio-0.1.0.dist-info/WHEEL +5 -0
  42. motion_studio-0.1.0.dist-info/entry_points.txt +2 -0
  43. motion_studio-0.1.0.dist-info/licenses/LICENSE +21 -0
  44. motion_studio-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,52 @@
1
+ """Motion Studio: a clean, installable multi-person SMPL motion editor.
2
+
3
+ The names re-exported here are the package's stable public API. Everything in
4
+ this top-level namespace is torch-free and safe to import in a numpy+flask-only
5
+ ("core") install: the heavy server stack and the built-in torch/SMPL plugins
6
+ are imported lazily, never at package import time.
7
+
8
+ Typical use::
9
+
10
+ import motion_studio as ms
11
+
12
+ bundle = ms.load_bundle("session.motion")
13
+ motion = bundle.original # ms.Motion
14
+ corrector = ms.load_corrector(ms.Config().corrector_spec,
15
+ smpl_dir="~/smpl/models")
16
+ fixed = corrector.correct(motion)
17
+ ms.save_motion_pkl(fixed, "fixed.pkl")
18
+ """
19
+ from __future__ import annotations
20
+
21
+ from .bundle import load_bundle, save_bundle
22
+ from .config import Config
23
+ from .core.plugins import (MotionCorrector, MotionMetrics, load_corrector,
24
+ load_metrics)
25
+ from .core.types import Floor, Motion
26
+ from .library import import_dataset, scan_dataset
27
+ from .smpl.io import load_motion_pkl, save_motion_pkl
28
+
29
+ __version__ = "0.1.0"
30
+
31
+ __all__ = [
32
+ # Core data types.
33
+ "Motion",
34
+ "Floor",
35
+ # Plugin contracts + loaders.
36
+ "MotionCorrector",
37
+ "MotionMetrics",
38
+ "load_corrector",
39
+ "load_metrics",
40
+ # SMPL pkl I/O.
41
+ "load_motion_pkl",
42
+ "save_motion_pkl",
43
+ # .motion bundle I/O.
44
+ "save_bundle",
45
+ "load_bundle",
46
+ # Dataset discovery / import.
47
+ "scan_dataset",
48
+ "import_dataset",
49
+ # Configuration.
50
+ "Config",
51
+ "__version__",
52
+ ]
@@ -0,0 +1,8 @@
1
+ """Allow ``python -m motion_studio`` to start the server."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
@@ -0,0 +1,424 @@
1
+ """The ``.motion`` save-file format for a Motion Studio editing session.
2
+
3
+ A ``.motion`` file is a single ZIP archive that bundles everything about one
4
+ editing session: the original SMPL motion, the edited motion (if any), the
5
+ source video and music, plus all the side metadata the editor needs to reopen
6
+ the session exactly where the user left off (camera/video placement, comments,
7
+ metrics, ...). Reopening a bundle restores the full session; a caller that only
8
+ wants the SMPL can read ``original``/``edited`` and ignore the rest.
9
+
10
+ The archive layout is::
11
+
12
+ manifest.json session metadata (see save_bundle for the schema)
13
+ motion_original.npz poses, trans, betas (+ scalar fields) of the original
14
+ motion_edited.npz same, for the edited motion (absent if no edits)
15
+ video.mp4 source video bytes (optional)
16
+ music.<ext> music bytes, original extension (optional)
17
+ thumbnail.png preview image (optional)
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import dataclasses
22
+ import datetime
23
+ import io
24
+ import json
25
+ import os
26
+ import zipfile
27
+ from typing import Any, Dict, List, Optional, Union
28
+
29
+ import numpy as np
30
+
31
+ from motion_studio.core.types import Motion
32
+
33
+ MOTION_EXT = ".motion"
34
+
35
+ _FORMAT = "motion-studio"
36
+ _VERSION = 1
37
+ _MANIFEST_NAME = "manifest.json"
38
+ _ORIGINAL_NAME = "motion_original.npz"
39
+ _EDITED_NAME = "motion_edited.npz"
40
+ _VIDEO_NAME = "video.mp4"
41
+ _THUMBNAIL_NAME = "thumbnail.png"
42
+ _MUSIC_STEM = "music"
43
+
44
+
45
+ @dataclasses.dataclass
46
+ class Bundle:
47
+ """A loaded ``.motion`` editing session.
48
+
49
+ Attributes:
50
+ original: The original (unedited) motion.
51
+ edited: The edited motion, or None if the session had no edits.
52
+ video: Raw source video bytes, or None.
53
+ music: Raw music bytes, or None.
54
+ music_ext: Extension of the music file, without the leading dot.
55
+ video_params: Video placement params (posX, posY, scale, ...).
56
+ comments: List of user comments attached to the session.
57
+ metrics: Mapping {"ref": {...}, "cur": {...}} of metric values.
58
+ manifest: The full manifest dict, as stored in the archive.
59
+ """
60
+
61
+ original: Motion
62
+ edited: Optional[Motion]
63
+ video: Optional[bytes]
64
+ music: Optional[bytes]
65
+ music_ext: str
66
+ video_params: Dict[str, Any]
67
+ comments: List[Any]
68
+ metrics: Dict[str, Any]
69
+ manifest: Dict[str, Any]
70
+
71
+
72
+ @dataclasses.dataclass
73
+ class BundleMeta:
74
+ """A cheaply loaded ``.motion`` session: motions + metadata, lazy media.
75
+
76
+ Reading a bundle's ``video.mp4`` / music bytes can be tens of megabytes;
77
+ the hot playback path (``/mesh_frame``, ``/mesh_faces``, ``resolve_motion``)
78
+ only needs the SMPL motions and the manifest. :func:`load_bundle_meta`
79
+ decodes just the manifest and the small ``.npz`` motion members and leaves
80
+ the heavy video/music bytes behind lazy accessors, so a playback request no
81
+ longer re-reads the whole archive.
82
+
83
+ Attributes:
84
+ original: The original (unedited) motion.
85
+ edited: The edited motion, or None if the session had no edits.
86
+ music_ext: Extension of the music file, without the leading dot.
87
+ video_params: Video placement params (posX, posY, scale, ...).
88
+ comments: List of user comments attached to the session.
89
+ metrics: Mapping {"ref": {...}, "cur": {...}} of metric values.
90
+ manifest: The full manifest dict, as stored in the archive.
91
+ has_video: Whether the archive carries a ``video.mp4`` member.
92
+ has_music: Whether the archive carries a music member.
93
+ """
94
+
95
+ original: Motion
96
+ edited: Optional[Motion]
97
+ music_ext: str
98
+ video_params: Dict[str, Any]
99
+ comments: List[Any]
100
+ metrics: Dict[str, Any]
101
+ manifest: Dict[str, Any]
102
+ has_video: bool
103
+ has_music: bool
104
+ _path: str
105
+
106
+ def video_bytes(self) -> Optional[bytes]:
107
+ """Read and return the archive's ``video.mp4`` bytes, or None."""
108
+ if not self.has_video:
109
+ return None
110
+ with zipfile.ZipFile(self._path, "r") as zf:
111
+ if _VIDEO_NAME not in set(zf.namelist()):
112
+ return None
113
+ return zf.read(_VIDEO_NAME)
114
+
115
+ def music_bytes(self) -> Optional[bytes]:
116
+ """Read and return the archive's music bytes, or None."""
117
+ if not self.has_music:
118
+ return None
119
+ music_name = "%s.%s" % (_MUSIC_STEM, self.music_ext)
120
+ with zipfile.ZipFile(self._path, "r") as zf:
121
+ if music_name not in set(zf.namelist()):
122
+ return None
123
+ return zf.read(music_name)
124
+
125
+
126
+ def motion_to_npz_dict(m: Motion) -> Dict[str, np.ndarray]:
127
+ """Serialize a Motion to a flat dict of arrays for ``np.savez``.
128
+
129
+ Scalar fields (gender, fps, name) are stored as 0-d arrays so they survive
130
+ the round-trip through an ``.npz`` archive. ``betas`` is stored only when
131
+ present; its absence is recorded by a ``has_betas`` flag.
132
+
133
+ Args:
134
+ m: The motion to serialize.
135
+
136
+ Returns:
137
+ A mapping of archive member name to numpy array.
138
+ """
139
+ out: Dict[str, np.ndarray] = {
140
+ "poses": np.asarray(m.poses),
141
+ "trans": np.asarray(m.trans),
142
+ "gender": np.asarray(m.gender),
143
+ "fps": np.asarray(float(m.fps)),
144
+ "name": np.asarray(m.name),
145
+ "has_betas": np.asarray(m.betas is not None),
146
+ }
147
+ if m.betas is not None:
148
+ out["betas"] = np.asarray(m.betas)
149
+ return out
150
+
151
+
152
+ def motion_from_npz(npz: "np.lib.npyio.NpzFile") -> Motion:
153
+ """Rebuild a Motion from an opened ``.npz`` produced by this module.
154
+
155
+ Args:
156
+ npz: A mapping (e.g. an open ``NpzFile``) with the keys written by
157
+ ``motion_to_npz_dict``.
158
+
159
+ Returns:
160
+ The reconstructed motion.
161
+ """
162
+ betas = npz["betas"] if bool(npz["has_betas"]) else None
163
+ return Motion(
164
+ poses=np.asarray(npz["poses"]),
165
+ trans=np.asarray(npz["trans"]),
166
+ betas=None if betas is None else np.asarray(betas),
167
+ gender=str(npz["gender"]),
168
+ fps=float(npz["fps"]),
169
+ name=str(npz["name"]),
170
+ )
171
+
172
+
173
+ def _read_bytes(data: Union[bytes, str]) -> bytes:
174
+ """Return ``data`` as bytes, reading from disk if it is a path.
175
+
176
+ Args:
177
+ data: Raw bytes, or a filesystem path to read.
178
+
179
+ Returns:
180
+ The raw bytes.
181
+ """
182
+ if isinstance(data, (bytes, bytearray)):
183
+ return bytes(data)
184
+ with open(data, "rb") as f:
185
+ return f.read()
186
+
187
+
188
+ def _motion_bytes(m: Motion) -> bytes:
189
+ """Serialize a Motion to the raw bytes of an ``.npz`` archive."""
190
+ buffer = io.BytesIO()
191
+ np.savez(buffer, **motion_to_npz_dict(m))
192
+ return buffer.getvalue()
193
+
194
+
195
+ def _load_motion_member(zf: zipfile.ZipFile, member: str) -> Motion:
196
+ """Read and deserialize a Motion from a member of an open archive."""
197
+ with io.BytesIO(zf.read(member)) as buffer:
198
+ with np.load(buffer, allow_pickle=False) as npz:
199
+ return motion_from_npz(npz)
200
+
201
+
202
+ def save_bundle(
203
+ path: str,
204
+ *,
205
+ original: Motion,
206
+ edited: Optional[Motion] = None,
207
+ video: Optional[Union[bytes, str]] = None,
208
+ music: Optional[Union[bytes, str]] = None,
209
+ music_ext: str = "wav",
210
+ video_params: Optional[Dict[str, Any]] = None,
211
+ comments: Optional[List[Any]] = None,
212
+ metrics: Optional[Dict[str, Any]] = None,
213
+ source_clip: str = "",
214
+ extra: Optional[Dict[str, Any]] = None,
215
+ thumbnail: Optional[Union[bytes, str]] = None,
216
+ created: Optional[str] = None,
217
+ modified: Optional[str] = None,
218
+ ) -> None:
219
+ """Write a full editing session to a ``.motion`` (ZIP) file.
220
+
221
+ The manifest stored in the archive has the schema::
222
+
223
+ {
224
+ "format": "motion-studio",
225
+ "version": 1,
226
+ "name": str,
227
+ "source_clip": str,
228
+ "created": ISO-8601 str,
229
+ "modified": ISO-8601 str,
230
+ "comments": [...],
231
+ "video_params": {"posX": ..., "scale": ..., ...},
232
+ "bg_removed": bool,
233
+ "metrics": {"ref": {...}, "cur": {...}},
234
+ "has_video": bool,
235
+ "has_music": bool,
236
+ "music_ext": str,
237
+ "gender": str,
238
+ "fps": float,
239
+ "n_persons": int,
240
+ "n_frames": int,
241
+ "extra": {...}
242
+ }
243
+
244
+ Args:
245
+ path: Destination path for the ``.motion`` file.
246
+ original: The original (unedited) motion; required.
247
+ edited: The edited motion, or None to omit it from the archive.
248
+ video: Source video as raw bytes or a filesystem path, or None.
249
+ music: Music as raw bytes or a filesystem path, or None.
250
+ music_ext: Extension to store the music under, without the dot.
251
+ video_params: Video placement params (posX, posY, scale, ...).
252
+ comments: User comments to attach to the session.
253
+ metrics: Mapping {"ref": {...}, "cur": {...}} of metric values.
254
+ source_clip: Identifier of the clip the session originated from.
255
+ extra: Arbitrary extra JSON-serializable metadata.
256
+ thumbnail: Preview image as raw bytes or a path, or None.
257
+ created: Creation timestamp (ISO-8601); defaults to now (UTC).
258
+ modified: Last-modified timestamp (ISO-8601); defaults to now (UTC).
259
+
260
+ Returns:
261
+ None.
262
+ """
263
+ video_params = dict(video_params or {})
264
+ comments = list(comments or [])
265
+ metrics = dict(metrics or {})
266
+ extra = dict(extra or {})
267
+ music_ext = music_ext.lstrip(".") or "wav"
268
+
269
+ now = datetime.datetime.now(datetime.timezone.utc).isoformat()
270
+ created = created or now
271
+ modified = modified or now
272
+
273
+ video_bytes = None if video is None else _read_bytes(video)
274
+ music_bytes = None if music is None else _read_bytes(music)
275
+ thumb_bytes = None if thumbnail is None else _read_bytes(thumbnail)
276
+
277
+ manifest: Dict[str, Any] = {
278
+ "format": _FORMAT,
279
+ "version": _VERSION,
280
+ "name": original.name,
281
+ "source_clip": source_clip,
282
+ "created": created,
283
+ "modified": modified,
284
+ "comments": comments,
285
+ "video_params": video_params,
286
+ "bg_removed": bool(video_params.get("bg_removed", False)),
287
+ "metrics": metrics,
288
+ "has_video": video_bytes is not None,
289
+ "has_music": music_bytes is not None,
290
+ "music_ext": music_ext,
291
+ "gender": original.gender,
292
+ "fps": float(original.fps),
293
+ "n_persons": original.n_persons,
294
+ "n_frames": original.n_frames,
295
+ "extra": extra,
296
+ }
297
+
298
+ with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
299
+ zf.writestr(
300
+ _MANIFEST_NAME,
301
+ json.dumps(manifest, indent=2, sort_keys=True),
302
+ )
303
+ zf.writestr(_ORIGINAL_NAME, _motion_bytes(original))
304
+ if edited is not None:
305
+ zf.writestr(_EDITED_NAME, _motion_bytes(edited))
306
+ if video_bytes is not None:
307
+ zf.writestr(_VIDEO_NAME, video_bytes)
308
+ if music_bytes is not None:
309
+ zf.writestr("%s.%s" % (_MUSIC_STEM, music_ext), music_bytes)
310
+ if thumb_bytes is not None:
311
+ zf.writestr(_THUMBNAIL_NAME, thumb_bytes)
312
+
313
+
314
+ def _read_manifest(zf: zipfile.ZipFile) -> Dict[str, Any]:
315
+ """Read and validate the manifest of an open ``.motion`` archive.
316
+
317
+ Args:
318
+ zf: An open ``.motion`` ZIP archive.
319
+
320
+ Returns:
321
+ The parsed manifest dict.
322
+
323
+ Raises:
324
+ ValueError: If the archive has no manifest member, or its ``format`` is
325
+ not a Motion Studio bundle.
326
+ """
327
+ if _MANIFEST_NAME not in set(zf.namelist()):
328
+ raise ValueError("not a Motion Studio bundle: missing manifest.json")
329
+ manifest = json.loads(zf.read(_MANIFEST_NAME).decode("utf-8"))
330
+ if manifest.get("format") != _FORMAT:
331
+ raise ValueError(
332
+ "not a Motion Studio bundle: %r" % (manifest.get("format"),))
333
+ return manifest
334
+
335
+
336
+ def load_bundle_meta(path: str) -> BundleMeta:
337
+ """Read a ``.motion`` file's motions + metadata, leaving media lazy.
338
+
339
+ The cheap counterpart to :func:`load_bundle`: it decodes only the manifest
340
+ and the small ``.npz`` motion members, never the (potentially large) video
341
+ or music bytes. Use it on the hot path (``resolve_motion`` / playback) and
342
+ pull media on demand via :meth:`BundleMeta.video_bytes` /
343
+ :meth:`BundleMeta.music_bytes`.
344
+
345
+ Args:
346
+ path: Path to the ``.motion`` file to read.
347
+
348
+ Returns:
349
+ The reconstructed :class:`BundleMeta`.
350
+
351
+ Raises:
352
+ ValueError: If the archive is not a Motion Studio bundle (bad/absent
353
+ manifest).
354
+ KeyError: If the mandatory original motion member is missing.
355
+ """
356
+ with zipfile.ZipFile(path, "r") as zf:
357
+ names = set(zf.namelist())
358
+ manifest = _read_manifest(zf)
359
+ original = _load_motion_member(zf, _ORIGINAL_NAME)
360
+ edited = (
361
+ _load_motion_member(zf, _EDITED_NAME)
362
+ if _EDITED_NAME in names else None
363
+ )
364
+ music_ext = manifest.get("music_ext", "wav")
365
+ music_name = "%s.%s" % (_MUSIC_STEM, music_ext)
366
+ has_video = _VIDEO_NAME in names
367
+ has_music = music_name in names
368
+
369
+ return BundleMeta(
370
+ original=original,
371
+ edited=edited,
372
+ music_ext=music_ext,
373
+ video_params=dict(manifest.get("video_params", {})),
374
+ comments=list(manifest.get("comments", [])),
375
+ metrics=dict(manifest.get("metrics", {})),
376
+ manifest=manifest,
377
+ has_video=has_video,
378
+ has_music=has_music,
379
+ _path=path,
380
+ )
381
+
382
+
383
+ def load_bundle(path: str) -> Bundle:
384
+ """Read a ``.motion`` file back into a full Bundle (media included).
385
+
386
+ Args:
387
+ path: Path to the ``.motion`` file to read.
388
+
389
+ Returns:
390
+ The reconstructed Bundle.
391
+
392
+ Raises:
393
+ ValueError: If the archive is not a Motion Studio bundle (bad/absent
394
+ manifest).
395
+ KeyError: If the mandatory original motion member is missing.
396
+ """
397
+ with zipfile.ZipFile(path, "r") as zf:
398
+ names = set(zf.namelist())
399
+
400
+ manifest = _read_manifest(zf)
401
+
402
+ original = _load_motion_member(zf, _ORIGINAL_NAME)
403
+ edited = (
404
+ _load_motion_member(zf, _EDITED_NAME)
405
+ if _EDITED_NAME in names else None
406
+ )
407
+
408
+ video = zf.read(_VIDEO_NAME) if _VIDEO_NAME in names else None
409
+
410
+ music_ext = manifest.get("music_ext", "wav")
411
+ music_name = "%s.%s" % (_MUSIC_STEM, music_ext)
412
+ music = zf.read(music_name) if music_name in names else None
413
+
414
+ return Bundle(
415
+ original=original,
416
+ edited=edited,
417
+ video=video,
418
+ music=music,
419
+ music_ext=music_ext,
420
+ video_params=dict(manifest.get("video_params", {})),
421
+ comments=list(manifest.get("comments", [])),
422
+ metrics=dict(manifest.get("metrics", {})),
423
+ manifest=manifest,
424
+ )