feat(ui): 添加渲染图像列表和清理功能
- 新增属性组 `Comfy_RenderedImageItem` 用于存储渲染图像信息 - 在面板中添加渲染图像列表显示,支持缩略图预览和打开文件夹 - 为深度和法线渲染添加结果自动记录功能 - 新增清理操作符,用于删除生成的多视角对象和相机并恢复原始对象 - 改进操作符的 poll 条件,在已有结果时提示先清理 - 在注销时清理预览集合和自定义属性
This commit is contained in:
16
__init__.py
16
__init__.py
@@ -12,7 +12,8 @@ bl_info = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
import bpy # noqa: E402
|
import bpy # noqa: E402
|
||||||
from .utils import classes # noqa: E402
|
from .utils import classes, cleanup_preview_collections # noqa: E402
|
||||||
|
from .utils import Comfy_RenderedImageItem # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
def register():
|
def register():
|
||||||
@@ -30,9 +31,21 @@ def register():
|
|||||||
],
|
],
|
||||||
default='1024',
|
default='1024',
|
||||||
)
|
)
|
||||||
|
bpy.types.Scene.sna_rendered_images = bpy.props.CollectionProperty(
|
||||||
|
type=Comfy_RenderedImageItem,
|
||||||
|
)
|
||||||
|
bpy.types.Scene.sna_rendered_images_index = bpy.props.IntProperty(
|
||||||
|
name="Active Rendered Image Index",
|
||||||
|
default=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def unregister():
|
def unregister():
|
||||||
|
cleanup_preview_collections()
|
||||||
|
if hasattr(bpy.types.Scene, "sna_rendered_images_index"):
|
||||||
|
del bpy.types.Scene.sna_rendered_images_index
|
||||||
|
if hasattr(bpy.types.Scene, "sna_rendered_images"):
|
||||||
|
del bpy.types.Scene.sna_rendered_images
|
||||||
if hasattr(bpy.types.Scene, "sna_render_resolution"):
|
if hasattr(bpy.types.Scene, "sna_render_resolution"):
|
||||||
del bpy.types.Scene.sna_render_resolution
|
del bpy.types.Scene.sna_render_resolution
|
||||||
if hasattr(bpy.types.Scene, "sna_activeobject_name"):
|
if hasattr(bpy.types.Scene, "sna_activeobject_name"):
|
||||||
@@ -43,3 +56,4 @@ def unregister():
|
|||||||
del bpy.types.Scene.sna_activeobject
|
del bpy.types.Scene.sna_activeobject
|
||||||
for cls in reversed(classes):
|
for cls in reversed(classes):
|
||||||
bpy.utils.unregister_class(cls)
|
bpy.utils.unregister_class(cls)
|
||||||
|
|
||||||
|
|||||||
260
utils.py
260
utils.py
@@ -2,6 +2,35 @@ import bpy
|
|||||||
import bpy.utils.previews
|
import bpy.utils.previews
|
||||||
from mathutils import Vector
|
from mathutils import Vector
|
||||||
import os
|
import os
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
# Global preview collection for rendered image thumbnails
|
||||||
|
_preview_collections = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_preview_collection():
|
||||||
|
"""Get or create the preview collection for rendered images."""
|
||||||
|
if 'rendered_images' not in _preview_collections:
|
||||||
|
_preview_collections['rendered_images'] = bpy.utils.previews.new()
|
||||||
|
return _preview_collections['rendered_images']
|
||||||
|
|
||||||
|
|
||||||
|
def _load_preview(filepath):
|
||||||
|
"""Load an image file as a preview icon, returns the preview icon_id."""
|
||||||
|
pcoll = _get_preview_collection()
|
||||||
|
name = os.path.basename(filepath)
|
||||||
|
if name in pcoll:
|
||||||
|
# Force reload
|
||||||
|
del pcoll[name]
|
||||||
|
preview = pcoll.load(name, filepath, 'IMAGE')
|
||||||
|
return preview.icon_id
|
||||||
|
|
||||||
|
|
||||||
|
class Comfy_RenderedImageItem(bpy.types.PropertyGroup):
|
||||||
|
"""A single rendered image entry."""
|
||||||
|
name: bpy.props.StringProperty(name="Name")
|
||||||
|
filepath: bpy.props.StringProperty(name="File Path", subtype='FILE_PATH')
|
||||||
|
icon_id: bpy.props.IntProperty(name="Icon ID", default=0)
|
||||||
def property_exists(prop_path, glob, loc):
|
def property_exists(prop_path, glob, loc):
|
||||||
try:
|
try:
|
||||||
eval(prop_path, glob, loc)
|
eval(prop_path, glob, loc)
|
||||||
@@ -132,37 +161,67 @@ def _append_from_materials_blend(data_type, name):
|
|||||||
return collection.get(name)
|
return collection.get(name)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_rendered_image(scene, name, filepath):
|
||||||
|
"""Add a rendered image to the scene's rendered image list."""
|
||||||
|
# Remove existing entry with the same name
|
||||||
|
for i, item in enumerate(scene.sna_rendered_images):
|
||||||
|
if item.name == name:
|
||||||
|
scene.sna_rendered_images.remove(i)
|
||||||
|
break
|
||||||
|
entry = scene.sna_rendered_images.add()
|
||||||
|
entry.name = name
|
||||||
|
entry.filepath = filepath
|
||||||
|
if os.path.isfile(filepath):
|
||||||
|
entry.icon_id = _load_preview(filepath)
|
||||||
|
# Set the active index to the new item
|
||||||
|
scene.sna_rendered_images_index = len(scene.sna_rendered_images) - 1
|
||||||
|
|
||||||
|
|
||||||
def _render_depth(operator):
|
def _render_depth(operator):
|
||||||
"""Render depth map using Depth_Mat compositor NodeTree."""
|
"""Render depth map using Depth_Mat compositor NodeTree."""
|
||||||
scene = bpy.context.scene
|
scene = bpy.context.scene
|
||||||
output_dir = _ensure_output_dir()
|
output_dir = _ensure_output_dir()
|
||||||
|
|
||||||
# Append the Depth_Mat compositor node tree
|
# Force fresh Depth_Mat by removing any cached version
|
||||||
|
if 'Depth_Mat' in bpy.data.node_groups:
|
||||||
|
bpy.data.node_groups.remove(bpy.data.node_groups['Depth_Mat'])
|
||||||
|
|
||||||
|
# Append fresh Depth_Mat from Materials.blend
|
||||||
depth_tree = _append_from_materials_blend('NodeTree', 'Depth_Mat')
|
depth_tree = _append_from_materials_blend('NodeTree', 'Depth_Mat')
|
||||||
if depth_tree is None:
|
if depth_tree is None:
|
||||||
operator.report({'ERROR'}, message='Failed to load Depth_Mat from Materials.blend')
|
operator.report({'ERROR'}, message='Failed to load Depth_Mat from Materials.blend')
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Save original compositor state
|
# Save original render settings
|
||||||
orig_use_nodes = scene.use_nodes
|
orig_use_nodes = scene.use_nodes
|
||||||
orig_comp_group = scene.compositing_node_group
|
orig_comp_group = scene.compositing_node_group
|
||||||
|
orig_filepath = scene.render.filepath
|
||||||
|
orig_format = scene.render.image_settings.file_format
|
||||||
|
orig_color_mode = scene.render.image_settings.color_mode
|
||||||
|
|
||||||
# Enable compositor and assign Depth_Mat as the compositing node group
|
# Enable compositor and assign Depth_Mat as the compositing node group
|
||||||
scene.use_nodes = True
|
scene.use_nodes = True
|
||||||
scene.compositing_node_group = depth_tree
|
scene.compositing_node_group = depth_tree
|
||||||
|
scene.view_layers["ViewLayer"].use_pass_z = True
|
||||||
|
|
||||||
# Setup output
|
# Setup output
|
||||||
scene.render.filepath = os.path.join(output_dir, 'depth.png')
|
out_path = os.path.join(output_dir, 'depth.png')
|
||||||
|
scene.render.filepath = out_path
|
||||||
scene.render.image_settings.file_format = 'PNG'
|
scene.render.image_settings.file_format = 'PNG'
|
||||||
scene.render.image_settings.color_mode = 'RGB'
|
scene.render.image_settings.color_mode = 'RGB'
|
||||||
scene.view_layers["ViewLayer"].use_pass_z = True
|
|
||||||
|
|
||||||
# Render
|
# Render
|
||||||
bpy.ops.render.render(write_still=True)
|
bpy.ops.render.render(write_still=True)
|
||||||
|
|
||||||
# Restore original compositor state
|
# Restore all settings
|
||||||
scene.compositing_node_group = orig_comp_group
|
scene.compositing_node_group = orig_comp_group
|
||||||
scene.use_nodes = orig_use_nodes
|
scene.use_nodes = orig_use_nodes
|
||||||
|
scene.render.filepath = orig_filepath
|
||||||
|
scene.render.image_settings.file_format = orig_format
|
||||||
|
scene.render.image_settings.color_mode = orig_color_mode
|
||||||
|
|
||||||
|
# Add to rendered images list
|
||||||
|
_add_rendered_image(scene, 'depth', out_path)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -184,31 +243,41 @@ def _render_normal(operator):
|
|||||||
if converted_obj is None or converted_obj.type != 'MESH':
|
if converted_obj is None or converted_obj.type != 'MESH':
|
||||||
converted_obj = obj
|
converted_obj = obj
|
||||||
|
|
||||||
# Save original materials
|
# Save original materials and render settings
|
||||||
orig_materials = [slot.material for slot in converted_obj.material_slots]
|
orig_materials = [slot.material for slot in converted_obj.material_slots]
|
||||||
|
orig_use_nodes = scene.use_nodes
|
||||||
|
orig_filepath = scene.render.filepath
|
||||||
|
orig_format = scene.render.image_settings.file_format
|
||||||
|
orig_color_mode = scene.render.image_settings.color_mode
|
||||||
|
|
||||||
# Clear and assign Normal_Mat
|
# Clear and assign Normal_Mat
|
||||||
converted_obj.data.materials.clear()
|
converted_obj.data.materials.clear()
|
||||||
converted_obj.data.materials.append(normal_mat)
|
converted_obj.data.materials.append(normal_mat)
|
||||||
|
|
||||||
# Disable compositor for normal pass (direct render)
|
# Disable compositor for normal pass (direct render)
|
||||||
orig_use_nodes = scene.use_nodes
|
|
||||||
scene.use_nodes = False
|
scene.use_nodes = False
|
||||||
|
|
||||||
# Setup output
|
# Setup output
|
||||||
scene.render.filepath = os.path.join(output_dir, 'normal.png')
|
out_path = os.path.join(output_dir, 'normal.png')
|
||||||
|
scene.render.filepath = out_path
|
||||||
scene.render.image_settings.file_format = 'PNG'
|
scene.render.image_settings.file_format = 'PNG'
|
||||||
scene.render.image_settings.color_mode = 'RGB'
|
scene.render.image_settings.color_mode = 'RGB'
|
||||||
|
|
||||||
# Render
|
# Render
|
||||||
bpy.ops.render.render(write_still=True)
|
bpy.ops.render.render(write_still=True)
|
||||||
|
|
||||||
# Restore original materials
|
# Restore original materials and settings
|
||||||
converted_obj.data.materials.clear()
|
converted_obj.data.materials.clear()
|
||||||
for mat in orig_materials:
|
for mat in orig_materials:
|
||||||
converted_obj.data.materials.append(mat)
|
converted_obj.data.materials.append(mat)
|
||||||
|
|
||||||
scene.use_nodes = orig_use_nodes
|
scene.use_nodes = orig_use_nodes
|
||||||
|
scene.render.filepath = orig_filepath
|
||||||
|
scene.render.image_settings.file_format = orig_format
|
||||||
|
scene.render.image_settings.color_mode = orig_color_mode
|
||||||
|
|
||||||
|
# Add to rendered images list
|
||||||
|
_add_rendered_image(scene, 'normal', out_path)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -220,12 +289,20 @@ class Comfy_OT_Mvgen_B6229(bpy.types.Operator):
|
|||||||
bl_options = {"REGISTER", "UNDO"}
|
bl_options = {"REGISTER", "UNDO"}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def poll(cls, _context):
|
def poll(cls, context):
|
||||||
if bpy.app.version >= (3, 0, 0) and True:
|
if "" == bpy.data.filepath:
|
||||||
cls.poll_message_set(
|
if bpy.app.version >= (3, 0, 0):
|
||||||
"Please save your Blender file before running the script."
|
cls.poll_message_set(
|
||||||
)
|
"Please save your Blender file before running the script."
|
||||||
return not ("" == bpy.data.filepath)
|
)
|
||||||
|
return False
|
||||||
|
if len(context.scene.sna_rendered_images) > 0:
|
||||||
|
if bpy.app.version >= (3, 0, 0):
|
||||||
|
cls.poll_message_set(
|
||||||
|
"Please cleanup existing results first (click trash icon)."
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
def execute(self, _context):
|
def execute(self, _context):
|
||||||
if "" == bpy.data.filepath:
|
if "" == bpy.data.filepath:
|
||||||
@@ -272,6 +349,88 @@ class Comfy_OT_Mvgen_B6229(bpy.types.Operator):
|
|||||||
return self.execute(context)
|
return self.execute(context)
|
||||||
|
|
||||||
|
|
||||||
|
class Comfy_OT_RemoveRenderedImage(bpy.types.Operator):
|
||||||
|
"""Remove a rendered image from the list."""
|
||||||
|
bl_idname = "comfyui.remove_rendered_image"
|
||||||
|
bl_label = "Remove"
|
||||||
|
bl_description = "Remove this image from the list"
|
||||||
|
bl_options = {"REGISTER", "UNDO"}
|
||||||
|
|
||||||
|
index: bpy.props.IntProperty()
|
||||||
|
|
||||||
|
def execute(self, context):
|
||||||
|
images = context.scene.sna_rendered_images
|
||||||
|
if 0 <= self.index < len(images):
|
||||||
|
images.remove(self.index)
|
||||||
|
# Adjust active index
|
||||||
|
if context.scene.sna_rendered_images_index >= len(images):
|
||||||
|
context.scene.sna_rendered_images_index = max(0, len(images) - 1)
|
||||||
|
return {'FINISHED'}
|
||||||
|
|
||||||
|
|
||||||
|
class Comfy_OT_OpenRenderedFolder(bpy.types.Operator):
|
||||||
|
"""Open the folder containing the rendered image."""
|
||||||
|
bl_idname = "comfyui.open_rendered_folder"
|
||||||
|
bl_label = "Open Folder"
|
||||||
|
bl_description = "Open the folder containing this image"
|
||||||
|
|
||||||
|
filepath: bpy.props.StringProperty()
|
||||||
|
|
||||||
|
def execute(self, context):
|
||||||
|
folder = os.path.dirname(self.filepath)
|
||||||
|
if os.path.isdir(folder):
|
||||||
|
subprocess.Popen(['explorer', folder])
|
||||||
|
return {'FINISHED'}
|
||||||
|
|
||||||
|
|
||||||
|
class Comfy_OT_CleanupMvgen(bpy.types.Operator):
|
||||||
|
"""Remove generated multi-view objects and restore original."""
|
||||||
|
bl_idname = "comfyui.cleanup_mvgen"
|
||||||
|
bl_label = "Cleanup"
|
||||||
|
bl_description = "Delete the generated multi-view copy, camera, and restore original object"
|
||||||
|
bl_options = {"REGISTER", "UNDO"}
|
||||||
|
|
||||||
|
def execute(self, context):
|
||||||
|
scene = context.scene
|
||||||
|
|
||||||
|
# Delete the converted copy (active object that is NOT the original)
|
||||||
|
orig_name = scene.sna_activeobject_name
|
||||||
|
if orig_name:
|
||||||
|
# The converted copy is typically named with .001 suffix
|
||||||
|
for obj in list(bpy.data.objects):
|
||||||
|
if obj.name != orig_name and obj.name.startswith(orig_name) and obj.type == 'MESH':
|
||||||
|
bpy.data.objects.remove(obj, do_unlink=True)
|
||||||
|
|
||||||
|
# Delete the bake camera
|
||||||
|
bake_cam = scene.sna_bakecam
|
||||||
|
if bake_cam is not None:
|
||||||
|
bpy.data.objects.remove(bake_cam, do_unlink=True)
|
||||||
|
scene.sna_bakecam = None
|
||||||
|
|
||||||
|
# Also remove any camera named ObjectCamera
|
||||||
|
if 'ObjectCamera' in bpy.data.objects:
|
||||||
|
bpy.data.objects.remove(bpy.data.objects['ObjectCamera'], do_unlink=True)
|
||||||
|
|
||||||
|
# Restore original object visibility
|
||||||
|
if orig_name and orig_name in bpy.data.objects:
|
||||||
|
orig_obj = bpy.data.objects[orig_name]
|
||||||
|
orig_obj.hide_render = False
|
||||||
|
orig_obj.hide_set(False)
|
||||||
|
# Make it active and selected
|
||||||
|
bpy.context.view_layer.objects.active = orig_obj
|
||||||
|
orig_obj.select_set(True)
|
||||||
|
|
||||||
|
# Clear rendered images list
|
||||||
|
scene.sna_rendered_images.clear()
|
||||||
|
scene.sna_rendered_images_index = 0
|
||||||
|
|
||||||
|
# Clear preview collection
|
||||||
|
pcoll = _get_preview_collection()
|
||||||
|
pcoll.clear()
|
||||||
|
|
||||||
|
return {'FINISHED'}
|
||||||
|
|
||||||
|
|
||||||
class Comfy_PT_ComfyProjectorPanel(bpy.types.Panel):
|
class Comfy_PT_ComfyProjectorPanel(bpy.types.Panel):
|
||||||
bl_label = "ComfyProjector"
|
bl_label = "ComfyProjector"
|
||||||
bl_idname = "Comfy_PT_comfyprojector"
|
bl_idname = "Comfy_PT_comfyprojector"
|
||||||
@@ -281,12 +440,79 @@ class Comfy_PT_ComfyProjectorPanel(bpy.types.Panel):
|
|||||||
|
|
||||||
def draw(self, context):
|
def draw(self, context):
|
||||||
layout = self.layout
|
layout = self.layout
|
||||||
layout.prop(context.scene, "sna_render_resolution", text="Resolution")
|
scene = context.scene
|
||||||
layout.operator("comfyui.mvgen_b6229", text="生成多视角视图")
|
|
||||||
|
# --- ControlNet Layers section ---
|
||||||
|
header_box = layout.box()
|
||||||
|
header_box.label(text="ControlNet Layers", icon='RENDERLAYERS')
|
||||||
|
|
||||||
|
# Resolution selector
|
||||||
|
row = header_box.row(align=True)
|
||||||
|
row.prop(scene, "sna_render_resolution", text="Resolution")
|
||||||
|
|
||||||
|
# Main generate button + cleanup button (only after generation)
|
||||||
|
has_results = len(scene.sna_rendered_images) > 0
|
||||||
|
btn_row = header_box.row(align=True)
|
||||||
|
btn_row.operator(
|
||||||
|
"comfyui.mvgen_b6229",
|
||||||
|
text="Generate Multiview Maps",
|
||||||
|
icon='RENDER_RESULT',
|
||||||
|
)
|
||||||
|
if has_results:
|
||||||
|
btn_row.operator(
|
||||||
|
"comfyui.cleanup_mvgen",
|
||||||
|
text="",
|
||||||
|
icon='TRASH',
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Rendered Images List ---
|
||||||
|
if has_results:
|
||||||
|
img_box = layout.box()
|
||||||
|
img_box.label(text="Rendered Images", icon='IMAGE_DATA')
|
||||||
|
|
||||||
|
for i, item in enumerate(scene.sna_rendered_images):
|
||||||
|
item_box = img_box.box()
|
||||||
|
row = item_box.row()
|
||||||
|
|
||||||
|
# Left: thumbnail preview
|
||||||
|
pcoll = _get_preview_collection()
|
||||||
|
preview_name = os.path.basename(item.filepath)
|
||||||
|
if preview_name in pcoll:
|
||||||
|
row.template_icon(
|
||||||
|
icon_value=pcoll[preview_name].icon_id,
|
||||||
|
scale=4.0,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
row.label(text="", icon='IMAGE_DATA')
|
||||||
|
|
||||||
|
# Right: name + filepath, vertically centered
|
||||||
|
col_info = row.column()
|
||||||
|
col_info.separator(factor=2.0)
|
||||||
|
col_info.label(text=item.name, icon='IMAGE_DATA')
|
||||||
|
info_row2 = col_info.row(align=True)
|
||||||
|
info_row2.label(text=item.filepath, icon='DISK_DRIVE')
|
||||||
|
op2 = info_row2.operator(
|
||||||
|
"comfyui.open_rendered_folder",
|
||||||
|
text="",
|
||||||
|
icon='FILEBROWSER',
|
||||||
|
emboss=False,
|
||||||
|
)
|
||||||
|
op2.filepath = item.filepath
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_preview_collections():
|
||||||
|
"""Remove all preview collections on unregister."""
|
||||||
|
for pcoll in _preview_collections.values():
|
||||||
|
bpy.utils.previews.remove(pcoll)
|
||||||
|
_preview_collections.clear()
|
||||||
|
|
||||||
|
|
||||||
classes = (
|
classes = (
|
||||||
|
Comfy_RenderedImageItem,
|
||||||
Comfy_OT_Aovbake_Ad980,
|
Comfy_OT_Aovbake_Ad980,
|
||||||
Comfy_OT_Mvgen_B6229,
|
Comfy_OT_Mvgen_B6229,
|
||||||
|
Comfy_OT_RemoveRenderedImage,
|
||||||
|
Comfy_OT_OpenRenderedFolder,
|
||||||
|
Comfy_OT_CleanupMvgen,
|
||||||
Comfy_PT_ComfyProjectorPanel,
|
Comfy_PT_ComfyProjectorPanel,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user