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.
@@ -0,0 +1,6 @@
1
+ """Local utilities for preparing motion-reference video clips."""
2
+
3
+ from .core import VideoError, prepare, preview, probe
4
+
5
+ __version__ = "0.1.0"
6
+ __all__ = ["VideoError", "prepare", "preview", "probe"]
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ motion-reference-prep = motion_reference_prep.cli:main
@@ -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.