motion-reference-prep 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.
- motion_reference_prep/__init__.py +6 -0
- motion_reference_prep/__main__.py +3 -0
- motion_reference_prep/cli.py +45 -0
- motion_reference_prep/core.py +139 -0
- motion_reference_prep-0.1.0.dist-info/METADATA +109 -0
- motion_reference_prep-0.1.0.dist-info/RECORD +9 -0
- motion_reference_prep-0.1.0.dist-info/WHEEL +4 -0
- motion_reference_prep-0.1.0.dist-info/entry_points.txt +2 -0
- motion_reference_prep-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Command-line interface."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from . import __version__
|
|
8
|
+
from .core import VideoError, prepare, preview, probe
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main(argv=None):
|
|
12
|
+
parser = argparse.ArgumentParser(description="Prepare local motion-reference videos with FFmpeg.")
|
|
13
|
+
parser.add_argument("--version", action="version", version=__version__)
|
|
14
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
15
|
+
inspect = commands.add_parser("inspect", help="Print ffprobe metadata as JSON")
|
|
16
|
+
inspect.add_argument("source")
|
|
17
|
+
convert = commands.add_parser("prepare", help="Trim and encode an H.264/AAC MP4")
|
|
18
|
+
convert.add_argument("source")
|
|
19
|
+
convert.add_argument("destination")
|
|
20
|
+
convert.add_argument("--start", type=float, default=0.0)
|
|
21
|
+
convert.add_argument("--duration", type=float, default=10.0)
|
|
22
|
+
convert.add_argument("--width", type=int, default=720)
|
|
23
|
+
convert.add_argument("--height", type=int, default=1280)
|
|
24
|
+
convert.add_argument("--fps", type=int, default=30)
|
|
25
|
+
convert.add_argument("--fit", choices=("pad", "crop"), default="pad")
|
|
26
|
+
convert.add_argument("--mute", action="store_true")
|
|
27
|
+
convert.add_argument("--overwrite", action="store_true")
|
|
28
|
+
frame = commands.add_parser("preview", help="Save a PNG frame")
|
|
29
|
+
frame.add_argument("source")
|
|
30
|
+
frame.add_argument("destination")
|
|
31
|
+
frame.add_argument("--at", type=float, default=0.0)
|
|
32
|
+
frame.add_argument("--overwrite", action="store_true")
|
|
33
|
+
args = vars(parser.parse_args(argv))
|
|
34
|
+
command = args.pop("command")
|
|
35
|
+
try:
|
|
36
|
+
if command == "inspect":
|
|
37
|
+
print(json.dumps(probe(**{"path": args["source"]}), indent=2))
|
|
38
|
+
elif command == "prepare":
|
|
39
|
+
print(prepare(**args))
|
|
40
|
+
else:
|
|
41
|
+
print(preview(**args))
|
|
42
|
+
except (VideoError, OSError, ValueError) as error:
|
|
43
|
+
print(f"error: {error}", file=sys.stderr)
|
|
44
|
+
return 1
|
|
45
|
+
return 0
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""FFmpeg wrappers for local files; no network client or Python dependencies."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import tempfile
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class VideoError(RuntimeError):
|
|
13
|
+
"""An input, dependency, or media-processing error."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _run(tool, args):
|
|
17
|
+
executable = shutil.which(tool)
|
|
18
|
+
if not executable:
|
|
19
|
+
raise VideoError(f"{tool} is required. Install FFmpeg and add it to PATH.")
|
|
20
|
+
result = subprocess.run(
|
|
21
|
+
[executable, *args], capture_output=True, text=True, check=False,
|
|
22
|
+
)
|
|
23
|
+
if result.returncode:
|
|
24
|
+
raise VideoError(result.stderr.strip()[-2000:] or f"{tool} failed")
|
|
25
|
+
return result.stdout
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _source(path):
|
|
29
|
+
source = Path(path).expanduser().resolve()
|
|
30
|
+
if not source.is_file():
|
|
31
|
+
raise VideoError(f"Input is not a local file: {source}")
|
|
32
|
+
return source
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _number(value, label, minimum=0, strict=False):
|
|
36
|
+
if not math.isfinite(value) or value < minimum or (strict and value == minimum):
|
|
37
|
+
comparison = "greater than" if strict else "at least"
|
|
38
|
+
raise VideoError(f"{label} must be finite and {comparison} {minimum}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def probe(path):
|
|
42
|
+
"""Return ffprobe JSON for a local file containing a video stream."""
|
|
43
|
+
source = _source(path)
|
|
44
|
+
data = json.loads(_run("ffprobe", [
|
|
45
|
+
"-v", "error", "-show_format", "-show_streams", "-of", "json", str(source),
|
|
46
|
+
]))
|
|
47
|
+
if not any(s.get("codec_type") == "video" and not s.get("disposition", {}).get("attached_pic")
|
|
48
|
+
for s in data.get("streams", [])):
|
|
49
|
+
raise VideoError("Input has no video stream")
|
|
50
|
+
return data
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _duration(data):
|
|
54
|
+
for stream in data.get("streams", []):
|
|
55
|
+
if stream.get("codec_type") == "video" and not stream.get("disposition", {}).get("attached_pic"):
|
|
56
|
+
value = stream.get("duration", data.get("format", {}).get("duration"))
|
|
57
|
+
try:
|
|
58
|
+
duration = float(value)
|
|
59
|
+
if math.isfinite(duration) and duration > 0:
|
|
60
|
+
return duration
|
|
61
|
+
except (TypeError, ValueError):
|
|
62
|
+
pass
|
|
63
|
+
raise VideoError("Cannot determine video duration")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _export(source, destination, args, overwrite, suffix):
|
|
67
|
+
target = Path(destination).expanduser().absolute()
|
|
68
|
+
if target.resolve() == source or (target.exists() and os.path.samefile(source, target)):
|
|
69
|
+
raise VideoError("Output must be different from input")
|
|
70
|
+
if target.exists() and not overwrite:
|
|
71
|
+
raise VideoError(f"Output already exists: {target}; use overwrite=True or --overwrite")
|
|
72
|
+
if not target.parent.is_dir():
|
|
73
|
+
raise VideoError(f"Output directory does not exist: {target.parent}")
|
|
74
|
+
# Encode beside the destination, then publish only a complete result.
|
|
75
|
+
with tempfile.TemporaryDirectory(prefix=".motion-reference-", dir=target.parent) as folder:
|
|
76
|
+
temporary = Path(folder) / ("result" + suffix)
|
|
77
|
+
_run("ffmpeg", ["-hide_banner", "-loglevel", "error", "-nostdin", *args, str(temporary)])
|
|
78
|
+
if not temporary.is_file() or not temporary.stat().st_size:
|
|
79
|
+
raise VideoError("FFmpeg produced no output")
|
|
80
|
+
if overwrite:
|
|
81
|
+
os.replace(temporary, target)
|
|
82
|
+
else:
|
|
83
|
+
try:
|
|
84
|
+
os.link(temporary, target)
|
|
85
|
+
except FileExistsError as error:
|
|
86
|
+
raise VideoError(f"Output already exists: {target}") from error
|
|
87
|
+
return target
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def prepare(source, destination, *, start=0.0, duration=10.0, width=720,
|
|
91
|
+
height=1280, fps=30, fit="pad", mute=False, overwrite=False):
|
|
92
|
+
"""Trim and encode an H.264/AAC MP4. Pad preserves the whole source frame.
|
|
93
|
+
|
|
94
|
+
The requested interval must fit inside the source. Existing files are
|
|
95
|
+
protected unless overwrite=True; input files are never overwritten.
|
|
96
|
+
"""
|
|
97
|
+
source = _source(source)
|
|
98
|
+
_number(start, "start")
|
|
99
|
+
_number(duration, "duration", strict=True)
|
|
100
|
+
for value, label in ((width, "width"), (height, "height")):
|
|
101
|
+
if not isinstance(value, int) or value < 2 or value > 7680 or value % 2:
|
|
102
|
+
raise VideoError(f"{label} must be an even integer between 2 and 7680")
|
|
103
|
+
if not isinstance(fps, int) or not 1 <= fps <= 120:
|
|
104
|
+
raise VideoError("fps must be an integer between 1 and 120")
|
|
105
|
+
if fit not in ("pad", "crop"):
|
|
106
|
+
raise VideoError("fit must be 'pad' or 'crop'")
|
|
107
|
+
if Path(destination).suffix.lower() != ".mp4":
|
|
108
|
+
raise VideoError("Prepared video output must have an .mp4 extension")
|
|
109
|
+
total = _duration(probe(source))
|
|
110
|
+
if start + duration > total + 0.001:
|
|
111
|
+
raise VideoError(f"Requested interval ends at {start + duration:g}s; video is {total:g}s")
|
|
112
|
+
# Normalize sample aspect ratio before fitting the display dimensions.
|
|
113
|
+
normalize = "scale=trunc(iw*sar/2)*2:ih,setsar=1"
|
|
114
|
+
if fit == "pad":
|
|
115
|
+
scale = f"scale={width}:{height}:force_original_aspect_ratio=decrease:force_divisible_by=2"
|
|
116
|
+
frame = f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:color=black"
|
|
117
|
+
else:
|
|
118
|
+
scale = f"scale={width}:{height}:force_original_aspect_ratio=increase:force_divisible_by=2"
|
|
119
|
+
frame = f"crop={width}:{height}"
|
|
120
|
+
args = ["-ss", str(start), "-i", str(source), "-t", str(duration),
|
|
121
|
+
"-map", "0:V:0", "-vf", f"{normalize},{scale},{frame},setsar=1,fps={fps}",
|
|
122
|
+
"-c:v", "libx264", "-preset", "medium", "-crf", "22", "-pix_fmt", "yuv420p"]
|
|
123
|
+
args += ["-an"] if mute else ["-map", "0:a:0?", "-c:a", "aac", "-b:a", "128k"]
|
|
124
|
+
args += ["-map_metadata", "-1", "-map_chapters", "-1", "-movflags", "+faststart"]
|
|
125
|
+
return _export(source, destination, args, overwrite, ".mp4")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def preview(source, destination, *, at=0.0, overwrite=False):
|
|
129
|
+
"""Export one PNG frame at a timestamp in seconds."""
|
|
130
|
+
source = _source(source)
|
|
131
|
+
_number(at, "at")
|
|
132
|
+
if Path(destination).suffix.lower() != ".png":
|
|
133
|
+
raise VideoError("Preview output must have a .png extension")
|
|
134
|
+
if at >= _duration(probe(source)):
|
|
135
|
+
raise VideoError("Preview timestamp must be before the end of the video")
|
|
136
|
+
return _export(source, destination, [
|
|
137
|
+
"-ss", str(at), "-i", str(source), "-map", "0:V:0", "-frames:v", "1",
|
|
138
|
+
"-vf", "scale=trunc(iw*sar/2)*2:ih,setsar=1", "-update", "1",
|
|
139
|
+
], overwrite, ".png")
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: motion-reference-prep
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Prepare local dance and motion-reference clips with FFmpeg: trim, resize, inspect, and preview.
|
|
5
|
+
Project-URL: Example workflow, https://motioncontrolaifree.com/bulin-47-ai
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: dance,ffmpeg,motion-reference,video
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Multimedia :: Video :: Conversion
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# motion-reference-prep
|
|
17
|
+
|
|
18
|
+
Prepare a short, consistently sized video before using it as a motion reference.
|
|
19
|
+
This small Python library and CLI wraps FFmpeg to inspect local footage, trim a
|
|
20
|
+
specific interval, export an H.264 MP4, and save a preview frame.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
Requires Python 3.10+ and FFmpeg 6+ with `libx264`. Install FFmpeg separately and
|
|
25
|
+
make both `ffmpeg` and `ffprobe` available on your `PATH`.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install motion-reference-prep
|
|
29
|
+
ffmpeg -version
|
|
30
|
+
motion-reference-prep --help
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Quick start
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
# Inspect duration, dimensions, codecs, and audio streams.
|
|
37
|
+
motion-reference-prep inspect rehearsal.mov
|
|
38
|
+
|
|
39
|
+
# Keep ten seconds starting at second two; preserve the whole body with padding.
|
|
40
|
+
motion-reference-prep prepare rehearsal.mov reference.mp4 --start 2 --duration 10
|
|
41
|
+
|
|
42
|
+
# Inspect a frame before uploading your reference to another tool.
|
|
43
|
+
motion-reference-prep preview reference.mp4 preview.png --at 4
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The default export is 720 × 1280 at 30 fps, H.264 video with AAC audio when an
|
|
47
|
+
audio stream exists. `--mute` removes audio. `--fit crop` fills the canvas with a
|
|
48
|
+
center crop; the default `--fit pad` preserves the whole frame with black borders.
|
|
49
|
+
Use `--width 1280 --height 720` for landscape output. Dimensions must be even.
|
|
50
|
+
|
|
51
|
+
Existing output files are protected unless you pass `--overwrite`. The source
|
|
52
|
+
file cannot be used as the output. Failed encodes do not replace an existing
|
|
53
|
+
destination. The selected interval must fit inside the source video.
|
|
54
|
+
|
|
55
|
+
## Example: prepare a dance reference
|
|
56
|
+
|
|
57
|
+
For a photo-driven dance workflow such as the
|
|
58
|
+
[Bulin 47 AI video generator](https://motioncontrolaifree.com/bulin-47-ai), first
|
|
59
|
+
prepare a short reference in which the performer's full body stays visible:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
motion-reference-prep prepare freestyle.mov dance-reference.mp4 --duration 10 --fit pad
|
|
63
|
+
motion-reference-prep preview dance-reference.mp4 framing.png --at 5
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Review the framing, motion, file size, and audio before manually uploading the
|
|
67
|
+
MP4. The linked service currently describes custom references of 3–15 seconds,
|
|
68
|
+
up to 50 MB, with each side between 720 and 3840 pixels (checked October 12,
|
|
69
|
+
2026). Check its current requirements before uploading; this package does not
|
|
70
|
+
enforce a service-specific size limit or guarantee acceptance. Use footage and
|
|
71
|
+
portraits you have permission to use.
|
|
72
|
+
|
|
73
|
+
This is a local preprocessing utility, not an AI generator or an SDK for that
|
|
74
|
+
service. It makes no API calls, uploads no files, and requires no account or API
|
|
75
|
+
key. No affiliation with Bulin 47 or endorsement by any performer is implied.
|
|
76
|
+
|
|
77
|
+
## Python API
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from motion_reference_prep import prepare, preview, probe
|
|
81
|
+
|
|
82
|
+
metadata = probe("rehearsal.mov")
|
|
83
|
+
output = prepare("rehearsal.mov", "reference.mp4", start=2, duration=10)
|
|
84
|
+
preview(output, "preview.png", at=4)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Functions return output `Path` objects; `probe` returns an ffprobe dictionary.
|
|
88
|
+
Catch `VideoError` for invalid inputs, missing FFmpeg, or encoder failures.
|
|
89
|
+
No additional Python runtime dependencies are needed.
|
|
90
|
+
|
|
91
|
+
## Limitations
|
|
92
|
+
|
|
93
|
+
- Resizing cannot recover detail missing from the source.
|
|
94
|
+
- Output size depends on the source and CRF encoding; no fixed size is promised.
|
|
95
|
+
- Padding does not detect or track a dancer. Inspect framing yourself.
|
|
96
|
+
- The first non-cover-art video stream and first audio stream are selected.
|
|
97
|
+
- Subtitles, additional audio tracks, chapters, and source metadata are omitted.
|
|
98
|
+
- Local inputs only. FFmpeg must support the source codec.
|
|
99
|
+
|
|
100
|
+
## Development
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
python -m pip install -e . build twine
|
|
104
|
+
python -m unittest discover -s tests -v
|
|
105
|
+
python -m build
|
|
106
|
+
python -m twine check dist/*
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Integration tests generate synthetic media locally. MIT licensed.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
motion_reference_prep/__init__.py,sha256=4wzHQmj76CTIv82Fs7pz_UtDT63IDLEbjMHD2lpLbag,200
|
|
2
|
+
motion_reference_prep/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
|
|
3
|
+
motion_reference_prep/cli.py,sha256=DXBbQGYNRbH8n6HA2GUEyAHFbMYu2c6eoljQAW-dJzs,1894
|
|
4
|
+
motion_reference_prep/core.py,sha256=rTYKf02ZcsoxKXjIZO8Yejz01U1PjKrqOfyWx7_kLg8,6223
|
|
5
|
+
motion_reference_prep-0.1.0.dist-info/METADATA,sha256=_4CD5rCF6KEAckPQwic4SkF8B8iCT0UntQru39pROBs,4262
|
|
6
|
+
motion_reference_prep-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
7
|
+
motion_reference_prep-0.1.0.dist-info/entry_points.txt,sha256=_tEYrqIEgawUiOV3m0R_sSmhnLOzHGNirYuLmEdegoA,73
|
|
8
|
+
motion_reference_prep-0.1.0.dist-info/licenses/LICENSE,sha256=jckisRUnCwq8Fy1gepTC9YBTX_wNoEwXo8SkQ8lo-zU,1091
|
|
9
|
+
motion_reference_prep-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 motion-reference-prep contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|