Initial SE Palette add-on
This commit is contained in:
+324
@@ -0,0 +1,324 @@
|
||||
"""SEの走査、試聴、メタデータ編集、VSE挿入オペレーター。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import bpy
|
||||
from bpy.props import StringProperty
|
||||
from bpy.types import Operator
|
||||
|
||||
from . import library
|
||||
|
||||
|
||||
_preview_handle = None
|
||||
_preview_device = None
|
||||
_preview_sound = None
|
||||
_preview_path = ""
|
||||
|
||||
|
||||
ICON_ITEMS = (
|
||||
("PLAY_SOUND", "Sound", "標準のサウンド", "PLAY_SOUND", 0),
|
||||
("SPEAKER", "Speaker", "スピーカー", "SPEAKER", 1),
|
||||
("OUTLINER_OB_SPEAKER", "Speaker Object", "スピーカーオブジェクト", "OUTLINER_OB_SPEAKER", 2),
|
||||
("CHECKMARK", "Success", "成功・完了", "CHECKMARK", 3),
|
||||
("ERROR", "Warning", "警告・失敗", "ERROR", 4),
|
||||
("QUESTION", "Question", "疑問・クイズ", "QUESTION", 5),
|
||||
("INFO", "Info", "情報・ポイント", "INFO", 6),
|
||||
("LIGHT", "Idea", "ひらめき", "LIGHT", 7),
|
||||
("SOLO_ON", "Highlight", "強調", "SOLO_ON", 8),
|
||||
("HEART", "Cute", "可愛い", "HEART", 9),
|
||||
("FUND", "Money", "金額・収益", "FUND", 10),
|
||||
("EVENT_A", "Text", "テロップ・文字", "EVENT_A", 11),
|
||||
("FILE_MOVIE", "Scene", "場面・映像", "FILE_MOVIE", 12),
|
||||
("FORWARD", "Transition", "移動・転換", "FORWARD", 13),
|
||||
("GHOST_ENABLED", "Horror", "ホラー・不穏", "GHOST_ENABLED", 14),
|
||||
("FORCE_TURBULENCE", "Impact", "衝撃・爆発", "FORCE_TURBULENCE", 15),
|
||||
)
|
||||
|
||||
|
||||
def _absolute_path(path: str) -> str:
|
||||
return os.path.abspath(bpy.path.abspath(path))
|
||||
|
||||
|
||||
def _find_item(props, filepath: str):
|
||||
normalized = os.path.normcase(os.path.abspath(filepath))
|
||||
for item in props.items:
|
||||
if os.path.normcase(os.path.abspath(item.filepath)) == normalized:
|
||||
return item
|
||||
return None
|
||||
|
||||
|
||||
def _strip_start(strip):
|
||||
return int(strip.left_handle if hasattr(strip, "left_handle") else strip.frame_final_start)
|
||||
|
||||
|
||||
def _strip_end(strip):
|
||||
return int(strip.right_handle if hasattr(strip, "right_handle") else strip.frame_final_end)
|
||||
|
||||
|
||||
def _strip_duration(strip):
|
||||
return int(strip.duration if hasattr(strip, "duration") else strip.frame_final_duration)
|
||||
|
||||
|
||||
def _available_channel(sequence_editor, preferred: int, start: int, duration: int, exclude=None) -> int:
|
||||
end = start + max(1, duration)
|
||||
for channel in range(max(1, preferred), 129):
|
||||
occupied = any(
|
||||
strip != exclude
|
||||
and strip.channel == channel
|
||||
and start < _strip_end(strip)
|
||||
and _strip_start(strip) < end
|
||||
for strip in sequence_editor.strips_all
|
||||
)
|
||||
if not occupied:
|
||||
return channel
|
||||
raise RuntimeError("配置できる空きチャンネルがありません")
|
||||
|
||||
|
||||
def stop_preview(release_device=False):
|
||||
"""再生中の音だけを止め、通常はOpenALデバイスを次回も再利用する。"""
|
||||
global _preview_handle, _preview_device, _preview_sound, _preview_path
|
||||
if _preview_handle is not None:
|
||||
try:
|
||||
_preview_handle.stop()
|
||||
except Exception:
|
||||
pass
|
||||
_preview_handle = None
|
||||
_preview_sound = None
|
||||
_preview_path = ""
|
||||
if release_device:
|
||||
if _preview_device is not None:
|
||||
try:
|
||||
_preview_device.stopAll()
|
||||
except Exception:
|
||||
pass
|
||||
_preview_device = None
|
||||
|
||||
|
||||
def _play_preview(filepath):
|
||||
"""共有デバイスで再生する。壊れたデバイスは一度だけ作り直す。"""
|
||||
global _preview_device, _preview_handle, _preview_sound, _preview_path
|
||||
import aud
|
||||
|
||||
last_error = None
|
||||
for attempt in range(2):
|
||||
try:
|
||||
if _preview_device is None:
|
||||
_preview_device = aud.Device()
|
||||
_preview_sound = aud.Sound(filepath)
|
||||
_preview_handle = _preview_device.play(_preview_sound)
|
||||
_preview_path = filepath
|
||||
return
|
||||
except Exception as error:
|
||||
last_error = error
|
||||
stop_preview(release_device=True)
|
||||
if attempt == 0:
|
||||
continue
|
||||
raise last_error
|
||||
|
||||
|
||||
def populate_items(props) -> int:
|
||||
folder = _absolute_path(props.folder)
|
||||
config = library.load_config(folder)
|
||||
metadata = config.get("items", {})
|
||||
props.items.clear()
|
||||
for path in library.scan_audio_files(folder):
|
||||
saved = metadata.get(path.name, {})
|
||||
item = props.items.add()
|
||||
item.filename = path.name
|
||||
item.filepath = str(path)
|
||||
item.label = saved.get("label") or library.default_label(path)
|
||||
item.icon = saved.get("icon") or "PLAY_SOUND"
|
||||
item.icon_path = saved.get("icon_path") or ""
|
||||
return len(props.items)
|
||||
|
||||
|
||||
def persist_items(props):
|
||||
folder = _absolute_path(props.folder)
|
||||
library.save_config(
|
||||
folder,
|
||||
[
|
||||
{
|
||||
"filename": item.filename,
|
||||
"label": item.label,
|
||||
"icon": item.icon,
|
||||
"icon_path": item.icon_path,
|
||||
}
|
||||
for item in props.items
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class SEPALETTE_OT_scan(Operator):
|
||||
bl_idname = "se_palette.scan"
|
||||
bl_label = "Refresh SE Folder"
|
||||
bl_description = "SEフォルダを再読み込みします"
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
def execute(self, context):
|
||||
props = context.scene.se_palette
|
||||
folder = _absolute_path(props.folder)
|
||||
if not os.path.isdir(folder):
|
||||
self.report({"ERROR"}, "SEフォルダが見つかりません")
|
||||
return {"CANCELLED"}
|
||||
count = populate_items(props)
|
||||
from . import panels
|
||||
|
||||
panels.clear_previews()
|
||||
self.report({"INFO"}, f"{count}個のSEを読み込みました")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class SEPALETTE_OT_insert(Operator):
|
||||
bl_idname = "se_palette.insert"
|
||||
bl_label = "Insert Sound Effect"
|
||||
bl_description = "現在のシークバー位置へSEを追加します"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
filepath: StringProperty(options={"HIDDEN"})
|
||||
|
||||
def execute(self, context):
|
||||
filepath = _absolute_path(self.filepath)
|
||||
if not os.path.isfile(filepath):
|
||||
self.report({"ERROR"}, "音声ファイルが見つかりません")
|
||||
return {"CANCELLED"}
|
||||
|
||||
scene = context.scene
|
||||
props = scene.se_palette
|
||||
if not scene.sequence_editor:
|
||||
scene.sequence_editor_create()
|
||||
sequence_editor = scene.sequence_editor
|
||||
start = int(scene.frame_current)
|
||||
|
||||
try:
|
||||
strip = sequence_editor.strips.new_sound(
|
||||
name=Path(filepath).stem,
|
||||
filepath=filepath,
|
||||
channel=props.channel,
|
||||
frame_start=start,
|
||||
)
|
||||
duration = _strip_duration(strip)
|
||||
channel = _available_channel(
|
||||
sequence_editor, props.channel, start, duration, exclude=strip
|
||||
)
|
||||
if strip.channel != channel:
|
||||
strip.channel = channel
|
||||
if hasattr(strip, "volume"):
|
||||
strip.volume = props.volume
|
||||
|
||||
for existing in sequence_editor.strips_all:
|
||||
existing.select = False
|
||||
strip.select = True
|
||||
sequence_editor.active_strip = strip
|
||||
if props.advance_playhead:
|
||||
scene.frame_current = _strip_end(strip)
|
||||
except Exception as error:
|
||||
self.report({"ERROR"}, f"SEを追加できません: {error}")
|
||||
return {"CANCELLED"}
|
||||
|
||||
self.report({"INFO"}, f"追加: {Path(filepath).name}")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class SEPALETTE_OT_preview(Operator):
|
||||
bl_idname = "se_palette.preview"
|
||||
bl_label = "Preview Sound Effect"
|
||||
bl_description = "SEをその場で試聴します。同じSEをもう一度押すと停止します"
|
||||
|
||||
filepath: StringProperty(options={"HIDDEN"})
|
||||
|
||||
def execute(self, context):
|
||||
filepath = _absolute_path(self.filepath)
|
||||
if not os.path.isfile(filepath):
|
||||
self.report({"ERROR"}, "音声ファイルが見つかりません")
|
||||
return {"CANCELLED"}
|
||||
if _preview_handle is not None and _preview_path == filepath:
|
||||
try:
|
||||
import aud
|
||||
|
||||
if _preview_handle.status == aud.STATUS_PLAYING:
|
||||
stop_preview()
|
||||
return {"FINISHED"}
|
||||
except Exception:
|
||||
pass
|
||||
stop_preview()
|
||||
try:
|
||||
_play_preview(filepath)
|
||||
except Exception as error:
|
||||
stop_preview(release_device=True)
|
||||
self.report(
|
||||
{"ERROR"},
|
||||
f"試聴できません: {error}。Blenderの音声デバイス設定を確認してください",
|
||||
)
|
||||
return {"CANCELLED"}
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class SEPALETTE_OT_stop_preview(Operator):
|
||||
bl_idname = "se_palette.stop_preview"
|
||||
bl_label = "Stop Preview"
|
||||
bl_description = "試聴を停止します"
|
||||
|
||||
def execute(self, context):
|
||||
stop_preview()
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class SEPALETTE_OT_edit_item(Operator):
|
||||
bl_idname = "se_palette.edit_item"
|
||||
bl_label = "Edit SE Button"
|
||||
bl_description = "表示名とアイコンを変更します"
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
filepath: StringProperty(options={"HIDDEN"})
|
||||
label: StringProperty(name="Label")
|
||||
icon: bpy.props.EnumProperty(name="Built-in Icon", items=ICON_ITEMS)
|
||||
icon_path: StringProperty(
|
||||
name="Custom Icon",
|
||||
description="PNGまたはJPEG画像。指定時は組み込みアイコンより優先",
|
||||
subtype="FILE_PATH",
|
||||
)
|
||||
|
||||
def invoke(self, context, event):
|
||||
item = _find_item(context.scene.se_palette, _absolute_path(self.filepath))
|
||||
if item is None:
|
||||
self.report({"ERROR"}, "SEが一覧にありません")
|
||||
return {"CANCELLED"}
|
||||
self.label = item.label
|
||||
self.icon = item.icon if item.icon in {entry[0] for entry in ICON_ITEMS} else "PLAY_SOUND"
|
||||
self.icon_path = item.icon_path
|
||||
return context.window_manager.invoke_props_dialog(self, width=520)
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
layout.prop(self, "label")
|
||||
layout.prop(self, "icon")
|
||||
layout.prop(self, "icon_path")
|
||||
if self.icon_path:
|
||||
path = _absolute_path(self.icon_path)
|
||||
if not os.path.isfile(path):
|
||||
row = layout.row()
|
||||
row.alert = True
|
||||
row.label(text="Custom icon file not found", icon="ERROR")
|
||||
|
||||
def execute(self, context):
|
||||
props = context.scene.se_palette
|
||||
item = _find_item(props, _absolute_path(self.filepath))
|
||||
if item is None:
|
||||
self.report({"ERROR"}, "SEが一覧にありません")
|
||||
return {"CANCELLED"}
|
||||
item.label = self.label.strip() or library.default_label(Path(item.filepath))
|
||||
item.icon = self.icon
|
||||
item.icon_path = self.icon_path
|
||||
try:
|
||||
persist_items(props)
|
||||
except OSError as error:
|
||||
self.report({"ERROR"}, f"設定を保存できません: {error}")
|
||||
return {"CANCELLED"}
|
||||
from . import panels
|
||||
|
||||
panels.clear_previews()
|
||||
self.report({"INFO"}, "SEボタンの設定を保存しました")
|
||||
return {"FINISHED"}
|
||||
Reference in New Issue
Block a user