from unittest.mock import patch import bpy import numpy as np import pytest from anyimage.common import image as images from anyimage.common import image_target as targets from tests.support.image_texture import texture, texture_context, write_result def test_target_uses_edit_tree_and_survives_renaming(texture, tmp_path): context = texture_context(texture, pin=True) target = targets.ImageEditTarget.capture(context) texture.name = "Renamed texture" bpy.data.materials["Texture material"].name = "Renamed material" other = texture.id_data.nodes.new("ShaderNodeTexImage") texture.id_data.nodes.active = other context.space_data.edit_tree = None write_result(tmp_path) target.apply(tmp_path) assert tuple(texture.image.size) == (8, 6) assert other.image is None @pytest.mark.parametrize("color_space,alpha_mode", [ ("sRGB", "PREMUL"), ("AgX Base sRGB", "STRAIGHT"), ("Filmic sRGB", "STRAIGHT"), ("sRGB", "STRAIGHT"), ("AgX Base sRGB", "PREMUL"), ]) @pytest.mark.parametrize("prefix", ["image.", "upscale."]) @pytest.mark.parametrize("shared", [False, True]) def test_image_actions_preserve_source_color_space_and_alpha(texture, tmp_path, color_space, alpha_mode, prefix, shared): texture.image.colorspace_settings.name = color_space texture.image.alpha_mode = alpha_mode original = texture.image original_pixels = images.image_pixels(original) if shared: other = texture.id_data.nodes.new("ShaderNodeTexImage") other.image = original target = targets.ImageEditTarget.capture(texture_context(texture)) write_result(tmp_path, prefix) target.apply(tmp_path, frame_prefix=prefix) result = texture.image assert result.colorspace_settings.name == color_space assert result.alpha_mode == alpha_mode np.testing.assert_allclose( images.image_pixels(result).reshape(-1, 4), np.tile(np.array([128, 64, 32, 80]) / 255, (48, 1)), atol=1 / 255, ) if shared: assert other.image == original assert original.alpha_mode == alpha_mode np.testing.assert_array_equal(images.image_pixels(original), original_pixels) @pytest.mark.parametrize("invalid", ["world", "light", "compositor", "geometry", "missing_image", "other_node", "no_tree"]) def test_invalid_node_context_does_not_fall_back_to_empty(texture, invalid): context = texture_context(texture) context.object = bpy.data.objects.new("Fallback Empty", None) context.object.empty_display_type = "IMAGE" context.object.data = texture.image if invalid == "world": context.space_data.shader_type = "WORLD" elif invalid == "light": context.space_data.id = bpy.data.lights.new("Light", type="POINT") elif invalid in {"compositor", "geometry"}: context.space_data.tree_type = "CompositorNodeTree" if invalid == "compositor" else "GeometryNodeTree" elif invalid == "missing_image": texture.image = None elif invalid == "other_node": texture.id_data.nodes.active = texture.id_data.nodes.get("Principled BSDF") else: context.space_data.edit_tree = None assert targets.image_edit_owner(context) is None with pytest.raises(RuntimeError, match="Select"): targets.ImageEditTarget.capture(context) @pytest.mark.parametrize("change", ["delete", "recreate", "rebind", "delete_material", "delete_image", "identity"]) def test_target_rejects_changed_identity_before_loading_result(texture, tmp_path, change): target = targets.ImageEditTarget.capture(texture_context(texture)) tree = texture.id_data if change in {"delete", "recreate"}: name = texture.name tree.nodes.remove(texture) if change == "recreate": replacement = tree.nodes.new("ShaderNodeTexImage") replacement.name = name replacement.image = target.image elif change == "rebind": texture.image = bpy.data.images.new("Replacement", width=2, height=2) elif change == "delete_material": bpy.data.materials.remove(bpy.data.materials["Texture material"]) elif change == "identity": # A reused RNA address must not make a replacement node the original target. texture["anyimage_identity"] = "replacement" else: bpy.data.images.remove(texture.image) count = len(bpy.data.images) with pytest.raises(RuntimeError, match="no longer available"): target.apply(tmp_path) assert len(bpy.data.images) == count @pytest.mark.parametrize("reference", ["sole", "fake_user", "editor", "same_tree", "other_material", "empty", "brush"]) def test_texture_commit_preserves_structure_and_isolates_users(texture, tmp_path, reference): original = texture.image original.colorspace_settings.name = "Non-Color" expected_source = images.image_pixels(original) source_packed = bytes(original.packed_file.data) original.use_fake_user = reference == "fake_user" target = targets.ImageEditTarget.capture(texture_context(texture)) # Add users after capture to exercise changes while an AI job is pending. if reference == "same_tree": other = texture.id_data.nodes.new("ShaderNodeTexImage") other.image = original elif reference == "other_material": material = bpy.data.materials.new("Other material") material.use_nodes = True other = material.node_tree.nodes.new("ShaderNodeTexImage") other.image = original elif reference == "empty": other = bpy.data.objects.new("Other Empty", None) other.empty_display_type = "IMAGE" other.data = original elif reference == "brush": other = bpy.data.textures.new("Brush texture", type="IMAGE") other.image = original elif reference == "editor": area = bpy.context.screen.areas[0] area.type = "IMAGE_EDITOR" area.spaces.active.image = original shared = reference in {"same_tree", "other_material", "empty", "brush"} links = [(link.from_socket, link.to_socket) for link in texture.id_data.links] write_result(tmp_path) target.apply(tmp_path) assert (texture.image == original) == (not shared) assert tuple(texture.image.size) == (8, 6) assert texture.image.colorspace_settings.name == "Non-Color" assert texture.image.alpha_mode == original.alpha_mode assert texture.image.packed_file is not None assert tuple(texture.location) == (-400, 100) assert (texture.interpolation, texture.extension, texture.projection) == ("Closest", "CLIP", "BOX") assert [(link.from_socket, link.to_socket) for link in texture.id_data.links] == links assert original.use_fake_user == (reference == "fake_user") if shared: assert (other.data if reference == "empty" else other.image) == original assert bytes(original.packed_file.data) == source_packed np.testing.assert_array_equal(images.image_pixels(original), expected_source) else: assert texture.image.name == "Texture.png" assert len(bpy.data.images) == 1 expected = images.image_pixels(texture.image) path = str(tmp_path / "texture.blend") bpy.ops.wm.save_as_mainfile(filepath=path) bpy.ops.wm.open_mainfile(filepath=path) reopened = bpy.data.materials["Texture material"].node_tree.nodes["Source texture"] np.testing.assert_allclose(images.image_pixels(reopened.image), expected, atol=1 / 255) assert len(reopened.id_data.links) == len(links) @pytest.mark.parametrize("shared", [False, True]) def test_animated_texture_preserves_playback_and_reopens(texture, tmp_path, shared): write_result(tmp_path, count=4) texture.image.source = "SEQUENCE" texture.image.filepath_raw = str(tmp_path / "image.0001.png") texture.image_user.frame_start = 9 texture.image_user.frame_offset = 1 texture.image_user.frame_duration = 2 texture.image_user.use_cyclic = True context = texture_context(texture) target = targets.ImageEditTarget.capture(context) assert (target.start_frame, target.frame_count) == (2, 2) if shared: other = texture.id_data.nodes.new("ShaderNodeTexImage") other.image = texture.image with patch.object(targets, "prepare_image_input", return_value=(tmp_path, False)): target.prepare(context) assert context.scene.anyimage_settings.video_frames == 2 target.apply(tmp_path) assert images.image_user_settings(texture) == (9, 0, 4, True) assert texture.image_user.use_auto_refresh assert texture.image.source == "SEQUENCE" path = str(tmp_path / "animated.blend") bpy.ops.wm.save_as_mainfile(filepath=path) bpy.ops.wm.open_mainfile(filepath=path) reopened = bpy.data.materials["Texture material"].node_tree.nodes["Source texture"] assert images.image_user_settings(reopened) == (9, 0, 4, True) assert len(images.collect_sequence_frames(reopened.image)) == 4 @pytest.mark.parametrize("shared", [False, True]) def test_texture_failed_commit_restores_dirty_pixels_and_playback(texture, tmp_path, shared): original = texture.image if shared: other = texture.id_data.nodes.new("ShaderNodeTexImage") other.image = original original.pixels.foreach_set(np.tile([0.1, 0.3, 0.7, 0.5], 12).astype(np.float32)) packed = bytes(original.packed_file.data) pixels = images.image_pixels(original) timing = images.image_user_settings(texture) write_result(tmp_path, count=2) result = bpy.data.images.load(str(tmp_path / "image.0001.png")) result.source = "SEQUENCE" count = len(bpy.data.images) - 1 def fail_configuration(user, *args, **kwargs): user.frame_start = 80 user.use_auto_refresh = True raise RuntimeError("Playback failed") with patch.object(targets, "configure_image_user", side_effect=fail_configuration): with pytest.raises(RuntimeError, match="Playback failed"): targets.replace_texture_image(texture, result) assert texture.image == original assert tuple(original.size) == (4, 3) assert bytes(original.packed_file.data) == packed np.testing.assert_array_equal(images.image_pixels(original), pixels) assert images.image_user_settings(texture) == timing assert not texture.image_user.use_auto_refresh assert len(bpy.data.images) == count