Nathan Lovato 2021-01-23 18:17:35 -06:00
parent b482ca0078
commit cd176b2617
37 changed files with 275 additions and 2330 deletions

View File

@ -38,11 +38,11 @@ bl_info = {
"name": "Power Sequencer",
"description": "Video editing tools for content creators",
"author": "Nathan Lovato",
"version": (1, 5, 1),
"blender": (2, 90, 1),
"version": (1, 5, 0),
"blender": (2, 81, 0),
"location": "Sequencer",
"tracker_url": "https://github.com/GDquest/Blender-power-sequencer/issues",
"doc_url": "https://www.gdquest.com/docs/documentation/power-sequencer/",
"wiki_url": "https://www.gdquest.com/docs/documentation/power-sequencer/",
"support": "COMMUNITY",
"category": "Sequencer",
}
@ -80,7 +80,7 @@ def register():
keymaps = register_shortcuts(classes_operator)
addon_keymaps += keymaps
# print("Registered {} with {} modules".format(bl_info["name"], len(modules)))
print("Registered {} with {} modules".format(bl_info["name"], len(modules)))
def unregister():
@ -104,4 +104,4 @@ def unregister():
unregister_properties()
unregister_handlers()
# print("Unregistered {}".format(bl_info["name"]))
print("Unregistered {}".format(bl_info["name"]))

View File

@ -55,7 +55,7 @@ class PowerSequencerPreferences(bpy.types.AddonPreferences):
error_message, info = "", ""
try:
info: str = subprocess.check_output([path, "-version"]).decode("utf-8")
info = info[:info.find("Copyright")]
info = info[: info.find("Copyright")]
print(info)
except (OSError, subprocess.CalledProcessError):
error_message = "Path `{}` is not a valid ffmpeg executable".format(path)

View File

@ -22,17 +22,13 @@ def get_operator_classes():
"""Returns the list of operators in the add-on"""
this_file = os.path.dirname(__file__)
module_files = [
f
for f in os.listdir(this_file)
if f.endswith(".py") and not f.startswith("__init__")
f for f in os.listdir(this_file) if f.endswith(".py") and not f.startswith("__init__")
]
module_paths = ["." + os.path.splitext(f)[0] for f in module_files]
classes = []
for path in module_paths:
module = importlib.import_module(path, package="power_sequencer.operators")
operator_names = [
entry for entry in dir(module) if entry.startswith("POWER_SEQUENCER_OT")
]
operator_names = [entry for entry in dir(module) if entry.startswith("POWER_SEQUENCER_OT")]
classes.extend([getattr(module, name) for name in operator_names])
return classes
@ -41,9 +37,7 @@ doc = {
"sequencer.refresh_all": {
"name": "Refresh All",
"description": "",
"shortcuts": [
({"type": "R", "value": "PRESS", "shift": True}, {}, "Refresh All")
],
"shortcuts": [({"type": "R", "value": "PRESS", "shift": True}, {}, "Refresh All")],
"demo": "",
"keymap": "Sequencer",
}

View File

@ -14,17 +14,12 @@
# You should have received a copy of the GNU General Public License along with Power Sequencer. If
# not, see <https://www.gnu.org/licenses/>.
#
import bpy
from operator import attrgetter
from .utils.doc import doc_name, doc_idname, doc_brief, doc_description
from .utils.functions import (
slice_selection,
get_frame_range,
get_channel_range,
trim_strips,
find_strips_in_range,
)
import bpy
from .utils.doc import doc_brief, doc_description, doc_idname, doc_name
from .utils.functions import find_strips_in_range, move_selection, trim_strips
class POWER_SEQUENCER_OT_channel_offset(bpy.types.Operator):
@ -39,7 +34,7 @@ class POWER_SEQUENCER_OT_channel_offset(bpy.types.Operator):
"shortcuts": [
(
{"type": "UP_ARROW", "value": "PRESS", "alt": True},
{"direction": "up"},
{"direction": "up", "trim_target_channel": False},
"Move to Open Channel Above",
),
(
@ -49,7 +44,7 @@ class POWER_SEQUENCER_OT_channel_offset(bpy.types.Operator):
),
(
{"type": "DOWN_ARROW", "value": "PRESS", "alt": True},
{"direction": "down"},
{"direction": "down", "trim_target_channel": False},
"Move to Open Channel Below",
),
(
@ -79,35 +74,71 @@ class POWER_SEQUENCER_OT_channel_offset(bpy.types.Operator):
description="Trim strips to make space in the target channel",
default=False,
)
keep_selection_offset: bpy.props.BoolProperty(
name="Keep selection offset",
description="The selected strips preserve their relative positions",
default=True,
)
@classmethod
def poll(cls, context):
return context.selected_sequences
def execute(self, context):
max_channel = 32
min_channel = 1
if self.direction == "up":
channel_offset = +1
limit_channel = max_channel
comparison_function = min
if self.direction == "down":
channel_offset = -1
limit_channel = min_channel
comparison_function = max
selection = [s for s in context.selected_sequences if not s.lock]
if not selection:
return {"FINISHED"}
selection_blocks = slice_selection(context, selection)
for block in selection_blocks:
sequences = sorted(block, key=attrgetter("channel", "frame_final_start"))
frame_start, frame_end = get_frame_range(sequences)
channel_start, channel_end = get_channel_range(sequences)
sequences = sorted(selection, key=attrgetter("channel", "frame_final_start"))
if self.direction == "up":
sequences = [s for s in reversed(sequences)]
if self.trim_target_channel:
to_delete, to_trim = find_strips_in_range(frame_start, frame_end, context.sequences)
channel_trim = (
channel_end + 1 if self.direction == "up" else max(1, channel_start - 1)
)
to_trim = [s for s in to_trim if s.channel == channel_trim]
to_delete = [s for s in to_delete if s.channel == channel_trim]
trim_strips(context, frame_start, frame_end, to_trim, to_delete)
head = sequences[0]
if not self.keep_selection_offset or (
head.channel != limit_channel and self.keep_selection_offset
):
for s in sequences:
if self.trim_target_channel:
channel_trim = s.channel + channel_offset
strips_in_trim_channel = [
sequence
for sequence in context.sequences
if (sequence.channel == channel_trim)
]
if strips_in_trim_channel:
to_delete, to_trim = find_strips_in_range(
s.frame_final_start, s.frame_final_end, strips_in_trim_channel
)
trim_strips(
context, s.frame_final_start, s.frame_final_end, to_trim, to_delete
)
if self.direction == "up":
for s in reversed(sequences):
s.channel += 1
elif self.direction == "down":
for s in sequences:
s.channel = max(1, s.channel - 1)
if not self.keep_selection_offset:
s.channel = comparison_function(limit_channel, s.channel + channel_offset)
if s.channel == limit_channel:
move_selection(context, [s], 0, 0)
if self.keep_selection_offset:
start_frame = head.frame_final_start
x_difference = 0
while not head.channel == limit_channel:
move_selection(context, sequences, -x_difference, channel_offset)
x_difference = head.frame_final_start - start_frame
if x_difference == 0:
break
return {"FINISHED"}

View File

@ -30,9 +30,7 @@ class POWER_SEQUENCER_OT_split_strips_under_cursor(bpy.types.Operator):
"name": doc_name(__qualname__),
"demo": "https://i.imgur.com/ZyEd0jD.gif",
"description": doc_description(__doc__),
"shortcuts": [
({"type": "K", "value": "PRESS"}, {}, "Cut All Strips Under Cursor")
],
"shortcuts": [({"type": "K", "value": "PRESS"}, {}, "Cut All Strips Under Cursor")],
"keymap": "Sequencer",
}
bl_idname = doc_idname(__qualname__)
@ -66,10 +64,5 @@ class POWER_SEQUENCER_OT_split_strips_under_cursor(bpy.types.Operator):
deselect = False
if deselect:
bpy.ops.sequencer.select_all(action="DESELECT")
(
context.selected_sequences
or bpy.ops.power_sequencer.select_strips_under_cursor()
)
return bpy.ops.sequencer.split(
frame=context.scene.frame_current, side=self.side
)
(context.selected_sequences or bpy.ops.power_sequencer.select_strips_under_cursor())
return bpy.ops.sequencer.split(frame=context.scene.frame_current, side=self.side)

View File

@ -50,9 +50,7 @@ class POWER_SEQUENCER_OT_delete_direct(bpy.types.Operator):
bl_description = doc_brief(doc["description"])
bl_options = {"REGISTER", "UNDO"}
is_removing_transitions: bpy.props.BoolProperty(
name="Remove Transitions", default=False
)
is_removing_transitions: bpy.props.BoolProperty(name="Remove Transitions", default=False)
@classmethod
def poll(cls, context):
@ -61,17 +59,12 @@ class POWER_SEQUENCER_OT_delete_direct(bpy.types.Operator):
def invoke(self, context, event):
frame, channel = get_mouse_frame_and_channel(context, event)
if not context.selected_sequences:
bpy.ops.power_sequencer.select_closest_to_mouse(
frame=frame, channel=channel
)
bpy.ops.power_sequencer.select_closest_to_mouse(frame=frame, channel=channel)
return self.execute(context)
def execute(self, context):
selection = context.selected_sequences
if (
self.is_removing_transitions
and bpy.ops.power_sequencer.transitions_remove.poll()
):
if self.is_removing_transitions and bpy.ops.power_sequencer.transitions_remove.poll():
bpy.ops.power_sequencer.transitions_remove()
bpy.ops.sequencer.delete()

View File

@ -33,11 +33,7 @@ class POWER_SEQUENCER_OT_expand_to_surrounding_cuts(bpy.types.Operator):
"demo": "",
"description": doc_description(__doc__),
"shortcuts": [
(
{"type": "E", "value": "PRESS", "ctrl": True},
{},
"Expand to Surrounding Cuts",
)
({"type": "E", "value": "PRESS", "ctrl": True}, {}, "Expand to Surrounding Cuts",)
],
"keymap": "Sequencer",
}
@ -68,37 +64,24 @@ class POWER_SEQUENCER_OT_expand_to_surrounding_cuts(bpy.types.Operator):
sequences_frame_start = min(
sequences, key=lambda s: s.frame_final_start
).frame_final_start
sequences_frame_end = max(
sequences, key=lambda s: s.frame_final_end
).frame_final_end
sequences_frame_end = max(sequences, key=lambda s: s.frame_final_end).frame_final_end
frame_left, frame_right = find_closest_cuts(
context, sequences_frame_start, sequences_frame_end
)
if (
sequences_frame_start == frame_left
and sequences_frame_end == frame_right
):
if sequences_frame_start == frame_left and sequences_frame_end == frame_right:
continue
to_extend_left = [
s for s in sequences if s.frame_final_start == sequences_frame_start
]
to_extend_right = [
s for s in sequences if s.frame_final_end == sequences_frame_end
]
to_extend_left = [s for s in sequences if s.frame_final_start == sequences_frame_start]
to_extend_right = [s for s in sequences if s.frame_final_end == sequences_frame_end]
for s in to_extend_left:
s.frame_final_start = (
frame_left
if frame_left < sequences_frame_start
else sequences_frame_start
frame_left if frame_left < sequences_frame_start else sequences_frame_start
)
for s in to_extend_right:
s.frame_final_end = (
frame_right
if frame_right > sequences_frame_end
else sequences_frame_end
frame_right if frame_right > sequences_frame_end else sequences_frame_end
)
return {"FINISHED"}
@ -110,8 +93,6 @@ def find_closest_cuts(context, frame_min, frame_max):
).frame_final_end
frame_right = min(
context.sequences,
key=lambda s: s.frame_final_start
if s.frame_final_start >= frame_max
else 1000000,
key=lambda s: s.frame_final_start if s.frame_final_start >= frame_max else 1000000,
).frame_final_start
return frame_left, frame_right

View File

@ -51,10 +51,7 @@ class POWER_SEQUENCER_OT_fade_add(bpy.types.Operator):
bl_options = {"REGISTER", "UNDO"}
duration_seconds: bpy.props.FloatProperty(
name="Fade Duration",
description="Duration of the fade in seconds",
default=1.0,
min=0.01,
name="Fade Duration", description="Duration of the fade in seconds", default=1.0, min=0.01,
)
type: bpy.props.EnumProperty(
items=[
@ -98,12 +95,8 @@ class POWER_SEQUENCER_OT_fade_add(bpy.types.Operator):
if s.frame_final_start < context.scene.frame_current < s.frame_final_end
]
max_duration = min(
sequences, key=lambda s: s.frame_final_duration
).frame_final_duration
max_duration = (
floor(max_duration / 2.0) if self.type == "IN_OUT" else max_duration
)
max_duration = min(sequences, key=lambda s: s.frame_final_duration).frame_final_duration
max_duration = floor(max_duration / 2.0) if self.type == "IN_OUT" else max_duration
faded_sequences = []
for sequence in sequences:
@ -113,15 +106,9 @@ class POWER_SEQUENCER_OT_fade_add(bpy.types.Operator):
if not self.is_long_enough(sequence, duration):
continue
animated_property = (
"volume" if hasattr(sequence, "volume") else "blend_alpha"
)
fade_fcurve = fade_find_or_create_fcurve(
context, sequence, animated_property
)
fades = self.calculate_fades(
sequence, fade_fcurve, animated_property, duration
)
animated_property = "volume" if hasattr(sequence, "volume") else "blend_alpha"
fade_fcurve = fade_find_or_create_fcurve(context, sequence, animated_property)
fades = self.calculate_fades(sequence, fade_fcurve, animated_property, duration)
fade_animation_clear(context, fade_fcurve, fades)
fade_animation_create(fade_fcurve, fades)
faded_sequences.append(sequence)
@ -129,9 +116,7 @@ class POWER_SEQUENCER_OT_fade_add(bpy.types.Operator):
sequence_string = "sequence" if len(faded_sequences) == 1 else "sequences"
self.report(
{"INFO"},
"Added fade animation to {} {}.".format(
len(faded_sequences), sequence_string
),
"Added fade animation to {} {}.".format(len(faded_sequences), sequence_string),
)
return {"FINISHED"}
@ -232,13 +217,9 @@ class Fade:
if type == "IN":
self.start = Vector((sequence.frame_final_start, 0.0))
self.end = Vector(
(sequence.frame_final_start + self.duration, self.max_value)
)
self.end = Vector((sequence.frame_final_start + self.duration, self.max_value))
elif type == "OUT":
self.start = Vector(
(sequence.frame_final_end - self.duration, self.max_value)
)
self.start = Vector((sequence.frame_final_end - self.duration, self.max_value))
self.end = Vector((sequence.frame_final_end, 0.0))
def calculate_max_value(self, sequence, fade_fcurve):
@ -253,15 +234,11 @@ class Fade:
else:
if self.type == "IN":
fade_end = sequence.frame_final_start + self.duration
keyframes = (
k for k in fade_fcurve.keyframe_points if k.co[0] >= fade_end
)
keyframes = (k for k in fade_fcurve.keyframe_points if k.co[0] >= fade_end)
if self.type == "OUT":
fade_start = sequence.frame_final_end - self.duration
keyframes = (
k
for k in reversed(fade_fcurve.keyframe_points)
if k.co[0] <= fade_start
k for k in reversed(fade_fcurve.keyframe_points) if k.co[0] <= fade_start
)
try:
max_value = next(keyframes).co[1]
@ -275,6 +252,4 @@ class Fade:
def calculate_duration_frames(context, duration_seconds):
return round(
duration_seconds * context.scene.render.fps / context.scene.render.fps_base
)
return round(duration_seconds * context.scene.render.fps / context.scene.render.fps_base)

View File

@ -32,11 +32,7 @@ class POWER_SEQUENCER_OT_fade_clear(bpy.types.Operator):
"demo": "",
"description": doc_description(__doc__),
"shortcuts": [
(
{"type": "F", "value": "PRESS", "alt": True, "ctrl": True},
{},
"Clear Fades",
)
({"type": "F", "value": "PRESS", "alt": True, "ctrl": True}, {}, "Clear Fades",)
],
"keymap": "Sequencer",
}
@ -53,9 +49,7 @@ class POWER_SEQUENCER_OT_fade_clear(bpy.types.Operator):
fcurves = context.scene.animation_data.action.fcurves
for sequence in context.selected_sequences:
animated_property = (
"volume" if hasattr(sequence, "volume") else "blend_alpha"
)
animated_property = "volume" if hasattr(sequence, "volume") else "blend_alpha"
data_path = sequence.path_from_id() + "." + animated_property
fcurve_map = {
curve.data_path: curve

View File

@ -71,9 +71,7 @@ class POWER_SEQUENCER_OT_gap_remove(bpy.types.Operator):
else context.sequences
)
sequences = [
s
for s in sequences
if s.frame_final_start >= frame or s.frame_final_end > frame
s for s in sequences if s.frame_final_start >= frame or s.frame_final_end > frame
]
sequence_blocks = slice_selection(context, sequences)
if not sequence_blocks:
@ -100,18 +98,12 @@ class POWER_SEQUENCER_OT_gap_remove(bpy.types.Operator):
Finds and returns the frame at which the gap starts.
Takes a list sequences sorted by frame_final_start.
"""
strips_start = min(
sorted_sequences, key=attrgetter("frame_final_start")
).frame_final_start
strips_end = max(
sorted_sequences, key=attrgetter("frame_final_end")
).frame_final_end
strips_start = min(sorted_sequences, key=attrgetter("frame_final_start")).frame_final_start
strips_end = max(sorted_sequences, key=attrgetter("frame_final_end")).frame_final_end
gap_frame = -1
if strips_start > frame:
strips_before_frame_start = [
s for s in context.sequences if s.frame_final_end <= frame
]
strips_before_frame_start = [s for s in context.sequences if s.frame_final_end <= frame]
frame_target = 0
if strips_before_frame_start:
frame_target = max(

View File

@ -77,9 +77,7 @@ class POWER_SEQUENCER_OT_make_hold_frame(bpy.types.Operator):
try:
next_strip_start = next(
s
for s in sorted(
context.sequences, key=operator.attrgetter("frame_final_start")
)
for s in sorted(context.sequences, key=operator.attrgetter("frame_final_start"))
if s.frame_final_start > active.frame_final_end
).frame_final_start
offset = next_strip_start - active.frame_final_end
@ -90,9 +88,7 @@ class POWER_SEQUENCER_OT_make_hold_frame(bpy.types.Operator):
source_blend_type = active.blend_type
sequencer.split(frame=scene.frame_current, type="SOFT", side="RIGHT")
transform.seq_slide(value=(offset, 0))
sequencer.split(
frame=scene.frame_current + offset + 1, type="SOFT", side="LEFT"
)
sequencer.split(frame=scene.frame_current + offset + 1, type="SOFT", side="LEFT")
transform.seq_slide(value=(-offset, 0))
sequencer.meta_make()

View File

@ -67,7 +67,6 @@ class POWER_SEQUENCER_OT_scene_create_from_selection(bpy.types.Operator):
context.window.scene.name = context.selected_sequences[0].name
new_scene_name = context.window.scene.name
###after full copy also unselected strips are in the sequencer... Delete those strips
bpy.ops.sequencer.select_all(action="INVERT")
bpy.ops.power_sequencer.delete_direct()
@ -85,8 +84,10 @@ class POWER_SEQUENCER_OT_scene_create_from_selection(bpy.types.Operator):
bpy.ops.power_sequencer.delete_direct()
bpy.ops.sequencer.scene_strip_add(
frame_start=selection_start_frame, channel=selection_start_channel, scene=new_scene_name
frame_start=selection_start_frame,
channel=selection_start_channel,
scene=new_scene_name,
)
scene_strip = context.selected_sequences[0]
# scene_strip.use_sequence = True
# scene_strip.use_sequence = True
return {"FINISHED"}

View File

@ -73,9 +73,7 @@ class POWER_SEQUENCER_OT_merge_from_scene_strip(bpy.types.Operator):
context.window.scene = strip_scene
bpy.ops.scene.delete()
context.window.scene = start_scene
self.report(
type={"WARNING"}, message="Merged scenes lose all their animation data."
)
self.report(type={"WARNING"}, message="Merged scenes lose all their animation data.")
return {"FINISHED"}

View File

@ -62,4 +62,4 @@ class POWER_SEQUENCER_OT_select_all_left_or_right(bpy.types.Operator):
return context.sequences
def execute(self, context):
return bpy.ops.sequencer.select_side_of_frame("INVOKE_DEFAULT", side=self.side)
return bpy.ops.sequencer.select("INVOKE_DEFAULT", left_right=self.side)

View File

@ -32,11 +32,7 @@ class POWER_SEQUENCER_OT_snap(bpy.types.Operator):
"demo": "",
"description": doc_description(__doc__),
"shortcuts": [
(
{"type": "S", "value": "PRESS", "shift": True},
{},
"Snap sequences to cursor",
)
({"type": "S", "value": "PRESS", "shift": True}, {}, "Snap sequences to cursor",)
],
"keymap": "Sequencer",
}

View File

@ -15,7 +15,7 @@
# not, see <https://www.gnu.org/licenses/>.
#
import bpy
from .utils.functions import get_sequences_under_cursor, apply_time_offset
from .utils.functions import get_sequences_under_cursor, move_selection
from .utils.doc import doc_name, doc_idname, doc_brief, doc_description
@ -32,11 +32,7 @@ class POWER_SEQUENCER_OT_snap_selection(bpy.types.Operator):
"demo": "",
"description": doc_description(__doc__),
"shortcuts": [
(
{"type": "S", "value": "PRESS", "alt": True},
{},
"Snap selection to cursor",
)
({"type": "S", "value": "PRESS", "alt": True}, {}, "Snap selection to cursor",)
],
"keymap": "Sequencer",
}
@ -55,9 +51,7 @@ class POWER_SEQUENCER_OT_snap_selection(bpy.types.Operator):
if context.selected_sequences
else get_sequences_under_cursor(context)
)
frame_first = min(
sequences, key=lambda s: s.frame_final_start
).frame_final_start
frame_first = min(sequences, key=lambda s: s.frame_final_start).frame_final_start
time_offset = context.scene.frame_current - frame_first
apply_time_offset(context, sequences, time_offset)
move_selection(context, sequences, time_offset)
return {"FINISHED"}

View File

@ -78,16 +78,9 @@ class POWER_SEQUENCER_OT_trim_left_or_right_handles(bpy.types.Operator):
frame_current = context.scene.frame_current
# Only select sequences under the time cursor
sequences = (
context.selected_sequences
if context.selected_sequences
else context.sequences
)
sequences = context.selected_sequences if context.selected_sequences else context.sequences
for s in sequences:
s.select = (
s.frame_final_start <= frame_current
and s.frame_final_end >= frame_current
)
s.select = s.frame_final_start <= frame_current and s.frame_final_end >= frame_current
sequences = [s for s in sequences if s.select]
if not sequences:
return {"FINISHED"}

View File

@ -21,6 +21,9 @@ import bpy
from .global_settings import SequenceTypes
max_channel = 32
min_channel = 1
def calculate_distance(x1, y1, x2, y2):
return sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2)
@ -34,10 +37,8 @@ def find_linked(context, sequences, selected_sequences):
"""
Takes a list of sequences and returns a list of all the sequences
and effects that are linked in time
Args:
- sequences: a list of sequences
- sequences: a list of sequences
Returns a list of all the linked sequences, but not the sequences passed to the function
"""
start, end = get_frame_range(sequences, selected_sequences)
@ -99,7 +100,7 @@ def find_sequences_after(context, sequence):
"""
Finds the strips following the sequences passed to the function
Args:
- Sequences, the sequences to check
- Sequences, the sequences to check
Returns all the strips after the sequence in the current context
"""
return [s for s in context.sequences if s.frame_final_start > sequence.frame_final_start]
@ -133,12 +134,10 @@ def find_snap_candidate(context, frame=0):
def find_strips_mouse(context, frame, channel, select_linked=False):
"""
Finds a list of sequences to select based on the frame and channel the mouse cursor is at
Args:
- frame: the frame the mouse or cursor is on
- channel: the channel the mouse is hovering
- select_linked: find and append the sequences linked in time if True
- frame: the frame the mouse or cursor is on
- channel: the channel the mouse is hovering
- select_linked: find and append the sequences linked in time if True
Returns the sequence(s) under the mouse cursor as a list
Returns an empty list if nothing found
"""
@ -201,10 +200,9 @@ def get_mouse_frame_and_channel(context, event):
def is_in_range(context, sequence, start, end):
"""
Checks if a single sequence's start or end is in the range
Args:
- sequence: the sequence to check for
- start, end: the start and end frames
- sequence: the sequence to check for
- start, end: the start and end frames
Returns True if the sequence is within the range, False otherwise
"""
s_start = sequence.frame_final_start
@ -225,47 +223,32 @@ def set_preview_range(context, start, end):
scene.frame_preview_end = end
def slice_selection(context, sequences):
def slice_selection(context, sequences, range_block=0):
"""
Takes a list of sequences and breaks it down
into multiple lists of connected sequences
Returns a list of lists of sequences,
each list corresponding to a block of sequences
that are connected in time and sorted by frame_final_start
"""
# Find when 2 sequences are not connected in time
if not sequences:
return []
break_ids = [0]
sorted_sequences = sorted(sequences, key=attrgetter("frame_final_start"))
last_sequence = sorted_sequences[0]
last_biggest_frame_end = last_sequence.frame_final_end
index = 0
for s in sorted_sequences:
if s.frame_final_start > last_biggest_frame_end + 1:
break_ids.append(index)
last_biggest_frame_end = max(last_biggest_frame_end, s.frame_final_end)
last_sequence = s
index += 1
# Create lists
break_ids.append(len(sorted_sequences))
cuts_count = len(break_ids) - 1
# Indicates the index number of the lists from the "broken_selection" list
index = -1
block_end = 0
broken_selection = []
index = 0
while index < cuts_count:
temp_list = []
index_range = range(break_ids[index], break_ids[index + 1] - 1)
if len(index_range) == 0:
temp_list.append(sorted_sequences[break_ids[index]])
else:
for counter in range(break_ids[index], break_ids[index + 1]):
temp_list.append(sorted_sequences[counter])
if temp_list:
broken_selection.append(temp_list)
index += 1
sorted_sequences = sorted(sequences, key=attrgetter("frame_final_start"))
for s in sorted_sequences:
if not broken_selection or (block_end + 1 + range_block < s.frame_final_start):
broken_selection.append([s])
block_end = s.frame_final_end
index += 1
continue
block_end = max(block_end, s.frame_final_end)
broken_selection[index].append(s)
return broken_selection
@ -278,8 +261,10 @@ def trim_strips(context, frame_start, frame_end, to_trim, to_delete=[]):
trim_end = max(frame_start, frame_end)
to_trim = [s for s in to_trim if s.type in SequenceTypes.CUTABLE]
initial_selection = context.selected_sequences
for s in to_trim:
strips_in_target_channel = []
# Cut strip longer than the trim range in three
is_strip_longer_than_trim_range = (
s.frame_final_start < trim_start and s.frame_final_end > trim_end
@ -290,6 +275,13 @@ def trim_strips(context, frame_start, frame_end, to_trim, to_delete=[]):
bpy.ops.sequencer.split(frame=trim_start, type="SOFT", side="RIGHT")
bpy.ops.sequencer.split(frame=trim_end, type="SOFT", side="LEFT")
to_delete.append(context.selected_sequences[0])
for c in context.sequences:
if c.channel == s.channel:
strips_in_target_channel.append(c)
if s in initial_selection:
initial_selection.append(strips_in_target_channel[0])
continue
# Resize strips that overlap the trim range
@ -298,20 +290,12 @@ def trim_strips(context, frame_start, frame_end, to_trim, to_delete=[]):
elif s.frame_final_end > trim_start and s.frame_final_start < trim_start:
s.frame_final_end = trim_start
delete_strips(to_delete)
return {"FINISHED"}
def delete_strips(to_delete):
"""
Deletes the list of sequences `to_delete`
"""
if not to_delete:
return
bpy.ops.sequencer.select_all(action="DESELECT")
for s in to_delete:
bpy.context.sequences.remove(s)
for s in initial_selection:
s.select = True
bpy.ops.sequencer.delete()
return {"FINISHED"}
def find_closest_surrounding_cuts(context, frame):
@ -378,30 +362,7 @@ def ripple_move(context, sequences, duration_frames, delete=False):
else:
to_ripple = set(to_ripple + sequences)
# Use the built-in seq_slide operator to move strips, for best performances
initial_selection = context.selected_sequences
bpy.ops.sequencer.select_all(action="DESELECT")
for s in to_ripple:
s.select = True
bpy.ops.transform.seq_slide(value=(duration_frames, 0))
bpy.ops.sequencer.select_all(action="DESELECT")
for s in initial_selection:
s.select = True
def apply_time_offset(context, sequences=[], offset=0):
"""Offsets a list of sequences in time using bpy.ops.transform.seq_slide. Mutates and restores the
user's selection. Use this function to ensure maximum performances and avoid having to figure
out the logic to move strips in the right order.
"""
selection = context.selected_sequences
bpy.ops.sequencer.select_all(action="DESELECT")
for s in sequences:
s.select = True
bpy.ops.transform.seq_slide(value=(offset, 0))
bpy.ops.sequencer.select_all(action="DESELECT")
for s in selection:
s.select = True
move_selection(context, to_ripple, duration_frames, 0)
def find_strips_in_range(frame_start, frame_end, sequences, find_overlapping=True):
@ -409,18 +370,18 @@ def find_strips_in_range(frame_start, frame_end, sequences, find_overlapping=Tru
Returns a tuple of two lists: (strips_inside_range, strips_overlapping_range)
strips_inside_range are strips entirely contained in the frame range.
strips_overlapping_range are strips that only overlap the frame range.
Args:
- frame_start, the start of the frame range
- frame_end, the end of the frame range
- sequences (optional): only work with these sequences.
If it doesn't receive any, the function works with all the sequences in the current context
- find_overlapping (optional): find and return a list of strips that overlap the
- frame_start, the start of the frame range
- frame_end, the end of the frame range
- sequences (optional): only work with these sequences.
If it doesn't receive any, the function works with all the sequences in the current context
- find_overlapping (optional): find and return a list of strips that overlap the
frame range
"""
strips_inside_range = []
strips_overlapping_range = []
if not sequences:
sequences = bpy.context.sequences
for s in sequences:
if (
frame_start <= s.frame_final_start <= frame_end
@ -429,8 +390,8 @@ def find_strips_in_range(frame_start, frame_end, sequences, find_overlapping=Tru
strips_inside_range.append(s)
elif find_overlapping:
if (
frame_start <= s.frame_final_end <= frame_end
or frame_start <= s.frame_final_start <= frame_end
frame_start < s.frame_final_end <= frame_end
or frame_start <= s.frame_final_start < frame_end
):
strips_overlapping_range.append(s)
@ -438,3 +399,19 @@ def find_strips_in_range(frame_start, frame_end, sequences, find_overlapping=Tru
if s.frame_final_start < frame_start and s.frame_final_end > frame_end:
strips_overlapping_range.append(s)
return strips_inside_range, strips_overlapping_range
def move_selection(context, sequences, frame_offset, channel_offset=0):
"""Offsets the selected `sequences` horizontally and vertically and preserves
the current selected sequences.
"""
if not sequences:
return
initial_selection = context.selected_sequences
bpy.ops.sequencer.select_all(action="DESELECT")
for s in sequences:
s.select = True
bpy.ops.transform.seq_slide(value=(frame_offset, channel_offset))
bpy.ops.sequencer.select_all(action="DESELECT")
for s in initial_selection:
s.select = True

View File

@ -0,0 +1,94 @@
#
# Copyright (C) 2016-2020 by Nathan Lovato, Daniel Oakey, Razvan Radulescu, and contributors
#
# This file is part of Power Sequencer.
#
# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the
# GNU General Public License as published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with Power Sequencer. If
# not, see <https://www.gnu.org/licenses/>.
#
import bpy
from .utils.doc import doc_brief, doc_description, doc_idname, doc_name
from .utils.functions import convert_duration_to_frames
class POWER_SEQUENCER_OT_value_offset(bpy.types.Operator):
"""Instantly offset selected strips, either using frames or seconds. Allows to
nudge the selection quickly, using keyboard shortcuts.
"""
doc = {
"name": doc_name(__qualname__),
"demo": "",
"description": doc_description(__doc__),
"shortcuts": [
(
{"type": "LEFT_ARROW", "value": "PRESS", "shift": True, "alt": True},
{"direction": "left"},
"Offset the selection to the left.",
),
(
{"type": "RIGHT_ARROW", "value": "PRESS", "shift": True, "alt": True},
{"direction": "right"},
"Offset the selection to the right.",
),
],
"keymap": "Sequencer",
}
bl_idname = doc_idname(__qualname__)
bl_label = doc["name"]
bl_description = doc_brief(doc["description"])
bl_options = {"REGISTER", "UNDO"}
direction: bpy.props.EnumProperty(
items=[
("left", "left", "Move the selection to the left"),
("right", "right", "Move the selection to the right"),
],
name="Direction",
description="Move the selection given frames or seconds",
default="right",
options={"HIDDEN"},
)
value_type: bpy.props.EnumProperty(
items=[
("seconds", "Seconds", "Move with the value as seconds"),
("frames", "Frames", "Move with the value as frames"),
],
name="Value Type",
description="Toggle between offset in frames or seconds",
default="seconds",
)
offset: bpy.props.FloatProperty(
name="Offset",
description="Offset amount to apply",
default=1.0,
step=5,
precision=3,
)
@classmethod
def poll(cls, context):
return context.selected_sequences
def invoke(self, context, event):
self.offset = abs(self.offset)
if self.direction == "left":
self.offset *= -1.0
return self.execute(context)
def execute(self, context):
offset_frames = (
convert_duration_to_frames(context, self.offset)
if self.value_type == "seconds"
else self.offset
)
return bpy.ops.transform.seq_slide(value=(offset_frames, 0))

View File

@ -1,16 +0,0 @@
#
# Copyright (C) 2016-2019 by Razvan Radulescu, Nathan Lovato, and contributors
#
# This file is part of Power Sequencer.
#
# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the
# GNU General Public License as published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with Power Sequencer. If
# not, see <https://www.gnu.org/licenses/>.
#

View File

@ -1,171 +0,0 @@
#
# Copyright (C) 2016-2019 by Razvan Radulescu, Nathan Lovato, and contributors
#
# This file is part of Power Sequencer.
#
# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the
# GNU General Public License as published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with Power Sequencer. If
# not, see <https://www.gnu.org/licenses/>.
#
"""
Tool to render video proxies using FFMPEG
Offers mp4 and webm options
"""
import argparse as ap
import glob as g
import logging as lg
import os.path as osp
import sys
from itertools import compress, starmap, tee
from .call import call, call_makedirs
from .commands import get_commands, get_commands_vi
from .config import CONFIG as C
from .config import LOGGER, LOGLEV
from .utils import checktools, printw, printd, prints, ToolError
def find_files(
directory=".", ignored_directory=C["proxy_directory"], extensions=C["extensions"]["all"]
):
"""
Find files to process.
Parameters
----------
directory: str
Working directory.
ignored_directory: str
Don't check for files in this directory. By default `BL_proxy`.
extensions: set(str)
Set of file extensions for filtering the directory tree.
Returns
-------
out: list(str)
List of file paths to be processed.
"""
if not osp.isdir(directory):
raise ValueError(("The given path '{}' is not a valid directory.".format(directory)))
xs = g.iglob("{}/**".format(osp.abspath(directory)), recursive=True)
xs = filter(lambda x: osp.isfile(x), xs)
xs = filter(lambda x: ignored_directory not in osp.dirname(x), xs)
xs = [x for x in xs if osp.splitext(x)[1].lower() in extensions]
return xs
def parse_arguments(cfg):
"""
Uses `argparse` to parse the command line arguments.
Parameters
----------
cfg: dict
Configuration dictionary.
Returns
-------
out: Namespace
Command line arguments.
"""
p = ap.ArgumentParser(description="Create proxies for Blender VSE using FFMPEG.")
p.add_argument(
"working_directory",
nargs="?",
default=".",
help="The directory containing media to create proxies for",
)
p.add_argument(
"-p",
"--preset",
default="mp4",
choices=cfg["presets"],
help="a preset name for proxy encoding",
)
p.add_argument(
"-s",
"--sizes",
nargs="+",
type=int,
default=[25],
choices=cfg["proxy_sizes"],
help="A list of sizes of the proxies to render, either 25, 50, or 100",
)
p.add_argument(
"-v", "--verbose", action="count", default=0, help="Increase verbosity level (eg. -vvv)."
)
p.add_argument(
"--dry-run",
action="store_true",
help=(
"Run the script without actual rendering or creating files and"
" folders. For DEBUGGING purposes"
),
)
clargs = p.parse_args()
# normalize directory
clargs.working_directory = osp.abspath(clargs.working_directory)
# --dry-run implies maximum verbosity level
clargs.verbose = 99999 if clargs.dry_run else clargs.verbose
return clargs
def main():
"""
Script entry point.
"""
tools = ["ffmpeg", "ffprobe"]
try:
# get command line arguments and set log level
clargs = parse_arguments(C)
lg.basicConfig(level=LOGLEV[min(clargs.verbose, len(LOGLEV) - 1)])
# log basic command line arguments
clargs.dry_run and LOGGER.info("DRY-RUN")
LOGGER.info("WORKING-DIRECTORY :: {}".format(clargs.working_directory))
LOGGER.info("PRESET :: {}".format(clargs.preset))
LOGGER.info("SIZES :: {}".format(clargs.sizes))
# check for external dependencies
checktools(tools)
# find files to process
path_i = find_files(clargs.working_directory)
kwargs = {"path_i": path_i}
printw(C, "Creating directories if necessary")
call_makedirs(C, clargs, **kwargs)
printw(C, "Checking for existing proxies")
cmds = tee(get_commands(C, clargs, what="check", **kwargs))
stdouts = call(C, clargs, cmds=cmds[0], check=False, shell=True, **kwargs)
checks = map(lambda s: s.strip().split(), stdouts)
checks = starmap(lambda fst, *tail: not all(fst == t for t in tail), checks)
kwargs["path_i"] = list(compress(kwargs["path_i"], checks))
if len(kwargs["path_i"]) != 0:
printw(C, "Processing", s="\n")
cmds = get_commands_vi(C, clargs, **kwargs)
call(C, clargs, cmds=cmds, **kwargs)
else:
printd(C, "All proxies exist or no files found, nothing to process", s="\n")
printd(C, "Done")
except (ToolError, ValueError) as e:
LOGGER.error(e)
prints(C, "Exiting")
except KeyboardInterrupt:
prints(C, "DirtyInterrupt. Exiting", s="\n\n")
sys.exit()
# this is so it can be ran as a module: `python3 -m bpsrender` (for testing)
if __name__ == "__main__":
main()

View File

@ -1,95 +0,0 @@
#
# Copyright (C) 2016-2019 by Razvan Radulescu, Nathan Lovato, and contributors
#
# This file is part of Power Sequencer.
#
# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the
# GNU General Public License as published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with Power Sequencer. If
# not, see <https://www.gnu.org/licenses/>.
#
# import multiprocessing as mp
import os
import subprocess as sp
import sys
from functools import partial
from itertools import chain, tee
from tqdm import tqdm
from .config import LOGGER
from .utils import get_dir, kickstart
WINDOWS = ("win32", "cygwin")
def call_makedirs(cfg, clargs, **kwargs):
"""
Make BL_proxy directories if necessary.
Parameters
----------
cfg: dict
Configuration dictionary.
clargs: Namespace
Command line arguments.
kwargs: dict
MANDATORY: path_i
Dictionary with additional information from previous step.
"""
path_i = kwargs["path_i"]
path_d = map(partial(get_dir, cfg, clargs, **kwargs), path_i)
path_d = tee(chain(*path_d))
kickstart(map(lambda p: LOGGER.info("Directory @ {}".format(p)), path_d[0]))
if clargs.dry_run:
return
path_d = (os.makedirs(p, exist_ok=True) for p in path_d[1])
kickstart(path_d)
def call(cfg, clargs, *, cmds, **kwargs):
"""
Generic subprocess calls.
Parameters
----------
cfg: dict
Configuration dictionary.
clargs: Namespace
Command line arguments.
cmds: iter(tuple(str))
kwargs: dict
MANDATORY: path_i
Dictionary with additional information from previous step.
Returns
-------
out: str
Stdout & Stderr gathered from subprocess call.
"""
kwargs_s = {
"stdout": sp.PIPE,
"stderr": sp.STDOUT,
"universal_newlines": True,
"check": kwargs.get("check", True),
"shell": kwargs.get("shell", False),
"creationflags": sp.CREATE_NEW_PROCESS_GROUP if sys.platform in WINDOWS else 0,
}
if kwargs_s["shell"]:
cmds = map(lambda cmd: (cmd[0], " ".join(cmd[1])), cmds)
cmds = tee(cmds)
kickstart(map(lambda cmd: LOGGER.debug("CALL :: {}".format(cmd[1])), cmds[0]))
if clargs.dry_run:
return []
n = len(kwargs["path_i"])
ps = tqdm(
map(lambda cmd: sp.run(cmd[1], **kwargs_s), cmds[1]),
total=n,
unit="file" if n == 1 else "files",
)
return [p.stdout for p in ps]

View File

@ -1,190 +0,0 @@
#
# Copyright (C) 2016-2019 by Razvan Radulescu, Nathan Lovato, and contributors
#
# This file is part of Power Sequencer.
#
# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the
# GNU General Public License as published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with Power Sequencer. If
# not, see <https://www.gnu.org/licenses/>.
#
import os.path as osp
import shlex as sl
from itertools import chain
from .utils import get_path
def get_commands_check(cfg, clargs, **kwargs):
"""
ffprobe subprocess command generation.
Parameters
----------
cfg: dict
Configuration dictionary.
clargs: Namespace
Command line arguments.
cmds: iter(tuple(str))
kwargs: dict
MANDATORY: path_i_1, path_o_1
Dictionary with additional information from previous step.
Returns
-------
out: iter(tuple(str))
Iterator containing commands.
"""
cmd = (
"ffprobe -v error -select_streams v:0 -show_entries stream=nb_frames -of"
" default=noprint_wrappers=1:nokey=1 '{file}'"
)
out = map(lambda s: kwargs["path_o_1"].format(size=s), clargs.sizes)
out = map(lambda f: cmd.format(file=f), out)
out = sl.split(cmd.format(file=kwargs["path_i_1"]) + " && " + " && ".join(out))
return iter((out,))
def get_commands_image_1(cfg, clargs, **kwargs):
"""
ffmpeg subprocess command generation for processing an image.
Parameters
----------
cfg: dict
Configuration dictionary.
clargs: Namespace
Command line arguments.
cmds: iter(tuple(str))
kwargs: dict
MANDATORY: path_i_1, path_o_1
Dictionary with additional information from previous step.
Returns
-------
out: iter(tuple(str))
Iterator containing commands.
"""
cmd = "ffmpeg -hwaccel auto -y -v quiet -stats -i '{path_i_1}' {common_all}"
common = "-f apng -filter:v scale=iw*{size}:ih*{size} '{path_o_1}'"
common_all = map(lambda s: kwargs["path_o_1"].format(size=s), clargs.sizes)
common_all = map(
lambda s: common.format(size=s[0] / 100.0, path_o_1=s[1]), zip(clargs.sizes, common_all)
)
common_all = " ".join(common_all)
out = sl.split(cmd.format(path_i_1=kwargs["path_i_1"], common_all=common_all))
return iter((out,))
def get_commands_video_1(cfg, clargs, **kwargs):
"""
ffmpeg subprocess command generation for processing a video.
Parameters
----------
cfg: dict
Configuration dictionary.
clargs: Namespace
Command line arguments.
cmds: iter(tuple(str))
kwargs: dict
MANDATORY: path_i_1, path_o_1
Dictionary with additional information from previous step.
Returns
-------
out: iter(tuple(str))
Iterator containing commands.
"""
cmd = "ffmpeg -hwaccel auto -y -v quiet -stats -noautorotate -i '{path_i_1}' {common_all}"
common = (
"-pix_fmt yuv420p"
" -g 1"
" -sn -an"
" -vf colormatrix=bt601:bt709"
" -vf scale=ceil(iw*{size}/2)*2:ceil(ih*{size}/2)*2"
" {preset}"
" '{path_o_1}'"
)
common_all = map(lambda s: kwargs["path_o_1"].format(size=s), clargs.sizes)
common_all = map(
lambda s: common.format(
preset=cfg["presets"][clargs.preset], size=s[0] / 100.0, path_o_1=s[1]
),
zip(clargs.sizes, common_all),
)
common_all = " ".join(common_all)
out = sl.split(cmd.format(path_i_1=kwargs["path_i_1"], common_all=common_all))
return iter((out,))
def get_commands(cfg, clargs, *, what, **kwargs):
"""
Delegates the creation of commands lists to appropriate functions based on `what` parameter.
Parameters
----------
cfg: dict
Configuration dictionary.
clargs: Namespace
Command line arguments.
cmds: iter(tuple(str))
what: str
Determines the returned value (see: Returns[out]).
kwargs: dict
MANDATORY: path_i
Dictionary with additional information from previous step.
Returns
-------
out: iter(tuple(str, tuple(str)))
An iterator with the 1st element as a tag (the `what` parameter) and the 2nd
element as the iterator of the actual commands.
"""
get_commands_f = {
"video": get_commands_video_1,
"image": get_commands_image_1,
"check": get_commands_check,
}
ps = (
kwargs["path_i"]
if what not in cfg["extensions"]
else filter(
lambda p: osp.splitext(p)[1].lower() in cfg["extensions"][what], kwargs["path_i"]
)
)
ps = map(lambda p: (p, get_path(cfg, clargs, p, **kwargs)), ps)
out = chain.from_iterable(
map(lambda p: get_commands_f[what](cfg, clargs, path_i_1=p[0], path_o_1=p[1], **kwargs), ps)
)
return map(lambda c: (what, c), out)
def get_commands_vi(cfg, clargs, **kwargs):
"""
Delegates the creation of commands lists to appropriate functions for video/image processing.
Parameters
----------
cfg: dict
Configuration dictionary.
clargs: Namespace
Command line arguments.
cmds: iter(tuple(str))
kwargs: dict
MANDATORY: path_i_1, path_o_1
Dictionary with additional information from previous step.
Returns
-------
out: iter(tuple(str, tuple(str)))
An iterator with the 1st element as a tag (the `what` parameter) and the 2nd
element as the iterator of the actual commands.
"""
ws = filter(lambda x: x is not "all", cfg["extensions"])
return chain.from_iterable(map(lambda w: get_commands(cfg, clargs, what=w, **kwargs), ws))

View File

@ -1,41 +0,0 @@
#
# Copyright (C) 2016-2019 by Razvan Radulescu, Nathan Lovato, and contributors
#
# This file is part of Power Sequencer.
#
# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the
# GNU General Public License as published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with Power Sequencer. If
# not, see <https://www.gnu.org/licenses/>.
#
import multiprocessing as mp
from itertools import chain
import logging as lg
CONFIG = {
"logger": "BPS",
"proxy_directory": "BL_proxy",
"proxy_sizes": (25, 50, 100),
"extensions": {
"video": {".mp4", ".mkv", ".mov", ".flv", ".mts"},
"image": {".png", ".jpg", ".jpeg"},
},
"presets": {
"webm": "-c:v libvpx -crf 25 -speed 16 -threads {}".format(str(mp.cpu_count())),
"mp4": "-c:v libx264 -crf 25 -preset faster -tune fastdecode",
"nvenc": "-c:v h264_nvenc -qp 25 -preset fast",
},
"pre": {"work": "»", "done": "", "skip": "~"},
}
CONFIG["extensions"]["all"] = set(chain(*CONFIG["extensions"].values()))
LOGGER = lg.getLogger(CONFIG["logger"])
LOGLEV = [lg.INFO, lg.DEBUG]
LOGLEV = [None] + sorted(LOGLEV, reverse=True)

View File

@ -1,109 +0,0 @@
#
# Copyright (C) 2016-2019 by Razvan Radulescu, Nathan Lovato, and contributors
#
# This file is part of Power Sequencer.
#
# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the
# GNU General Public License as published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with Power Sequencer. If
# not, see <https://www.gnu.org/licenses/>.
#
"""
Collection of utility functions, class-independent
"""
import os.path as osp
from collections import deque
from shutil import which
class ToolError(Exception):
"""Raised if external dependencies aren't found on system.
"""
pass
def checktools(tools):
tools = [(t, which(t) or "") for t in tools]
check = {"tools": tools, "test": all(map(lambda x: x[1], tools))}
if not check["test"]:
msg = ["BPSProxy couldn't find external dependencies:"]
msg += [
"[{check}] {tool}: {path}".format(
check="v" if path is not "" else "X", tool=tool, path=path or "NOT FOUND"
)
for tool, path in check["tools"]
]
msg += [
(
"Check if you have them properly installed and available in the PATH"
" environemnt variable."
)
]
raise ToolError("\n".join(msg))
def get_path_video(cfg, clargs, path, **kwargs):
return osp.join(
osp.dirname(path), cfg["proxy_directory"], osp.basename(path), "proxy_{size}.avi"
)
def get_path_image(cfg, clargs, path, **kwargs):
return osp.join(
osp.dirname(path),
cfg["proxy_directory"],
"images",
"{size}",
"{file}_proxy.jpg".format(file=osp.basename(path)),
)
def get_path(cfg, clargs, path, **kwargs):
get_path_f = {"video": get_path_video, "image": get_path_image}
what = what_vi(cfg, clargs, path, **kwargs)
return get_path_f[what](cfg, clargs, path, **kwargs)
def get_dir_video(cfg, clargs, path, **kwargs):
return iter((osp.join(osp.dirname(path), cfg["proxy_directory"], osp.basename(path)),))
def get_dir_image(cfg, clargs, path, **kwargs):
ps = osp.join(osp.dirname(path), cfg["proxy_directory"], "images", "{size}")
return map(lambda s: ps.format(size=s), clargs.sizes)
def get_dir(cfg, clargs, path, **kwargs):
get_dir_f = {"video": get_dir_video, "image": get_dir_image}
what = what_vi(cfg, clargs, path, **kwargs)
return get_dir_f[what](cfg, clargs, path, **kwargs)
def what_vi(cfg, clargs, p, **kwargs):
return "video" if osp.splitext(p)[1].lower() in cfg["extensions"]["video"] else "image"
def kickstart(it):
deque(it, maxlen=0)
def printw(cfg, text, s="\n", e="...", p="", **kwargs):
p = p or cfg["pre"]["work"]
print("{s}{p} {}{e}".format(text, s=s, e=e, p=p), **kwargs)
def printd(cfg, text, s="", e=".", p="", **kwargs):
p = p or cfg["pre"]["done"]
printw(cfg, text, s=s, e=e, p=p, **kwargs)
def prints(cfg, text, s="", e=".", p="", **kwargs):
p = p or cfg["pre"]["skip"]
printw(cfg, text, s=s, e=e, p=p, **kwargs)

View File

@ -1,57 +0,0 @@
#
# Copyright (C) 2016-2019 by Razvan Radulescu, Nathan Lovato, and contributors
#
# This file is part of Power Sequencer.
#
# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the
# GNU General Public License as published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with Power Sequencer. If
# not, see <https://www.gnu.org/licenses/>.
#
from setuptools import setup
def readme():
with open("README.md") as f:
return f.read()
if __name__ == "__main__":
setup(
name="bpsproxy",
version="0.2.1",
description="Blender Power Sequencer proxy generator tool",
long_description=readme(),
long_description_content_type="text/markdown",
classifiers=[
"Development Status :: 5 - Production/Stable",
"Environment :: Console",
"Intended Audience :: End Users/Desktop",
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
"Natural Language :: English",
"Programming Language :: Python :: 3.3",
"Programming Language :: Python :: 3.4",
"Programming Language :: Python :: 3.5",
"Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3",
"Topic :: Multimedia :: Video",
"Topic :: Utilities",
],
url="https://github.com/GDquest/BPSProxy",
keywords="blender proxy vse sequence editor productivity",
author="Răzvan C. Rădulescu",
author_email="razcore.art@gmail.com",
license="GPLv3",
packages=["bpsproxy"],
install_requires=["tqdm"],
zip_safe=False,
entry_points={"console_scripts": ["bpsproxy=bpsproxy.__main__:main"]},
include_package_data=True,
)

View File

@ -1,16 +0,0 @@
#
# Copyright (C) 2016-2019 by Razvan Radulescu, Nathan Lovato, and contributors
#
# This file is part of Power Sequencer.
#
# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the
# GNU General Public License as published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with Power Sequencer. If
# not, see <https://www.gnu.org/licenses/>.
#

View File

@ -1,147 +0,0 @@
#
# Copyright (C) 2016-2019 by Razvan Radulescu, Nathan Lovato, and contributors
#
# This file is part of Power Sequencer.
#
# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the
# GNU General Public License as published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with Power Sequencer. If
# not, see <https://www.gnu.org/licenses/>.
#
"""
Renders videos edited in Blender 3D's Video Sequence Editor using multiple CPU
cores. Original script by Justin Warren:
https://github.com/sciactive/pulverize/blob/master/pulverize.py
Modified by sudopluto (Pranav Sharma), gdquest (Nathan Lovato) and
razcore (Razvan Radulescu)
Under GPLv3 license
"""
import argparse as ap
import os.path as osp
import sys
from functools import partial
from .calls import call
from .config import CONFIG as C
from .config import LOGGER
from .helpers import BSError, ToolError, checktools, kickstart, prints
from .setup import setup
# https://github.com/mikeycal/the-video-editors-render-script-for-blender#configuring-the-script
# there seems no easy way to grab the ram usage in a mulitplatform way
# without writing platform dependent code, or by using a python module
# Most popluar config is 4 cores, 8 GB ram, this is the default for the script
# https://store.steampowered.com/hwsurvey/
def parse_arguments(cfg):
"""
Uses `argparse` to parse the command line arguments.
Parameters
----------
cfg: dict
Configuration dictionary.
Returns
-------
out: Namespace
Command line arguments (normalized).
"""
p = ap.ArgumentParser(
description="Multi-process Blender VSE rendering - will attempt to"
" create a folder called `render` inside of the folder"
" containing `blendfile`. Insider `render` another folder called"
" `parts` will be created for storing temporary files. These files"
" will be joined together as the last step to produce the final"
" render which will be stored inside `render` and it will have the"
" same name as `blendfile`"
)
p.add_argument(
"-o",
"--output",
default=".",
help="Output folder (will contain a `bpsrender` temp folder for" "rendering parts).",
)
p.add_argument(
"-w",
"--workers",
type=int,
default=cfg["cpu_count"],
help="Number of workers in the pool (for video rendering).",
)
p.add_argument(
"-v", "--verbose", action="count", default=0, help="Increase verbosity level (eg. -vvv)."
)
p.add_argument(
"--dry-run",
action="store_true",
help=(
"Run the script without actual rendering or creating files and"
" folders. For DEBUGGING purposes"
),
)
p.add_argument("-s", "--start", type=int, default=None, help="Start frame")
p.add_argument("-e", "--end", type=int, default=None, help="End frame")
p.add_argument(
"-m", "--mixdown-only", action="store_true", help="ONLY render the audio MIXDOWN"
)
p.add_argument(
"-c",
"--concatenate-only",
action="store_true",
help="ONLY CONCATENATE the (already) available video chunks",
)
p.add_argument(
"-d",
"--video-only",
action="store_true",
help="ONLY render the VIDEO (implies --concatenate-only).",
)
p.add_argument(
"-j",
"--join-only",
action="store_true",
help="ONLY JOIN the mixdown with the video. This will produce the" " final render",
)
p.add_argument("blendfile", help="Blender project file to render.")
clargs = p.parse_args()
clargs.blendfile = osp.abspath(clargs.blendfile)
clargs.output = osp.abspath(clargs.output)
# --video-only implies --concatenate-only
clargs.concatenate_only = clargs.concatenate_only or clargs.video_only
# --dry-run implies maximum verbosity level
clargs.verbose = 99999 if clargs.dry_run else clargs.verbose
return clargs
def main():
"""
Script entry point.
"""
tools = ["blender", "ffmpeg"]
try:
clargs = parse_arguments(C)
checktools(tools)
cmds, kwargs = setup(C, clargs)
kickstart(map(partial(call, C, clargs, **kwargs), cmds))
except (BSError, ToolError) as e:
LOGGER.error(e)
except KeyboardInterrupt:
# TODO: add actual clean up code
prints(C, "DirtyInterrupt. Exiting", s="\n\n", e="...")
sys.exit()
# this is so it can be ran as a module: `python3 -m bpsrender` (for testing)
if __name__ == "__main__":
main()

View File

@ -1,30 +0,0 @@
#
# Copyright (C) 2016-2019 by Razvan Radulescu, Nathan Lovato, and contributors
#
# This file is part of Power Sequencer.
#
# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the
# GNU General Public License as published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with Power Sequencer. If
# not, see <https://www.gnu.org/licenses/>.
#
import bpy
import os.path as osp
import sys
if __name__ == "__main__":
for strip in bpy.context.scene.sequence_editor.sequences_all:
if strip.type == "META":
continue
if strip.type != "SOUND":
strip.mute = True
path = sys.argv[-1]
ext = osp.splitext(path)[1][1:].upper()
bpy.ops.sound.mixdown(filepath=path, check_existing=False, container=ext, codec=ext)

View File

@ -1,30 +0,0 @@
#
# Copyright (C) 2016-2019 by Razvan Radulescu, Nathan Lovato, and contributors
#
# This file is part of Power Sequencer.
#
# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the
# GNU General Public License as published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with Power Sequencer. If
# not, see <https://www.gnu.org/licenses/>.
#
import bpy
EXT = {
"AVI_JPEG": ".avi",
"AVI_RAW": ".avi",
"FFMPEG": {"MKV": ".mkv", "OGG": ".ogv", "QUICKTIME": ".mov", "AVI": ".avi", "MPEG4": ".mp4"},
}
scene = bpy.context.scene
ext = EXT.get(scene.render.image_settings.file_format, "UNDEFINED")
if scene.render.image_settings.file_format == "FFMPEG":
ext = ext[scene.render.ffmpeg.format]
print("\nBPS:{} {} {}\n".format(scene.frame_start, scene.frame_end, ext))

View File

@ -1,19 +0,0 @@
#
# Copyright (C) 2016-2019 by Razvan Radulescu, Nathan Lovato, and contributors
#
# This file is part of Power Sequencer.
#
# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the
# GNU General Public License as published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with Power Sequencer. If
# not, see <https://www.gnu.org/licenses/>.
#
import bpy
bpy.context.scene.render.ffmpeg.audio_codec = "NONE"

View File

@ -1,410 +0,0 @@
#
# Copyright (C) 2016-2019 by Razvan Radulescu, Nathan Lovato, and contributors
#
# This file is part of Power Sequencer.
#
# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the
# GNU General Public License as published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with Power Sequencer. If
# not, see <https://www.gnu.org/licenses/>.
#
# IMPURE
import multiprocessing as mp
import os
import signal as sig
import subprocess as sp
from functools import partial, reduce
from itertools import chain, islice, starmap, tee
from multiprocessing import Queue
from tqdm import tqdm
from .config import LOGGER
from .helpers import BSError, checkblender, kickstart, printd, prints, printw
def chunk_frames(cfg, clargs, cmds, **kwargs):
"""
Recover the chunk start/end frames from the constructed commands for the
video step. This is necessary to preserve purity until later steps.
Parameters
----------
cfg: dict
Configuration dictionary.
clargs: Namespace
Command line arguments (normalized).
cmds: iter(tuple)
Iterator of commands to be passed to `subprocess`.
kwargs: dict
Dictionary with additional information from the setup step.
Returns
-------
out: iter(tuple)
Start/end pairs of frames corresponding to the chunk commands created at
the video step.
"""
out = map(lambda x: (x, islice(x, 1, None)), cmds)
out = map(lambda x: zip(*x), out)
out = map(lambda x: filter(lambda y: y[0] in ("-s", "-e"), x), out)
out = map(lambda x: map(lambda y: int(y[1]), x), out)
out = map(lambda x: reduce(lambda acc, y: acc + (y,), x, ()), out)
return out
def append_chunks_file(cfg, clargs, cmds, **kwargs):
"""
IMPURE
Helper function for creating the chunks file that will be used by `ffmpeg`
to concatenate the chunks into one video file.
Parameters
----------
cfg: dict
Configuration dictionary.
clargs: Namespace
Command line arguments (normalized).
cmds: iter(tuple)
Iterator of commands to be passed to `subprocess`.
kwargs: dict
MANDATORY w_frame_start, w_frame_end, ext
Dictionary with additional information from the setup step.
"""
with open(kwargs["chunks_file_path"], "a") as f:
for fs, fe in chunk_frames(cfg, clargs, cmds, **kwargs):
f.write(
"file '{rcp}{fs}-{fe}{ext}'\n".format(
rcp=kwargs["render_chunk_path"].rstrip("#"),
fs="{fs:0{frame_pad}d}".format(fs=fs, **cfg),
fe="{fe:0{frame_pad}d}".format(fe=fe, **cfg),
**kwargs
)
)
def call_probe(cfg, clargs, cmds, **kwargs):
"""
IMPURE
Probe `clargs.blendfile` for frame start, frame end and extension (for
video only).
Parameters
----------
cfg: dict
Configuration dictionary.
clargs: Namespace
Command line arguments (normalized).
cmds: iter(tuple)
Iterator of commands to be passed to `subprocess`.
kwargs: dict
Dictionary with additional information from the setup step.
Returns
-------
out: dict
Dictionary with info extracted from `clargs.blendfile`, namely: start
frame, end frame and extension (only useful for video step).
"""
kwargs_p = {"stdout": sp.PIPE, "stderr": sp.STDOUT, "universal_newlines": True}
printw(cfg, "Probing")
printw(cfg, "Input(blend) @ {}".format(clargs.blendfile), s="")
frame_start, frame_end, ext = (0, 0, "")
if not clargs.dry_run:
with sp.Popen(next(cmds), **kwargs_p) as cp:
try:
tmp = map(partial(checkblender, "PROBE", [cfg["probe_py"]], cp), cp.stdout)
tmp = filter(lambda x: x.startswith("BPS"), tmp)
tmp = map(lambda x: x[4:].strip().split(), tmp)
frame_start, frame_end, ext = chain(*tmp)
except BSError as e:
LOGGER.error(e)
except KeyboardInterrupt:
raise
finally:
cp.terminate()
returncode = cp.poll()
if returncode != 0:
raise sp.CalledProcessError(returncode, cp.args)
frame_start = frame_start if clargs.start is None else clargs.start
frame_end = frame_end if clargs.end is None else clargs.end
out = {
"frame_start": int(frame_start),
"frame_end": int(frame_end),
"frames_total": int(frame_end) - int(frame_start) + 1,
"ext": ext,
}
if out["ext"] == "UNDEFINED":
raise BSError("Video extension is {ext}. Stopping!".format(ext=ext))
printd(cfg, "Probing done")
return out
def call_mixdown(cfg, clargs, cmds, **kwargs):
"""
IMPURE
Calls blender to render the audio mixdown.
Parameters
----------
cfg: dict
Configuration dictionary.
clargs: Namespace
Command line arguments (normalized).
cmds: iter(tuple)
Iterator of commands to be passed to `subprocess`.
kwargs: dict
MANDATORY render_mixdown_path
Dictionary with additional information from the setup step.
"""
kwargs_p = {"stdout": sp.PIPE, "stderr": sp.STDOUT, "universal_newlines": True}
printw(cfg, "Rendering mixdown")
printw(cfg, "Output @ {}".format(kwargs["render_mixdown_path"]), s="")
if not clargs.dry_run:
with sp.Popen(next(cmds), **kwargs_p) as cp:
try:
tmp = map(partial(checkblender, "MIXDOWN", [cfg["mixdown_py"]], cp), cp.stdout)
tmp = filter(lambda x: x.startswith("BPS"), tmp)
tmp = map(lambda x: x[4:].strip().split(), tmp)
kickstart(tmp)
except BSError as e:
LOGGER.error(e)
except KeyboardInterrupt:
raise
finally:
cp.terminate()
returncode = cp.poll()
if returncode != 0:
raise sp.CalledProcessError(returncode, cp.args)
printd(cfg, "Mixdown done")
def call_chunk(cfg, clargs, queue, cmd, **kwargs):
"""
IMPURE
Calls blender to render one chunk (which part is determined by `cmd`).
Parameters
----------
cfg: dict
Configuration dictionary.
clargs: Namespace
Command line arguments (normalized).
cmd: tuple
Tuple to be passed to `subprocess`.
kwargs: dict
Dictionary with additional information from the setup step.
"""
sig.signal(sig.SIGINT, sig.SIG_IGN)
kwargs_p = {"stdout": sp.PIPE, "stderr": sp.STDOUT, "universal_newlines": True}
if not clargs.dry_run:
# can't use nice functional syntax if we want to simplify with `with`
with sp.Popen(cmd, **kwargs_p) as cp:
try:
tmp = map(
partial(
checkblender,
"VIDEO",
[cfg["video_py"], "The encoder timebase is not set"],
cp,
),
cp.stdout,
)
tmp = filter(lambda x: x.startswith("Append frame"), tmp)
tmp = map(lambda x: x.split()[-1], tmp)
tmp = map(int, tmp)
tmp = map(lambda x: True, tmp)
kickstart(map(queue.put, tmp))
queue.put(False)
except BSError as e:
LOGGER.error(e)
def call_video(cfg, clargs, cmds, **kwargs):
"""
IMPURE
Multi-process call to blender for rendering the (video) chunks.
Parameters
----------
cfg: dict
Configuration dictionary.
clargs: Namespace
Command line arguments (normalized).
cmds: iter(tuple)
Iterator of commands to be passed to `subprocess`.
kwargs: dict
Dictionary with additional information from the setup step.
"""
printw(cfg, "Rendering video (w/o audio)")
printw(cfg, "Output @ {}".format(kwargs["render_chunk_path"]), s="")
try:
not clargs.dry_run and os.remove(kwargs["chunks_file_path"])
LOGGER.info("CALL-VIDEO: generating {}".format(kwargs["chunks_file_path"]))
except OSError as e:
LOGGER.info("CALL-VIDEO: skipping {}: {}".format(e.filename, e.strerror))
cmds, cmds_cf = tee(cmds)
(not clargs.dry_run and append_chunks_file(cfg, clargs, cmds_cf, **kwargs))
# prepare queue/worker
queues = queues_close = (Queue(),) * clargs.workers
# prpare processes
proc = starmap(
lambda q, cmd: mp.Process(target=partial(call_chunk, cfg, clargs, **kwargs), args=(q, cmd)),
zip(queues, cmds),
)
# split iterator in 2 for later joining the processes and sum
# one of them
proc, proc_close = tee(proc)
proc = map(lambda p: p.start(), proc)
try:
not clargs.dry_run and kickstart(proc)
# communicate with processes through the queues and use tqdm to show a
# simple terminal progress bar baesd on video total frames
queues = map(lambda q: iter(q.get, False), queues)
queues = chain(*queues)
queues = tqdm(queues, total=kwargs["frame_end"] - kwargs["frame_start"] + 1, unit="frames")
not clargs.dry_run and kickstart(queues)
except KeyboardInterrupt:
proc_close = map(lambda x: x.terminate(), proc_close)
not clargs.dry_run and kickstart(proc_close)
raise
finally:
# close and join processes and queues
proc_close = map(lambda x: x.join(), proc_close)
not clargs.dry_run and kickstart(proc_close)
queues_close = map(lambda q: (q, q.close()), queues_close)
queues_close = starmap(lambda q, _: q.join_thread(), queues_close)
not clargs.dry_run and kickstart(queues_close)
printd(cfg, "Video chunks rendering done")
def call_concatenate(cfg, clargs, cmds, **kwargs):
"""
IMPURE
Calls ffmpeg in order to concatenate the video chunks together.
Parameters
----------
cfg: dict
Configuration dictionary.
clargs: Namespace
Command line arguments (normalized).
cmds: iter(tuple)
Iterator of commands to be passed to `subprocess`.
kwargs: dict
MANDATORY: render_video_path
Dictionary with additional information from the setup step.
Note
----
It expects the video chunk files to already be available.
"""
kwargs_p = {"stdout": sp.DEVNULL, "stderr": sp.DEVNULL}
printw(cfg, "Concatenating (video) chunks")
printw(cfg, "Output @ {}".format(kwargs["render_video_path"]), s="")
if not clargs.dry_run:
with sp.Popen(next(cmds), **kwargs_p) as cp:
try:
returncode = cp.wait()
if returncode != 0:
raise sp.CalledProcessError(returncode, cp.args)
except KeyboardInterrupt:
raise
finally:
cp.terminate()
printd(cfg, "Concatenating done")
def call_join(cfg, clargs, cmds, **kwargs):
"""
IMPURE
Calls ffmpeg for joining the audio mixdown and the video.
Parameters
----------
cfg: dict
Configuration dictionary.
clargs: Namespace
Command line arguments (normalized).
cmds: iter(tuple)
Iterator of commands to be passed to `subprocess`.
kwargs: dict
MANDATORY: render_audiovideo_path
Dictionary with additional information from the setup step.
Note
----
It expects the audio mixdown and video files to already be available.
"""
kwargs_p = {"stdout": sp.DEVNULL, "stderr": sp.DEVNULL}
printw(cfg, "Joining audio/video")
printw(cfg, "Output @ {}".format(kwargs["render_audiovideo_path"]), s="")
if not clargs.dry_run:
with sp.Popen(next(cmds), **kwargs_p) as cp:
try:
returncode = cp.wait()
if returncode != 0:
raise sp.CalledProcessError(returncode, cp.args)
except KeyboardInterrupt:
raise
finally:
cp.terminate()
printd(cfg, "Joining done")
def call(cfg, clargs, cmds, **kwargs):
"""
IMPURE
Delegates work to appropriate `call_*` functions.
Parameters
----------
cfg: dict
Configuration dictionary.
clargs: Namespace
Command line arguments (normalized).
cmds: iter(tuple)
Iterator of commands to be passed to `subprocess`
kwargs: dict
MANDATORY: render_audiovideo_path
Dictionary with additional information from the setup step.
Returns
-------
out: dict or None
It passes on the output from the `call_*` functions. See `call_*` for
specific details.
Note
----
It tries to be smart and skip steps if child subprocesses give errors.
Example if `--join-only` is passed, but the audio mixdown or video file
aren't available on hard drive.
"""
calls = {
"probe": call_probe,
"mixdown": call_mixdown,
"video": call_video,
"concatenate": call_concatenate,
"join": call_join,
}
try:
out = calls[cmds[0]](cfg, clargs, cmds[1], **kwargs)
return out
except sp.CalledProcessError:
prints(
cfg,
("WARNING:{}: Something went wrong when calling" " command - SKIPPING").format(cmds[0]),
)

View File

@ -1,341 +0,0 @@
#
# Copyright (C) 2016-2019 by Razvan Radulescu, Nathan Lovato, and contributors
#
# This file is part of Power Sequencer.
#
# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the
# GNU General Public License as published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with Power Sequencer. If
# not, see <https://www.gnu.org/licenses/>.
#
import math as m
from collections import OrderedDict
from itertools import chain, islice
from .config import LOGGER
def get_commands_probe(cfg, clargs, **kwargs):
"""
Create the command for probing the `clargs.blendfile`.
Parameters
----------
cfg: dict
Configuration dictionary.
clargs: Namespace
Command line arguments (normalized).
kwargs: dict
Dictionary with additional information from the setup step.
Returns
-------
out: iter(list)
An iterator for which each element is a list to be sent to functions like
`subprocess.run`.
"""
out = (
"blender",
"--background",
clargs.blendfile,
"--python",
kwargs["probe_py_normalized"],
"--disable-autoexec",
)
LOGGER.debug("CMD-PROBE: {cmd}".format(cmd=" ".join(out)))
return iter((out,))
def get_commands_chunk(cfg, clargs, **kwargs):
"""
Create the command for rendering a (video) chunk from `clargs.blendfile`.
Parameters
----------
cfg: dict
Configuration dictionary.
clargs: Namespace
Command line arguments (normalized).
kwargs: dict
MANDATORY render_chunk_path, w_frame_start, w_frame_end
Dictionary with additional information from the setup step.
Returns
-------
out: iter(list)
An iterator for which each element is a list to be sent to functions like
`subprocess.run`.
"""
out = (
"blender",
"--background",
clargs.blendfile,
"--python",
kwargs["video_py_normalized"],
"--disable-autoexec",
"--render-output",
kwargs["render_chunk_path"],
"-s",
str(kwargs["w_frame_start"]),
"-e",
str(kwargs["w_frame_end"]),
"--render-anim",
)
LOGGER.debug(
"CMD-CHUNK({w_frame_start}-{w_frame_end}): {cmd}".format(cmd=" ".join(out), **kwargs)
)
return iter((out,))
def get_commands_video(cfg, clargs, **kwargs):
"""
Create the list of commands (one command per chunk) for rendering a video
from `clargs.blendfile`.
Parameters
----------
cfg: dict
Configuration dictionary.
clargs: Namespace
Command line arguments (normalized).
kwargs: dict
MANDATORY chunk_file_path, frame_start, frame_end, frames_total
Dictionary with additional information from the setup step.
Returns
-------
out: iter(tuple)
An iterator for which each element is a tuple to be sent to functions like
`subprocess.run`.
"""
LOGGER.debug("CMD-VIDEO:")
chunk_length = int(m.floor(kwargs["frames_total"] / clargs.workers))
out = map(lambda w: (w, kwargs["frame_start"] + w * chunk_length), range(clargs.workers))
out = map(
lambda x: (
x[1],
x[1] + chunk_length - 1 if x[0] != clargs.workers - 1 else kwargs["frame_end"],
),
out,
)
out = map(
lambda x: get_commands(
cfg, clargs, "chunk", w_frame_start=x[0], w_frame_end=x[1], **kwargs
),
out,
)
out = map(lambda x: x[1], out)
out = chain(*out)
return tuple(out)
def get_commands_mixdown(cfg, clargs, **kwargs):
"""
Create the command to render the mixdown from `clargs.blendfile`.
Parameters
----------
cfg: dict
Configuration dictionary.
clargs: Namespace
Command line arguments (normalized).
kwargs: dict
MANDATORY render_mixdown_path
Dictionary with additional information from the setup step.
Returns
-------
out: iter(tuple)
An iterator for which each element is a tuple to be sent to functions like
`subprocess.run`.
"""
out = (
"blender --background {blendfile} --python {mixdown_py_normalized}"
" --disable-autoexec -- {render_mixdown_path}".format(**cfg, **vars(clargs), **kwargs)
)
out = (
"blender",
"--background",
clargs.blendfile,
"--python",
kwargs["mixdown_py_normalized"],
"--disable-autoexec",
"--",
kwargs["render_mixdown_path"],
)
LOGGER.debug("CMD-MIXDOWN: {cmd}".format(cmd=" ".join(out)))
return iter((out,))
def get_commands_concatenate(cfg, clargs, **kwargs):
"""
Create the command to concatenate the available video chunks generated
beforehand.
Parameters
----------
cfg: dict
Configuration dictionary.
clargs: Namespace
Command line arguments (normalized).
kwargs: dict
MANDATORY chunks_file_path, render_video_path
Dictionary with additional information from the setup step.
Returns
-------
out: iter(tuple)
An iterator for which each element is a tuple to be sent to functions like
`subprocess.run`.
"""
out = (
"ffmpeg",
"-stats",
"-f",
"concat",
"-safe",
"-0",
"-i",
kwargs["chunks_file_path"],
"-c",
"copy",
"-y",
kwargs["render_video_path"],
)
LOGGER.debug("CMD-CONCATENATE: {cmd}".format(cmd=" ".join(out)))
return iter((out,))
def get_commands_join(cfg, clargs, **kwargs):
"""
Create the command to join the available audio mixdown and video generated
beforehand.
Parameters
----------
cfg: dict
Configuration dictionary.
clargs: Namespace
Command line arguments (normalized).
kwargs: dict
MANDATORY chunks_file_path, render_video_path
Dictionary with additional information from the setup step.
Returns
-------
out: iter(tuple)
An iterator for which each element is a tuple to be sent to functions like
`subprocess.run`.
"""
out = (
"ffmpeg",
"-stats",
"-i",
kwargs["render_video_path"],
"-i",
kwargs["render_mixdown_path"],
"-map",
"0:v:0",
"-c:v",
"copy",
"-map",
"1:a:0",
"-c:a",
"aac",
"-b:a",
"320k",
"-y",
kwargs["render_audiovideo_path"],
)
LOGGER.debug("CMD-JOIN: {cmd}".format(cmd=" ".join(out)))
return iter((out,))
def get_commands(cfg, clargs, what="", **kwargs):
"""
Delegates the creation of commands lists to appropriate functions based on
`what` parameter.
Parameters
----------
cfg: dict
Configuration dictionary.
clargs: Namespace
Command line arguments (normalized).
what: str (default = '')
Determines the returned value (see: Returns[out]).
kwargs: dict
MANDATORY -- see individual functions for the list of mandatory keys
Dictionary with additional information from the setup step.
Returns
-------
out: iter or (str, iter)
|- what == '' is True
An iterator with elements of the type (str) for determining the order in
which to call the functions in the setup step.
NOTE: it skipps the "internal use only" functions.
|- else
A tuple with the 1st element as a tag (the `what` parameter) and the 2nd
element as the iterator of the actual commands.
"""
get_commands_f = OrderedDict(
(
# internal use only
("probe", get_commands_probe),
("chunk", get_commands_chunk),
# direct connection to command line arguments - in order of execution
("mixdown", get_commands_mixdown),
("video", get_commands_video),
("concatenate", get_commands_concatenate),
("join", get_commands_join),
)
)
return (
islice(get_commands_f, 2, None)
if what == ""
else (what, get_commands_f[what](cfg, clargs, **kwargs))
)
def get_commands_all(cfg, clargs, **kwargs):
"""
Prepare the list of commands to be executed depending on the command line
arguments.
Parameters
----------
cfg: dict
Configuration dictionary.
clargs: Namespace
Command line arguments (normalized).
kwargs: dict
MANDATORY -- see individual functions for the list of mandatory keys
Dictionary with additional information from the setup step.
Returns
-------
out: iter((str, tuple))
An iterator for which each element is a (str, iter(tuple)). The string
value is for tagging the iterator command list (2nd element) for filtering
later based on the given command line arguments.
"""
end = "_only"
out = filter(lambda x: x[0].endswith(end), vars(clargs).items())
out = map(lambda x: (x[0][: -len(end)], x[1]), out)
order = list(get_commands(cfg, clargs))
out = sorted(out, key=lambda x: order.index(x[0]))
out = (
map(lambda k: k[0], out)
if all(map(lambda k: not k[1], out))
else map(lambda k: k[0], filter(lambda k: k[1], out))
)
out = map(lambda k: get_commands(cfg, clargs, k, **kwargs), out)
return out

View File

@ -1,37 +0,0 @@
#
# Copyright (C) 2016-2019 by Razvan Radulescu, Nathan Lovato, and contributors
#
# This file is part of Power Sequencer.
#
# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the
# GNU General Public License as published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with Power Sequencer. If
# not, see <https://www.gnu.org/licenses/>.
#
import logging as lg
import multiprocessing as mp
import os.path as osp
CONFIG = {
"logger": "BPS",
"cpu_count": min(int(mp.cpu_count() / 2), 6),
"bs_path": osp.join(osp.dirname(osp.abspath(__file__)), "bscripts"),
"frame_pad": 7,
"parts_folder": "bpsrender",
"chunks_file": "chunks.txt",
"video_file": "video{}",
"pre": {"work": "»", "done": "", "skip": "~"},
"probe_py": "probe.py",
"mixdown_py": "mixdown.py",
"video_py": "video.py",
}
LOGGER = lg.getLogger(CONFIG["logger"])
LOGLEV = [lg.INFO, lg.DEBUG]
LOGLEV = [None] + sorted(LOGLEV, reverse=True)

View File

@ -1,110 +0,0 @@
#
# Copyright (C) 2016-2019 by Razvan Radulescu, Nathan Lovato, and contributors
#
# This file is part of Power Sequencer.
#
# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the
# GNU General Public License as published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with Power Sequencer. If
# not, see <https://www.gnu.org/licenses/>.
#
from collections import deque
from shutil import which
class BSError(Exception):
"""
Custom Exception raised if Blender is called with a python script argument
and gives error while trying to execute the script.
"""
pass
class ToolError(Exception):
"""Raised if external dependencies aren't found on system.
"""
pass
def checktools(tools):
tools = [(t, which(t) or "") for t in tools]
check = {"tools": tools, "test": all(map(lambda x: x[1], tools))}
if not check["test"]:
msg = ["BPSRender couldn't find external dependencies:"]
msg += [
"[{check}] {tool}: {path}".format(
check="v" if path is not "" else "X", tool=tool, path=path or "NOT FOUND"
)
for tool, path in check["tools"]
]
msg += [
(
"Check if you have them properly installed and available in the PATH"
" environemnt variable."
),
"Exiting...",
]
raise ToolError("\n".join(msg))
def checkblender(what, search, cp, s):
"""
IMPURE
Check Blender output for python script execution error.
Parameters
----------
what: str
A tag used in the exception message.
search: iter(str)
One or more string(s) to search for in Blender's output.
cp: Popen
Blender subprocess.
s: PIPE
Blender's output.
Returns
-------
out: PIPE
The same pipe `s` is returned so that it can be iterated over on later
steps.
"""
if not isinstance(search, list):
search = [search]
for search_item in search:
if search_item in s:
message = (
"Script {what} was not properly executed in" " Blender".format(what=what),
"CMD: {cmd}".format(what=what, cmd=" ".join(cp.args)),
"DUMP:".format(what=what),
s,
)
raise BSError("\n".join(message))
return s
def printw(cfg, text, s="\n", e="...", p="", **kwargs):
p = p or cfg["pre"]["work"]
print("{s}{p} {}{e}".format(text, s=s, e=e, p=p), **kwargs)
def printd(cfg, text, s="", e=".", p="", **kwargs):
p = p or cfg["pre"]["done"]
printw(cfg, text, s=s, e=e, p=p, **kwargs)
def prints(cfg, text, s="", e=".", p="", **kwargs):
p = p or cfg["pre"]["skip"]
printw(cfg, text, s=s, e=e, p=p, **kwargs)
def kickstart(it):
deque(it, maxlen=0)

View File

@ -1,182 +0,0 @@
#
# Copyright (C) 2016-2019 by Razvan Radulescu, Nathan Lovato, and contributors
#
# This file is part of Power Sequencer.
#
# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the
# GNU General Public License as published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with Power Sequencer. If
# not, see <https://www.gnu.org/licenses/>.
#
# IMPURE
import logging as lg
import os
import os.path as osp
from functools import reduce
from itertools import starmap
from .calls import call
from .commands import get_commands, get_commands_all
from .config import LOGGER, LOGLEV
from .helpers import kickstart
def setup_bspy(cfg, clargs, **kwargs):
"""
Normalize the names of the script to be ran in Blender for certain steps.
Eg. the probe step depends on the script located in
`bpsrender/cfg['probe_py']`.
Parameters
----------
cfg: dict
Configuration dictionary.
clargs: Namespace
Command line arguments (normalized).
kwargs: dict
Returns
-------
out: dict
Dictoinary to be used in call steps.
"""
out = filter(lambda x: x[0].endswith("_py"), cfg.items())
out = starmap(lambda k, v: ("{}_normalized".format(k), osp.join(cfg["bs_path"], v)), out)
return dict(out)
def setup_probe(cfg, clargs, **kwargs):
"""
IMPURE
Call Blender and extract information that will be necessary for later
steps.
Parameters
----------
cfg: dict
Configuration dictionary.
clargs: Namespace
Command line arguments (normalized).
kwargs: dict
MANDATORY -- see individual functions for the list of mandatory keys
Dictionary with additional information from the previous setup step.
Returns
-------
out: dict
Dictoinary to be used in call steps.
"""
return call(cfg, clargs, get_commands(cfg, clargs, "probe", **kwargs), **kwargs)
def setup_paths(cfg, clargs, **kwargs):
"""
Figure out appropriate path locations to store output for parts and final
render.
Parameters
----------
cfg: dict
Configuration dictionary.
clargs: Namespace
Command line arguments (normalized).
kwargs: dict
MANDATORY -- see individual functions for the list of mandatory keys
Dictionary with additional information from the previous setup step.
Returns
-------
out: dict
Dictionary storing all relevant information pertaining to folder and file
paths.
Note
----
It also creates the folder structure 'render/parts' where
`clargs.blendfile` is stored on disk.
"""
render_parts_path = osp.join(clargs.output, cfg["parts_folder"])
name = osp.splitext(osp.basename(clargs.blendfile))[0]
render_mixdown_path = osp.join(render_parts_path, "{}_m.flac".format(name))
render_chunk_path = osp.join(render_parts_path, "{}_c_{}".format(name, "#" * cfg["frame_pad"]))
render_video_path = osp.join(render_parts_path, "{}_v{}".format(name, kwargs["ext"]))
render_audiovideo_path = osp.join(clargs.output, "{}{}".format(name, kwargs["ext"]))
chunks_file_path = osp.join(render_parts_path, cfg["chunks_file"])
out = {
"render_path": clargs.output,
"render_parts_path": render_parts_path,
"chunks_file_path": chunks_file_path,
"render_chunk_path": render_chunk_path,
"render_video_path": render_video_path,
"render_mixdown_path": render_mixdown_path,
"render_audiovideo_path": render_audiovideo_path,
}
return out
def setup_folders_hdd(cfg, clargs, **kwargs):
"""
IMPURE
Prepares the folder structure `cfg['render']/cfg['parts']'`.
Parameters
----------
cfg: dict
Configuration dictionary.
clargs: Namespace
Command line arguments (normalized).
kwargs: dict
Dictionary with additional information from the previous setup step.
Returns
-------
out: (iter((str, iter(tuple))), dict)
1st element: see commands.py:get_commands_all
2nd elment: the keyword arguments used by calls.py:call
"""
# create folder structure if it doesn't exist already only if
# appropriate command line arguments are given
do_it = filter(lambda x: x[0].endswith("_only"), vars(clargs).items())
do_it = all(map(lambda x: not x[1], do_it))
do_it = not clargs.dry_run and clargs.video_only or clargs.mixdown_only or do_it
do_it and os.makedirs(kwargs["render_parts_path"], exist_ok=True)
return {}
def setup(cfg, clargs):
"""
IMPURE -- setup_paths
Prepares the folder structure 'render/parts', the appropriate command lists
to be called and the keyword arguments to be passed to call functions
(calls.py).
Parameters
----------
cfg: dict
Configuration dictionary.
clargs: Namespace
Command line arguments (normalized).
kwargs: dict
Dictionary with additional information from the previous setup step.
Returns
-------
out: (iter((str, iter(tuple))), dict)
1st element: see commands.py:get_commands_all
2nd elment: the keyword arguments used by calls.py:call
"""
setups_f = (setup_bspy, setup_probe, setup_paths, setup_folders_hdd)
lg.basicConfig(level=LOGLEV[min(clargs.verbose, len(LOGLEV) - 1)])
kwargs = dict(reduce(lambda acc, sf: {**acc, **sf(cfg, clargs, **acc)}, setups_f, {}))
LOGGER.info("Setup:")
kickstart(starmap(lambda k, v: LOGGER.info("{}: {}".format(k, v)), kwargs.items()))
return get_commands_all(cfg, clargs, **kwargs), kwargs

View File

@ -1,56 +0,0 @@
#
# Copyright (C) 2016-2019 by Razvan Radulescu, Nathan Lovato, and contributors
#
# This file is part of Power Sequencer.
#
# Power Sequencer is free software: you can redistribute it and/or modify it under the terms of the
# GNU General Public License as published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# Power Sequencer is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with Power Sequencer. If
# not, see <https://www.gnu.org/licenses/>.
#
from setuptools import setup
def readme():
with open("README.rst") as f:
return f.read()
if __name__ == "__main__":
setup(
name="bpsrender",
version="0.1.40.post1",
description="Blender Power Sequencer Renderer",
long_description=readme(),
classifiers=[
"Development Status :: 4 - Beta",
"Environment :: Console",
"Intended Audience :: End Users/Desktop",
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
"Natural Language :: English",
"Programming Language :: Python :: 3.3",
"Programming Language :: Python :: 3.4",
"Programming Language :: Python :: 3.5",
"Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7",
"Topic :: Text Processing :: Linguistic",
"Topic :: Multimedia :: Video",
"Topic :: Utilities",
],
url="https://gitlab.com/razcore/BPSRender",
keywords="blender render parallel multiprocess speedup utility" " productivty",
author="Răzvan C. Rădulescu",
author_email="razcore.art@gmail.com",
license="GPLv3",
packages=["bpsrender"],
install_requires=["tqdm"],
zip_safe=False,
entry_points={"console_scripts": ["bpsrender=bpsrender.__main__:main"]},
include_package_data=True,
)