Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
chsh2 committed Jan 8, 2024
0 parents commit 0f7462f
Show file tree
Hide file tree
Showing 10 changed files with 1,496 additions and 0 deletions.
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.vscode/
__pycache__/
/tests/
*.py[cod]
*.blend1
*.zip
674 changes: 674 additions & 0 deletions LICENSE

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Texture VFX Control: Blender Add-on

WIP
47 changes: 47 additions & 0 deletions __init__.py
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)
157 changes: 157 additions & 0 deletions auto_load.py
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
168 changes: 168 additions & 0 deletions op_driver.py
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'}
Loading

0 comments on commit 0f7462f

Please sign in to comment.