Merge pull request #3616 from simonebarbieri/feature/blender-review

This commit is contained in:
Ondřej Samohel 2023-03-17 14:15:27 +01:00 committed by GitHub
commit b45a907a17
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 781 additions and 3 deletions

View file

@ -31,10 +31,13 @@ from .lib import (
lsattrs,
read,
maintained_selection,
maintained_time,
get_selection,
# unique_name,
)
from .capture import capture
__all__ = [
"install",
@ -56,9 +59,11 @@ __all__ = [
# Utility functions
"maintained_selection",
"maintained_time",
"lsattr",
"lsattrs",
"read",
"get_selection",
"capture",
# "unique_name",
]

View file

@ -0,0 +1,278 @@
"""Blender Capture
Playblasting with independent viewport, camera and display options
"""
import contextlib
import bpy
from .lib import maintained_time
from .plugin import deselect_all, create_blender_context
def capture(
camera=None,
width=None,
height=None,
filename=None,
start_frame=None,
end_frame=None,
step_frame=None,
sound=None,
isolate=None,
maintain_aspect_ratio=True,
overwrite=False,
image_settings=None,
display_options=None
):
"""Playblast in an independent windows
Arguments:
camera (str, optional): Name of camera, defaults to "Camera"
width (int, optional): Width of output in pixels
height (int, optional): Height of output in pixels
filename (str, optional): Name of output file path. Defaults to current
render output path.
start_frame (int, optional): Defaults to current start frame.
end_frame (int, optional): Defaults to current end frame.
step_frame (int, optional): Defaults to 1.
sound (str, optional): Specify the sound node to be used during
playblast. When None (default) no sound will be used.
isolate (list): List of nodes to isolate upon capturing
maintain_aspect_ratio (bool, optional): Modify height in order to
maintain aspect ratio.
overwrite (bool, optional): Whether or not to overwrite if file
already exists. If disabled and file exists and error will be
raised.
image_settings (dict, optional): Supplied image settings for render,
using `ImageSettings`
display_options (dict, optional): Supplied display options for render
"""
scene = bpy.context.scene
camera = camera or "Camera"
# Ensure camera exists.
if camera not in scene.objects and camera != "AUTO":
raise RuntimeError("Camera does not exist: {0}".format(camera))
# Ensure resolution.
if width and height:
maintain_aspect_ratio = False
width = width or scene.render.resolution_x
height = height or scene.render.resolution_y
if maintain_aspect_ratio:
ratio = scene.render.resolution_x / scene.render.resolution_y
height = round(width / ratio)
# Get frame range.
if start_frame is None:
start_frame = scene.frame_start
if end_frame is None:
end_frame = scene.frame_end
if step_frame is None:
step_frame = 1
frame_range = (start_frame, end_frame, step_frame)
if filename is None:
filename = scene.render.filepath
render_options = {
"filepath": "{}.".format(filename.rstrip(".")),
"resolution_x": width,
"resolution_y": height,
"use_overwrite": overwrite,
}
with _independent_window() as window:
applied_view(window, camera, isolate, options=display_options)
with contextlib.ExitStack() as stack:
stack.enter_context(maintain_camera(window, camera))
stack.enter_context(applied_frame_range(window, *frame_range))
stack.enter_context(applied_render_options(window, render_options))
stack.enter_context(applied_image_settings(window, image_settings))
stack.enter_context(maintained_time())
bpy.ops.render.opengl(
animation=True,
render_keyed_only=False,
sequencer=False,
write_still=False,
view_context=True
)
return filename
ImageSettings = {
"file_format": "FFMPEG",
"color_mode": "RGB",
"ffmpeg": {
"format": "QUICKTIME",
"use_autosplit": False,
"codec": "H264",
"constant_rate_factor": "MEDIUM",
"gopsize": 18,
"use_max_b_frames": False,
},
}
def isolate_objects(window, objects):
"""Isolate selection"""
deselect_all()
for obj in objects:
obj.select_set(True)
context = create_blender_context(selected=objects, window=window)
bpy.ops.view3d.view_axis(context, type="FRONT")
bpy.ops.view3d.localview(context)
deselect_all()
def _apply_options(entity, options):
for option, value in options.items():
if isinstance(value, dict):
_apply_options(getattr(entity, option), value)
else:
setattr(entity, option, value)
def applied_view(window, camera, isolate=None, options=None):
"""Apply view options to window."""
area = window.screen.areas[0]
space = area.spaces[0]
area.ui_type = "VIEW_3D"
meshes = [obj for obj in window.scene.objects if obj.type == "MESH"]
if camera == "AUTO":
space.region_3d.view_perspective = "ORTHO"
isolate_objects(window, isolate or meshes)
else:
isolate_objects(window, isolate or meshes)
space.camera = window.scene.objects.get(camera)
space.region_3d.view_perspective = "CAMERA"
if isinstance(options, dict):
_apply_options(space, options)
else:
space.shading.type = "SOLID"
space.shading.color_type = "MATERIAL"
space.show_gizmo = False
space.overlay.show_overlays = False
@contextlib.contextmanager
def applied_frame_range(window, start, end, step):
"""Context manager for setting frame range."""
# Store current frame range
current_frame_start = window.scene.frame_start
current_frame_end = window.scene.frame_end
current_frame_step = window.scene.frame_step
# Apply frame range
window.scene.frame_start = start
window.scene.frame_end = end
window.scene.frame_step = step
try:
yield
finally:
# Restore frame range
window.scene.frame_start = current_frame_start
window.scene.frame_end = current_frame_end
window.scene.frame_step = current_frame_step
@contextlib.contextmanager
def applied_render_options(window, options):
"""Context manager for setting render options."""
render = window.scene.render
# Store current settings
original = {}
for opt in options.copy():
try:
original[opt] = getattr(render, opt)
except ValueError:
options.pop(opt)
# Apply settings
_apply_options(render, options)
try:
yield
finally:
# Restore previous settings
_apply_options(render, original)
@contextlib.contextmanager
def applied_image_settings(window, options):
"""Context manager to override image settings."""
options = options or ImageSettings.copy()
ffmpeg = options.pop("ffmpeg", {})
render = window.scene.render
# Store current image settings
original = {}
for opt in options.copy():
try:
original[opt] = getattr(render.image_settings, opt)
except ValueError:
options.pop(opt)
# Store current ffmpeg settings
original_ffmpeg = {}
for opt in ffmpeg.copy():
try:
original_ffmpeg[opt] = getattr(render.ffmpeg, opt)
except ValueError:
ffmpeg.pop(opt)
# Apply image settings
for opt, value in options.items():
setattr(render.image_settings, opt, value)
# Apply ffmpeg settings
for opt, value in ffmpeg.items():
setattr(render.ffmpeg, opt, value)
try:
yield
finally:
# Restore previous settings
for opt, value in original.items():
setattr(render.image_settings, opt, value)
for opt, value in original_ffmpeg.items():
setattr(render.ffmpeg, opt, value)
@contextlib.contextmanager
def maintain_camera(window, camera):
"""Context manager to override camera."""
current_camera = window.scene.camera
if camera in window.scene.objects:
window.scene.camera = window.scene.objects.get(camera)
try:
yield
finally:
window.scene.camera = current_camera
@contextlib.contextmanager
def _independent_window():
"""Create capture-window context."""
context = create_blender_context()
current_windows = set(bpy.context.window_manager.windows)
bpy.ops.wm.window_new(context)
window = list(set(bpy.context.window_manager.windows) - current_windows)[0]
context["window"] = window
try:
yield window
finally:
bpy.ops.wm.window_close(context)

View file

@ -284,3 +284,13 @@ def maintained_selection():
# This could happen if the active node was deleted during the
# context.
log.exception("Failed to set active object.")
@contextlib.contextmanager
def maintained_time():
"""Maintain current frame during context."""
current_time = bpy.context.scene.frame_current
try:
yield
finally:
bpy.context.scene.frame_current = current_time

View file

@ -62,7 +62,8 @@ def prepare_data(data, container_name=None):
def create_blender_context(active: Optional[bpy.types.Object] = None,
selected: Optional[bpy.types.Object] = None,):
selected: Optional[bpy.types.Object] = None,
window: Optional[bpy.types.Window] = None):
"""Create a new Blender context. If an object is passed as
parameter, it is set as selected and active.
"""
@ -72,7 +73,9 @@ def create_blender_context(active: Optional[bpy.types.Object] = None,
override_context = bpy.context.copy()
for win in bpy.context.window_manager.windows:
windows = [window] if window else bpy.context.window_manager.windows
for win in windows:
for area in win.screen.areas:
if area.type == 'VIEW_3D':
for region in area.regions:

View file

@ -0,0 +1,47 @@
"""Create review."""
import bpy
from openpype.pipeline import legacy_io
from openpype.hosts.blender.api import plugin, lib, ops
from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES
class CreateReview(plugin.Creator):
"""Single baked camera"""
name = "reviewDefault"
label = "Review"
family = "review"
icon = "video-camera"
def process(self):
""" Run the creator on Blender main thread"""
mti = ops.MainThreadItem(self._process)
ops.execute_in_main_thread(mti)
def _process(self):
# Get Instance Container or create it if it does not exist
instances = bpy.data.collections.get(AVALON_INSTANCES)
if not instances:
instances = bpy.data.collections.new(name=AVALON_INSTANCES)
bpy.context.scene.collection.children.link(instances)
# Create instance object
asset = self.data["asset"]
subset = self.data["subset"]
name = plugin.asset_name(asset, subset)
asset_group = bpy.data.collections.new(name=name)
instances.children.link(asset_group)
self.data['task'] = legacy_io.Session.get('AVALON_TASK')
lib.imprint(asset_group, self.data)
if (self.options or {}).get("useSelection"):
selected = lib.get_selection()
for obj in selected:
asset_group.objects.link(obj)
elif (self.options or {}).get("asset_group"):
obj = (self.options or {}).get("asset_group")
asset_group.objects.link(obj)
return asset_group

View file

@ -0,0 +1,64 @@
import bpy
import pyblish.api
from openpype.pipeline import legacy_io
class CollectReview(pyblish.api.InstancePlugin):
"""Collect Review data
"""
order = pyblish.api.CollectorOrder + 0.3
label = "Collect Review Data"
families = ["review"]
def process(self, instance):
self.log.debug(f"instance: {instance}")
# get cameras
cameras = [
obj
for obj in instance
if isinstance(obj, bpy.types.Object) and obj.type == "CAMERA"
]
assert len(cameras) == 1, (
f"Not a single camera found in extraction: {cameras}"
)
camera = cameras[0].name
self.log.debug(f"camera: {camera}")
# get isolate objects list from meshes instance members .
isolate_objects = [
obj
for obj in instance
if isinstance(obj, bpy.types.Object) and obj.type == "MESH"
]
if not instance.data.get("remove"):
task = legacy_io.Session.get("AVALON_TASK")
instance.data.update({
"subset": f"{task}Review",
"review_camera": camera,
"frameStart": instance.context.data["frameStart"],
"frameEnd": instance.context.data["frameEnd"],
"fps": instance.context.data["fps"],
"isolate": isolate_objects,
})
self.log.debug(f"instance data: {instance.data}")
# TODO : Collect audio
audio_tracks = []
instance.data["audio"] = []
for track in audio_tracks:
instance.data["audio"].append(
{
"offset": track.offset.get(),
"filename": track.filename.get(),
}
)

View file

@ -0,0 +1,123 @@
import os
import clique
import bpy
import pyblish.api
from openpype.pipeline import publish
from openpype.hosts.blender.api import capture
from openpype.hosts.blender.api.lib import maintained_time
class ExtractPlayblast(publish.Extractor):
"""
Extract viewport playblast.
Takes review camera and creates review Quicktime video based on viewport
capture.
"""
label = "Extract Playblast"
hosts = ["blender"]
families = ["review"]
optional = True
order = pyblish.api.ExtractorOrder + 0.01
def process(self, instance):
self.log.info("Extracting capture..")
self.log.info(instance.data)
# get scene fps
fps = instance.data.get("fps")
if fps is None:
fps = bpy.context.scene.render.fps
instance.data["fps"] = fps
self.log.info(f"fps: {fps}")
# If start and end frames cannot be determined,
# get them from Blender timeline.
start = instance.data.get("frameStart", bpy.context.scene.frame_start)
end = instance.data.get("frameEnd", bpy.context.scene.frame_end)
self.log.info(f"start: {start}, end: {end}")
assert end > start, "Invalid time range !"
# get cameras
camera = instance.data("review_camera", None)
# get isolate objects list
isolate = instance.data("isolate", None)
# get ouput path
stagingdir = self.staging_dir(instance)
filename = instance.name
path = os.path.join(stagingdir, filename)
self.log.info(f"Outputting images to {path}")
project_settings = instance.context.data["project_settings"]["blender"]
presets = project_settings["publish"]["ExtractPlayblast"]["presets"]
preset = presets.get("default")
preset.update({
"camera": camera,
"start_frame": start,
"end_frame": end,
"filename": path,
"overwrite": True,
"isolate": isolate,
})
preset.setdefault(
"image_settings",
{
"file_format": "PNG",
"color_mode": "RGB",
"color_depth": "8",
"compression": 15,
},
)
with maintained_time():
path = capture(**preset)
self.log.debug(f"playblast path {path}")
collected_files = os.listdir(stagingdir)
collections, remainder = clique.assemble(
collected_files,
patterns=[f"{filename}\\.{clique.DIGITS_PATTERN}\\.png$"],
)
if len(collections) > 1:
raise RuntimeError(
f"More than one collection found in stagingdir: {stagingdir}"
)
elif len(collections) == 0:
raise RuntimeError(
f"No collection found in stagingdir: {stagingdir}"
)
frame_collection = collections[0]
self.log.info(f"We found collection of interest {frame_collection}")
instance.data.setdefault("representations", [])
tags = ["review"]
if not instance.data.get("keepImages"):
tags.append("delete")
representation = {
"name": "png",
"ext": "png",
"files": list(frame_collection),
"stagingDir": stagingdir,
"frameStart": start,
"frameEnd": end,
"fps": fps,
"preview": True,
"tags": tags,
"camera_name": camera
}
instance.data["representations"].append(representation)

View file

@ -0,0 +1,99 @@
import os
import glob
import pyblish.api
from openpype.pipeline import publish
from openpype.hosts.blender.api import capture
from openpype.hosts.blender.api.lib import maintained_time
import bpy
class ExtractThumbnail(publish.Extractor):
"""Extract viewport thumbnail.
Takes review camera and creates a thumbnail based on viewport
capture.
"""
label = "Extract Thumbnail"
hosts = ["blender"]
families = ["review"]
order = pyblish.api.ExtractorOrder + 0.01
presets = {}
def process(self, instance):
self.log.info("Extracting capture..")
stagingdir = self.staging_dir(instance)
filename = instance.name
path = os.path.join(stagingdir, filename)
self.log.info(f"Outputting images to {path}")
camera = instance.data.get("review_camera", "AUTO")
start = instance.data.get("frameStart", bpy.context.scene.frame_start)
family = instance.data.get("family")
isolate = instance.data("isolate", None)
preset = self.presets.get(family, {})
preset.update({
"camera": camera,
"start_frame": start,
"end_frame": start,
"filename": path,
"overwrite": True,
"isolate": isolate,
})
preset.setdefault(
"image_settings",
{
"file_format": "JPEG",
"color_mode": "RGB",
"quality": 100,
},
)
with maintained_time():
path = capture(**preset)
thumbnail = os.path.basename(self._fix_output_path(path))
self.log.info(f"thumbnail: {thumbnail}")
instance.data.setdefault("representations", [])
representation = {
"name": "thumbnail",
"ext": "jpg",
"files": thumbnail,
"stagingDir": stagingdir,
"thumbnail": True
}
instance.data["representations"].append(representation)
def _fix_output_path(self, filepath):
""""Workaround to return correct filepath.
To workaround this we just glob.glob() for any file extensions and
assume the latest modified file is the correct file and return it.
"""
# Catch cancelled playblast
if filepath is None:
self.log.warning(
"Playblast did not result in output path. "
"Playblast is probably interrupted."
)
return None
if not os.path.exists(filepath):
files = glob.glob(f"{filepath}.*.jpg")
if not files:
raise RuntimeError(f"Couldn't find playblast from: {filepath}")
filepath = max(files, key=os.path.getmtime)
return filepath

View file

@ -42,6 +42,7 @@ class ExtractReview(pyblish.api.InstancePlugin):
hosts = [
"nuke",
"maya",
"blender",
"shell",
"hiero",
"premiere",

View file

@ -80,6 +80,94 @@
"enabled": true,
"optional": true,
"active": false
},
"ExtractThumbnail": {
"enabled": true,
"optional": true,
"active": true,
"presets": {
"model": {
"image_settings": {
"file_format": "JPEG",
"color_mode": "RGB",
"quality": 100
},
"display_options": {
"shading": {
"light": "STUDIO",
"studio_light": "Default",
"type": "SOLID",
"color_type": "OBJECT",
"show_xray": false,
"show_shadows": false,
"show_cavity": true
},
"overlay": {
"show_overlays": false
}
}
},
"rig": {
"image_settings": {
"file_format": "JPEG",
"color_mode": "RGB",
"quality": 100
},
"display_options": {
"shading": {
"light": "STUDIO",
"studio_light": "Default",
"type": "SOLID",
"color_type": "OBJECT",
"show_xray": true,
"show_shadows": false,
"show_cavity": false
},
"overlay": {
"show_overlays": true,
"show_ortho_grid": false,
"show_floor": false,
"show_axis_x": false,
"show_axis_y": false,
"show_axis_z": false,
"show_text": false,
"show_stats": false,
"show_cursor": false,
"show_annotation": false,
"show_extras": false,
"show_relationship_lines": false,
"show_outline_selected": false,
"show_motion_paths": false,
"show_object_origins": false,
"show_bones": true
}
}
}
}
},
"ExtractPlayblast": {
"enabled": true,
"optional": true,
"active": true,
"presets": {
"default": {
"image_settings": {
"file_format": "PNG",
"color_mode": "RGB",
"color_depth": "8",
"compression": 15
},
"display_options": {
"shading": {
"type": "MATERIAL",
"render_pass": "COMBINED"
},
"overlay": {
"show_overlays": false
}
}
}
}
}
}
}

View file

@ -112,6 +112,66 @@
"label": "Extract Layout as JSON"
}
]
},
{
"type": "dict",
"collapsible": true,
"key": "ExtractThumbnail",
"label": "ExtractThumbnail",
"checkbox_key": "enabled",
"is_group": true,
"children": [
{
"type": "boolean",
"key": "enabled",
"label": "Enabled"
},
{
"type": "boolean",
"key": "optional",
"label": "Optional"
},
{
"type": "boolean",
"key": "active",
"label": "Active"
},
{
"type": "raw-json",
"key": "presets",
"label": "Presets"
}
]
},
{
"type": "dict",
"collapsible": true,
"key": "ExtractPlayblast",
"label": "ExtractPlayblast",
"checkbox_key": "enabled",
"is_group": true,
"children": [
{
"type": "boolean",
"key": "enabled",
"label": "Enabled"
},
{
"type": "boolean",
"key": "optional",
"label": "Optional"
},
{
"type": "boolean",
"key": "active",
"label": "Active"
},
{
"type": "raw-json",
"key": "presets",
"label": "Presets"
}
]
}
]
}
}