farmhand-bpy 0.1.0__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.
- farmhand_bpy-0.1.0/LICENSE +21 -0
- farmhand_bpy-0.1.0/PKG-INFO +151 -0
- farmhand_bpy-0.1.0/README.md +122 -0
- farmhand_bpy-0.1.0/farmhand/__init__.py +35 -0
- farmhand_bpy-0.1.0/farmhand/cli.py +329 -0
- farmhand_bpy-0.1.0/farmhand/config.py +160 -0
- farmhand_bpy-0.1.0/farmhand/log.py +40 -0
- farmhand_bpy-0.1.0/farmhand/modal_app.py +293 -0
- farmhand_bpy-0.1.0/farmhand/render.py +191 -0
- farmhand_bpy-0.1.0/farmhand/video.py +54 -0
- farmhand_bpy-0.1.0/pyproject.toml +86 -0
- farmhand_bpy-0.1.0/pyproject.toml.orig +69 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Brady Johnston
|
|
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.
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: farmhand-bpy
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Distributed Blender rendering on Modal
|
|
5
|
+
Keywords: blender,rendering,modal,render-farm,cycles,gpu
|
|
6
|
+
Author: Brady Johnston
|
|
7
|
+
Author-email: Brady Johnston <brady.johnston@me.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Multimedia :: Graphics :: 3D Rendering
|
|
20
|
+
Requires-Dist: click>=8.0
|
|
21
|
+
Requires-Dist: modal>=1.3.5
|
|
22
|
+
Requires-Dist: pyyaml>=6.0
|
|
23
|
+
Requires-Dist: rich>=13.0
|
|
24
|
+
Requires-Python: >=3.11
|
|
25
|
+
Project-URL: Homepage, https://github.com/BradyAJohnston/farmhand
|
|
26
|
+
Project-URL: Repository, https://github.com/BradyAJohnston/farmhand
|
|
27
|
+
Project-URL: Issues, https://github.com/BradyAJohnston/farmhand/issues
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# farmhand
|
|
31
|
+
|
|
32
|
+
Distributed Blender rendering on [Modal](https://modal.com). farmhand uploads a `.blend` file, splits its frame range across GPU containers, streams the finished frames back to your machine, and can stitch them into a video on the server. It was built for rendering [Molecular Nodes](https://github.com/BradyAJohnston/MolecularNodes) animations, which need nothing installed on the server beyond bpy because the node groups and materials travel inside the `.blend`.
|
|
33
|
+
|
|
34
|
+
## Requirements
|
|
35
|
+
|
|
36
|
+
- Python 3.11 or newer.
|
|
37
|
+
- A [Modal](https://modal.com) account. Modal bills per second of GPU time; an L40S is about $2 per hour.
|
|
38
|
+
- `ffmpeg` on your machine, only if you want to combine already-downloaded frames locally.
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
uv tool install farmhand-bpy # or: pip install farmhand-bpy
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The distribution is `farmhand-bpy`; the command and the import name are `farmhand`.
|
|
47
|
+
|
|
48
|
+
## Quickstart
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
farmhand setup # log in to Modal (opens a browser)
|
|
52
|
+
farmhand deploy # build the render image and deploy the app (minutes, first time)
|
|
53
|
+
farmhand render scene.blend --video scene.mp4 # render every frame, stitch the video on the server
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Frames land in `render/<job_id>/frame_00001.png` and so on, and the video in `render/<job_id>/scene.mp4`. The job ID is printed in the table at the start of the render and by `farmhand jobs`.
|
|
57
|
+
|
|
58
|
+
Two things to know before the first real render:
|
|
59
|
+
|
|
60
|
+
- **The `.blend` must be self-contained.** Only that one file is uploaded. Pack external data first (File > External Data > Pack Resources) or linked libraries and textures will be missing.
|
|
61
|
+
- **`bpy_version` must match the Blender that saved the file.** The default is 5.1.0. Set it in your config to the version you use, then `farmhand deploy`. A file saved by a newer Blender can lose node groups or materials when opened by an older bpy without any error, giving blank frames after paying for GPU time.
|
|
62
|
+
|
|
63
|
+
## Commands
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
farmhand config # resolved settings, which file they came from, Modal auth status
|
|
67
|
+
farmhand setup # Modal login; creates the configured environment if it is missing
|
|
68
|
+
farmhand deploy # deploy; rerun after changing gpu, bpy_version, timeout, max_containers or volume
|
|
69
|
+
|
|
70
|
+
farmhand render scene.blend # all frames from the file's range, into render/<job_id>/
|
|
71
|
+
farmhand render scene.blend --frame-start 1 --frame-end 48 --samples 64 --resolution-percentage 50
|
|
72
|
+
farmhand render scene.blend --video out.mp4 # also stitch an mp4 server-side, no local ffmpeg needed
|
|
73
|
+
farmhand render scene.blend --ephemeral # no deploy needed; image built on first use and cached
|
|
74
|
+
|
|
75
|
+
farmhand jobs # jobs still on the Modal volume
|
|
76
|
+
farmhand download <job_id> # fetch a job's frames again, into render/<job_id>/
|
|
77
|
+
farmhand combine render/<job_id> -o out.mp4 # local frames -> video with your ffmpeg
|
|
78
|
+
farmhand combine <job_id> -o out.mp4 # frames still on the volume -> video, server-side
|
|
79
|
+
farmhand cleanup <job_id> [--yes] # delete a job's blend file and frames from the volume
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Every job's input file and frames stay on the Modal volume, and count towards its storage, until you run `cleanup`.
|
|
83
|
+
|
|
84
|
+
The global options `--config`, `--environment` and `--app-name` go before the subcommand:
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
farmhand --environment prod render scene.blend
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Configuration
|
|
91
|
+
|
|
92
|
+
farmhand reads one config file: the nearest `farmhand.yml`, or `pyproject.toml` with a `[tool.farmhand]` table, searching upward from the current directory. Within a directory `farmhand.yml` wins. Files are not merged. Command-line flags override the file, and anything unset uses the built-in default. `farmhand config` shows the result.
|
|
93
|
+
|
|
94
|
+
```toml
|
|
95
|
+
# pyproject.toml, in the project you render from
|
|
96
|
+
[tool.farmhand]
|
|
97
|
+
environment = "render" # Modal environment; omit for your profile's default
|
|
98
|
+
bpy_version = "5.2.2" # the Blender version that saved your .blend files
|
|
99
|
+
gpu = "L40S" # or a fallback list: ["RTX-PRO-6000", "L40S"]
|
|
100
|
+
frames_per_container = 4
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
| Key | Default | When it is read |
|
|
104
|
+
|---|---|---|
|
|
105
|
+
| `profile` | active Modal profile | every command |
|
|
106
|
+
| `environment` | profile default | every command |
|
|
107
|
+
| `app_name` | `farmhand` | every command |
|
|
108
|
+
| `volume` | `<app_name>-data` | deploy |
|
|
109
|
+
| `gpu` | `L40S` | deploy |
|
|
110
|
+
| `max_containers` | `50` | deploy, capped by your Modal plan |
|
|
111
|
+
| `timeout` | `7200` | deploy, seconds per container |
|
|
112
|
+
| `bpy_version` | `5.1.0` | deploy |
|
|
113
|
+
| `output_dir` | `render` | render, download |
|
|
114
|
+
| `frames_per_container` | `1` | render |
|
|
115
|
+
| `fps` | `30` | render, combine |
|
|
116
|
+
| `codec` | `libx265` | render, combine |
|
|
117
|
+
| `crf` | `20` | render, combine |
|
|
118
|
+
|
|
119
|
+
Keys read at deploy are baked into the deployed app; change them and run `farmhand deploy` again. The rest are per-run defaults that the matching flags override. Modal credentials come from `farmhand setup`, or from the `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET` environment variables in CI.
|
|
120
|
+
|
|
121
|
+
Each container opens the file once and renders `frames_per_container` frames in sequence, so a higher value amortises the container start-up and scene evaluation over more frames. Keep `frames_per_container` times the per-frame render time under `timeout`.
|
|
122
|
+
|
|
123
|
+
## Choosing a GPU
|
|
124
|
+
|
|
125
|
+
See [docs/gpus.md](https://github.com/BradyAJohnston/farmhand/blob/master/docs/gpus.md). Short version: Cycles wants RT cores and FP32 throughput, so the visualisation cards (`RTX-PRO-6000`, `L40S`, `L4`) are both faster and several times cheaper per frame than the AI cards (`A100`, `H100`, `H200`, `B200`). The default `L40S` is a good balance.
|
|
126
|
+
|
|
127
|
+
## Python API
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
from farmhand import RenderJob, combine_frames, config, submit_render
|
|
131
|
+
|
|
132
|
+
cfg = config.load() # same lookup as the CLI; or Config(environment="render", bpy_version="5.2.2")
|
|
133
|
+
config.apply_env(cfg) # exports cfg.profile for Modal; call before importing farmhand.render
|
|
134
|
+
|
|
135
|
+
job = RenderJob("scene.blend", frame_end=48, samples=64, video="out.mp4")
|
|
136
|
+
result = submit_render(job, cfg) # blocks until done
|
|
137
|
+
result.frames # list[Path] under result.output_dir, which is <output_dir>/<job_id>
|
|
138
|
+
result.video # Path or None
|
|
139
|
+
|
|
140
|
+
combine_frames("render/abc123def456", "out.mp4", fps=30) # local ffmpeg
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`RenderJob` fields left as `None` take the config default, or, for the frame range, the values saved in the `.blend`.
|
|
144
|
+
|
|
145
|
+
## Example
|
|
146
|
+
|
|
147
|
+
[`examples/6n2y-spin`](https://github.com/BradyAJohnston/farmhand/tree/master/examples/6n2y-spin) builds a Molecular Nodes scene of PDB 6n2y turning on the spot and renders it with farmhand.
|
|
148
|
+
|
|
149
|
+
## License
|
|
150
|
+
|
|
151
|
+
MIT. See [LICENSE](https://github.com/BradyAJohnston/farmhand/blob/master/LICENSE).
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# farmhand
|
|
2
|
+
|
|
3
|
+
Distributed Blender rendering on [Modal](https://modal.com). farmhand uploads a `.blend` file, splits its frame range across GPU containers, streams the finished frames back to your machine, and can stitch them into a video on the server. It was built for rendering [Molecular Nodes](https://github.com/BradyAJohnston/MolecularNodes) animations, which need nothing installed on the server beyond bpy because the node groups and materials travel inside the `.blend`.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- Python 3.11 or newer.
|
|
8
|
+
- A [Modal](https://modal.com) account. Modal bills per second of GPU time; an L40S is about $2 per hour.
|
|
9
|
+
- `ffmpeg` on your machine, only if you want to combine already-downloaded frames locally.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
uv tool install farmhand-bpy # or: pip install farmhand-bpy
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The distribution is `farmhand-bpy`; the command and the import name are `farmhand`.
|
|
18
|
+
|
|
19
|
+
## Quickstart
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
farmhand setup # log in to Modal (opens a browser)
|
|
23
|
+
farmhand deploy # build the render image and deploy the app (minutes, first time)
|
|
24
|
+
farmhand render scene.blend --video scene.mp4 # render every frame, stitch the video on the server
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Frames land in `render/<job_id>/frame_00001.png` and so on, and the video in `render/<job_id>/scene.mp4`. The job ID is printed in the table at the start of the render and by `farmhand jobs`.
|
|
28
|
+
|
|
29
|
+
Two things to know before the first real render:
|
|
30
|
+
|
|
31
|
+
- **The `.blend` must be self-contained.** Only that one file is uploaded. Pack external data first (File > External Data > Pack Resources) or linked libraries and textures will be missing.
|
|
32
|
+
- **`bpy_version` must match the Blender that saved the file.** The default is 5.1.0. Set it in your config to the version you use, then `farmhand deploy`. A file saved by a newer Blender can lose node groups or materials when opened by an older bpy without any error, giving blank frames after paying for GPU time.
|
|
33
|
+
|
|
34
|
+
## Commands
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
farmhand config # resolved settings, which file they came from, Modal auth status
|
|
38
|
+
farmhand setup # Modal login; creates the configured environment if it is missing
|
|
39
|
+
farmhand deploy # deploy; rerun after changing gpu, bpy_version, timeout, max_containers or volume
|
|
40
|
+
|
|
41
|
+
farmhand render scene.blend # all frames from the file's range, into render/<job_id>/
|
|
42
|
+
farmhand render scene.blend --frame-start 1 --frame-end 48 --samples 64 --resolution-percentage 50
|
|
43
|
+
farmhand render scene.blend --video out.mp4 # also stitch an mp4 server-side, no local ffmpeg needed
|
|
44
|
+
farmhand render scene.blend --ephemeral # no deploy needed; image built on first use and cached
|
|
45
|
+
|
|
46
|
+
farmhand jobs # jobs still on the Modal volume
|
|
47
|
+
farmhand download <job_id> # fetch a job's frames again, into render/<job_id>/
|
|
48
|
+
farmhand combine render/<job_id> -o out.mp4 # local frames -> video with your ffmpeg
|
|
49
|
+
farmhand combine <job_id> -o out.mp4 # frames still on the volume -> video, server-side
|
|
50
|
+
farmhand cleanup <job_id> [--yes] # delete a job's blend file and frames from the volume
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Every job's input file and frames stay on the Modal volume, and count towards its storage, until you run `cleanup`.
|
|
54
|
+
|
|
55
|
+
The global options `--config`, `--environment` and `--app-name` go before the subcommand:
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
farmhand --environment prod render scene.blend
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Configuration
|
|
62
|
+
|
|
63
|
+
farmhand reads one config file: the nearest `farmhand.yml`, or `pyproject.toml` with a `[tool.farmhand]` table, searching upward from the current directory. Within a directory `farmhand.yml` wins. Files are not merged. Command-line flags override the file, and anything unset uses the built-in default. `farmhand config` shows the result.
|
|
64
|
+
|
|
65
|
+
```toml
|
|
66
|
+
# pyproject.toml, in the project you render from
|
|
67
|
+
[tool.farmhand]
|
|
68
|
+
environment = "render" # Modal environment; omit for your profile's default
|
|
69
|
+
bpy_version = "5.2.2" # the Blender version that saved your .blend files
|
|
70
|
+
gpu = "L40S" # or a fallback list: ["RTX-PRO-6000", "L40S"]
|
|
71
|
+
frames_per_container = 4
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
| Key | Default | When it is read |
|
|
75
|
+
|---|---|---|
|
|
76
|
+
| `profile` | active Modal profile | every command |
|
|
77
|
+
| `environment` | profile default | every command |
|
|
78
|
+
| `app_name` | `farmhand` | every command |
|
|
79
|
+
| `volume` | `<app_name>-data` | deploy |
|
|
80
|
+
| `gpu` | `L40S` | deploy |
|
|
81
|
+
| `max_containers` | `50` | deploy, capped by your Modal plan |
|
|
82
|
+
| `timeout` | `7200` | deploy, seconds per container |
|
|
83
|
+
| `bpy_version` | `5.1.0` | deploy |
|
|
84
|
+
| `output_dir` | `render` | render, download |
|
|
85
|
+
| `frames_per_container` | `1` | render |
|
|
86
|
+
| `fps` | `30` | render, combine |
|
|
87
|
+
| `codec` | `libx265` | render, combine |
|
|
88
|
+
| `crf` | `20` | render, combine |
|
|
89
|
+
|
|
90
|
+
Keys read at deploy are baked into the deployed app; change them and run `farmhand deploy` again. The rest are per-run defaults that the matching flags override. Modal credentials come from `farmhand setup`, or from the `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET` environment variables in CI.
|
|
91
|
+
|
|
92
|
+
Each container opens the file once and renders `frames_per_container` frames in sequence, so a higher value amortises the container start-up and scene evaluation over more frames. Keep `frames_per_container` times the per-frame render time under `timeout`.
|
|
93
|
+
|
|
94
|
+
## Choosing a GPU
|
|
95
|
+
|
|
96
|
+
See [docs/gpus.md](https://github.com/BradyAJohnston/farmhand/blob/master/docs/gpus.md). Short version: Cycles wants RT cores and FP32 throughput, so the visualisation cards (`RTX-PRO-6000`, `L40S`, `L4`) are both faster and several times cheaper per frame than the AI cards (`A100`, `H100`, `H200`, `B200`). The default `L40S` is a good balance.
|
|
97
|
+
|
|
98
|
+
## Python API
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
from farmhand import RenderJob, combine_frames, config, submit_render
|
|
102
|
+
|
|
103
|
+
cfg = config.load() # same lookup as the CLI; or Config(environment="render", bpy_version="5.2.2")
|
|
104
|
+
config.apply_env(cfg) # exports cfg.profile for Modal; call before importing farmhand.render
|
|
105
|
+
|
|
106
|
+
job = RenderJob("scene.blend", frame_end=48, samples=64, video="out.mp4")
|
|
107
|
+
result = submit_render(job, cfg) # blocks until done
|
|
108
|
+
result.frames # list[Path] under result.output_dir, which is <output_dir>/<job_id>
|
|
109
|
+
result.video # Path or None
|
|
110
|
+
|
|
111
|
+
combine_frames("render/abc123def456", "out.mp4", fps=30) # local ffmpeg
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`RenderJob` fields left as `None` take the config default, or, for the frame range, the values saved in the `.blend`.
|
|
115
|
+
|
|
116
|
+
## Example
|
|
117
|
+
|
|
118
|
+
[`examples/6n2y-spin`](https://github.com/BradyAJohnston/farmhand/tree/master/examples/6n2y-spin) builds a Molecular Nodes scene of PDB 6n2y turning on the spot and renders it with farmhand.
|
|
119
|
+
|
|
120
|
+
## License
|
|
121
|
+
|
|
122
|
+
MIT. See [LICENSE](https://github.com/BradyAJohnston/farmhand/blob/master/LICENSE).
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Farmhand - Distributed Blender rendering on Modal."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("farmhand-bpy")
|
|
7
|
+
except PackageNotFoundError: # running from a source tree
|
|
8
|
+
__version__ = "0.0.0"
|
|
9
|
+
|
|
10
|
+
__all__ = ["Config", "FarmhandError", "RenderJob", "RenderResult", "combine_frames", "submit_render"]
|
|
11
|
+
|
|
12
|
+
_LAZY = {
|
|
13
|
+
"Config": ("farmhand.config", "Config"),
|
|
14
|
+
"FarmhandError": ("farmhand.config", "FarmhandError"),
|
|
15
|
+
"RenderJob": ("farmhand.render", "RenderJob"),
|
|
16
|
+
"RenderResult": ("farmhand.render", "RenderResult"),
|
|
17
|
+
"submit_render": ("farmhand.render", "submit_render"),
|
|
18
|
+
"combine_frames": ("farmhand.video", "combine_frames"),
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def __getattr__(name):
|
|
23
|
+
# Everything is imported lazily. The render containers import this package with
|
|
24
|
+
# only bpy installed, and Modal reads its profile from the environment at import
|
|
25
|
+
# time, so nothing may pull in rich or modal before config has been applied.
|
|
26
|
+
if name in _LAZY:
|
|
27
|
+
import importlib
|
|
28
|
+
|
|
29
|
+
module, attr = _LAZY[name]
|
|
30
|
+
return getattr(importlib.import_module(module), attr)
|
|
31
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def __dir__():
|
|
35
|
+
return sorted([*__all__, "__version__"])
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""CLI entrypoint for farmhand."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from dataclasses import fields
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from farmhand import config, log
|
|
12
|
+
from farmhand.config import Config, FarmhandError, check_job_id
|
|
13
|
+
|
|
14
|
+
# `modal` is imported inside each command, after the config has been loaded and
|
|
15
|
+
# exported to the environment, because Modal reads its profile at import time.
|
|
16
|
+
|
|
17
|
+
CONFIG_HELP = "Config file (default: nearest farmhand.yml, or pyproject.toml with [tool.farmhand])."
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _job_id_arg(ctx, param, value):
|
|
21
|
+
return check_job_id(value)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@click.group()
|
|
25
|
+
@click.option("--config", "config_path", type=click.Path(exists=True, dir_okay=False), default=None, help=CONFIG_HELP)
|
|
26
|
+
@click.option("--environment", default=None, help="Modal environment (default: Modal profile default).")
|
|
27
|
+
@click.option("--app-name", default=None, help="Modal app name (default: farmhand).")
|
|
28
|
+
@click.pass_context
|
|
29
|
+
def main(ctx, config_path, environment, app_name):
|
|
30
|
+
"""Distributed Blender rendering on Modal.
|
|
31
|
+
|
|
32
|
+
These options are global and go before the subcommand:
|
|
33
|
+
farmhand --environment prod render scene.blend
|
|
34
|
+
"""
|
|
35
|
+
ctx.obj = {"config_path": config_path, "environment": environment, "app_name": app_name}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _load(ctx, **overrides) -> Config:
|
|
39
|
+
opts = ctx.obj
|
|
40
|
+
cfg = config.load(opts["config_path"], environment=opts["environment"], app_name=opts["app_name"], **overrides)
|
|
41
|
+
config.apply_env(cfg)
|
|
42
|
+
return cfg
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _remote(fn_name: str, cfg: Config):
|
|
46
|
+
from farmhand.render import _lookup
|
|
47
|
+
|
|
48
|
+
return _lookup(fn_name, cfg)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _modal_cli(cfg: Config, *args: str, capture: bool = False) -> str:
|
|
52
|
+
"""Run Modal's own CLI in a fresh process so it sees credentials written moments ago."""
|
|
53
|
+
cmd = [sys.executable, "-m", "modal", *args]
|
|
54
|
+
if cfg.profile:
|
|
55
|
+
cmd += ["--profile", cfg.profile]
|
|
56
|
+
result = subprocess.run(cmd, check=True, capture_output=capture, text=True)
|
|
57
|
+
return result.stdout if capture else ""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _authenticated() -> bool:
|
|
61
|
+
import modal.config
|
|
62
|
+
|
|
63
|
+
return bool(modal.config.config.get("token_id"))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@main.command("config")
|
|
67
|
+
@click.pass_context
|
|
68
|
+
def config_cmd(ctx):
|
|
69
|
+
"""Show the resolved configuration and Modal auth status."""
|
|
70
|
+
cfg = _load(ctx)
|
|
71
|
+
log.console.print(f"[dim]source:[/dim] {cfg.source or '(built-in defaults)'}")
|
|
72
|
+
for f in fields(cfg):
|
|
73
|
+
if f.name != "source":
|
|
74
|
+
log.console.print(f" [bold]{f.name}[/bold] = {getattr(cfg, f.name)!r}")
|
|
75
|
+
import modal.config
|
|
76
|
+
|
|
77
|
+
status = "[green]token found[/green]" if _authenticated() else "[yellow]no token, run `farmhand setup`[/yellow]"
|
|
78
|
+
log.console.print(f"[dim]modal:[/dim] profile [bold]{modal.config._profile}[/bold], {status}")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@main.command("setup")
|
|
82
|
+
@click.pass_context
|
|
83
|
+
def setup_cmd(ctx):
|
|
84
|
+
"""Log in to Modal (opens a browser) and create the configured environment if it is missing."""
|
|
85
|
+
cfg = _load(ctx)
|
|
86
|
+
log.init()
|
|
87
|
+
|
|
88
|
+
if _authenticated():
|
|
89
|
+
log.success("Modal credentials found.")
|
|
90
|
+
else:
|
|
91
|
+
log.log("No Modal credentials found. Opening a browser to create a token...")
|
|
92
|
+
_modal_cli(cfg, "token", "new")
|
|
93
|
+
log.success("Token saved.")
|
|
94
|
+
|
|
95
|
+
if cfg.environment:
|
|
96
|
+
envs = json.loads(_modal_cli(cfg, "environment", "list", "--json", capture=True))
|
|
97
|
+
if cfg.environment in {e["name"] for e in envs}:
|
|
98
|
+
log.success(f"Environment [bold]{cfg.environment}[/bold] exists.")
|
|
99
|
+
else:
|
|
100
|
+
log.log(f"Creating environment [bold]{cfg.environment}[/bold]...")
|
|
101
|
+
_modal_cli(cfg, "environment", "create", cfg.environment)
|
|
102
|
+
log.success(f"Environment [bold]{cfg.environment}[/bold] created.")
|
|
103
|
+
else:
|
|
104
|
+
log.log("No environment configured; using the Modal profile default.")
|
|
105
|
+
|
|
106
|
+
log.success("Ready. Next: [bold]farmhand deploy[/bold]")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@main.command("deploy")
|
|
110
|
+
@click.option("--gpu", default=None, help="GPU type, or comma-separated fallback list (see docs/gpus.md).")
|
|
111
|
+
@click.option("--max-containers", type=int, default=None, help="Max concurrent render containers.")
|
|
112
|
+
@click.option("--timeout", type=int, default=None, help="Per-container timeout in seconds.")
|
|
113
|
+
@click.option("--bpy-version", default=None, help="bpy version to install; match the Blender that saved your files.")
|
|
114
|
+
@click.option("--volume", default=None, help="Modal volume name (default: <app_name>-data).")
|
|
115
|
+
@click.pass_context
|
|
116
|
+
def deploy_cmd(ctx, gpu, max_containers, timeout, bpy_version, volume):
|
|
117
|
+
"""Build the render image and deploy the app. Rerun after changing gpu, bpy_version, timeout, etc."""
|
|
118
|
+
import modal
|
|
119
|
+
|
|
120
|
+
cfg = _load(ctx, gpu=gpu, max_containers=max_containers, timeout=timeout, bpy_version=bpy_version, volume=volume)
|
|
121
|
+
config.set_active(cfg)
|
|
122
|
+
from farmhand.modal_app import app
|
|
123
|
+
|
|
124
|
+
log.init()
|
|
125
|
+
env = cfg.environment or "(modal default)"
|
|
126
|
+
log.log(f"Deploying [bold]{cfg.app_name}[/bold] to environment [bold]{env}[/bold] (gpu={cfg.gpu!r})...")
|
|
127
|
+
with modal.enable_output():
|
|
128
|
+
app.deploy(environment_name=cfg.environment)
|
|
129
|
+
log.success("Deployed! The app will stay running and accept render jobs.")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@main.command("render")
|
|
133
|
+
@click.argument("blend_file", type=click.Path(exists=True, dir_okay=False))
|
|
134
|
+
@click.option("-o", "--output-dir", default=None, help="Frames go to <output-dir>/<job_id>/ (default: render).")
|
|
135
|
+
# Frame range
|
|
136
|
+
@click.option("--frame-start", type=int, default=None, help="Start frame (default: from blend file).")
|
|
137
|
+
@click.option("--frame-end", type=int, default=None, help="End frame (default: from blend file).")
|
|
138
|
+
@click.option("--frame-step", type=int, default=None, help="Frame step (default: from blend file).")
|
|
139
|
+
# Render settings
|
|
140
|
+
@click.option("--resolution-x", type=int, default=None, help="Override render width.")
|
|
141
|
+
@click.option("--resolution-y", type=int, default=None, help="Override render height.")
|
|
142
|
+
@click.option("--resolution-percentage", type=int, default=None, help="Override resolution scale %.")
|
|
143
|
+
@click.option("--samples", type=int, default=None, help="Override Cycles sample count.")
|
|
144
|
+
@click.option("--engine", default=None, help="Render engine, e.g. CYCLES or BLENDER_EEVEE (default: from blend file).")
|
|
145
|
+
# Video output
|
|
146
|
+
@click.option("--video", default=None, metavar="FILENAME", help="Also stitch frames into a video on the server.")
|
|
147
|
+
@click.option("--fps", type=int, default=None, help="Video framerate.")
|
|
148
|
+
@click.option("--codec", default=None, help="Video codec.")
|
|
149
|
+
@click.option("--crf", type=int, default=None, help="Video quality, lower=better.")
|
|
150
|
+
@click.option(
|
|
151
|
+
"--video-only",
|
|
152
|
+
is_flag=True,
|
|
153
|
+
help="Do not save frames locally, only the video (frames stay on the volume).",
|
|
154
|
+
)
|
|
155
|
+
# Infrastructure
|
|
156
|
+
@click.option("--frames-per-container", type=int, default=None, help="Frames each container renders in sequence.")
|
|
157
|
+
@click.option(
|
|
158
|
+
"--ephemeral",
|
|
159
|
+
is_flag=True,
|
|
160
|
+
help="Run without a deployed app; the image is built on first use and cached.",
|
|
161
|
+
)
|
|
162
|
+
@click.pass_context
|
|
163
|
+
def render_cmd(
|
|
164
|
+
ctx,
|
|
165
|
+
blend_file,
|
|
166
|
+
output_dir,
|
|
167
|
+
frame_start,
|
|
168
|
+
frame_end,
|
|
169
|
+
frame_step,
|
|
170
|
+
resolution_x,
|
|
171
|
+
resolution_y,
|
|
172
|
+
resolution_percentage,
|
|
173
|
+
samples,
|
|
174
|
+
engine,
|
|
175
|
+
video,
|
|
176
|
+
fps,
|
|
177
|
+
codec,
|
|
178
|
+
crf,
|
|
179
|
+
video_only,
|
|
180
|
+
frames_per_container,
|
|
181
|
+
ephemeral,
|
|
182
|
+
):
|
|
183
|
+
"""Render BLEND_FILE frames in parallel on Modal GPU containers."""
|
|
184
|
+
cfg = _load(ctx)
|
|
185
|
+
from farmhand.render import RenderJob, submit_render
|
|
186
|
+
|
|
187
|
+
job = RenderJob(
|
|
188
|
+
blend_file=blend_file,
|
|
189
|
+
output_dir=output_dir,
|
|
190
|
+
frame_start=frame_start,
|
|
191
|
+
frame_end=frame_end,
|
|
192
|
+
frame_step=frame_step,
|
|
193
|
+
resolution_x=resolution_x,
|
|
194
|
+
resolution_y=resolution_y,
|
|
195
|
+
resolution_percentage=resolution_percentage,
|
|
196
|
+
samples=samples,
|
|
197
|
+
engine=engine,
|
|
198
|
+
frames_per_container=frames_per_container,
|
|
199
|
+
ephemeral=ephemeral,
|
|
200
|
+
video=video,
|
|
201
|
+
fps=fps,
|
|
202
|
+
codec=codec,
|
|
203
|
+
crf=crf,
|
|
204
|
+
video_only=video_only,
|
|
205
|
+
)
|
|
206
|
+
submit_render(job, cfg)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@main.command("jobs")
|
|
210
|
+
@click.pass_context
|
|
211
|
+
def jobs_cmd(ctx):
|
|
212
|
+
"""List render jobs still stored on the Modal volume."""
|
|
213
|
+
from rich.table import Table
|
|
214
|
+
|
|
215
|
+
cfg = _load(ctx)
|
|
216
|
+
log.init()
|
|
217
|
+
log.log("Fetching jobs from volume...")
|
|
218
|
+
jobs = _remote("list_jobs", cfg).remote()
|
|
219
|
+
|
|
220
|
+
if not jobs:
|
|
221
|
+
log.warn("No jobs found on volume.")
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
table = Table(title="Render Jobs")
|
|
225
|
+
table.add_column("Job ID", style="bold")
|
|
226
|
+
table.add_column("Frames", justify="right")
|
|
227
|
+
table.add_column("Blend", justify="center")
|
|
228
|
+
table.add_column("Total Size", justify="right")
|
|
229
|
+
for j in jobs:
|
|
230
|
+
size_mb = j["total_size"] / 1024 / 1024
|
|
231
|
+
table.add_row(j["job_id"], str(j["frame_count"]), "✓" if j["has_blend"] else "✗", f"{size_mb:.1f} MB")
|
|
232
|
+
log.console.print(table)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@main.command("download")
|
|
236
|
+
@click.argument("job_id", callback=_job_id_arg)
|
|
237
|
+
@click.option("-o", "--output-dir", default=None, help="Frames go to <output-dir>/<job_id>/ (default: render).")
|
|
238
|
+
@click.pass_context
|
|
239
|
+
def download_cmd(ctx, job_id, output_dir):
|
|
240
|
+
"""Download the frames of JOB_ID from the Modal volume."""
|
|
241
|
+
cfg = _load(ctx)
|
|
242
|
+
log.init()
|
|
243
|
+
output_directory = Path(output_dir or cfg.output_dir).expanduser().resolve() / job_id
|
|
244
|
+
output_directory.mkdir(parents=True, exist_ok=True)
|
|
245
|
+
|
|
246
|
+
log.log(f"Downloading frames for job [bold]{job_id}[/bold]...")
|
|
247
|
+
files = _remote("download_job_frames", cfg).remote(job_id)
|
|
248
|
+
for filename, data in files:
|
|
249
|
+
(output_directory / filename).write_bytes(data)
|
|
250
|
+
log.success(f"Downloaded {len(files)} frames to {output_directory}")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@main.command("combine")
|
|
254
|
+
@click.argument("target")
|
|
255
|
+
@click.option(
|
|
256
|
+
"-o",
|
|
257
|
+
"--output",
|
|
258
|
+
default=None,
|
|
259
|
+
help="Output video (default: <dir>.mp4 next to the directory, or <job_id>.mp4).",
|
|
260
|
+
)
|
|
261
|
+
@click.option("--fps", type=int, default=None, help="Video framerate.")
|
|
262
|
+
@click.option("--codec", default=None, help="Video codec.")
|
|
263
|
+
@click.option("--crf", type=int, default=None, help="Video quality, lower=better.")
|
|
264
|
+
@click.pass_context
|
|
265
|
+
def combine_cmd(ctx, target, output, fps, codec, crf):
|
|
266
|
+
"""Combine frames into a video. TARGET is a local directory of frames, or a job ID still on the volume."""
|
|
267
|
+
cfg = _load(ctx)
|
|
268
|
+
log.init()
|
|
269
|
+
fps = cfg.fps if fps is None else fps
|
|
270
|
+
codec = cfg.codec if codec is None else codec
|
|
271
|
+
crf = cfg.crf if crf is None else crf
|
|
272
|
+
local_dir = Path(target).expanduser()
|
|
273
|
+
|
|
274
|
+
if local_dir.is_dir():
|
|
275
|
+
from farmhand.video import combine_frames, find_frames
|
|
276
|
+
|
|
277
|
+
local_dir = local_dir.resolve()
|
|
278
|
+
out_path = Path(output) if output else local_dir.parent / f"{local_dir.name}.mp4"
|
|
279
|
+
log.log(f"Combining {len(find_frames(local_dir))} frames from {local_dir} with local ffmpeg...")
|
|
280
|
+
combine_frames(local_dir, out_path, fps=fps, codec=codec, crf=crf)
|
|
281
|
+
else:
|
|
282
|
+
job_id = check_job_id(target)
|
|
283
|
+
out_path = Path(output) if output else Path(f"{job_id}.mp4")
|
|
284
|
+
log.log(f"Combining frames for job [bold]{job_id}[/bold] on the server...")
|
|
285
|
+
video_bytes = _remote("combine", cfg).remote(job_id, fps=fps, codec=codec, crf=crf)
|
|
286
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
287
|
+
out_path.write_bytes(video_bytes)
|
|
288
|
+
|
|
289
|
+
log.success(f"Video saved → {out_path} ({out_path.stat().st_size / 1024 / 1024:.1f} MB)")
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
@main.command("cleanup")
|
|
293
|
+
@click.argument("job_id", callback=_job_id_arg)
|
|
294
|
+
@click.confirmation_option(prompt="Delete this job's blend file and frames from the volume?")
|
|
295
|
+
@click.pass_context
|
|
296
|
+
def cleanup_cmd(ctx, job_id):
|
|
297
|
+
"""Delete JOB_ID's blend file and frames from the Modal volume."""
|
|
298
|
+
cfg = _load(ctx)
|
|
299
|
+
log.init()
|
|
300
|
+
log.log(f"Deleting job [bold]{job_id}[/bold]...")
|
|
301
|
+
count = _remote("delete_job", cfg).remote(job_id)
|
|
302
|
+
log.success(f"Deleted {count} files for job {job_id}")
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def entry():
|
|
306
|
+
try:
|
|
307
|
+
main()
|
|
308
|
+
except FarmhandError as e:
|
|
309
|
+
log.error(str(e))
|
|
310
|
+
sys.exit(1)
|
|
311
|
+
except subprocess.CalledProcessError as e:
|
|
312
|
+
log.error(f"`{' '.join(e.cmd)}` failed with exit code {e.returncode}")
|
|
313
|
+
sys.exit(1)
|
|
314
|
+
except Exception as e:
|
|
315
|
+
from modal.exception import AuthError, NotFoundError
|
|
316
|
+
|
|
317
|
+
if isinstance(e, AuthError):
|
|
318
|
+
log.error("Not logged in to Modal. Run [bold]farmhand setup[/bold] to create a token.")
|
|
319
|
+
elif isinstance(e, NotFoundError):
|
|
320
|
+
log.error(
|
|
321
|
+
f"{e}\nThe app is not deployed here. Run [bold]farmhand deploy[/bold], or render with --ephemeral."
|
|
322
|
+
)
|
|
323
|
+
else:
|
|
324
|
+
raise
|
|
325
|
+
sys.exit(1)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
if __name__ == "__main__":
|
|
329
|
+
entry()
|