ComfyUI Higgsfield Opensource Challenge The plan and the build record ← Build log

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-CameraReference3D17 camera-move presets + Plücker export
WanCameraEmbedding9 camera presets
comfy-reshoot's reliability zoneA 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
1in_frameboolSubject's eye anchor in front of camera and |ndcX|≤1, |ndcY|≤1"re-aim/pull back: face is off-frame or behind camera"
2rule_of_thirdsvalueDistance 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"
3headroomvalue1 − 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"
4shot_sizevalue(ndcY(top) − ndcY(base)) / 2; closeup ≥0.7, medium 0.4–0.75, wide 0.15–0.45"dolly/zoom to hit <type>"
5lead_roomvalue−ndcX if subject faces screen-right else +ndcX; pass ≥ −0.05"flip composition: leave space where the subject faces"
6negative_spacevalue1 − subArea, subArea ≈ min(1, h·(0.38h+0.05)); always passes (informational)—
7camera_anglevaluecamEyeY − subjectEyeY; class low/high/eye by sign + pitch; always passes (informational)—
8levelvalue|roll − intent.dutch| < 1"match the intended camera roll"
9direct_addressvalueAngle 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_frame111
rule_of_thirds1.00.80.6
headroom1.00.70.4
shot_size1.01.01.0
lead_room0.40.40.3
negative_space0.30.30.4
camera_angle0.30.30.3
level0.50.50.5

3.2 SceneFramingExtras — 3 rules (multi-entity)

Source: internal/contentstudio/cinematography_scene.go:58-143

# Rule key Measurement Correction
10size_importanceMax other.area / key.area over props + non-key characters; pass ≤1.0 (Hitchcock)"a <role> out-sizes the key subject — resize/reposition"
11foreground_occlusionA 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—
12eyelineTwo-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
13180_axisSign of the camera's position relative to the subject's facing line, per shot. A flip between consecutive shots = axis crossedsideA == sideB
1430_degreeangDelta(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


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, a converged: bool flag, 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
CinematographerEvaluatesubject_json (STRING), camera_json (STRING), shot_type (COMBO: closeup/medium/wide), intent_json (STRING, optional)report_json (STRING), overall_pass (BOOLEAN), summary (STRING)
CinematographerAutoFramesubject_json, shot_type, intent_jsoncamera_json (STRING), report_json (STRING), converged (BOOLEAN), iterations (INT)
ChorosContinuityshots_json (STRING — array of {shot_number, base, yaw_deg, camera})issues_json (STRING), all_pass (BOOLEAN), summary (STRING)
SceneFramingExtrasscene_json, camera_json, key_subject_jsonscores_json (STRING), summary (STRING)
CameraReliabilityZonecamera_json, subject_json, azimuth_deg (FLOAT), elevation_deg (FLOAT)zone (COMBO: green/yellow/red), reason (STRING)
CinematographerCorrectionreport_jsoncorrections (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)
yellow45° < |azimuth| ≤ 60°, or |elevation| > 30°
redbeyond 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 videoLTX 2.3 (22B)LTX-2.x Community (<$10M rev)Required for the $2,000 LTX bonus
View steeringCrossView-Warp (Cseti)Apache-2.0IC-LoRA for LTX 2.3. Green ±45° azimuth. Steers the viewpoint; does not reproject it
DepthMoGe-2openDepth for the warp guide
Licence-clean alternativeWan 2.2 (VACE / Fun-Camera)Apache-2.0Documented 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

  1. cinematographer/geometry.py, framing.py, scene.py, continuity.py, autoframe.py, reliability.py must not import ComfyUI and must import with only the stdlib.
  2. Every rule must have a unit test with a hand-computed expected value.
  3. nodes.py is the only file that may import ComfyUI, and it must degrade gracefully if ComfyUI is absent (so tests run in CI without it).
  4. No file may contain a Higgsfield trademark in its name or in user-facing strings.

9. Acceptance criteria

# Criterion How it is checked
1python -c "import cinematographer" works with no ComfyUI installedCI
2All 14 rules produce a measured value + correction on a fixturepytest
3A known-bad frame is reported failing with the correct lever and signhand-computed fixture
4AutoFrameCamera reports converged: True on a solvable fixture, and False honestly on an unsolvable onepytest
5Choros flags a deliberate axis cross and a <30° jump cutpytest
6The workflow JSON loads as valid ComfyUI API formatschema check
7The panel runs and shows the arithmeticmanual + screenshot
8README setup works from a clean clonemanual

10. Non-goals (explicitly out of scope)