-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
0 parents
commit 0f7462f
Showing
10 changed files
with
1,496 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
# Auto detect text files and perform LF normalization | ||
* text=auto |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
.vscode/ | ||
__pycache__/ | ||
/tests/ | ||
*.py[cod] | ||
*.blend1 | ||
*.zip |
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
# Texture VFX Control: Blender Add-on | ||
|
||
WIP |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,47 @@ | ||
|
||
bl_info = { | ||
"name" : "Texture VFX Control", | ||
"author" : "https://github.com/chsh2/", | ||
"description" : "Shader-based video playback control and composition VFXs", | ||
"blender" : (3, 3, 0), | ||
"version" : (0, 1, 0), | ||
"location" : "Node Editor", | ||
"warning" : "This addon is still in an early stage of development", | ||
"doc_url": "", | ||
"wiki_url": "", | ||
"tracker_url": "", | ||
"category" : "Node" | ||
} | ||
|
||
import bpy | ||
from . import auto_load | ||
|
||
auto_load.init() | ||
|
||
class NODE_MT_add_tfx_submenu(bpy.types.Menu): | ||
bl_label = "Texture VFX Control" | ||
bl_idname = "NODE_MT_add_tfx_submenu" | ||
|
||
def draw(self, context): | ||
layout = self.layout | ||
layout.operator("node.tfx_add_playback_driver", icon='PLAY') | ||
layout.operator("node.tfx_grainy_blur", icon='SHADERFX') | ||
layout.operator("node.tfx_chroma_key", icon='SHADERFX') | ||
layout.operator("node.tfx_outline", icon='SHADERFX') | ||
layout.operator("node.tfx_append_node_groups", icon='NODE') | ||
|
||
def menu_func(self, context): | ||
layout = self.layout | ||
layout.menu("NODE_MT_add_tfx_submenu", icon='SHADERFX') | ||
|
||
def register(): | ||
auto_load.register() | ||
bpy.utils.register_class(NODE_MT_add_tfx_submenu) | ||
bpy.types.NODE_MT_add.append(menu_func) | ||
bpy.types.VIEW3D_MT_object_quick_effects.append(menu_func) | ||
|
||
def unregister(): | ||
auto_load.unregister() | ||
bpy.utils.unregister_class(NODE_MT_add_tfx_submenu) | ||
bpy.types.NODE_MT_add.remove(menu_func) | ||
bpy.types.VIEW3D_MT_object_quick_effects.remove(menu_func) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,157 @@ | ||
import os | ||
import bpy | ||
import sys | ||
import typing | ||
import inspect | ||
import pkgutil | ||
import importlib | ||
from pathlib import Path | ||
|
||
__all__ = ( | ||
"init", | ||
"register", | ||
"unregister", | ||
) | ||
|
||
blender_version = bpy.app.version | ||
|
||
modules = None | ||
ordered_classes = None | ||
|
||
def init(): | ||
global modules | ||
global ordered_classes | ||
|
||
modules = get_all_submodules(Path(__file__).parent) | ||
ordered_classes = get_ordered_classes_to_register(modules) | ||
|
||
def register(): | ||
for cls in ordered_classes: | ||
bpy.utils.register_class(cls) | ||
|
||
for module in modules: | ||
if module.__name__ == __name__: | ||
continue | ||
if hasattr(module, "register"): | ||
module.register() | ||
|
||
def unregister(): | ||
for cls in reversed(ordered_classes): | ||
bpy.utils.unregister_class(cls) | ||
|
||
for module in modules: | ||
if module.__name__ == __name__: | ||
continue | ||
if hasattr(module, "unregister"): | ||
module.unregister() | ||
|
||
|
||
# Import modules | ||
################################################# | ||
|
||
def get_all_submodules(directory): | ||
return list(iter_submodules(directory, directory.name)) | ||
|
||
def iter_submodules(path, package_name): | ||
for name in sorted(iter_submodule_names(path)): | ||
yield importlib.import_module("." + name, package_name) | ||
|
||
def iter_submodule_names(path, root=""): | ||
for _, module_name, is_package in pkgutil.iter_modules([str(path)]): | ||
if is_package: | ||
sub_path = path / module_name | ||
sub_root = root + module_name + "." | ||
yield from iter_submodule_names(sub_path, sub_root) | ||
else: | ||
yield root + module_name | ||
|
||
|
||
# Find classes to register | ||
################################################# | ||
|
||
def get_ordered_classes_to_register(modules): | ||
return toposort(get_register_deps_dict(modules)) | ||
|
||
def get_register_deps_dict(modules): | ||
my_classes = set(iter_my_classes(modules)) | ||
my_classes_by_idname = {cls.bl_idname : cls for cls in my_classes if hasattr(cls, "bl_idname")} | ||
|
||
deps_dict = {} | ||
for cls in my_classes: | ||
deps_dict[cls] = set(iter_my_register_deps(cls, my_classes, my_classes_by_idname)) | ||
return deps_dict | ||
|
||
def iter_my_register_deps(cls, my_classes, my_classes_by_idname): | ||
yield from iter_my_deps_from_annotations(cls, my_classes) | ||
yield from iter_my_deps_from_parent_id(cls, my_classes_by_idname) | ||
|
||
def iter_my_deps_from_annotations(cls, my_classes): | ||
for value in typing.get_type_hints(cls, {}, {}).values(): | ||
dependency = get_dependency_from_annotation(value) | ||
if dependency is not None: | ||
if dependency in my_classes: | ||
yield dependency | ||
|
||
def get_dependency_from_annotation(value): | ||
if blender_version >= (2, 93): | ||
if isinstance(value, bpy.props._PropertyDeferred): | ||
return value.keywords.get("type") | ||
else: | ||
if isinstance(value, tuple) and len(value) == 2: | ||
if value[0] in (bpy.props.PointerProperty, bpy.props.CollectionProperty): | ||
return value[1]["type"] | ||
return None | ||
|
||
def iter_my_deps_from_parent_id(cls, my_classes_by_idname): | ||
if bpy.types.Panel in cls.__bases__: | ||
parent_idname = getattr(cls, "bl_parent_id", None) | ||
if parent_idname is not None: | ||
parent_cls = my_classes_by_idname.get(parent_idname) | ||
if parent_cls is not None: | ||
yield parent_cls | ||
|
||
def iter_my_classes(modules): | ||
base_types = get_register_base_types() | ||
for cls in get_classes_in_modules(modules): | ||
if any(base in base_types for base in cls.__bases__): | ||
if not getattr(cls, "is_registered", False): | ||
yield cls | ||
|
||
def get_classes_in_modules(modules): | ||
classes = set() | ||
for module in modules: | ||
for cls in iter_classes_in_module(module): | ||
classes.add(cls) | ||
return classes | ||
|
||
def iter_classes_in_module(module): | ||
for value in module.__dict__.values(): | ||
if inspect.isclass(value): | ||
yield value | ||
|
||
def get_register_base_types(): | ||
return set(getattr(bpy.types, name) for name in [ | ||
"Panel", "Operator", "PropertyGroup", | ||
"AddonPreferences", "Header", "Menu", | ||
"Node", "NodeSocket", "NodeTree", | ||
"UIList", "RenderEngine", | ||
"Gizmo", "GizmoGroup", | ||
]) | ||
|
||
|
||
# Find order to register to solve dependencies | ||
################################################# | ||
|
||
def toposort(deps_dict): | ||
sorted_list = [] | ||
sorted_values = set() | ||
while len(deps_dict) > 0: | ||
unsorted = [] | ||
for value, deps in deps_dict.items(): | ||
if len(deps) == 0: | ||
sorted_list.append(value) | ||
sorted_values.add(value) | ||
else: | ||
unsorted.append(value) | ||
deps_dict = {value : deps_dict[value] - sorted_values for value in unsorted} | ||
return sorted_list |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,168 @@ | ||
import bpy | ||
from .utils import get_target_node | ||
|
||
def add_driver_variable(driver, id, data_path, name): | ||
var = driver.variables.new() | ||
var.name = name | ||
var.type = 'SINGLE_PROP' | ||
var.targets[0].id = id | ||
var.targets[0].data_path = f'["{data_path}"]' | ||
|
||
class AddTexturePlaybackDriverOperator(bpy.types.Operator): | ||
"""Map the playback of a movie/sequence texture to a custom float property by adding a driver to the offset value""" | ||
bl_idname = "node.tfx_add_playback_driver" | ||
bl_label = "Add Texture Playback Driver" | ||
bl_category = 'View' | ||
bl_options = {'REGISTER', 'UNDO'} | ||
|
||
start: bpy.props.IntProperty( | ||
name='First Frame', | ||
description='The first frame from the movie/sequence to be played. Determine automatically when set to -1 or 0', | ||
default=-1, min=-1 | ||
) | ||
duration: bpy.props.IntProperty( | ||
name='Duration', | ||
description='Number of frames from the movie/sequence to be played. Determine automatically when set to -1 or 0', | ||
default=-1, min=-1 | ||
) | ||
add_keyframes: bpy.props.BoolProperty( | ||
name='Add Keyframes', | ||
default=True, | ||
description='Add keyframes automatically to realize some basic playback modes' | ||
) | ||
playback_loops: bpy.props.IntProperty( | ||
name='Loop', | ||
description='Number of repeated playbacks', | ||
default=1, min=1 | ||
) | ||
playback_rate: bpy.props.FloatProperty( | ||
name='Playback Rate', | ||
description='Speed up or slow down the playback', | ||
default=1, min=0.05, soft_max=5 | ||
) | ||
playback_reversed: bpy.props.BoolProperty( | ||
name='Reverse', | ||
default=False, | ||
description='Play the video in another direction' | ||
) | ||
playback_pingpong: bpy.props.BoolProperty( | ||
name='Ping-Pong', | ||
default=False, | ||
description='Change the direction of playback in every loop' | ||
) | ||
|
||
def draw(self, context): | ||
layout = self.layout | ||
layout.label(text='Video Frames to Play:') | ||
box1 = layout.box() | ||
box1.prop(self, 'start') | ||
box1.prop(self, 'duration') | ||
layout.prop(self, "add_keyframes") | ||
if self.add_keyframes: | ||
box2 = layout.box() | ||
box2.prop(self, 'playback_rate') | ||
box2.prop(self, 'playback_loops') | ||
row = box2.row() | ||
row.prop(self, "playback_reversed") | ||
row.prop(self, "playback_pingpong") | ||
|
||
def invoke(self, context, event): | ||
return context.window_manager.invoke_props_dialog(self) | ||
|
||
def execute(self, context): | ||
obj = context.object | ||
mat_idx = obj.active_material_index | ||
mat = obj.material_slots[mat_idx].material | ||
|
||
# Process the active node if possible, otherwise find the first node that qualifies | ||
tex_node, target_node_tree = get_target_node(mat.node_tree, | ||
filter=lambda node: (node.type == 'TEX_IMAGE' and node.image and | ||
(node.image.source == 'MOVIE' or node.image.source == 'SEQUENCE'))) | ||
if not tex_node: | ||
self.report({"WARNING"}, "Cannot find any eligible texture node to perform the operation.") | ||
return {'FINISHED'} | ||
|
||
tex_name = tex_node.image.name | ||
datapath_start = f'frame_start_{tex_name}' | ||
datapath_duration = f'frame_duration_{tex_name}' | ||
datapath_playhead = f'playhead_{tex_name}' | ||
|
||
# Get the playback frame range from multiple sources | ||
frame_duration = self.duration if self.duration > 0 else \ | ||
obj[datapath_duration] if datapath_duration in obj else \ | ||
tex_node.image_user.frame_duration | ||
frame_start = self.start if self.start > 0 else \ | ||
obj[datapath_start] if datapath_start in obj else \ | ||
tex_node.image_user.frame_start | ||
|
||
# Set custom properties as inputs of the driver | ||
obj[datapath_duration] = frame_duration | ||
obj[datapath_start] = frame_start | ||
obj[datapath_playhead] = 0.0 | ||
ui = obj.id_properties_ui(datapath_playhead) | ||
ui.update(soft_min=0.0, soft_max=1.0) | ||
|
||
def add_driver_to_node(node): | ||
# Must modify the original attributes of the texture node, otherwise cannot play correctly | ||
node.image_user.frame_duration = 65535 | ||
node.image_user.frame_start = 1 | ||
|
||
# Add a new driver and fill it with the expression | ||
node.image_user.driver_remove('frame_offset') | ||
playback_driver = node.image_user.driver_add('frame_offset') | ||
playback_driver.driver.type = 'SCRIPTED' | ||
add_driver_variable(playback_driver.driver, obj, datapath_start, 's') | ||
add_driver_variable(playback_driver.driver, obj, datapath_playhead, 'p') | ||
add_driver_variable(playback_driver.driver, obj, datapath_duration, 'd') | ||
|
||
scene_var = playback_driver.driver.variables.new() | ||
scene_var.name = 't' | ||
scene_var.type = 'SINGLE_PROP' | ||
scene_var.targets[0].id_type = 'SCENE' | ||
scene_var.targets[0].id = bpy.context.scene | ||
scene_var.targets[0].data_path = 'frame_current' | ||
|
||
playback_driver.driver.expression = 'min(max(floor(p*d)+s-t, s-t), s+d-t-1)' | ||
|
||
# Remove existing keyframes | ||
if obj.animation_data: | ||
fcurves = obj.animation_data.action.fcurves | ||
for fcurve in fcurves: | ||
if fcurve.data_path == f'["{datapath_playhead}"]': | ||
fcurve.keyframe_points.clear() | ||
|
||
# Insert new keyframes | ||
if self.add_keyframes: | ||
frame_current = bpy.context.scene.frame_current | ||
pingpong = False | ||
for _ in range(self.playback_loops): | ||
# Set start frame | ||
obj[datapath_playhead] = float(self.playback_reversed ^ pingpong) | ||
obj.keyframe_insert(f'["{datapath_playhead}"]') | ||
# Set end frame | ||
bpy.context.scene.frame_current += max(1, round( (frame_duration-1.0) / self.playback_rate) ) | ||
obj[datapath_playhead] = 1.0 - float(self.playback_reversed ^ pingpong) | ||
obj.keyframe_insert(f'["{datapath_playhead}"]') | ||
bpy.context.scene.frame_current += 1 | ||
|
||
if self.playback_pingpong: | ||
pingpong = not pingpong | ||
|
||
fcurves = obj.animation_data.action.fcurves | ||
for fcurve in fcurves: | ||
if fcurve.data_path == f'["{datapath_playhead}"]': | ||
for point in fcurve.keyframe_points: | ||
point.interpolation = 'LINEAR' | ||
bpy.context.scene.frame_current = frame_current | ||
|
||
# Refresh the driver to put it into effect | ||
playback_driver.driver.expression = playback_driver.driver.expression | ||
|
||
add_driver_to_node(tex_node) | ||
# Set the same driver to derivative nodes | ||
for tag in ['V-', 'V+', 'U-', 'U+']: | ||
expected_name = f'{tex_node.name}.{tag}' | ||
if expected_name in target_node_tree.nodes: | ||
add_driver_to_node(target_node_tree.nodes[expected_name]) | ||
|
||
return {'FINISHED'} |
Oops, something went wrong.