Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions server/codex_bridge/canonical_binder.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ def _bind_action(
return _bind_noise(settings, action)
if action.action == "grade-color":
return _bind_grade(settings, action)
if action.action == "rotate":
return _bind_rotate(settings, action)
if action.action == "crop-normalized":
return _bind_crop(settings, action)
if action.action == "crop-to-bounding-box":
Expand Down Expand Up @@ -316,6 +318,26 @@ def _bind_grade(
)


def _bind_rotate(
settings: list[EditableSetting], action: CanonicalEditAction
) -> _BindingResult:
setting = _find_setting(
settings,
kind="set-float",
exact_action_paths=("iop/clipping/angle", "iop/crop/angle"),
module_ids=("clipping", "crop"),
action_keywords=("angle", "rotate"),
label_keywords=("angle", "rotation"),
)
if setting is None:
return _BindingResult([], ["rotate could not find a rotation control"])
assert action.angleDegrees is not None
return _BindingResult(
[_float_operation(setting, action.angleDegrees, action.rationale)],
[],
)


def _bind_crop(
settings: list[EditableSetting], action: CanonicalEditAction
) -> _BindingResult:
Expand Down
4 changes: 2 additions & 2 deletions server/codex_bridge/prompts/turn_prompt.j2
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ Tool usage:
- Before finalizing, consider whether additional provided controls would materially improve tone, color, detail, crop, or noise; do not stop at basic exposure/contrast edits when stronger supported tools are available.
- Always optimize toward refinement.goalText.
{% if live_run_enabled %}Live run mode is enabled: use apply_operations for iterative edits inside this same run.
For this multi-turn path, you may pass `canonicalActions` to apply_operations instead of raw operations for these supported intent-level edits: `adjust-exposure`, `adjust-white-balance`, `recover-highlights`, `reduce-noise`, `grade-color`, `crop-normalized`, `crop-to-bounding-box`.
Canonical fields: `adjust-exposure` uses `exposureEv`; `adjust-white-balance` uses `temperatureDelta`, `tintDelta`, and/or `presetChoiceId`; `recover-highlights` and `reduce-noise` use `strength`; `grade-color` uses `target` + `amount`; `crop-normalized` uses `left`, `top`, `right`, `bottom` in normalized [0,1] coordinates.
For this multi-turn path, you may pass `canonicalActions` to apply_operations instead of raw operations for these supported intent-level edits: `adjust-exposure`, `adjust-white-balance`, `recover-highlights`, `reduce-noise`, `grade-color`, `rotate`, `crop-normalized`, `crop-to-bounding-box`.
Canonical fields: `adjust-exposure` uses `exposureEv`; `adjust-white-balance` uses `temperatureDelta`, `tintDelta`, and/or `presetChoiceId`; `recover-highlights` and `reduce-noise` use `strength`; `grade-color` uses `target` + `amount`; `rotate` uses signed `angleDegrees` for precise rotation deltas, with positive values rotating right and negative values rotating left; `crop-normalized` uses `left`, `top`, `right`, `bottom` in normalized [0,1] coordinates.
For subject-centric crops, prefer `crop-to-bounding-box` over raw crop edges: provide `boxLeft`, `boxTop`, `boxWidth`, `boxHeight`, and optional `paddingRatio`, and the runtime will deterministically translate that box into concrete crop/clipping controls.
The runtime binds supported canonical actions to concrete darktable controls deterministically before applying them.
Inside each apply_operations call, operations are auto-applied one at a time with a fresh render after each step.
Expand Down
11 changes: 11 additions & 0 deletions server/evals/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,17 @@ def editable_settings() -> list[dict[str, object]]:
minimum=-3.14,
maximum=3.14,
),
float_setting(
module_id="clipping",
module_label="crop and rotate",
setting_id="setting.clipping.angle",
capability_id="clipping.angle",
label="angle",
action_path="iop/clipping/angle",
current=0.0,
minimum=-180.0,
maximum=180.0,
),
float_setting(
module_id="clipping",
module_label="crop and rotate",
Expand Down
90 changes: 89 additions & 1 deletion server/tests/test_codex_app_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,20 @@ def _sample_request_with_white_balance_controls() -> RequestEnvelope:
def _sample_request_with_canonical_controls() -> RequestEnvelope:
payload = _sample_request_with_white_balance_controls().model_dump(mode="json")
extra_targets = [
{
"moduleId": "clipping",
"moduleLabel": "crop",
"capabilityId": "clipping.angle",
"label": "angle",
"kind": "set-float",
"targetType": "darktable-action",
"actionPath": "iop/clipping/angle",
"supportedModes": ["set", "delta"],
"minNumber": -180.0,
"maxNumber": 180.0,
"defaultNumber": 0.0,
"stepNumber": 1.0,
},
{
"moduleId": "clipping",
"moduleLabel": "crop",
Expand Down Expand Up @@ -428,6 +442,21 @@ def _sample_request_with_canonical_controls() -> RequestEnvelope:
},
]
extra_settings = [
{
"moduleId": "clipping",
"moduleLabel": "crop",
"settingId": "setting.clipping.angle",
"capabilityId": "clipping.angle",
"label": "angle",
"actionPath": "iop/clipping/angle",
"kind": "set-float",
"currentNumber": 0.0,
"supportedModes": ["set", "delta"],
"minNumber": -180.0,
"maxNumber": 180.0,
"defaultNumber": 0.0,
"stepNumber": 1.0,
},
{
"moduleId": "clipping",
"moduleLabel": "crop",
Expand Down Expand Up @@ -854,6 +883,8 @@ def test_turn_prompt_tells_codex_to_infer_broad_edit_plan_from_visual_context()
assert "you may pass `canonicalActions` to apply_operations" in prompt
assert "adjust-exposure" in prompt
assert "grade-color" in prompt
assert "rotate" in prompt
assert "angleDegrees" in prompt
assert "crop-to-bounding-box" in prompt
assert "boxLeft" in prompt
assert "paddingRatio" in prompt
Expand Down Expand Up @@ -938,6 +969,18 @@ def test_crop_to_bounding_box_requires_box_coordinates() -> None:
)


def test_rotate_requires_angle_degrees() -> None:
with pytest.raises(ValueError, match="rotate requires angleDegrees"):
AgentPlan.model_validate(
{
"assistantText": "Rotate slightly.",
"continueRefining": False,
"operations": [],
"canonicalActions": [{"action": "rotate"}],
}
)


def test_canonical_binder_resolves_supported_actions_without_raw_ids() -> None:
request = _sample_request_with_canonical_controls()
plan = AgentPlan.model_validate(
Expand Down Expand Up @@ -982,6 +1025,11 @@ def test_canonical_binder_resolves_supported_actions_without_raw_ids() -> None:
"bottom": 0.95,
"rationale": "Tighten framing.",
},
{
"action": "rotate",
"angleDegrees": 2.5,
"rationale": "Straighten the frame slightly clockwise.",
},
],
}
)
Expand All @@ -1001,10 +1049,43 @@ def test_canonical_binder_resolves_supported_actions_without_raw_ids() -> None:
"iop/clipping/cy",
"iop/clipping/cw",
"iop/clipping/ch",
"iop/clipping/angle",
]
assert bound_plan.operations[0].value.mode == "delta"
assert bound_plan.operations[6].value.mode == "set"
assert bound_plan.operations[6].value.number == pytest.approx(0.1)
assert bound_plan.operations[10].value.mode == "delta"
assert bound_plan.operations[10].value.number == pytest.approx(2.5)


def test_canonical_binder_binds_rotate_actions_to_clipping_angle() -> None:
request = _sample_request_with_canonical_controls()
plan = AgentPlan.model_validate(
{
"assistantText": "Rotate the image.",
"continueRefining": False,
"operations": [],
"canonicalActions": [
{"action": "rotate", "angleDegrees": -1.25},
{"action": "rotate", "angleDegrees": 2.5},
],
}
)

bound_plan = bind_canonical_plan(request, plan)

assert [operation.target.actionPath for operation in bound_plan.operations] == [
"iop/clipping/angle",
"iop/clipping/angle",
]
assert [operation.value.mode for operation in bound_plan.operations] == [
"delta",
"delta",
]
assert [operation.value.number for operation in bound_plan.operations] == [
pytest.approx(-1.25),
pytest.approx(2.5),
]


def test_canonical_binder_translates_bounding_box_crop_to_crop_controls() -> None:
Expand Down Expand Up @@ -1839,6 +1920,10 @@ def _mock_wait(timeout=None, *, context=turn_context):
"right": 0.9,
"bottom": 0.9,
},
{
"action": "rotate",
"angleDegrees": 2.5,
},
]
},
},
Expand All @@ -1847,7 +1932,7 @@ def _mock_wait(timeout=None, *, context=turn_context):

result = sent_payloads[0]["result"]
assert result["success"] is True
assert "Applied 5 operations" in result["contentItems"][0]["text"]
assert "Applied 6 operations" in result["contentItems"][0]["text"]
turn_context = bridge._get_turn_context("thread-1", "turn-1") # type: ignore[attr-defined]
assert turn_context is not None
assert turn_context.setting_by_id["setting.exposure.primary"][
Expand All @@ -1856,6 +1941,9 @@ def _mock_wait(timeout=None, *, context=turn_context):
assert turn_context.setting_by_id["setting.clipping.cx"][
"currentNumber"
] == pytest.approx(0.1)
assert turn_context.setting_by_id["setting.clipping.angle"][
"currentNumber"
] == pytest.approx(2.5)
finally:
bridge._clear_turn_context("thread-1", "turn-1") # type: ignore[attr-defined]

Expand Down
5 changes: 5 additions & 0 deletions shared/canonical_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"recover-highlights",
"reduce-noise",
"grade-color",
"rotate",
"crop-normalized",
"crop-to-bounding-box",
]
Expand Down Expand Up @@ -37,6 +38,7 @@ class CanonicalEditAction(CanonicalBaseModel):
noiseType: CanonicalNoiseType | None = None
target: CanonicalGradeTarget | None = None
amount: float | None = None
angleDegrees: float | None = None
left: float | None = None
top: float | None = None
right: float | None = None
Expand Down Expand Up @@ -75,6 +77,9 @@ def validate_action_shape(self) -> "CanonicalEditAction":
raise ValueError("grade-color requires target")
if self.amount is None:
raise ValueError("grade-color requires amount")
elif self.action == "rotate":
if self.angleDegrees is None:
raise ValueError("rotate requires angleDegrees")
elif self.action == "crop-normalized":
bounds = (self.left, self.top, self.right, self.bottom)
if any(value is None for value in bounds):
Expand Down