feat: Update CoreML device support and server controls

- Replace 'mps' with 'coreml' as the identifier for Apple CoreML Execution Provider across the codebase.
- Remove Apple chip generation checks and ensure CoreML is available on all macOS arm64 devices.
- Adjust ONNX Runtime session creation to use default CoreML configurations without forcing 'MLProgram'.
- Implement support for MoGe-2 to utilize CoreML with static input shape requirements.
- Enhance server panel to display restart and stop controls consistently across running states, including 'BUSY'.
- Update UI tests to verify new behavior for server controls and cached model display.
- Bump blendjob dependency version to 0.1.16 in packaging tests and configurations.
This commit is contained in:
2026-09-02 10:50:02 +08:00
parent 7f6e47432e
commit 5a011b3b57
24 changed files with 619 additions and 117 deletions

View File

@@ -17,10 +17,20 @@ class BlenderAddonTest(unittest.TestCase):
def setUpClass(cls):
cls.remove_modules()
def draw_status_bar(_owner, _context):
pass
draw_status_bar._draw_funcs = [draw_status_bar]
def append_status_bar(draw):
draw._owner = "anyimage"
draw_status_bar._draw_funcs.append(draw)
cls.status_bar_draws.append(draw)
def remove_status_bar(draw):
draw_status_bar._draw_funcs.remove(draw)
cls.status_bar_draws.remove(draw)
fake_bpy = ModuleType("bpy")
fake_bpy.props = SimpleNamespace(
BoolProperty=lambda **options: options,
@@ -41,8 +51,9 @@ class BlenderAddonTest(unittest.TestCase):
Image=type("Image", (), {}),
Scene=type("Scene", (), {}),
STATUSBAR_HT_header=SimpleNamespace(
draw=draw_status_bar,
append=append_status_bar,
remove=lambda draw: cls.status_bar_draws.remove(draw),
remove=remove_status_bar,
),
VIEW3D_MT_object_context_menu=SimpleNamespace(
append=lambda draw: cls.context_menu_draws.append(draw),
@@ -158,26 +169,55 @@ class BlenderAddonTest(unittest.TestCase):
if hasattr(self.fake_bpy.types.Image, "anyimage_upscale_source"):
del self.fake_bpy.types.Image.anyimage_upscale_source
def test_mps_requires_m4_or_newer(self):
def test_all_apple_silicon_supports_coreml(self):
properties = self.anyimage.properties
parse = properties.parse_apple_silicon_generation
self.assertEqual(parse("Apple M2"), 2)
self.assertEqual(parse("Apple M4 Pro"), 4)
self.assertEqual(parse("Apple M10 Max"), 10)
self.assertIsNone(parse("Intel(R) Core(TM) i9"))
self.assertFalse(properties.supports_mps(3))
self.assertTrue(properties.supports_mps(4))
for generation in range(1, 5):
with (
self.subTest(generation=generation),
patch.object(
self.anyimage.runtime,
"environment_manifest",
return_value={},
),
patch.object(properties.sys, "platform", "darwin"),
patch.object(
properties.platform,
"machine",
return_value="arm64",
),
):
self.assertEqual(
properties.supported_devices(),
["cpu", "coreml"],
)
def test_unsupported_mac_hides_manifest_mps_device(self):
def test_non_apple_silicon_mac_uses_cpu(self):
properties = self.anyimage.properties
manifest = {"devices": ["mps", "cpu"]}
with (
patch.object(
self.anyimage.runtime,
"environment_manifest",
return_value={},
),
patch.object(properties.sys, "platform", "darwin"),
patch.object(
properties.platform,
"machine",
return_value="x86_64",
),
):
self.assertEqual(properties.supported_devices(), ["cpu"])
def test_manifest_coreml_requires_apple_silicon(self):
properties = self.anyimage.properties
manifest = {"devices": ["coreml", "cpu"]}
with (
patch.object(
self.anyimage.runtime,
"environment_manifest",
return_value=manifest,
),
patch.object(properties, "supports_mps", return_value=False),
patch.object(properties.sys, "platform", "linux"),
):
self.assertEqual(properties.supported_devices(), ["cpu"])
with (
@@ -186,9 +226,34 @@ class BlenderAddonTest(unittest.TestCase):
"environment_manifest",
return_value=manifest,
),
patch.object(properties, "supports_mps", return_value=True),
patch.object(properties.sys, "platform", "darwin"),
patch.object(
properties.platform,
"machine",
return_value="aarch64",
),
):
self.assertEqual(properties.supported_devices(), ["mps", "cpu"])
self.assertEqual(
properties.supported_devices(),
["cpu", "coreml"],
)
def test_manifest_rejects_old_mps_device(self):
properties = self.anyimage.properties
with (
patch.object(
self.anyimage.runtime,
"environment_manifest",
return_value={"devices": ["mps", "cpu"]},
),
patch.object(properties.sys, "platform", "darwin"),
patch.object(
properties.platform,
"machine",
return_value="arm64",
),
):
self.assertEqual(properties.supported_devices(), ["cpu"])
def test_windows_keeps_default_directml_device(self):
properties = self.anyimage.properties
@@ -2957,6 +3022,105 @@ class BlenderAddonTest(unittest.TestCase):
offenders.append(path.relative_to(source_root))
self.assertEqual(offenders, [])
def _draw_server_panel(self, state, cached_names=()):
events = SimpleNamespace(labels=[], operators=[])
class Layout:
def __init__(self, kind="layout"):
self.kind = kind
self.enabled = True
def row(self, **_options):
return Layout("row")
def column(self, **_options):
return Layout("column")
def label(self, **options):
events.labels.append(
(self.kind, options.get("text"), options.get("icon"))
)
def operator(self, identifier, **_options):
events.operators.append((identifier, self.enabled))
return SimpleNamespace()
panel_module = importlib.import_module("anyimage.panel")
panel = panel_module.ServerPanel()
panel.layout = Layout()
loaded = {
str(index): name
for index, name in enumerate(cached_names)
}
with (
patch.object(
panel_module.runtime,
"environment_ready",
return_value=True,
),
patch.object(
panel_module.runtime,
"server_busy",
return_value=state == "BUSY",
),
patch.object(
panel_module.runtime,
"server_status",
return_value={
"state": state,
"message": state.title(),
"resources": {
"model_manager": {"loaded": loaded},
},
},
),
):
panel.draw(None)
return events
def test_server_panel_shows_controls_for_every_running_state(self):
for state, label, icon in (
("READY", "Running", "RADIOBUT_ON"),
("BUSY", "Running (Busy)", "TIME"),
):
with self.subTest(state=state):
events = self._draw_server_panel(state)
operators = [identifier for identifier, _enabled in events.operators]
self.assertIn(("row", label, icon), events.labels)
self.assertIn("anyimage.restart_server", operators)
self.assertIn("anyimage.stop_server", operators)
for state in ("STOPPED", "STARTING", "ERROR"):
with self.subTest(state=state):
events = self._draw_server_panel(state)
operators = [identifier for identifier, _enabled in events.operators]
self.assertNotIn("anyimage.restart_server", operators)
self.assertNotIn("anyimage.stop_server", operators)
def test_server_panel_wraps_multiple_cached_models(self):
for cached_names, expected_labels in (
((), [("row", "Cached: None")]),
(("Model A",), [("row", "Cached: Model A")]),
(
("Model A", "Model B"),
[
("column", "Cached:"),
("column", "Model A"),
("column", "Model B"),
],
),
):
with self.subTest(cached_names=cached_names):
events = self._draw_server_panel("READY", cached_names)
cached_labels = [
(kind, text)
for kind, text, _icon in events.labels
if text != "Running"
]
self.assertEqual(cached_labels, expected_labels)
operators = [identifier for identifier, _enabled in events.operators]
self.assertIn("anyimage.clear_models", operators)
def test_status_bar_reads_runtime_state(self):
calls = []