夜風のMixedReality

xRと出会って変わった人生と出会った技術を書き残すためのGeekなHoloRangerの居場所

BlenderでGIF書き出しプラグインを作る その⑥ 動画編集機能としてのGIFエクスポートの対応

本日はBlender及びPython枠です。

前回まででBlenderでGIFのレンダリングプラグインを作成しました。

redhologerbera.hatenablog.com

もともと作成の動機としてはGIF画像を作成する際によいソフトウェアが見つからなかったため自作していました。今回は仕上げとして動画編集機能にGIFエクスポートの機能を実装して、動画編集機能としてのGIFサポートを実装します。

Blenderの動画編集機能

Blenderを使用する人の多くは3DCGの作成を挙げると思います。筆者も主にこの用途で使用していますが、Blender自体には高度な動画編集機能を備えています。

動画ファイルおよび今回は使用しませんが音源ファイルをChannnelのウィンドウにドラッグアンドドロップすると動画がエディタに読み込まれます。

チャンネルに登録されているファイルから任意のキーフレームでKキーを押すことでそのフレームでファイルをカットすることができます。

通常は上部メニューレンダーからアニメーションレンダリングを選択することでレンダリングが始まり、出力プロパティに指定されている出力パス、フォーマットに従ってレンダリングがされます。

出力プロパティのフォーマットに従ってレンダリングされる。

プラグインUIの場所

基本的な考え方は過去のものと同じで、シーケンス画像として出力した画像とパスを使用してGIFを作成するスクリプトを実行します。

つまるところGIFを作成するコードは変更せずにBlenderPythonスクリプトを変更してビデオエディタでも使用できるようにしていきます。

具体的には3Dビューポート上に新しくタブを作る方向性や上部のレンダーコマンドでレンダリングを実行させるUIを実装するような流れになりそうです。

〇動画編集エディタでのUI登録

GIFExporterPanlクラスを複製し新しくGIFExporterPanelVideoEditorクラスを作成します。

 またbl_space_typeSEQUENCE_EDITORに変更します。

class GIFExporterPanelVideoEditor(bpy.types.Panel):
    bl_label = "Video Editor Exporter"
    bl_idname = "SEQUENCE_PT_video_editor_exporter"
    bl_space_type = 'SEQUENCE_EDITOR'
    bl_region_type = 'UI'
    bl_category = 'GIFExporter'

    def draw(self, context):
        layout = self.layout
        layout.prop(context.scene, "gif_input")
        layout.prop(context.scene, "gif_output")
        layout.prop(context.scene, "loop_start_frame") 
        layout.prop(context.scene, "loop_end_frame")
        layout.prop(context.scene, "loop_step_frame")
        layout.label(text="GIF Export Settings")
        layout.prop(context.scene, "use_duration")
        if context.scene.use_duration:
            layout.prop(context.scene, "gif_duration")

        # Execute button for video editing
        layout.label(text="Execute Video Editing")
        layout.operator("object.video_editor_render")

またオペレータークラスも作成します。

class VideoEditorRenderOperator(bpy.types.Operator):
    bl_idname = "object.video_editor_render"
    bl_label = "Video Editor Render"
    
    def excute(self,context):
        self.report({'INFO'}, "Hello, World!")
        return {'FINISHED'}

最後にこの二つのクラスをBlenderに登録していきます。

def register():
        bpy.utils.register_class(GIFExporterOperator)
        bpy.utils.register_class(GIFExporterPanel)
        bpy.utils.register_class(LoopRenderOperator)
        bpy.utils.register_class(VideoEditorRenderOperator)#追加
        bpy.utils.register_class(GIFExporterPanelVideoEditor)#追加
        bpy.types.Scene.gif_input = bpy.props.StringProperty(name="Temp Path:",default="tmp")
 ・・・

これを実行することでVideoEditorにタブが現れます。

次にレンダリングの処理をVideoEditorに合わせます。

具体的には次のようなコードになります。

class VideoEditorRenderOperator(bpy.types.Operator):
    bl_idname = "object.video_editor_render"
    bl_label = "Video Editor Render"
    
    def execute(self, context):
        bpy.context.scene.gif_input = f'{bpy.context.scene.gif_input}\\tmp'
        print(bpy.context.scene.gif_input)
        
        # Switch to the Video Sequence Editor
        bpy.context.area.type = 'SEQUENCE_EDITOR'
        
        # Set the frame range for rendering
        bpy.context.scene.frame_start = bpy.context.scene.loop_start_frame
        bpy.context.scene.frame_end = bpy.context.scene.loop_end_frame
        
        for frame in range(bpy.context.scene.loop_start_frame, bpy.context.scene.loop_end_frame + 1, bpy.context.scene.loop_step_frame):
            # Set the current frame
            bpy.context.scene.frame_set(frame)
            
            # Update the Scene render file path
            bpy.context.scene.render.filepath = f"{bpy.context.scene.gif_input}/render_{frame}"
            
            # Perform OpenGL rendering
            bpy.ops.render.opengl(animation=False, sequencer=True, write_still=True)
        
        # Switch back to the 3D Viewport
        bpy.context.area.type = 'VIEW_3D'
        
        gif_maker_init()
        return {'FINISHED'}

これは3Dのレンダリングではbpy.ops.render.renderが使用できますが、動画編集エディタでは使用できないためOpenGLを使用したスクリプトでのレンダリングを使用します。

 これによって動画編集にも対応したGIFエクスポーターができました。

 3Dにも2Dにもどちらにも対応しています。

 本日は以上です。

 なお、一般的なGIF画像作成ツールの場合圧縮なども行われており、データ量を削減したりもしているようなのでその機能も後々実装していきます。

〇コード全文

#アドオンの定義
bl_info = {
    "name": "GIFMaker",
    "blender": (3, 5, 0),
    "category": "Object",
}


import bpy
import os
import re
import subprocess
import shutil

# Define a new operator (action or function)
class GIFExporterOperator(bpy.types.Operator):
    bl_idname = "object.gif_maker"
    bl_label = "Hello, World!"

    def execute(self, context):
        self.report({'INFO'}, "Hello, World!")
        return {'FINISHED'}


# Define a new UI panel
class GIFExporterPanel(bpy.types.Panel):
    bl_label = "GIF Exporter"
    bl_idname = "OBJECT_PT_hello_world"
    bl_space_type = 'VIEW_3D'
    bl_region_type = 'UI'
    bl_category = 'GIFExporter'

    def draw(self, context):
        layout = self.layout
        layout.prop(context.scene, "gif_input")
        layout.prop(context.scene, "gif_output")
        layout.prop(context.scene, "loop_start_frame") 
        layout.prop(context.scene, "loop_end_frame")
        layout.prop(context.scene, "loop_step_frame")
        layout.label(text="GIF Export Settings")
        layout.prop(context.scene, "use_duration")
        if context.scene.use_duration:
            layout.prop(context.scene, "gif_duration")

        layout.label(text="Excute")
        layout.operator("object.loop_render")

class VideoEditorRenderOperator(bpy.types.Operator):
    bl_idname = "object.video_editor_render"
    bl_label = "Video Editor Render"
    
    def execute(self, context):
        self.report({'INFO'}, "Hello, World!")
        return {'FINISHED'}

class GIFExporterPanelVideoEditor(bpy.types.Panel):
    bl_label = "Video Editor Exporter"
    bl_idname = "SEQUENCE_PT_video_editor_exporter"
    bl_space_type = 'SEQUENCE_EDITOR'
    bl_region_type = 'UI'
    bl_category = 'GIFExporter'

    def draw(self, context):
        layout = self.layout
        layout.prop(context.scene, "gif_input")
        layout.prop(context.scene, "gif_output")
        layout.prop(context.scene, "loop_start_frame") 
        layout.prop(context.scene, "loop_end_frame")
        layout.prop(context.scene, "loop_step_frame")
        layout.label(text="GIF Export Settings")
        layout.prop(context.scene, "use_duration")
        if context.scene.use_duration:
            layout.prop(context.scene, "gif_duration")

        # Execute button for video editing
        layout.label(text="Execute Video Editing")
        layout.operator("object.video_editor_render")

class VideoEditorRenderOperator(bpy.types.Operator):
    bl_idname = "object.video_editor_render"
    bl_label = "Video Editor Render"
    
    def execute(self, context):
        bpy.context.scene.gif_input = f'{bpy.context.scene.gif_input}\\tmp'
        print(bpy.context.scene.gif_input)
        
        # Switch to the Video Sequence Editor
        bpy.context.area.type = 'SEQUENCE_EDITOR'
        
        # Set the frame range for rendering
        bpy.context.scene.frame_start = bpy.context.scene.loop_start_frame
        bpy.context.scene.frame_end = bpy.context.scene.loop_end_frame
        
        # Set the render output format to a single file format (e.g., PNG)
        bpy.context.scene.render.image_settings.file_format = 'PNG'
        
        for frame in range(bpy.context.scene.loop_start_frame, bpy.context.scene.loop_end_frame + 1, bpy.context.scene.loop_step_frame):
            # Set the current frame
            bpy.context.scene.frame_set(frame)
            
            # Update the Scene render file path
            bpy.context.scene.render.filepath = f"{bpy.context.scene.gif_input}/render_{frame}"
            
            # Perform OpenGL rendering
            bpy.ops.render.opengl(animation=False, sequencer=True, write_still=True)
        
        # Switch back to the 3D Viewport
        bpy.context.area.type = 'VIEW_3D'
        
        gif_maker_init()
        return {'FINISHED'}



class LoopRenderOperator(bpy.types.Operator):
    bl_idname = "object.loop_render"
    bl_label = "Loop Render"


    def execute(self, context):
        bpy.context.scene.gif_input = f'{bpy.context.scene.gif_input}\\tmp'
        print(bpy.context.scene.gif_input)
        for frame in range(bpy.context.scene.loop_start_frame, bpy.context.scene.loop_end_frame + 1, bpy.context.scene.loop_step_frame):
            bpy.context.scene.frame_set(frame)
            bpy.context.scene.render.filepath = f"{bpy.context.scene.gif_input}/render_{frame}"
            bpy.ops.render.render(write_still=True)
        gif_maker_init()
        return {'FINISHED'}

def cleanup_gif_maker():
    dir_path = f'C:\\{bpy.context.scene.gif_input}'
    if os.path.exists(dir_path):
        shutil.rmtree(dir_path)
    else:
        print("The directory does not exist")
    return {'FINISHED'}


def gif_maker_excute(script_path):
    python_path = r"C:\Users\seiri\anaconda3\envs\gif-creator\python.exe" 

    if not os.path.exists(script_path):
        print(f"スクリプトが見つかりません: {script_path}")
    else:
        # subprocessを使用してスクリプトを実行します
        result = subprocess.run([python_path, script_path], capture_output=True, text=True,encoding='utf-8',errors='replace')
    cleanup_gif_maker()
    return {'FINISHED'}

def gif_maker_init():
    if not bpy.context.scene.use_duration:
        # 現在のシーンを取得
        current_scene = bpy.context.scene

        # 現在のシーンのfpsを取得
        fps = current_scene.render.fps
        bpy.context.scene.gif_duration = int(bpy.context.scene.loop_step_frame * 1000 / fps)


    gif_maker_file_path = r'C:\Users\seiri\Documents\PythonStudy\GIFCreator\gif_creator.py'
    with open(gif_maker_file_path, 'r') as f:
        content = f.read()

    input_pattern = r'(input_path\s*=\s*r\s*[\'\"])(.*?)([\'\"])'
    output_pattern = r'(gif_output_path\s*=\s*r\s*[\'\"])(.*?)([\'\"])'
    duration_pattern = r'(duration_num_str\s*=\s*r\s*[\'\"])(.*?)([\'\"])'
    input_matches = re.findall(input_pattern, content)
    output_matches = re.findall(output_pattern, content)
    duration_matches = re.findall(duration_pattern, content)
    print(len(duration_matches))
    new_content = content
    for match in input_matches:
        old_string = f'{match[0]}{match[1]}{match[2]}'
        new_string = f'{match[0]}C:\{bpy.context.scene.gif_input}{match[2]}'
        new_content = new_content.replace(old_string, new_string)
    for match in output_matches:
        old_string = f'{match[0]}{match[1]}{match[2]}'
        new_string = f'{match[0]}C:\{bpy.context.scene.gif_output}\output.gif{match[2]}'
        new_content = new_content.replace(old_string, new_string)
    for match in duration_matches:
        old_string = f'{match[0]}{match[1]}'
        new_string = f'{match[0]}{bpy.context.scene.gif_duration}'
        new_content = new_content.replace(old_string, new_string)
        print(new_content)
    with open(gif_maker_file_path, 'w') as f:
        f.write(new_content)

    gif_maker_excute(gif_maker_file_path)


        

def register():
        bpy.utils.register_class(GIFExporterOperator)
        bpy.utils.register_class(GIFExporterPanel)
        bpy.utils.register_class(LoopRenderOperator)
        bpy.utils.register_class(VideoEditorRenderOperator)
        bpy.utils.register_class(GIFExporterPanelVideoEditor)
        bpy.types.Scene.gif_input = bpy.props.StringProperty(name="Temp Path:",default="tmp")
        bpy.types.Scene.gif_output = bpy.props.StringProperty(name="GIFOutput Path:",default="tmp")
        bpy.types.Scene.loop_start_frame = bpy.props.IntProperty(name="Start Frame", default=1)  # Add this line
        bpy.types.Scene.loop_end_frame = bpy.props.IntProperty(name="End Frame", default =20)
        bpy.types.Scene.loop_step_frame = bpy.props.IntProperty(name="Step",default =1)
        bpy.types.Scene.use_duration = bpy.props.BoolProperty(name="Use_Duration",default =False)
        bpy.types.Scene.gif_duration = bpy.props.IntProperty(name="GIF Duration(/mSec)" ,default = 1)
        


def unregister():
        bpy.utils.unregister_class(GIFExporterOperator)
        bpy.utils.unregister_class(GIFExporterPanel)
        bpy.utils/unregister_class(LoopRenderOperator)
        del bpy.types.Scene.my_text_input

if __name__ == "__main__":
    register()