The plan and the build record
01 — Entry spec (frozen)
Frozen: 2026-10-29 · Deadline: 2026-10-30 09:00 PT Entry: ComfyUI-Cinematographer — a novel ComfyUI custom node pack + re-shoot app Target categories: Open Weights Only · VFX Workflow built with LTX ($2,000) · Most Innovative
1. The one-line pitch
Every re-shoot app asks you to aim a camera and hope. ComfyUI-Cinematographer tells you the frame is wrong, by how much, and which way to move — before you spend the render.
2. What is novel (the argument the demo video must make)
Nothing in the ComfyUI ecosystem evaluates a frame geometrically and returns a correction in metres. The closest neighbours are:
| Existing | What it gives you |
|---|---|
ComfyUI-CameraReference3D | 17 camera-move presets + Plücker export |
WanCameraEmbedding | 9 camera presets |
comfy-reshoot's reliability zone | A hard-coded green/yellow/red indicator |
We give you a judgement, not a preset: per rule, a measured value, a pass/fail, and the camera lever that fixes it — plus a solver that moves the camera for you, and a continuity engine that checks the 180° line across cuts.
The demo's 30-second proof: move the camera, watch the numbers change. It is arithmetic, not a conditioning claim.
3. The rule set — 14 rules, honestly counted
The internal rulebook documents 38 rules. Only 14 are machine-evaluated in the Go source. We port the 14 and say so. (This honesty is a feature: the prior report's headline claim of "38 rules" does not survive contact with the code, and we will not repeat it.)
3.1 EvaluateFraming — 9 rules (per shot)
Source: internal/contentstudio/cinematography.go:130-233
| # | Rule key | Type | Measurement | Correction lever |
|---|---|---|---|---|
| 1 | in_frame | bool | Subject's eye anchor in front of camera and |ndcX|≤1, |ndcY|≤1 | "re-aim/pull back: face is off-frame or behind camera" |
| 2 | rule_of_thirds | value | Distance from eye anchor ndcX to ±1/3; pass ≤0.12. If intent.center, pass is |ndcX|≤0.12 instead | "offset subject to a third: shift camera x / aim" |
| 3 | headroom | value | 1 − ndcY(top); band by shot type — closeup [0.05,0.30], medium [0.10,0.45], wide [0.20,0.70] | too tight → "lower target or pull back"; too much → "raise target or push in" |
| 4 | shot_size | value | (ndcY(top) − ndcY(base)) / 2; closeup ≥0.7, medium 0.4–0.75, wide 0.15–0.45 | "dolly/zoom to hit <type>" |
| 5 | lead_room | value | −ndcX if subject faces screen-right else +ndcX; pass ≥ −0.05 | "flip composition: leave space where the subject faces" |
| 6 | negative_space | value | 1 − subArea, subArea ≈ min(1, h·(0.38h+0.05)); always passes (informational) | — |
| 7 | camera_angle | value | camEyeY − subjectEyeY; class low/high/eye by sign + pitch; always passes (informational) | — |
| 8 | level | value | |roll − intent.dutch| < 1 | "match the intended camera roll" |
| 9 | direct_address | value | Angle between subject gaze and camera→subject axis; <8° = down the lens, <25° = near-lens; always passes (informational) | — |
Overall pass: every rule with weight ≥ 0.8 must pass.
Weights by shot type (ruleWeights):
| rule | closeup | medium | wide |
|---|---|---|---|
in_frame | 1 | 1 | 1 |
rule_of_thirds | 1.0 | 0.8 | 0.6 |
headroom | 1.0 | 0.7 | 0.4 |
shot_size | 1.0 | 1.0 | 1.0 |
lead_room | 0.4 | 0.4 | 0.3 |
negative_space | 0.3 | 0.3 | 0.4 |
camera_angle | 0.3 | 0.3 | 0.3 |
level | 0.5 | 0.5 | 0.5 |
3.2 SceneFramingExtras — 3 rules (multi-entity)
Source: internal/contentstudio/cinematography_scene.go:58-143
| # | Rule key | Measurement | Correction |
|---|---|---|---|
| 10 | size_importance | Max other.area / key.area over props + non-key characters; pass ≤1.0 (Hitchcock) | "a <role> out-sizes the key subject — resize/reposition" |
| 11 | foreground_occlusion | A prop nearer than the subject hugging a frame edge (minX<−0.4 || maxX>0.4 || maxY>0.6) that does not cover the face; informational | — |
| 12 | eyeline | Two-shot only: angle between primary's gaze and the direction to the other; pass <45° | "turn the primary subject to look toward the other" |
3.3 EvaluateContinuity (Choros) — 2 rules (across cuts)
Source: internal/contentstudio/cinematography_continuity.go:59-83
| # | Rule key | Measurement | Pass |
|---|---|---|---|
| 13 | 180_axis | Sign of the camera's position relative to the subject's facing line, per shot. A flip between consecutive shots = axis crossed | sideA == sideB |
| 14 | 30_degree | angDelta(camAzimuth(A), camAzimuth(B)) where azimuth = atan2(eyeX−baseX, eyeZ−baseZ) | ≥ 30° |
4. The geometry — port this exactly
4.1 Camera pose
@dataclass
class CamPose:
eye: tuple[float,float,float]
target: tuple[float,float,float]
focal_mm: float = 35.0
sensor_mm: float = 36.0 # horizontal aperture
aspect: float = 16/9
roll_deg: float = 0.0 # Dutch angle
fov: float = 0.0 # vertical FOV degrees; >0 overrides focal/sensor
4.2 Subject
@dataclass
class Subject:
name: str
base: tuple[float,float,float] # feet, world space, METRES
height: float # metres
facing_yaw_deg: float = 0.0 # 0 = +Z
4.3 Projector — the heart
fwd = normalize(target − eye)
right = normalize(cross(fwd, (0,1,0)))
up = cross(right, fwd)
# Dutch: rotate (right, up) about the optical axis by roll_deg
vfov = 2·atan((sensor/aspect)/(2·focal)) # or fov if fov > 0
f = 1 / tan(vfov/2)
project(p):
d = p − eye
cx, cy, cz = dot(d,right), dot(d,up), dot(d,fwd)
if cz <= 1e-4: return (0,0,cz,False) # behind camera
return ((cx/cz)·(f/aspect), (cy/cz)·f, cz, True)
Anchors (this is load-bearing — the eye anchor is at 0.92 × height, not 1.0):
base = subject.base
eye = (base.x, base.y + 0.92·height, base.z)
top = (base.x, base.y + height, base.z)
4.4 Numeric conventions
- NDC: x,y ∈ [−1,1], frame edges at ±1. y is up (screen-top = +1).
- All distances in metres; all angles in degrees at the API boundary.
- Round reported values to 3 dp (
round3).
5. The solver — and the fix we are making
Source: internal/contentstudio/cinematography_autoframe.go:64-117
AutoFrameCamera(subject, shot_type, intent) runs exactly 24 iterations:
fov, aspect = 40.0, 16/9
f = 1/tan(fov/2)
E = base.y + 0.92·height
target_frac = {closeup: 0.85, wide: 0.30, medium: 0.55}[shot_type]
dist = max(0.3, height·f/(2·target_frac))
azR = (facing_yaw + intent.azimuth_deg) in radians
heightY = E, or base.y+0.4 if low, or E+0.8 if high
desired = 0 if intent.center else (−1/3 if intent.side=="left" else +1/3)
midHead = midpoint of headroom band
offX = offY = 0
repeat 24 times:
eye = (base.x + sin(azR)·dist, heightY, base.z + cos(azR)·dist)
fwd = normalize(look − eye); right = normalize(cross(fwd,(0,1,0))); up = cross(right,fwd)
target = look + right·offX + up·offY
cam = CamPose(eye, target, fov=fov, roll=intent.dutch_deg, aspect=aspect)
rep = EvaluateFraming(subject, cam, shot_type, intent)
frac = rep["shot_size"]; ex = rep["rule_of_thirds"]; head = rep["headroom"]
if frac > 0.01: dist *= 1 + 0.6·(frac/target_frac − 1)
dist = max(0.3, dist)
offX += 0.6·(ex − desired)·dist·aspect/f
offY += 0.6·(midHead − head)·dist/f
offX = clamp(offX, −0.8·dist, 0.8·dist)
offY = clamp(offY, −0.6·dist, 0.6·dist)
return cam, rep
⚠️ The fix we are making — and it is a selling point
The Go original has no convergence test and no early exit. It returns the 24th iterate's report unconditionally, and nothing guarantees
rep.overall_pass. The prior report's claim that it "solves a compliant camera pose" is overstated.Our port adds: early exit on
overall_pass, aconverged: boolflag, the iteration count actually used, and the residual per rule. If it does not converge in 24 iterations we say so rather than returning a pose that looks solved.This is exactly the kind of rigour that wins a judged competition — and it is the difference between a demo that shows a number and a demo that shows a guarantee.
6. The node pack API
ComfyUI custom nodes: a class with INPUT_TYPES, RETURN_TYPES, RETURN_NAMES, FUNCTION, CATEGORY, plus a NODE_CLASS_MAPPINGS / NODE_DISPLAY_NAME_MAPPINGS export in __init__.py.
Category: Cinematographer
| Node | Inputs | Outputs |
|---|---|---|
CinematographerEvaluate | subject_json (STRING), camera_json (STRING), shot_type (COMBO: closeup/medium/wide), intent_json (STRING, optional) | report_json (STRING), overall_pass (BOOLEAN), summary (STRING) |
CinematographerAutoFrame | subject_json, shot_type, intent_json | camera_json (STRING), report_json (STRING), converged (BOOLEAN), iterations (INT) |
ChorosContinuity | shots_json (STRING — array of {shot_number, base, yaw_deg, camera}) | issues_json (STRING), all_pass (BOOLEAN), summary (STRING) |
SceneFramingExtras | scene_json, camera_json, key_subject_json | scores_json (STRING), summary (STRING) |
CameraReliabilityZone | camera_json, subject_json, azimuth_deg (FLOAT), elevation_deg (FLOAT) | zone (COMBO: green/yellow/red), reason (STRING) |
CinematographerCorrection | report_json | corrections (STRING — newline-joined levers) |
All JSON I/O is plain json.dumps/json.loads so the nodes compose with anything and are trivially testable without ComfyUI installed. The geometry modules must import with zero ComfyUI dependency — that is what makes them testable and is a hard requirement.
6.1 CameraReliabilityZone — the bridge node
comfy-reshoot shows a hard-coded green/yellow/red zone. Ours computes it from the CrossView-Warp training envelope:
| Zone | Condition |
|---|---|
| green | |azimuth| ≤ 45° (the trained range) |
| yellow | 45° < |azimuth| ≤ 60°, or |elevation| > 30° |
| red | beyond both — "outside training; the hidden side will be invented" |
Returns a reason string naming which axis left the envelope.
7. The model stack — licence-clean, and why
| Role | Model | Licence | Note |
|---|---|---|---|
| Core video | LTX 2.3 (22B) | LTX-2.x Community (<$10M rev) | Required for the $2,000 LTX bonus |
| View steering | CrossView-Warp (Cseti) | Apache-2.0 | IC-LoRA for LTX 2.3. Green ±45° azimuth. Steers the viewpoint; does not reproject it |
| Depth | MoGe-2 | open | Depth for the warp guide |
| Licence-clean alternative | Wan 2.2 (VACE / Fun-Camera) | Apache-2.0 | Documented as the zero-caveat path |
Explicitly excluded: MiniMax H3. Open weights, but the United States is an excluded territory and local commercial use needs a $5,000/month licence. Both reference apps depend on it. Our not depending on it is a defensible differentiator and we will say so in the video.
8. Repo layout
app/
README.md # setup that actually works — the comfy-reshoot README is the bar
LICENSE # Apache-2.0
pyproject.toml
requirements.txt
cinematographer/
__init__.py # NODE_CLASS_MAPPINGS
geometry.py # vec3, projector, CamPose, Subject, ShotIntent
framing.py # EvaluateFraming — 9 rules
scene.py # SceneFramingExtras — 3 rules
continuity.py # Choros — 2 rules
autoframe.py # solver + convergence
reliability.py # CameraReliabilityZone
nodes.py # the ComfyUI node classes
workflows/
reshoot_ltx_crossview.json # API-format graph
tests/
test_geometry.py
test_framing.py
test_continuity.py
test_autoframe.py
web/ # the director's panel
Hard requirements
cinematographer/geometry.py,framing.py,scene.py,continuity.py,autoframe.py,reliability.pymust not import ComfyUI and must import with only the stdlib.- Every rule must have a unit test with a hand-computed expected value.
nodes.pyis the only file that may import ComfyUI, and it must degrade gracefully if ComfyUI is absent (so tests run in CI without it).- No file may contain a Higgsfield trademark in its name or in user-facing strings.
9. Acceptance criteria
| # | Criterion | How it is checked |
|---|---|---|
| 1 | python -c "import cinematographer" works with no ComfyUI installed | CI |
| 2 | All 14 rules produce a measured value + correction on a fixture | pytest |
| 3 | A known-bad frame is reported failing with the correct lever and sign | hand-computed fixture |
| 4 | AutoFrameCamera reports converged: True on a solvable fixture, and False honestly on an unsolvable one | pytest |
| 5 | Choros flags a deliberate axis cross and a <30° jump cut | pytest |
| 6 | The workflow JSON loads as valid ComfyUI API format | schema check |
| 7 | The panel runs and shows the arithmetic | manual + screenshot |
| 8 | README setup works from a clean clone | manual |
10. Non-goals (explicitly out of scope)
- Porting the PER (perceptual / vision-model) rules — they need a vision model and are not deterministic. We ship DET only and say so.
- Porting the DLO scene graph, the rasteriser, or the depth pass.
- Any dependency on MiniMax H3, GPT Image, or any closed API.
- Rebuilding Openjutsu — the ground is taken (
comfy-openjutsu, 53★). - Claiming 38 rules, or claiming the solver converges when it does not.