Initial SE Palette add-on

This commit is contained in:
2026-09-03 03:17:27 +09:00
commit 524d98f620
11 changed files with 877 additions and 0 deletions
+324
View File
@@ -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"}