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.
@@ -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()