Enhance image editing tests and functionality

- Added parameterized tests for image edit replacement to ensure alpha mode preservation.
- Refactored existing tests to utilize new image editing methods and improve clarity.
- Introduced new tests for image target functionality, ensuring proper handling of shared textures and context.
- Updated various tests to validate alpha mode handling across different scenarios.
- Improved undo/redo functionality in image operators to maintain state consistency.
- Added support for texture node actions, enhancing integration with Blender's node system.
- Ensured that image properties are preserved during operations and that invalid contexts are handled gracefully.
This commit is contained in:
2026-09-10 00:00:13 +08:00
parent f48bbb3d05
commit 1622afa7a2
28 changed files with 1068 additions and 236 deletions

View File

@@ -8,6 +8,14 @@ from pathlib import Path
import bpy
VIEW_TRANSFORM_COLOR_SPACES = (
("ACES 1.3", "ACES 1.3 sRGB"),
("ACES 2.0", "ACES 2.0 sRGB"),
("AgX", "AgX Base sRGB"),
("Filmic", "Filmic sRGB"),
("Khronos PBR Neutral", "Khronos PBR Neutral sRGB"),
)
IMAGE_NAME_SUFFIXES = (
".jpeg",
".jpg",
@@ -296,7 +304,7 @@ def video_total_frames(image):
def image_user_settings(source_object):
"""Return the Empty's image user settings as a four-item tuple."""
"""Return an image owner's playback settings as a four-item tuple."""
image_user = getattr(source_object, "image_user", None)
if image_user is None:
return 1, 0, 0, False
@@ -308,14 +316,13 @@ def image_user_settings(source_object):
)
def extract_frame_range(source_object):
def extract_frame_range(image, image_user):
"""Return the clamped (start_frame, frame_count) range extracted from the source."""
total_frames = video_total_frames(getattr(source_object, "data", None))
total_frames = video_total_frames(image)
if total_frames <= 1:
return 1, total_frames
_frame_start, frame_offset, image_frames, _use_cyclic = (
image_user_settings(source_object)
)
frame_offset = int(getattr(image_user, "frame_offset", 0) or 0)
image_frames = int(getattr(image_user, "frame_duration", 0) or 0)
frame_count = (
min(image_frames, total_frames)
if image_frames >= 1
@@ -326,14 +333,9 @@ def extract_frame_range(source_object):
return start_frame, frame_count
def default_extract_frames(source_object):
"""Return the Empty's Image Frames value, or the full frame count when unset."""
return extract_frame_range(source_object)[1]
def source_start_frame(source_object_name):
source_object = bpy.data.objects.get(source_object_name)
return extract_frame_range(source_object)[0] if source_object else 1
return extract_frame_range(source_object.data, source_object.image_user)[0] if source_object else 1
def image_path(image):
@@ -615,16 +617,22 @@ def load_normal_result_image(path, source_object):
return image
def color_alpha_mode(color_space):
"""Pair inverse view color spaces with straight alpha."""
return "STRAIGHT" if color_space in dict(VIEW_TRANSFORM_COLOR_SPACES).values() else "PREMUL"
def configure_color_alpha(image):
"""Apply the presentation mode to an owned Color image."""
if image.alpha_mode != "PREMUL":
alpha_mode = color_alpha_mode(image.colorspace_settings.name)
if image.alpha_mode != alpha_mode:
pixels = (
image_pixels(image)
if getattr(image, "source", None) == "GENERATED"
or getattr(image, "is_dirty", False)
else None
)
image.alpha_mode = "PREMUL"
image.alpha_mode = alpha_mode
if pixels is not None:
image.pixels.foreach_set(pixels)
image.update()
@@ -645,7 +653,7 @@ def create_image_edit_result(source_image, pixels, output_size):
)
try:
image.colorspace_settings.name = source_image.colorspace_settings.name
configure_color_alpha(image)
image.alpha_mode = source_image.alpha_mode
image.pixels.foreach_set(pixels)
image.pack()
image.update()
@@ -801,11 +809,12 @@ def replace_empty_image(source_object, result_image, *, placement_bounds=None,
def _load_packed_result_image(
source_object,
source_image,
output_directory,
*,
frame_prefix,
result_name,
alpha_mode,
):
output_directory = Path(output_directory)
color_paths = sorted_frame_paths(output_directory, frame_prefix)
@@ -813,7 +822,6 @@ def _load_packed_result_image(
raise RuntimeError(
f"Color result has no images: {output_directory}"
)
source_image = source_object.data
source_color_space = source_image.colorspace_settings.name
animated = is_animated_image(source_image)
try:
@@ -822,7 +830,7 @@ def _load_packed_result_image(
check_existing=False,
)
result_image.colorspace_settings.name = source_color_space
configure_color_alpha(result_image)
result_image.alpha_mode = alpha_mode
if animated:
force_sequence_source(result_image)
except (OSError, RuntimeError, TypeError, ValueError) as error:
@@ -843,17 +851,18 @@ def _load_packed_result_image(
def load_image_edit_result(
source_object,
source_image,
output_directory,
*,
frame_prefix="image.",
):
"""Load one packed Job result that directly edits the source Image."""
return _load_packed_result_image(
source_object,
source_image,
output_directory,
frame_prefix=frame_prefix,
result_name=source_object.data.name,
result_name=source_image.name,
alpha_mode=source_image.alpha_mode,
)
@@ -865,10 +874,11 @@ def load_color_result_image(
):
"""Load one packed Color texture result for a generated material."""
return _load_packed_result_image(
source_object,
source_object.data,
output_directory,
frame_prefix=frame_prefix,
result_name=color_data_name(source_object),
alpha_mode=color_alpha_mode(source_object.data.colorspace_settings.name),
)

View File

@@ -0,0 +1,167 @@
"""Resolve and commit AI image edits for Empty objects and texture nodes."""
from dataclasses import dataclass
import bpy
from .image import (
configure_image_user,
extract_frame_range,
image_content_state,
image_user_settings,
is_animated_image,
is_image_empty,
load_image_edit_result,
prepare_image_input,
replace_empty_image,
restore_image_content,
video_total_frames,
)
def active_texture_node(context):
"""Return the editable image texture in the current material edit tree."""
space = getattr(context, "space_data", None)
if (
getattr(space, "type", None) != "NODE_EDITOR"
or getattr(space, "tree_type", None) != "ShaderNodeTree"
or getattr(space, "shader_type", None) != "OBJECT"
or not isinstance(getattr(space, "id", None), bpy.types.Material)
):
return None
tree = space.edit_tree
if tree is None or not tree.is_editable:
return None
node = tree.nodes.active
if (
node is None
or node.bl_idname != "ShaderNodeTexImage"
or node.image is None
or not node.image.is_editable
):
return None
return node
def image_edit_owner(context):
"""Resolve the editor's image owner without falling back across editors."""
if getattr(getattr(context, "space_data", None), "type", None) == "NODE_EDITOR":
return active_texture_node(context)
owner = getattr(context, "object", None)
return owner if is_image_empty(owner) else None
def owner_image(owner):
"""Return the Image assigned to an image Empty or texture node."""
return owner.data if is_image_empty(owner) else owner.image
def has_other_texture_image_user(node):
"""Check actual Image references, excluding editor display and Fake User."""
tree = node.id_data
image = node.image
for user in bpy.data.user_map(subset={image})[image]:
if isinstance(user, bpy.types.Screen):
continue
if user == tree or getattr(user, "node_tree", None) == tree:
if any(
other != node and getattr(other, "image", None) == image
for other in tree.nodes
):
return True
else:
return True
return False
def replace_texture_image(node, result_image):
"""Commit one texture image transaction while preserving other users."""
try:
source_image = node.image
original_timing = image_user_settings(node)
original_auto_refresh = node.image_user.use_auto_refresh
original_content = None
frame_count = video_total_frames(result_image) if is_animated_image(result_image) else None
try:
if has_other_texture_image_user(node):
final_image = result_image
else:
result_content = image_content_state(result_image)
original_content = image_content_state(source_image)
restore_image_content(source_image, result_content)
final_image = source_image
node.image = final_image
if frame_count is not None:
configure_image_user(
node.image_user,
frame_count,
frame_start=original_timing[0],
use_cyclic=original_timing[3],
)
except Exception:
if original_content is not None:
restore_image_content(source_image, original_content)
node.image = source_image
user = node.image_user
(user.frame_start, user.frame_offset, user.frame_duration, user.use_cyclic) = original_timing
user.use_auto_refresh = original_auto_refresh
raise
return final_image
finally:
if result_image.users == 0:
bpy.data.images.remove(result_image)
@dataclass
class ImageEditTarget:
"""Hold main-thread RNA identities for a single asynchronous image edit."""
owner: object
image: object
tree: object = None
start_frame: int = 1
frame_count: int = 1
@classmethod
def capture(cls, context):
owner = image_edit_owner(context)
if owner is None:
raise RuntimeError("Select an Image Empty or a material Image Texture")
image = owner_image(owner)
start_frame, frame_count = extract_frame_range(image, owner.image_user)
return cls(
owner, image,
None if is_image_empty(owner) else owner.id_data,
start_frame, frame_count,
)
def validate(self):
"""Reject a removed owner or a changed image assignment."""
try:
if self.tree is None:
valid = is_image_empty(self.owner) and self.owner.data == self.image
else:
valid = (
self.tree.is_editable
and self.image.is_editable
and self.tree.nodes.get(self.owner.name) == self.owner
and self.owner.image == self.image
)
if valid:
return
except ReferenceError:
pass
raise RuntimeError("The source image target is no longer available or has changed")
def prepare(self, context):
self.validate()
if video_total_frames(self.image) > 1:
context.scene.anyimage_settings.video_frames = self.frame_count
return prepare_image_input(self.image, context.scene)
def apply(self, directory, *, frame_prefix="image."):
self.validate()
result_image = load_image_edit_result(self.image, directory, frame_prefix=frame_prefix)
if self.tree is None:
return replace_empty_image(self.owner, result_image)
return replace_texture_image(self.owner, result_image)

View File

@@ -4,6 +4,8 @@ import bpy
from ..preferences import configured_material_view_adaptation
from .image import (
VIEW_TRANSFORM_COLOR_SPACES,
color_alpha_mode,
configure_image_user,
image_base_name,
image_pixels,
@@ -15,13 +17,6 @@ from .node import load_node_group
IMAGE_MATERIAL_NODE_GROUP_NAME = "O Image Layer"
DEPTH_MATERIAL_NODE_GROUP_NAME = "O Image Depth Layer"
SHADELESS_NODE_GROUP_NAME = "O Shadeless"
VIEW_TRANSFORM_COLOR_SPACES = (
("ACES 1.3", "ACES 1.3 sRGB"),
("ACES 2.0", "ACES 2.0 sRGB"),
("AgX", "AgX Base sRGB"),
("Filmic", "Filmic sRGB"),
("Khronos PBR Neutral", "Khronos PBR Neutral sRGB"),
)
def material_color_space_name(scene):
@@ -64,7 +59,7 @@ def configure_material_color_image(image, scene=None):
break
else:
raise RuntimeError("Unable to configure the material color space")
result.alpha_mode = "PREMUL" if settings.name == "sRGB" else "STRAIGHT"
result.alpha_mode = color_alpha_mode(settings.name)
if pixels is not None:
result.pixels.foreach_set(pixels)
result.update()