diff --git a/openspec/changes/add-texture-node-image-actions/.openspec.yaml b/openspec/changes/add-texture-node-image-actions/.openspec.yaml new file mode 100644 index 0000000..1ea7e36 --- /dev/null +++ b/openspec/changes/add-texture-node-image-actions/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-09 diff --git a/openspec/changes/add-texture-node-image-actions/design.md b/openspec/changes/add-texture-node-image-actions/design.md new file mode 100644 index 0000000..aa4dabf --- /dev/null +++ b/openspec/changes/add-texture-node-image-actions/design.md @@ -0,0 +1,55 @@ +## Context + +现有菜单挂载于对象右键菜单,两个 AI 操作通过 Empty 取得 Image 和播放设置。`common/image.py` 已提供输入准备、结果打包、Image 内容快照和恢复;现有共享判定专用于 Empty。动机见 proposal.md。 + +## Goals / Non-Goals + +本设计围绕材质 Shader Editor 的 Image Texture 节点完成图片输入、异步目标定位和原位提交;Empty 的现有行为作为回归基线。 + +## Decisions + +### 菜单按编辑器和当前节点确定目标 + +在 `NODE_MT_context_menu` 注册回调,以材质 Shader Editor、当前编辑树、活动 Image Texture 节点及有效 Image 为显示条件。支持进入材质节点组和固定材质后的编辑上下文,节点编辑器中的无效目标直接判为不可用。新增 `AnyImageTextureNodeMenu`,包含两个图片操作及现有 AI 设置入口;Upscale 标签使用目标图片尺寸。注册与逆序注销对称。 + +### 在 common 中集中处理图片编辑目标 + +新增职责明确的 `common/image_target.py`,统一取得源 Image、ImageUser、目标校验与结果提交分派。复用两个现有 Operator 和服务端 Job;将结果加载中对 `source_object.data` 的依赖改为显式 Image 参数,并同步修改真实调用点。Empty 特有的显示对齐和共享规则仍由现有提交函数负责,节点提交复用 Image 内容事务函数。 + +```mermaid +flowchart TD + A[节点右键选择操作] --> B[解析并捕获节点和源 Image] + B --> C[复用输入准备与 AI Job] + C --> D[主线程验证原目标仍有效] + D --> E[加载并打包处理结果] + E --> F{源图有其他实际使用者} + F -->|有| G[当前节点绑定独立结果] + F -->|无| H[保留 Image 身份并更新内容] + G --> I[完成撤销记录和临时资源清理] + H --> I +``` + +### 捕获提交时的节点身份 + +在主线程捕获编辑树、节点与源 Image 的运行期身份,以及节点 ImageUser 播放参数。响应在主线程使用捕获目标,校验节点仍属于原树且仍绑定源图;名称只用于提示,防止删除后同名重建误命中。运行期引用仅保存在本地任务状态,Job 请求使用路径、帧参数等普通字典数据。切换活动节点不改变目标;目标删除、源绑定变化或变为只读时报告错误并清理结果。 + +### 原位替换保留节点结构并隔离共享图片 + +节点独占 Image 时复用内容快照与恢复,保留 Image 身份、名称和 Fake User;存在其他实际 Image 使用者时,仅当前节点绑定新结果,保留原图。共享判定覆盖其他节点树、Empty 和其他数据使用者,忽略 Fake User、编辑器显示和本次临时持有;在响应提交时计算。该规则仅用于节点目标。 + +保持节点位置、插值、投影、扩展方式和连线。结果继承源色彩空间并使用现有 alpha 配置;去背景输出透明图片,材质的 Alpha 连线和渲染设置由用户已有材质决定。动画输入沿用现有处理帧数约定,起始帧和播放设置从节点 ImageUser 读取,结果序列以同样时间位置播放。 + +### 失败恢复和撤销采用完整提交边界 + +节点提交覆盖 Image 内容、绑定和 ImageUser 设置的快照、恢复与清理。沿用两个操作现有撤销入口,验证异步成功仅产生一个有效图片编辑撤销步骤,重做恢复结果且无需再次推理。 + +## Risks / Trade-offs + +- [节点组被多个材质实例使用] → 编辑共享节点树内的节点会作用于所有该组实例,遵循 Blender 节点树共享语义;图片隔离以节点身份为边界。 +- [共享判定遗漏其他 Image 使用者] → 结合实际数据引用校验,测试跨材质、跨节点组、Empty 及 Fake User 情况。 +- [异步完成时上下文已变化] → 使用捕获的运行期身份并在提交前验证;覆盖改名、删除和重新绑定图片。 +- [Image 内容原位修改的撤销与打包行为] → 使用真实 Blender 测试验证撤销重做、保存重开、动画和失败恢复。 + +## Migration Plan + +先集中目标解析与图片结果加载,再接入菜单和节点提交,最后完成注册、回归及 Blender 验证。无需数据迁移;回退代码后,已有处理结果继续作为普通 Image 使用。 diff --git a/openspec/changes/add-texture-node-image-actions/proposal.md b/openspec/changes/add-texture-node-image-actions/proposal.md new file mode 100644 index 0000000..9848009 --- /dev/null +++ b/openspec/changes/add-texture-node-image-actions/proposal.md @@ -0,0 +1,24 @@ +## Why + +材质的 Image Texture 节点目前无法直接使用 AnyImage 去背景和放大。用户需要在节点右键菜单中完成处理,并像 Empty Image 一样在当前位置使用结果。 + +## What Changes + +- 在材质 Shader Editor 的有效 Image Texture 节点右键菜单增加 AnyImage 菜单,提供 Remove Background 和 Upscale。 +- 复用现有 AI 环境准备、模型配置、输入限制、任务进度和图片处理流程。 +- 处理成功后原位更新目标节点图片,保留节点、连线及设置;单独使用的图片保留 Image 身份,共享图片仅替换当前节点的绑定。 +- 为异步目标校验、失败恢复、撤销重做和菜单注册增加覆盖。 + +## Capabilities + +### New Capabilities + +- `texture-node-image-actions`: 材质 Image Texture 节点的 AI 图片操作入口及原位提交语义。 + +### Modified Capabilities + +无。 + +## Impact + +涉及 `src/anyimage/menu.py`、扩展注册入口、Remove Background / Upscale Operator、`common` 图片输入与提交逻辑及对应测试。服务端沿用现有 Job 协议,依赖和节点资产保持现状。 diff --git a/openspec/changes/add-texture-node-image-actions/specs/texture-node-image-actions/spec.md b/openspec/changes/add-texture-node-image-actions/specs/texture-node-image-actions/spec.md new file mode 100644 index 0000000..e1e0850 --- /dev/null +++ b/openspec/changes/add-texture-node-image-actions/specs/texture-node-image-actions/spec.md @@ -0,0 +1,85 @@ +## Purpose + +为材质 Shader Editor 中的 Image Texture 节点提供直接去背景与放大的操作入口,让用户在现有节点位置使用处理结果,并明确共享图片、异步完成和撤销恢复的可见行为。 + +## ADDED Requirements + +### Requirement: Material image texture context actions + +系统 SHALL 在材质 Shader Editor 中为具有有效图片的活动 Image Texture 节点提供 AnyImage 右键菜单,包含 Remove Background 和 Upscale;一次操作 SHALL 处理当前节点。 + +#### Scenario: An image texture is active +- **WHEN** 用户在材质编辑上下文中对有图片的活动 Image Texture 节点打开右键菜单 +- **THEN** 菜单提供两项操作,Upscale 显示预期 2× 输出尺寸 + +#### Scenario: The target is unavailable +- **WHEN** 当前上下文为 World、Compositor、Geometry Nodes,或活动节点不是 Image Texture、没有图片、不可编辑 +- **THEN** 系统不提供可执行的节点图片操作,且不使用活动 Empty 作为替代目标 + +#### Scenario: A material or node group is being edited +- **WHEN** 用户固定材质或进入其节点组并对有效 Image Texture 发起操作 +- **THEN** 系统处理该编辑树内的目标节点图片 + +### Requirement: Existing AI processing settings apply + +系统 SHALL 沿用 Empty Image 操作的 AI 环境准备、模型选择、任务状态和输入限制;Upscale SHALL 使用当前模型输出 2× 结果。 + +#### Scenario: Environment preparation is needed +- **WHEN** 用户点击操作且 AI 环境或模型尚未准备好 +- **THEN** 系统进入现有准备流程 + +#### Scenario: Upscale is unavailable +- **WHEN** 服务忙碌或图片长边达到当前最大 AI 输入尺寸 +- **THEN** Upscale 不可执行;服务忙碌时 Remove Background 也不可执行 + +#### Scenario: An animated texture is processed +- **WHEN** 目标图片为现有流程支持的视频或序列 +- **THEN** 系统依据节点播放设置和现有处理帧数约定取得输入,结果序列保留对应播放时间位置 + +### Requirement: Results replace the image at the target node + +系统 SHALL 在成功后保留目标节点、位置、连线和纹理设置,并在该节点使用结果图片。源图独占时 SHALL 保留 Image 身份、名称与 Fake User;源图有其他实际使用者时 SHALL 仅替换当前节点图片绑定并保持其他使用者和原图不变。结果 SHALL 继承源色彩空间、正确保存透明信息并支持保存重开。 + +#### Scenario: A sole image is processed +- **WHEN** 目标节点独占源图并完成去背景或放大 +- **THEN** 原 Image 内容及尺寸更新为结果,身份和名称保留,临时结果被释放 + +#### Scenario: Other users share the image +- **WHEN** 另一纹理节点或 Empty 等数据使用者仍引用源图 +- **THEN** 当前节点使用独立处理结果,其他使用者继续使用未改变的原图 + +#### Scenario: Only a fake user is present +- **WHEN** 源图除当前节点外只有 Fake User 或编辑器显示引用 +- **THEN** 系统保留源 Image 身份并更新内容 + +#### Scenario: Background removal succeeds +- **WHEN** 用户对节点执行 Remove Background 成功 +- **THEN** 图片带有处理后的透明信息,材质原有连线和渲染设置保留 + +### Requirement: Asynchronous completion keeps the original target + +系统 SHALL 将结果提交到发起任务时的节点,并在提交前验证原节点和图片绑定仍有效。 + +#### Scenario: Active selection changes +- **WHEN** 处理期间用户切换编辑器、材质或活动节点 +- **THEN** 结果仍提交到原节点 + +#### Scenario: The target is renamed +- **WHEN** 原节点或所属材质在任务期间改名且绑定仍有效 +- **THEN** 结果提交到同一原节点 + +#### Scenario: The original target is invalid +- **WHEN** 原节点被删除、同名重建、变为只读或改绑图片 +- **THEN** 系统报告目标失效,保留现有节点和图片状态,并清理本次结果 + +### Requirement: Edits support failure recovery and undo + +系统 SHALL 在失败或取消时保持或恢复原图片内容、节点绑定和播放设置,并清理临时资源。成功操作 SHALL 支持一次撤销还原及重做恢复。 + +#### Scenario: Commit fails +- **WHEN** 写入图片内容或绑定、配置播放设置期间发生错误 +- **THEN** 节点绑定与原图完整恢复,其他使用者保持不变 + +#### Scenario: The user undoes and redoes an edit +- **WHEN** 用户撤销一次成功操作,然后重做 +- **THEN** 撤销恢复原图、尺寸、绑定和播放设置,重做恢复处理结果且无需再次推理 diff --git a/openspec/changes/add-texture-node-image-actions/tasks.md b/openspec/changes/add-texture-node-image-actions/tasks.md new file mode 100644 index 0000000..564b23c --- /dev/null +++ b/openspec/changes/add-texture-node-image-actions/tasks.md @@ -0,0 +1,20 @@ +## 1. 图片编辑目标 + +- [x] 1.1 在 common 集中解析 Empty 与材质纹理节点目标,捕获源 Image、编辑树身份和播放参数;测试材质固定、节点组编辑、无效上下文和节点改名。 +- [x] 1.2 将图片结果加载改为显式 Image 输入并更新真实调用点;通过现有 Empty 图片处理测试,检查旧调用签名无残留。 + +## 2. 原位提交与任务接入 + +- [x] 2.1 实现节点图片事务提交,复用内容快照、恢复与打包;测试独占 Image 身份、Fake User、共享节点/Empty 隔离、连线设置保留及失败回滚。 +- [x] 2.2 接入现有 Remove Background 和 Upscale 的目标解析、输入准备和响应;测试模型配置、2× 尺寸、输入限制、动画帧参数及任务清理。 +- [x] 2.3 在异步响应中校验原树、节点及源绑定;测试切换选择后仍命中原目标,删除、同名重建和改绑图片时安全退出。 + +## 3. 右键菜单 + +- [x] 3.1 添加纹理节点 AnyImage 菜单,复用两项操作及 AI 设置入口,显示目标图片的 Upscale 尺寸;测试可见条件、服务忙碌及环境准备入口。 +- [x] 3.2 接入节点菜单回调与菜单类型注册、逆序注销;通过注册测试验证重复启用与禁用没有遗留回调。 + +## 4. 综合验证 + +- [x] 4.1 使用真实 Blender 测试验证两项异步操作的撤销重做、图片打包保存重开、共享节点组实例与动画播放,确认透明结果及原节点结构正确。 +- [x] 4.2 运行 `uv run pytest` 全量测试并确认正常退出,检查最终差异与旧引用;保留既有未提交修改,不构建文档或产物。 diff --git a/src/anyimage/__init__.py b/src/anyimage/__init__.py index 13fa08c..4381f5b 100644 --- a/src/anyimage/__init__.py +++ b/src/anyimage/__init__.py @@ -2,7 +2,12 @@ import bpy from .operators import clipboard_image from .operators.clipboard_image import CLASSES as CLIPBOARD_CLASSES -from .menu import AnyImageImageMenu, draw_image_context_menu +from .menu import ( + AnyImageImageMenu, + AnyImageTextureNodeMenu, + draw_image_context_menu, + draw_texture_node_context_menu, +) from .operators import CLASSES as OPERATOR_CLASSES from .operators.cutout_tool import CutoutTool from .operators.frame_tool import FrameTool @@ -20,6 +25,7 @@ CLASSES = ( *CLIPBOARD_CLASSES, *OPERATOR_CLASSES, AnyImageImageMenu, + AnyImageTextureNodeMenu, ServerPanel, ) @@ -42,6 +48,7 @@ def register(): bpy.types.Scene.anyimage_settings = bpy.props.PointerProperty(type=AnyImageSettings) bpy.types.VIEW3D_MT_object_context_menu.prepend(draw_image_context_menu) bpy.types.OUTLINER_MT_object.prepend(draw_image_context_menu) + bpy.types.NODE_MT_context_menu.prepend(draw_texture_node_context_menu) clipboard_image.register_keymaps() runtime.register() @@ -53,8 +60,9 @@ def unregister(): bpy.utils.unregister_tool(RectifyTool) bpy.utils.unregister_tool(MaskTool) bpy.utils.unregister_tool(FrameTool) - bpy.types.VIEW3D_MT_object_context_menu.remove(draw_image_context_menu) + bpy.types.NODE_MT_context_menu.remove(draw_texture_node_context_menu) bpy.types.OUTLINER_MT_object.remove(draw_image_context_menu) + bpy.types.VIEW3D_MT_object_context_menu.remove(draw_image_context_menu) if hasattr(bpy.types.Scene, "anyimage_settings"): del bpy.types.Scene.anyimage_settings for class_type in reversed(CLASSES): diff --git a/src/anyimage/common/image.py b/src/anyimage/common/image.py index ccc8eb2..697eec7 100644 --- a/src/anyimage/common/image.py +++ b/src/anyimage/common/image.py @@ -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), ) diff --git a/src/anyimage/common/image_target.py b/src/anyimage/common/image_target.py new file mode 100644 index 0000000..b53f973 --- /dev/null +++ b/src/anyimage/common/image_target.py @@ -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) diff --git a/src/anyimage/common/material.py b/src/anyimage/common/material.py index 9dba4b9..e03a0b2 100644 --- a/src/anyimage/common/material.py +++ b/src/anyimage/common/material.py @@ -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() diff --git a/src/anyimage/menu.py b/src/anyimage/menu.py index da43660..b823c38 100644 --- a/src/anyimage/menu.py +++ b/src/anyimage/menu.py @@ -1,6 +1,7 @@ import bpy from .common.image import is_image_empty +from .common.image_target import active_texture_node, image_edit_owner, owner_image from .operators.upscale import upscale_output_size @@ -9,8 +10,6 @@ class AnyImageImageMenu(bpy.types.Menu): bl_label = "AnyImage" def draw(self, context): - from .properties import ai_setup_label, ai_status - layout = self.layout layout.operator_context = "INVOKE_DEFAULT" layout.operator( @@ -29,37 +28,52 @@ class AnyImageImageMenu(bpy.types.Menu): icon="MOD_DISPLACE", ) layout.separator() + draw_image_actions(layout, context) + + +class AnyImageTextureNodeMenu(bpy.types.Menu): + bl_idname = "ANYIMAGE_MT_texture_node_context" + bl_label = "AnyImage" + + def draw(self, context): + draw_image_actions(self.layout, context) + + +def draw_image_actions(layout, context): + from .properties import ai_setup_label, ai_status + + layout.operator_context = "INVOKE_DEFAULT" + layout.operator( + "anyimage.remove_image_background", + text="Remove Background", + icon="IMAGE_ALPHA", + ) + layout.operator( + "anyimage.upscale_image", + text=upscale_menu_label(context), + icon="FULLSCREEN_ENTER", + ) + layout.separator() + status = ai_status() + if status["ready"]: layout.operator( - "anyimage.remove_image_background", - text="Remove Background", - icon="IMAGE_ALPHA", + "anyimage.open_ai_environment_settings", + text="Settings", + icon="PREFERENCES", ) + else: layout.operator( - "anyimage.upscale_image", - text=upscale_menu_label(context), - icon="FULLSCREEN_ENTER", + "anyimage.setup_ai_environment", + text=ai_setup_label(status), + icon="IMPORT", ) - layout.separator() - status = ai_status() - if status["ready"]: - layout.operator( - "anyimage.open_ai_environment_settings", - text="Settings", - icon="PREFERENCES", - ) - else: - layout.operator( - "anyimage.setup_ai_environment", - text=ai_setup_label(status), - icon="IMPORT", - ) def upscale_menu_label(context): - source_object = getattr(context, "object", None) - if not is_image_empty(source_object): + owner = image_edit_owner(context) + if owner is None: return "Upscale" - output_size = upscale_output_size(source_object.data) + output_size = upscale_output_size(owner_image(owner)) if output_size is None: return "Upscale" width, height = output_size @@ -71,3 +85,10 @@ def draw_image_context_menu(self, context): return self.layout.menu(AnyImageImageMenu.bl_idname, text="AnyImage", icon="PLUGIN") self.layout.separator() + + +def draw_texture_node_context_menu(self, context): + if active_texture_node(context) is None: + return + self.layout.menu(AnyImageTextureNodeMenu.bl_idname, text="AnyImage", icon="PLUGIN") + self.layout.separator() diff --git a/src/anyimage/operators/remove_background.py b/src/anyimage/operators/remove_background.py index e13fc24..0146d34 100644 --- a/src/anyimage/operators/remove_background.py +++ b/src/anyimage/operators/remove_background.py @@ -10,14 +10,8 @@ from ..common.ai import ( require_model, ) -from ..common.image import ( - cleanup_image_input, - default_extract_frames, - is_image_empty, - prepare_image_input, - source_start_frame, - video_total_frames, -) +from ..common.image import cleanup_image_input +from ..common.image_target import ImageEditTarget, image_edit_owner _HIDDEN = {"HIDDEN", "SKIP_SAVE"} @@ -26,14 +20,14 @@ _HIDDEN = {"HIDDEN", "SKIP_SAVE"} class RemoveImageBackground(bpy.types.Operator): bl_idname = "anyimage.remove_image_background" bl_label = "Remove Background" - bl_description = "Remove the background from this Image Empty with BEN2" + bl_description = "Remove the image background with BEN2" bl_options = {"REGISTER"} @classmethod def poll(cls, context): from ..runtime import runtime - return is_image_empty(context.object) and not runtime.server_busy() + return image_edit_owner(context) is not None and not runtime.server_busy() def invoke(self, _context, _event): setup_result = invoke_ai_setup_if_needed() @@ -41,58 +35,35 @@ class RemoveImageBackground(bpy.types.Operator): return setup_result return self.execute(_context) - def execute(self, context): - source_object = context.object - total_frames = video_total_frames(source_object.data) - if total_frames > 1: - context.scene.anyimage_settings.video_frames = ( - default_extract_frames(source_object) - ) - try: - source_path, delete_input = prepare_image_input( - source_object.data, - context.scene, - ) - except RuntimeError as error: - self.report({"ERROR"}, str(error)) - return {"CANCELLED"} - - result = bpy.ops.anyimage.run_background_removal( - input_path=str(source_path), - source_object_name=source_object.name, - delete_input=delete_input, - ) + def execute(self, _context): + result = bpy.ops.anyimage.run_background_removal() if "CANCELLED" not in result: return {"FINISHED"} - if delete_input: - cleanup_image_input(source_path, True) return {"CANCELLED"} -def apply_background_response(result, source_object_name): - from ..common.image import ( - load_image_edit_result, - replace_empty_image, - require_image_empty, - ) - - source_object = require_image_empty(source_object_name) - result_image = load_image_edit_result(source_object, result.directory) - replace_empty_image(source_object, result_image) - if "FINISHED" not in bpy.ops.ed.undo_push(message="Remove Background"): - raise RuntimeError("Unable to create the Remove Background Undo step") - - class RunBackgroundRemoval(JobOperatorBase, bpy.types.Operator): bl_idname = "anyimage.run_background_removal" bl_label = "Run Background Removal" job_type = "remove-background" - starting_message = "Starting Image Empty background removal" + starting_message = "Starting image background removal" input_path: bpy.props.StringProperty(options=_HIDDEN) - source_object_name: bpy.props.StringProperty(options=_HIDDEN) delete_input: bpy.props.BoolProperty(default=False, options=_HIDDEN) + def execute(self, context): + try: + self._image_target = ImageEditTarget.capture(context) + source_path, self.delete_input = self._image_target.prepare(context) + except RuntimeError as error: + self.report({"ERROR"}, str(error)) + return {"CANCELLED"} + self.input_path = str(source_path) + result = super().execute(context) + if "CANCELLED" in result: + self.cleanup() + return result + def request(self, context): require_environment() require_model(BEN2_MODEL_KEY) @@ -102,12 +73,15 @@ class RunBackgroundRemoval(JobOperatorBase, bpy.types.Operator): "model": BEN2_MODEL_KEY, "device": production_device().lower(), "video_frames": settings.video_frames, - "start_frame": source_start_frame(self.source_object_name), + "start_frame": self._image_target.start_frame, } def response(self, _context, result): - apply_background_response(result, self.source_object_name) + self._image_target.apply(result.directory) + if "FINISHED" not in bpy.ops.ed.undo_push(message="Remove Background"): + raise RuntimeError("Unable to create the Remove Background Undo step") return "Image background is removed" def cleanup(self): cleanup_image_input(self.input_path, self.delete_input) + self._image_target = None diff --git a/src/anyimage/operators/upscale.py b/src/anyimage/operators/upscale.py index d9ace05..37f822c 100644 --- a/src/anyimage/operators/upscale.py +++ b/src/anyimage/operators/upscale.py @@ -15,17 +15,8 @@ from ..common.ai import ( require_environment, require_model, ) -from ..common.image import ( - cleanup_image_input, - default_extract_frames, - is_image_empty, - load_image_edit_result, - prepare_image_input, - source_start_frame, - replace_empty_image, - require_image_empty, - video_total_frames, -) +from ..common.image import cleanup_image_input +from ..common.image_target import ImageEditTarget, image_edit_owner, owner_image _HIDDEN = {"HIDDEN", "SKIP_SAVE"} @@ -49,10 +40,10 @@ def upscale_input_size_allowed(image, max_input_size): class UpscaleImage(JobOperatorBase, bpy.types.Operator): bl_idname = "anyimage.upscale_image" bl_label = "Upscale" - bl_description = "Upscale this Image Empty by 2x with an AI model" + bl_description = "Upscale the image by 2x with an AI model" bl_options = {"UNDO"} job_type = "upscale-image" - starting_message = "Starting Image Empty upscale" + starting_message = "Starting image upscale" model: bpy.props.StringProperty( default="REALESRGAN_GENERAL_WDN_X4V3", @@ -69,19 +60,17 @@ class UpscaleImage(JobOperatorBase, bpy.types.Operator): options=_HIDDEN, ) input_path: bpy.props.StringProperty(options=_HIDDEN) - source_object_name: bpy.props.StringProperty(options=_HIDDEN) - source_image_name: bpy.props.StringProperty(options=_HIDDEN) delete_input: bpy.props.BoolProperty(default=False, options=_HIDDEN) @classmethod def poll(cls, context): from ..runtime import runtime - source_object = getattr(context, "object", None) + owner = image_edit_owner(context) return ( - is_image_empty(source_object) + owner is not None and upscale_input_size_allowed( - source_object.data, + owner_image(owner), configured_max_ai_input_size(context), ) and not runtime.server_busy() @@ -96,11 +85,12 @@ class UpscaleImage(JobOperatorBase, bpy.types.Operator): def execute(self, context): self.model = configured_upscale_model(context) self.max_input_size = configured_max_ai_input_size(context) - source_object = context.object - if not is_image_empty(source_object): - self.report({"ERROR"}, "Select an Image Empty to upscale") + try: + self._image_target = ImageEditTarget.capture(context) + except RuntimeError as error: + self.report({"ERROR"}, str(error)) return {"CANCELLED"} - source_image = source_object.data + source_image = self._image_target.image if not upscale_input_size_allowed(source_image, self.max_input_size): self.report( {"ERROR"}, @@ -108,26 +98,17 @@ class UpscaleImage(JobOperatorBase, bpy.types.Operator): f"({self.max_input_size} px)", ) return {"CANCELLED"} - self.source_image_name = source_image.name - if video_total_frames(source_image) > 1: - context.scene.anyimage_settings.video_frames = ( - default_extract_frames(source_object) - ) try: - source_path, delete_input = prepare_image_input( - source_image, - context.scene, - ) + source_path, delete_input = self._image_target.prepare(context) except RuntimeError as error: self.report({"ERROR"}, str(error)) return {"CANCELLED"} self.input_path = str(source_path) - self.source_object_name = source_object.name self.delete_input = delete_input result = super().execute(context) if "CANCELLED" in result: - cleanup_image_input(self.input_path, self.delete_input) + self.cleanup() return result def request(self, context): @@ -139,23 +120,15 @@ class UpscaleImage(JobOperatorBase, bpy.types.Operator): "model": self.model, "device": production_device().lower(), "video_frames": settings.video_frames, - "start_frame": source_start_frame(self.source_object_name), + "start_frame": self._image_target.start_frame, "max_input_size": int(self.max_input_size), } def response(self, _context, result): - source_object = require_image_empty(self.source_object_name) - source_image = bpy.data.images.get(self.source_image_name) - if source_image is None: - raise RuntimeError("The source image is no longer available") - result_image = load_image_edit_result( - source_object, - result.directory, - frame_prefix="upscale.", - ) - replace_empty_image(source_object, result_image) + self._image_target.apply(result.directory, frame_prefix="upscale.") model_label = upscale_model_label(self.model) return f"Image is upscaled with {model_label}" def cleanup(self): cleanup_image_input(self.input_path, self.delete_input) + self._image_target = None diff --git a/tests/anyimage/common/test_alpha_presentation.py b/tests/anyimage/common/test_alpha_presentation.py index d155df1..f08c364 100644 --- a/tests/anyimage/common/test_alpha_presentation.py +++ b/tests/anyimage/common/test_alpha_presentation.py @@ -24,7 +24,8 @@ def test_owned_color_images_preserve_source_and_use_premul(floating): try: assert original.alpha_mode == "STRAIGHT" np.testing.assert_allclose(images.image_rgba(original), expected) - for image in (copied, cropped, edited): + assert edited.alpha_mode == original.alpha_mode + for image in (copied, cropped): assert image.alpha_mode == "PREMUL" np.testing.assert_allclose(images.image_rgba(image), expected, atol=1 / 255) finally: @@ -33,13 +34,14 @@ def test_owned_color_images_preserve_source_and_use_premul(floating): @pytest.mark.parametrize("loader", [images.load_image_edit_result, images.load_color_result_image]) -def test_job_color_loader_defaults_to_premul(tmp_path, loader): +def test_job_loader_uses_edit_or_material_alpha_policy(tmp_path, loader): rgba = np.array([[[51, 102, 204, 64]]], dtype=np.uint8) Image.fromarray(rgba).save(tmp_path / "image.0001.png") original = bpy.data.images.new("Job source", width=1, height=1, alpha=True) - result = loader(SimpleNamespace(data=original), tmp_path) + source = original if loader == images.load_image_edit_result else SimpleNamespace(data=original) + result = loader(source, tmp_path) try: - assert result.alpha_mode == "PREMUL" + assert result.alpha_mode == ("STRAIGHT" if loader == images.load_image_edit_result else "PREMUL") assert result.packed_file is not None assert original.alpha_mode == "STRAIGHT" finally: @@ -70,6 +72,7 @@ def test_failed_empty_replacement_restores_blending_and_timing(): def test_business_pixels_ignore_premul_read_interpretation(): original = bpy.data.images.new("Premul read source", width=1, height=1, alpha=True) + original.alpha_mode = "PREMUL" expected = np.array([0.4, 0.25, 0.1, 0.5], dtype=np.float32) result = images.create_image_edit_result(original, expected, (1, 1)) try: @@ -87,6 +90,7 @@ def test_business_pixels_ignore_premul_read_interpretation(): @pytest.mark.parametrize("dirty", [False, True]) def test_business_read_preserves_shared_packed_source(dirty): original = bpy.data.images.new("Read integrity", width=1, height=1, alpha=True) + original.alpha_mode = "PREMUL" result = images.create_image_edit_result(original, np.array([0.2, 0.4, 0.8, 0.25], dtype=np.float32), (1, 1)) owner = bpy.data.objects.new("Read owner", None) owner.empty_display_type = "IMAGE" @@ -219,7 +223,7 @@ def test_mask_pack_reopen_add_and_repeat_frame_preserve_hidden_color(tmp_path): bpy.data.images.remove(result) result = next_result result.reload() - assert result.alpha_mode == "PREMUL" and result.packed_file is not None + assert result.alpha_mode == "STRAIGHT" and result.packed_file is not None np.testing.assert_allclose(images.image_rgba(result), rgba, atol=1 / 255) finally: bpy.data.images.remove(result) diff --git a/tests/anyimage/common/test_empty_image_edits.py b/tests/anyimage/common/test_empty_image_edits.py index c4094d4..9f9fb07 100644 --- a/tests/anyimage/common/test_empty_image_edits.py +++ b/tests/anyimage/common/test_empty_image_edits.py @@ -33,6 +33,27 @@ def edit_result(source): ) +@pytest.mark.parametrize("alpha_mode", ["STRAIGHT", "PREMUL", "CHANNEL_PACKED", "NONE"]) +@pytest.mark.parametrize("shared", [False, True]) +@pytest.mark.parametrize("entry", ["pixels", "image.", "upscale."]) +def test_image_edit_replacement_preserves_alpha_mode(source, tmp_path, alpha_mode, shared, entry): + original = source.data + original.alpha_mode = alpha_mode + if shared: + other = create_empty("Other", original) + if entry == "pixels": + result = edit_result(source) + else: + Image.new("RGBA", (4, 3), (100, 150, 200, 128)).save(tmp_path / f"{entry}0001.png") + result = images.load_image_edit_result(original, tmp_path, frame_prefix=entry) + assert result.alpha_mode == alpha_mode + images.replace_empty_image(source, result) + assert source.data.alpha_mode == alpha_mode + assert (source.data == original) == (not shared) + if shared: + assert other.data == original and original.alpha_mode == alpha_mode + + @pytest.mark.parametrize("sharing", ["visible", "hidden", "other_scene"]) def test_shared_edit_preserves_other_empty(source, sharing): original = source.data @@ -179,7 +200,7 @@ def test_animated_result_preserves_id_and_reopens(source, tmp_path): for index in (1, 2): Image.new("RGBA", (4, 3), (index * 80, 100, 50, 255)).save(tmp_path / f"image.{index:04d}.png") original.source = "SEQUENCE" - result = images.load_image_edit_result(source, tmp_path) + result = images.load_image_edit_result(source.data, tmp_path) expected_path = result.filepath_raw expected_packed = bytes(result.packed_file.data) if result.packed_file else None final = images.replace_empty_image(source, result) diff --git a/tests/anyimage/common/test_image_data.py b/tests/anyimage/common/test_image_data.py index 793f3ce..8d00e55 100644 --- a/tests/anyimage/common/test_image_data.py +++ b/tests/anyimage/common/test_image_data.py @@ -46,30 +46,30 @@ class ImageDataTest(unittest.TestCase): self.assertEqual(self.modules.image.video_total_frames(image), 1) - def test_default_extract_frames_uses_empty_image_frames(self): + def test_extract_frame_count_uses_empty_image_frames(self): source_object = SimpleNamespace( data=SimpleNamespace(source="MOVIE", frame_duration=97), image_user=SimpleNamespace(frame_duration=24), ) - self.assertEqual(self.modules.image.default_extract_frames(source_object), 24) + self.assertEqual(self.modules.image.extract_frame_range(source_object.data, getattr(source_object, "image_user", None))[1], 24) - def test_default_extract_frames_caps_at_video_total(self): + def test_extract_frame_count_caps_at_video_total(self): source_object = SimpleNamespace( data=SimpleNamespace(source="MOVIE", frame_duration=97), image_user=SimpleNamespace(frame_duration=200), ) - self.assertEqual(self.modules.image.default_extract_frames(source_object), 97) + self.assertEqual(self.modules.image.extract_frame_range(source_object.data, getattr(source_object, "image_user", None))[1], 97) - def test_default_extract_frames_falls_back_to_video_total(self): + def test_extract_frame_count_falls_back_to_video_total(self): source_object = SimpleNamespace( data=SimpleNamespace(source="MOVIE", frame_duration=97), ) - self.assertEqual(self.modules.image.default_extract_frames(source_object), 97) + self.assertEqual(self.modules.image.extract_frame_range(source_object.data, getattr(source_object, "image_user", None))[1], 97) def test_extract_frame_range_honors_offset_within_clip(self): @@ -83,7 +83,7 @@ class ImageDataTest(unittest.TestCase): ) self.assertEqual( - self.modules.image.extract_frame_range(source_object), + self.modules.image.extract_frame_range(source_object.data, source_object.image_user), (71, 24), ) @@ -99,7 +99,7 @@ class ImageDataTest(unittest.TestCase): ) self.assertEqual( - self.modules.image.extract_frame_range(source_object), + self.modules.image.extract_frame_range(source_object.data, source_object.image_user), (74, 24), ) @@ -115,7 +115,7 @@ class ImageDataTest(unittest.TestCase): ) self.assertEqual( - self.modules.image.extract_frame_range(source_object), + self.modules.image.extract_frame_range(source_object.data, source_object.image_user), (1, 24), ) @@ -131,7 +131,7 @@ class ImageDataTest(unittest.TestCase): ) self.assertEqual( - self.modules.image.extract_frame_range(source_object), + self.modules.image.extract_frame_range(source_object.data, source_object.image_user), (1, 1), ) @@ -147,7 +147,7 @@ class ImageDataTest(unittest.TestCase): ) self.assertEqual( - self.modules.image.extract_frame_range(source_object), + self.modules.image.extract_frame_range(source_object.data, source_object.image_user), (71, 1), ) diff --git a/tests/anyimage/common/test_image_target.py b/tests/anyimage/common/test_image_target.py new file mode 100644 index 0000000..4927431 --- /dev/null +++ b/tests/anyimage/common/test_image_target.py @@ -0,0 +1,222 @@ +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"]) +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"]) + 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 diff --git a/tests/anyimage/common/test_material_view_adaptation.py b/tests/anyimage/common/test_material_view_adaptation.py index 65fabe0..de54e1c 100644 --- a/tests/anyimage/common/test_material_view_adaptation.py +++ b/tests/anyimage/common/test_material_view_adaptation.py @@ -115,7 +115,7 @@ def test_material_pixels_survive_pack_and_library_reload(color_data, tmp_path, e assert (restored.colorspace_settings.name, restored.alpha_mode) == selected np.testing.assert_allclose(images.image_pixels(restored), before, atol=1 / 255) edited = images.create_image_edit_result(restored, images.image_pixels(restored), (2, 1)) - assert edited.alpha_mode == "PREMUL" + assert edited.alpha_mode == selected[1] np.testing.assert_allclose(images.image_pixels(edited), before, atol=1 / 255) @@ -179,16 +179,16 @@ def test_new_material_uses_current_preference_and_preserves_existing(color_data, np.testing.assert_array_equal(images.image_pixels(image), pixels) -def test_edit_results_keep_their_policy_with_adaptation_enabled(color_data, tmp_path): +def test_edit_results_preserve_source_alpha_with_adaptation_enabled(color_data, tmp_path): source = bpy.data.images.new("Editing source", width=1, height=1, alpha=True) rgba = np.array([0.2, 0.4, 0.8, 0.25], dtype=np.float32) Image.fromarray(np.array([[[51, 102, 204, 64]]], dtype=np.uint8)).save(tmp_path / "image.0001.png") with patch.object(preferences, "addon_preferences", return_value=SimpleNamespace(adapt_material_to_view_transform=True)): edited = images.create_image_edit_result(source, rgba, (1, 1)) - loaded = images.load_image_edit_result(SimpleNamespace(data=source), tmp_path) + loaded = images.load_image_edit_result(source, tmp_path) for result in (edited, loaded): assert result.colorspace_settings.name == "sRGB" - assert result.alpha_mode == "PREMUL" + assert result.alpha_mode == source.alpha_mode np.testing.assert_allclose(images.image_pixels(result), rgba, atol=1 / 255) assert source.colorspace_settings.name == "sRGB" assert source.alpha_mode == "STRAIGHT" diff --git a/tests/anyimage/operators/test_ai_image_operators.py b/tests/anyimage/operators/test_ai_image_operators.py index 602e937..5885ac8 100644 --- a/tests/anyimage/operators/test_ai_image_operators.py +++ b/tests/anyimage/operators/test_ai_image_operators.py @@ -21,7 +21,7 @@ class AiImageOperatorsTest(BlenderTestCase): self.assertEqual(operator_type.bl_options, {"UNDO"}) operator = operator_type() operator.input_path = "input.png" - operator.source_object_name = "Source" + operator._image_target = SimpleNamespace(start_frame=4) operator.model = "SAFMN_REAL_X4" operator.max_input_size = 1024 context = SimpleNamespace( @@ -34,7 +34,6 @@ class AiImageOperatorsTest(BlenderTestCase): self.upscale, "require_input_path", return_value=Path("input.png") ), patch.object(self.upscale, "production_device", return_value="CUDA"), - patch.object(self.upscale, "source_start_frame", return_value=4), ): request = operator.request(context) @@ -89,63 +88,37 @@ class AiImageOperatorsTest(BlenderTestCase): def test_remove_background_loads_a_plain_image_edit_result(self): - source = SimpleNamespace() - result_image = SimpleNamespace() result = SimpleNamespace(directory=Path("result")) + operator = self.remove_background.RunBackgroundRemoval() + operator._image_target = Mock() ops = SimpleNamespace( ed=SimpleNamespace(undo_push=Mock(return_value={"FINISHED"})) ) with ( - patch.object(self.image_data, "require_image_empty", return_value=source), - patch.object( - self.image_data, - "load_image_edit_result", - return_value=result_image, - ) as load, - patch.object(self.image_data, "replace_empty_image") as replace, patch.object(self.remove_background.bpy, "ops", ops, create=True), ): - self.remove_background.apply_background_response(result, "Source") + operator.response(None, result) - load.assert_called_once_with(source, Path("result")) - replace.assert_called_once_with(source, result_image) + operator._image_target.apply.assert_called_once_with(Path("result")) + ops.ed.undo_push.assert_called_once_with(message="Remove Background") def test_upscale_loads_a_plain_image_edit_result(self): - source_image = SimpleNamespace() - source = SimpleNamespace(data=source_image) - result_image = SimpleNamespace() result = SimpleNamespace(directory=Path("result")) operator = self.upscale.UpscaleImage() - operator.source_object_name = "Source" - operator.source_image_name = "Poster.png" + operator._image_target = Mock() operator.model = "SAFMN_REAL_X4" - images = SimpleNamespace(get=lambda name: source_image if name == "Poster.png" else None) with ( - patch.object(self.upscale, "require_image_empty", return_value=source), - patch.object( - self.upscale, - "load_image_edit_result", - return_value=result_image, - ) as load, - patch.object(self.upscale, "replace_empty_image") as replace, patch.object( self.upscale, "upscale_model_label", return_value="SAFMN Real x4", ), - patch.object( - self.upscale.bpy, - "data", - SimpleNamespace(images=images), - ), ): message = operator.response(None, result) - load.assert_called_once_with( - source, + operator._image_target.apply.assert_called_once_with( Path("result"), frame_prefix="upscale.", ) - replace.assert_called_once_with(source, result_image) self.assertEqual(message, "Image is upscaled with SAFMN Real x4") diff --git a/tests/anyimage/operators/test_frame_antialiasing.py b/tests/anyimage/operators/test_frame_antialiasing.py index d75ca05..1599340 100644 --- a/tests/anyimage/operators/test_frame_antialiasing.py +++ b/tests/anyimage/operators/test_frame_antialiasing.py @@ -126,7 +126,7 @@ def test_packed_frame_mask_add_keeps_boundary_color(background): ).reshape(1, 1, 4) np.testing.assert_allclose(restored[0, 0, :3], expected, atol=1 / 255) assert restored[0, 0, 3] == 1 - assert result.alpha_mode == "PREMUL" + assert result.alpha_mode == "STRAIGHT" finally: bpy.data.images.remove(original) bpy.data.images.remove(result) diff --git a/tests/anyimage/operators/test_image_edit_integration.py b/tests/anyimage/operators/test_image_edit_integration.py index addf0ed..f2d2b6e 100644 --- a/tests/anyimage/operators/test_image_edit_integration.py +++ b/tests/anyimage/operators/test_image_edit_integration.py @@ -31,7 +31,7 @@ def test_image_edit_result_uses_complete_shared_source_name(): assert shared.data == source_image assert source_image.alpha_mode == "STRAIGHT" - assert result_image.alpha_mode == "PREMUL" + assert result_image.alpha_mode == "STRAIGHT" assert source.use_empty_image_alpha assert source.data == result_image assert result_image.name == "Poster.png.001" @@ -92,14 +92,14 @@ def test_mask_frame_mask_add_preserves_packed_hidden_rgb(): ) result = image_data.create_image_edit_result(source.data, framed, (4, 4)) result = image_data.replace_empty_image(source, result) - assert result.alpha_mode == "PREMUL" + assert result.alpha_mode == "STRAIGHT" assert source.use_empty_image_alpha assert result.packed_file is not None packed = np.asarray(Image.open(BytesIO(result.packed_file.data)).convert("RGBA")) / 255.0 np.testing.assert_allclose(packed[..., :3], expected[..., :3], atol=1 / 255) np.testing.assert_array_equal(packed[..., 3], 0) result.reload() - assert result.alpha_mode == "PREMUL" + assert result.alpha_mode == "STRAIGHT" operator.mode = "ADD" assert edit_mask.EditImageAlpha._apply_mask( operator, SimpleNamespace(), source, selection diff --git a/tests/anyimage/operators/test_mask_undo.py b/tests/anyimage/operators/test_mask_undo.py index 2a33147..bcd5995 100644 --- a/tests/anyimage/operators/test_mask_undo.py +++ b/tests/anyimage/operators/test_mask_undo.py @@ -9,8 +9,10 @@ from anyimage.operators import mask_tool as mask @pytest.mark.parametrize("gesture", ["LASSO", "BRUSH", "POLYLINE"]) -def test_mask_commit_is_one_undo_transaction(gesture): +@pytest.mark.parametrize("alpha_mode", ["STRAIGHT", "PREMUL"]) +def test_mask_commit_is_one_undo_transaction(gesture, alpha_mode): source = bpy.data.images.new("Undo source", width=8, height=8, alpha=True) + source.alpha_mode = alpha_mode source.pixels.foreach_set(np.ones(8 * 8 * 4, dtype=np.float32)) source.pack() owner = bpy.data.objects.new("Mask undo owner", None) @@ -47,11 +49,12 @@ def test_mask_commit_is_one_undo_transaction(gesture): assert bpy.ops.anyimage.edit_image_alpha("EXEC_DEFAULT", True) == {"FINISHED"} create.assert_called_once() result = bpy.data.objects[owner_name].data - assert result.packed_file is not None and result.alpha_mode == "PREMUL" + assert result.packed_file is not None and result.alpha_mode == alpha_mode assert bpy.data.objects[shared_name].data.name == source_name np.testing.assert_array_equal(image.image_pixels(bpy.data.images[source_name]), before) assert bpy.ops.ed.undo() == {"FINISHED"} assert bpy.data.objects[owner_name].data.name == source_name + assert bpy.data.objects[owner_name].data.alpha_mode == alpha_mode np.testing.assert_array_equal(image.image_pixels(bpy.data.objects[owner_name].data), before) finally: bpy.utils.unregister_class(mask.EditImageAlpha) diff --git a/tests/anyimage/operators/test_texture_node_actions.py b/tests/anyimage/operators/test_texture_node_actions.py new file mode 100644 index 0000000..538840b --- /dev/null +++ b/tests/anyimage/operators/test_texture_node_actions.py @@ -0,0 +1,225 @@ +from contextlib import ExitStack +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock, patch + +import bpy +import numpy as np +import pytest + +from anyimage import menu +from anyimage.common import image as images +from anyimage.common.image_target import ImageEditTarget, active_texture_node +from anyimage.operators import remove_background, upscale +from anyimage.runtime import runtime +from tests.support.image_texture import texture, texture_context, write_result + + +@pytest.fixture +def shader_editor(texture): + mesh = bpy.data.meshes.new("Material owner") + obj = bpy.data.objects.new("Material owner", mesh) + bpy.context.collection.objects.link(obj) + obj.data.materials.append(bpy.data.materials["Texture material"]) + bpy.context.view_layer.objects.active = obj + obj.select_set(True) + area = next(area for area in bpy.context.screen.areas if area.type == "VIEW_3D") + area.type = "NODE_EDITOR" + area.ui_type = "ShaderNodeTree" + with bpy.context.temp_override(area=area): + yield bpy.context + + +def test_shader_editor_pin_and_shared_group_target(shader_editor, tmp_path): + context = shader_editor + node = active_texture_node(context) + assert node is not None + assert context.space_data.id == bpy.data.materials["Texture material"] + context.space_data.pin = True + bpy.context.view_layer.objects.active = None + assert active_texture_node(context) == node + # Use Blender's own grouping operation to exercise nested editor context. + for candidate in node.id_data.nodes: + candidate.select = candidate == node + assert bpy.ops.node.group_make() == {"FINISHED"} + nested = active_texture_node(context) + assert nested is not None + assert context.space_data.edit_tree != context.space_data.node_tree + group = nested.id_data + second_instance = context.space_data.node_tree.nodes.new("ShaderNodeGroup") + second_instance.node_tree = group + source = nested.image + target = ImageEditTarget.capture(context) + write_result(tmp_path) + target.apply(tmp_path) + assert nested.image == source + assert tuple(source.size) == (8, 6) + assert second_instance.node_tree.nodes[nested.name].image == source + + +def test_linked_material_target_is_unavailable(texture, tmp_path): + path = str(tmp_path / "linked.blend") + bpy.data.libraries.write(path, {bpy.data.materials["Texture material"]}) + with bpy.data.libraries.load(path, link=True) as (source, destination): + destination.materials = source.materials + linked = destination.materials[0].node_tree.nodes["Source texture"] + context = texture_context(linked) + assert active_texture_node(context) is None + target = ImageEditTarget(linked, linked.image, linked.id_data) + with pytest.raises(RuntimeError, match="no longer available"): + target.validate() + + +def test_texture_menu_uses_node_dimensions_and_ai_settings(texture): + context = texture_context(texture) + layout = SimpleNamespace(operator=Mock(), separator=Mock(), menu=Mock()) + with patch("anyimage.properties.ai_status", return_value={"ready": True}): + menu.AnyImageTextureNodeMenu.draw(SimpleNamespace(layout=layout), context) + assert layout.operator_context == "INVOKE_DEFAULT" + assert [call.args[0] for call in layout.operator.call_args_list] == [ + "anyimage.remove_image_background", "anyimage.upscale_image", "anyimage.open_ai_environment_settings", + ] + assert layout.operator.call_args_list[1].kwargs["text"] == "Upscale (8 × 6)" + menu.draw_texture_node_context_menu(SimpleNamespace(layout=layout), context) + layout.menu.assert_called_once_with(menu.AnyImageTextureNodeMenu.bl_idname, text="AnyImage", icon="PLUGIN") + texture.image = None + layout.menu.reset_mock() + menu.draw_texture_node_context_menu(SimpleNamespace(layout=layout), context) + layout.menu.assert_not_called() + + +@pytest.mark.parametrize("busy,limit,expected", [(False, 2048, True), (True, 2048, False), (False, 4, False)]) +def test_texture_poll_applies_busy_and_size_limits(texture, busy, limit, expected): + context = texture_context(texture) + with patch.object(runtime, "server_busy", return_value=busy), patch.object( + upscale, "configured_max_ai_input_size", return_value=limit, + ): + assert upscale.UpscaleImage.poll(context) == expected + assert remove_background.RemoveImageBackground.poll(context) == (not busy) + + +@pytest.mark.parametrize("operation,prefix", [(upscale.UpscaleImage, "upscale."), (remove_background.RunBackgroundRemoval, "image.")]) +@pytest.mark.parametrize("shared", [False, True]) +def test_texture_action_undo_redo_restores_image_and_binding(texture, tmp_path, operation, prefix, shared): + source_name = texture.image.name + source_pixels = images.image_pixels(texture.image) + if shared: + other = texture.id_data.nodes.new("ShaderNodeTexImage") + other.image = texture.image + other.name = "Other texture" + write_result(tmp_path, prefix) + + def execute(operator, context): + node = bpy.data.materials["Texture material"].node_tree.nodes["Source texture"] + operator._image_target = ImageEditTarget.capture(texture_context(node)) + if operation == upscale.UpscaleImage: + operator.model = "PISA_SR" + operator.response(context, SimpleNamespace(directory=tmp_path)) + return {"FINISHED"} + + bpy.context.preferences.edit.use_global_undo = True + with patch.object(operation, "execute", execute), patch.object( + operation, "poll", classmethod(lambda cls, context: True), create=True, + ): + bpy.utils.register_class(operation) + try: + bpy.ops.ed.undo_push(message="Before texture processing") + call = getattr(bpy.ops.anyimage, operation.bl_idname.split(".")[1]) + assert call("EXEC_DEFAULT", True) == {"FINISHED"} + result_pixels = images.image_pixels(texture.image) + assert tuple(texture.image.size) == (8, 6) + assert bpy.ops.ed.undo() == {"FINISHED"} + restored = bpy.data.materials["Texture material"].node_tree.nodes["Source texture"] + assert restored.image.name == source_name + assert tuple(restored.image.size) == (4, 3) + np.testing.assert_array_equal(images.image_pixels(restored.image), source_pixels) + assert bpy.ops.ed.redo() == {"FINISHED"} + redone = bpy.data.materials["Texture material"].node_tree.nodes["Source texture"] + assert tuple(redone.image.size) == (8, 6) + np.testing.assert_array_equal(images.image_pixels(redone.image), result_pixels) + if shared: + other = redone.id_data.nodes["Other texture"] + np.testing.assert_array_equal(images.image_pixels(other.image), source_pixels) + assert other.image != redone.image + finally: + bpy.utils.unregister_class(operation) + + +@pytest.mark.parametrize("operation,module,prefix", [ + (upscale.UpscaleImage, upscale, "upscale."), + (remove_background.RunBackgroundRemoval, remove_background, "image."), +]) +@pytest.mark.parametrize("outcome", ["succeeded", "cancelled", "failed", "invalid_target"]) +def test_texture_job_request_and_completion(texture, tmp_path, operation, module, prefix, outcome): + context = texture_context(texture) + context.window = object() + context.window_manager = SimpleNamespace( + progress_begin=Mock(), progress_end=Mock(), event_timer_add=Mock(return_value=object()), + event_timer_remove=Mock(), modal_handler_add=Mock(), + ) + write_result(tmp_path, prefix) + controller = SimpleNamespace( + submit=Mock(return_value={"job_id": "texture-job", "directory": str(tmp_path)}), + status=Mock(return_value={"state": "failed" if outcome == "invalid_target" else outcome}), + mark_job_complete=Mock(), cancel=Mock(), + ) + original_pixels = images.image_pixels(texture.image) + original_execute = operation.execute + + def execute(operator, _context): + with ExitStack() as stack: + for name in ("require_environment", "require_model"): + stack.enter_context(patch.object(module, name)) + stack.enter_context(patch.object(module, "production_device", return_value="CPU")) + stack.enter_context(patch.object(operation, "controller", return_value=controller)) + stack.enter_context(patch.object(runtime, "update_ui")) + stack.enter_context(patch.object(runtime, "redraw_ui")) + if operation == upscale.UpscaleImage: + stack.enter_context(patch.object(upscale, "configured_upscale_model", return_value="SAFMN_REAL_X4")) + stack.enter_context(patch.object(upscale, "configured_max_ai_input_size", return_value=2048)) + assert original_execute(operator, context) == {"RUNNING_MODAL"} + job = runtime.active_job + job.start_thread.join(timeout=5) + assert not job.start_thread.is_alive() + kind, parameters = controller.submit.call_args.args + assert kind == operation.job_type + assert parameters["start_frame"] == 1 + assert parameters["device"] == "cpu" + assert parameters["model"] == ("SAFMN_REAL_X4" if operation == upscale.UpscaleImage else "BEN2_BASE") + input_path = operator.input_path + assert operator.delete_input + # Selection changes must not redirect completion. + other = texture.id_data.nodes.new("ShaderNodeTexImage") + texture.id_data.nodes.active = other + if outcome == "invalid_target": + texture.image = None + controller.status.return_value = {"state": "succeeded"} + bpy.ops.ed.undo_push(message="Before asynchronous texture result") + result = operator.modal(context, SimpleNamespace(type="TIMER")) + assert result == ({"FINISHED"} if outcome == "succeeded" else {"CANCELLED"}) + assert runtime.active_job is None + assert operator._image_target is None + assert not Path(input_path).exists() + assert other.image is None + if outcome == "succeeded": + assert tuple(texture.image.size) == (8, 6) + elif outcome != "invalid_target": + np.testing.assert_array_equal(images.image_pixels(texture.image), original_pixels) + context.window_manager.event_timer_remove.assert_called_once() + return result + + with patch.object(operation, "execute", execute), patch.object( + operation, "poll", classmethod(lambda cls, context: True), create=True, + ): + bpy.utils.register_class(operation) + try: + call = getattr(bpy.ops.anyimage, operation.bl_idname.split(".")[1]) + if outcome in {"failed", "invalid_target"}: + message = "Server task failed" if outcome == "failed" else "no longer available" + with pytest.raises(RuntimeError, match=message): + call("EXEC_DEFAULT", True) + else: + assert call("EXEC_DEFAULT", True) == ({"FINISHED"} if outcome == "succeeded" else {"CANCELLED"}) + finally: + runtime.close_active() + bpy.utils.unregister_class(operation) diff --git a/tests/anyimage/operators/test_upscale_undo.py b/tests/anyimage/operators/test_upscale_undo.py index 4be5b99..7691fc4 100644 --- a/tests/anyimage/operators/test_upscale_undo.py +++ b/tests/anyimage/operators/test_upscale_undo.py @@ -6,6 +6,7 @@ from PIL import Image from anyimage.operators.upscale import UpscaleImage from anyimage.common.image import image_pixels +from anyimage.common.image_target import ImageEditTarget def test_upscale_result_replacement_undo_restores_source(tmp_path): @@ -21,8 +22,7 @@ def test_upscale_result_replacement_undo_restores_source(tmp_path): Image.new("RGBA", (16,16), (32,64,96,128)).save(tmp_path / "upscale.0001.png") def execute(operator, context): - operator.source_object_name = owner_name - operator.source_image_name = source_name + operator._image_target = ImageEditTarget.capture(SimpleNamespace(object=owner)) operator.model = "PISA_SR" operator.response(context, SimpleNamespace(directory=tmp_path)) return {"FINISHED"} diff --git a/tests/anyimage/test_addon_registration.py b/tests/anyimage/test_addon_registration.py index 3d96297..b80198b 100644 --- a/tests/anyimage/test_addon_registration.py +++ b/tests/anyimage/test_addon_registration.py @@ -290,8 +290,10 @@ class AddonRegistrationTest(BlenderTestCase): ) existing_context_draw = object() existing_outliner_draw = object() + existing_node_draw = object() self.context_menu_draws.append(existing_context_draw) self.outliner_menu_draws.append(existing_outliner_draw) + self.node_menu_draws.append(existing_node_draw) self.assertFalse(any("debug" in cls.bl_idname.lower() for cls in self.anyimage.CLASSES if hasattr(cls, "bl_idname"))) settings = self.anyimage.properties.AnyImageSettings.__annotations__ @@ -346,6 +348,9 @@ class AddonRegistrationTest(BlenderTestCase): ) self.assertFalse(hasattr(self.fake_bpy.types.Image, "anyimage_upscale_source")) + self.assertEqual(self.node_menu_draws, [self.anyimage.draw_texture_node_context_menu, existing_node_draw]) + self.assertIn(self.anyimage.AnyImageTextureNodeMenu, self.registered) + unregister_order = [] original_unregister = self.fake_bpy.utils.unregister_class def unregister(cls): @@ -362,4 +367,9 @@ class AddonRegistrationTest(BlenderTestCase): self.assertEqual(self.status_bar_draws, []) self.assertEqual(self.context_menu_draws, [existing_context_draw]) self.assertEqual(self.outliner_menu_draws, [existing_outliner_draw]) + self.assertEqual(self.node_menu_draws, [existing_node_draw]) + self.anyimage.register() + self.assertEqual(self.node_menu_draws, [self.anyimage.draw_texture_node_context_menu, existing_node_draw]) + self.anyimage.unregister() + self.assertEqual(self.node_menu_draws, [existing_node_draw]) self.assertFalse(hasattr(self.fake_bpy.types.Image, "anyimage_upscale_source")) diff --git a/tests/anyimage/test_image_menu.py b/tests/anyimage/test_image_menu.py index 2cd3fdb..309fdce 100644 --- a/tests/anyimage/test_image_menu.py +++ b/tests/anyimage/test_image_menu.py @@ -96,12 +96,12 @@ class ImageMenuTest(BlenderTestCase): def test_image_operators_stay_clickable_until_server_is_busy(self): context = SimpleNamespace( - object=SimpleNamespace(data=SimpleNamespace()), + object=SimpleNamespace(type="EMPTY", empty_display_type="IMAGE", data=SimpleNamespace()), ) operators = ( - (self.convert_to_plane, self.convert_to_plane.ConvertToDepthPlane), - (self.convert_to_plane, self.convert_to_plane.ConvertToReliefPlane), - (self.remove_background, self.remove_background.RemoveImageBackground), + self.convert_to_plane.ConvertToDepthPlane, + self.convert_to_plane.ConvertToReliefPlane, + self.remove_background.RemoveImageBackground, ) for busy, expected in ((False, True), (True, False)): @@ -113,18 +113,6 @@ class ImageMenuTest(BlenderTestCase): return_value=busy, ), ): - for module, operator in operators: - with ( - self.subTest(operator=operator.__name__), - patch.object( - module, - "is_image_empty", - return_value=True, - ), - patch.object( - module, - "video_total_frames", - return_value=1, - ), - ): + for operator in operators: + with self.subTest(operator=operator.__name__): self.assertEqual(operator.poll(context), expected) diff --git a/tests/anyimage/test_preferences.py b/tests/anyimage/test_preferences.py index a36ac95..066e898 100644 --- a/tests/anyimage/test_preferences.py +++ b/tests/anyimage/test_preferences.py @@ -439,6 +439,7 @@ class PreferencesTest(BlenderTestCase): type="EMPTY", empty_display_type="IMAGE", data=source_image, + image_user=None, ) context = SimpleNamespace( object=source_object, @@ -457,8 +458,8 @@ class PreferencesTest(BlenderTestCase): return_value=1024, ), patch.object( - self.upscale, - "prepare_image_input", + self.upscale.ImageEditTarget, + "prepare", return_value=(Path("input.png"), False), ), patch.object( diff --git a/tests/support/blender.py b/tests/support/blender.py index 9158f0a..7600b5e 100644 --- a/tests/support/blender.py +++ b/tests/support/blender.py @@ -78,12 +78,17 @@ class BlenderTestCase(unittest.TestCase): prepend=lambda draw: cls.outliner_menu_draws.insert(0, draw), remove=lambda draw: cls.outliner_menu_draws.remove(draw), ), + NODE_MT_context_menu=SimpleNamespace( + prepend=lambda draw: cls.node_menu_draws.insert(0, draw), + remove=lambda draw: cls.node_menu_draws.remove(draw), + ), ) cls.registered = [] cls.registered_tools = [] cls.status_bar_draws = [] cls.context_menu_draws = [] cls.outliner_menu_draws = [] + cls.node_menu_draws = [] fake_bpy.utils = SimpleNamespace( register_class=cls.registered.append, unregister_class=cls.registered.remove, @@ -177,6 +182,7 @@ class BlenderTestCase(unittest.TestCase): self.status_bar_draws.clear() self.context_menu_draws.clear() self.outliner_menu_draws.clear() + self.node_menu_draws.clear() self.fake_bpy.data = SimpleNamespace( objects=SimpleNamespace(get=lambda _name: None), ) diff --git a/tests/support/image_texture.py b/tests/support/image_texture.py new file mode 100644 index 0000000..0dbc7b1 --- /dev/null +++ b/tests/support/image_texture.py @@ -0,0 +1,45 @@ +from types import SimpleNamespace + +import bpy +import numpy as np +import pytest +from PIL import Image + + +@pytest.fixture +def texture(): + bpy.ops.wm.read_factory_settings(use_empty=True) + material = bpy.data.materials.new("Texture material") + material.use_nodes = True + material.use_fake_user = True + tree = material.node_tree + node = tree.nodes.new("ShaderNodeTexImage") + node.name = "Source texture" + node.image = bpy.data.images.new("Texture.png", width=4, height=3, alpha=True) + node.image.pixels.foreach_set(np.tile([0.2, 0.4, 0.6, 0.75], 12).astype(np.float32)) + node.image.pack() + node.location = (-400, 100) + node.interpolation = "Closest" + node.extension = "CLIP" + node.projection = "BOX" + tree.nodes.active = node + tree.links.new(node.outputs["Color"], tree.nodes.get("Principled BSDF").inputs["Base Color"]) + yield node + bpy.ops.wm.read_factory_settings(use_empty=True) + + +def texture_context(node, **space_options): + return SimpleNamespace( + space_data=SimpleNamespace( + **dict(type="NODE_EDITOR", tree_type="ShaderNodeTree", shader_type="OBJECT", + edit_tree=node.id_data, id=bpy.data.materials["Texture material"], **space_options), + ), + object=None, + scene=SimpleNamespace(anyimage_settings=SimpleNamespace(video_frames=16)), + ) + + +def write_result(directory, prefix="image.", count=1): + for index in range(1, count + 1): + Image.new("RGBA", (8, 6), (128, 64, 32, 80)).save(directory / f"{prefix}{index:04d}.png") +