Initial SE Palette add-on
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.zip
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Hare
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# SE Palette for Blender
|
||||||
|
|
||||||
|
Blender 5.x の Video Sequencer へ、フォルダ内の効果音をワンタッチで挿入するアドオンです。VSEのNサイドバーに常駐します。専用のVSE Areaを作り、そのサイドバーを幅いっぱいに広げればSE専用エリアとして使えます。
|
||||||
|
|
||||||
|
## 機能
|
||||||
|
|
||||||
|
- MP3 / WAV / FLAC / OGG / M4A / AAC をフォルダから自動一覧化
|
||||||
|
- 各SEのメインボタンを押すだけで、現在のシークバー位置へ挿入
|
||||||
|
- 再生ボタンでタイムラインへ追加せずに試聴、同じボタンの再押下で停止
|
||||||
|
- SEごとに表示名、Blender組み込みアイコン、任意のPNG/JPEGアイコンを設定
|
||||||
|
- 名前検索、音量指定、配置チャンネル指定
|
||||||
|
- 1〜10列から選べるカード型グリッドレイアウト
|
||||||
|
- 同じ時間帯に音がある場合は、上の空きチャンネルへ自動配置
|
||||||
|
- 任意で挿入後にシークバーをSE末尾へ移動
|
||||||
|
|
||||||
|
初期SEフォルダは `/home/hare/Assets/SE/` に設定されています。
|
||||||
|
|
||||||
|
## インストール
|
||||||
|
|
||||||
|
1. Blenderの `Edit > Preferences > Get Extensions` を開く
|
||||||
|
2. 右上メニューから `Install from Disk...` を選択
|
||||||
|
3. ビルド済みの `se_palette-1.3.0.zip` を選択
|
||||||
|
4. Video EditingワークスペースでVideo Sequencerを開く
|
||||||
|
5. `N` キーでサイドバーを開き、`SE Palette` タブを選択
|
||||||
|
|
||||||
|
`Unable to parse the manifest` と表示される環境では、マニフェストを使わない
|
||||||
|
`se_palette-legacy-1.3.0.zip` を `Install from Disk...` から選択してください。
|
||||||
|
|
||||||
|
## 試聴について
|
||||||
|
|
||||||
|
試聴用のOpenALデバイスはアドオン内で1つだけ生成し、繰り返し再利用します。以前の版で
|
||||||
|
`Buffer generation failed while starting playback with OpenAL` が発生した場合は、1.0.2以降へ
|
||||||
|
更新後にBlenderを一度再起動してください。引き続き再生できない場合は
|
||||||
|
`Preferences > System > Sound` のAudio Deviceを確認してください。
|
||||||
|
|
||||||
|
## 使い方
|
||||||
|
|
||||||
|
1. 上部でSEフォルダを選び、更新ボタンを押す
|
||||||
|
2. SE名のボタンで挿入、右側の再生ボタンで試聴
|
||||||
|
3. 歯車ボタンから表示名・組み込みアイコン・カスタム画像を編集
|
||||||
|
|
||||||
|
編集内容はSEフォルダ内の `.se_palette.json` に保存されます。カスタム画像を指定すると組み込みアイコンより優先されます。
|
||||||
|
|
||||||
|
## ライセンス
|
||||||
|
|
||||||
|
MIT License
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
"""SE Palette - ワンタッチで効果音をVSEへ追加するBlenderアドオン。"""
|
||||||
|
|
||||||
|
# Legacy add-on metadata. Blender Extensions uses blender_manifest.toml,
|
||||||
|
# while this keeps the same source installable through the legacy add-on path.
|
||||||
|
bl_info = {
|
||||||
|
"name": "SE Palette",
|
||||||
|
"author": "Hare",
|
||||||
|
"version": (1, 3, 0),
|
||||||
|
"blender": (5, 0, 0),
|
||||||
|
"location": "Video Sequencer > Sidebar > SE Palette",
|
||||||
|
"description": "Preview and insert sound effects into the Video Sequencer",
|
||||||
|
"category": "Sequencer",
|
||||||
|
}
|
||||||
|
|
||||||
|
from . import operators, panels, properties
|
||||||
|
|
||||||
|
|
||||||
|
classes = (
|
||||||
|
properties.SEPaletteItem,
|
||||||
|
properties.SEPaletteProperties,
|
||||||
|
operators.SEPALETTE_OT_scan,
|
||||||
|
operators.SEPALETTE_OT_insert,
|
||||||
|
operators.SEPALETTE_OT_preview,
|
||||||
|
operators.SEPALETTE_OT_stop_preview,
|
||||||
|
operators.SEPALETTE_OT_edit_item,
|
||||||
|
panels.SEPALETTE_PT_main,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def register():
|
||||||
|
import bpy
|
||||||
|
|
||||||
|
for cls in classes:
|
||||||
|
bpy.utils.register_class(cls)
|
||||||
|
bpy.types.Scene.se_palette = bpy.props.PointerProperty(
|
||||||
|
type=properties.SEPaletteProperties
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def unregister():
|
||||||
|
import bpy
|
||||||
|
|
||||||
|
operators.stop_preview(release_device=True)
|
||||||
|
panels.clear_previews()
|
||||||
|
if hasattr(bpy.types.Scene, "se_palette"):
|
||||||
|
del bpy.types.Scene.se_palette
|
||||||
|
for cls in reversed(classes):
|
||||||
|
bpy.utils.unregister_class(cls)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
register()
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
schema_version = "1.0.0"
|
||||||
|
|
||||||
|
id = "se_palette"
|
||||||
|
version = "1.3.0"
|
||||||
|
name = "SE Palette"
|
||||||
|
tagline = "Preview and insert sound effects into the Video Sequencer"
|
||||||
|
maintainer = "Hare <noreply@example.com>"
|
||||||
|
type = "add-on"
|
||||||
|
|
||||||
|
tags = ["Sequencer"]
|
||||||
|
blender_version_min = "5.0.0"
|
||||||
|
|
||||||
|
license = ["SPDX:MIT"]
|
||||||
|
|
||||||
|
[build]
|
||||||
|
paths_exclude_pattern = [
|
||||||
|
"__pycache__/",
|
||||||
|
"/.git/",
|
||||||
|
"/tests/",
|
||||||
|
"*.pyc",
|
||||||
|
"*.zip",
|
||||||
|
]
|
||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
"""Blenderに依存しないSEライブラリの走査・設定処理。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
AUDIO_EXTENSIONS = {".mp3", ".wav", ".flac", ".ogg", ".m4a", ".aac"}
|
||||||
|
CONFIG_FILENAME = ".se_palette.json"
|
||||||
|
|
||||||
|
|
||||||
|
def scan_audio_files(folder: str) -> list[Path]:
|
||||||
|
"""フォルダ直下の対応音声ファイルを名前順で返す。"""
|
||||||
|
root = Path(folder).expanduser()
|
||||||
|
if not root.is_dir():
|
||||||
|
return []
|
||||||
|
return sorted(
|
||||||
|
(
|
||||||
|
path
|
||||||
|
for path in root.iterdir()
|
||||||
|
if path.is_file() and path.suffix.lower() in AUDIO_EXTENSIONS
|
||||||
|
),
|
||||||
|
key=lambda path: path.name.casefold(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def default_label(path: Path) -> str:
|
||||||
|
"""拡張子と管理用の先頭番号を除いた表示名を作る。"""
|
||||||
|
label = path.stem
|
||||||
|
if len(label) > 3 and label[:2].isdigit() and label[2] in {"_", "-", " "}:
|
||||||
|
label = label[3:]
|
||||||
|
return label.replace("_", " ")
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(folder: str) -> dict:
|
||||||
|
config_path = Path(folder).expanduser() / CONFIG_FILENAME
|
||||||
|
if not config_path.is_file():
|
||||||
|
return {"items": {}}
|
||||||
|
try:
|
||||||
|
data = json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return {"items": {}}
|
||||||
|
if not isinstance(data, dict) or not isinstance(data.get("items", {}), dict):
|
||||||
|
return {"items": {}}
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def save_config(folder: str, items: list[dict]) -> Path:
|
||||||
|
root = Path(folder).expanduser()
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
config_path = root / CONFIG_FILENAME
|
||||||
|
payload = {
|
||||||
|
"version": 1,
|
||||||
|
"items": {
|
||||||
|
item["filename"]: {
|
||||||
|
"label": item.get("label", ""),
|
||||||
|
"icon": item.get("icon", "PLAY_SOUND"),
|
||||||
|
"icon_path": item.get("icon_path", ""),
|
||||||
|
}
|
||||||
|
for item in items
|
||||||
|
},
|
||||||
|
}
|
||||||
|
temporary_path = config_path.with_suffix(config_path.suffix + ".tmp")
|
||||||
|
temporary_path.write_text(
|
||||||
|
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
os.replace(temporary_path, config_path)
|
||||||
|
return config_path
|
||||||
+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"}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
"""Video SequencerサイドバーのSE Palette UI。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import bpy
|
||||||
|
import bpy.utils.previews as previews
|
||||||
|
from bpy.types import Panel
|
||||||
|
|
||||||
|
from .operators import populate_items
|
||||||
|
|
||||||
|
|
||||||
|
_preview_collection = None
|
||||||
|
|
||||||
|
|
||||||
|
def clear_previews():
|
||||||
|
global _preview_collection
|
||||||
|
if _preview_collection is not None:
|
||||||
|
try:
|
||||||
|
previews.remove(_preview_collection)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_preview_collection = None
|
||||||
|
|
||||||
|
|
||||||
|
def _custom_icon_value(item) -> int:
|
||||||
|
global _preview_collection
|
||||||
|
raw_path = item.icon_path.strip()
|
||||||
|
if not raw_path:
|
||||||
|
return 0
|
||||||
|
filepath = os.path.abspath(bpy.path.abspath(raw_path))
|
||||||
|
if not os.path.isfile(filepath):
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
if _preview_collection is None:
|
||||||
|
_preview_collection = previews.new()
|
||||||
|
key = f"{filepath}:{os.path.getmtime(filepath)}"
|
||||||
|
if key not in _preview_collection:
|
||||||
|
_preview_collection.load(key, filepath, "IMAGE")
|
||||||
|
return _preview_collection[key].icon_id
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_insert_button(row, item):
|
||||||
|
icon_value = _custom_icon_value(item)
|
||||||
|
kwargs = {"text": item.label}
|
||||||
|
if icon_value:
|
||||||
|
kwargs["icon_value"] = icon_value
|
||||||
|
else:
|
||||||
|
kwargs["icon"] = item.icon or "PLAY_SOUND"
|
||||||
|
operator = row.operator("se_palette.insert", **kwargs)
|
||||||
|
operator.filepath = item.filepath
|
||||||
|
|
||||||
|
|
||||||
|
def draw_palette(layout, context):
|
||||||
|
"""ポップアップなど任意のUILayoutへSE Paletteを描画する。"""
|
||||||
|
props = context.scene.se_palette
|
||||||
|
|
||||||
|
folder_box = layout.box()
|
||||||
|
row = folder_box.row(align=True)
|
||||||
|
row.prop(props, "folder", text="")
|
||||||
|
row.operator("se_palette.scan", text="", icon="FILE_REFRESH")
|
||||||
|
|
||||||
|
if not props.items and os.path.isdir(os.path.abspath(bpy.path.abspath(props.folder))):
|
||||||
|
try:
|
||||||
|
populate_items(props)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
settings = layout.box()
|
||||||
|
row = settings.row(align=True)
|
||||||
|
row.prop(props, "channel")
|
||||||
|
row.prop(props, "volume")
|
||||||
|
row = settings.row(align=True)
|
||||||
|
row.prop(props, "advance_playhead")
|
||||||
|
row.prop(props, "compact")
|
||||||
|
settings.prop(props, "grid_columns", slider=True)
|
||||||
|
|
||||||
|
row = layout.row(align=True)
|
||||||
|
row.prop(props, "search", text="", icon="VIEWZOOM")
|
||||||
|
row.operator("se_palette.stop_preview", text="", icon="PAUSE")
|
||||||
|
|
||||||
|
folder = os.path.abspath(bpy.path.abspath(props.folder))
|
||||||
|
if not os.path.isdir(folder):
|
||||||
|
warning = layout.row()
|
||||||
|
warning.alert = True
|
||||||
|
warning.label(text="Select an SE folder", icon="ERROR")
|
||||||
|
return
|
||||||
|
|
||||||
|
query = props.search.strip().casefold()
|
||||||
|
visible_items = [
|
||||||
|
item
|
||||||
|
for item in props.items
|
||||||
|
if not query
|
||||||
|
or query in item.label.casefold()
|
||||||
|
or query in item.filename.casefold()
|
||||||
|
]
|
||||||
|
|
||||||
|
if not visible_items:
|
||||||
|
layout.label(
|
||||||
|
text="No matching sound effects" if query else "No sound effects found",
|
||||||
|
icon="INFO",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
grid = layout.grid_flow(
|
||||||
|
row_major=True,
|
||||||
|
columns=props.grid_columns,
|
||||||
|
even_columns=True,
|
||||||
|
even_rows=True,
|
||||||
|
align=True,
|
||||||
|
)
|
||||||
|
for item in visible_items:
|
||||||
|
card = grid.box()
|
||||||
|
insert_row = card.row(align=True)
|
||||||
|
insert_row.scale_y = 1.0 if props.compact else 1.5
|
||||||
|
_draw_insert_button(insert_row, item)
|
||||||
|
|
||||||
|
controls = card.row(align=True)
|
||||||
|
preview = controls.operator(
|
||||||
|
"se_palette.preview",
|
||||||
|
text="" if props.compact else "Preview",
|
||||||
|
icon="PLAY",
|
||||||
|
)
|
||||||
|
preview.filepath = item.filepath
|
||||||
|
edit = controls.operator(
|
||||||
|
"se_palette.edit_item",
|
||||||
|
text="" if props.compact else "Edit",
|
||||||
|
icon="PREFERENCES",
|
||||||
|
)
|
||||||
|
edit.filepath = item.filepath
|
||||||
|
|
||||||
|
layout.label(text=f"{len(visible_items)} / {len(props.items)} sounds", icon="SOUND")
|
||||||
|
|
||||||
|
|
||||||
|
class SEPALETTE_PT_main(Panel):
|
||||||
|
"""VSEサイドバーに表示するSE Palette。"""
|
||||||
|
|
||||||
|
bl_label = "SE Palette"
|
||||||
|
bl_idname = "SEPALETTE_PT_main"
|
||||||
|
bl_space_type = "SEQUENCE_EDITOR"
|
||||||
|
bl_region_type = "UI"
|
||||||
|
bl_category = "SE Palette"
|
||||||
|
|
||||||
|
def draw(self, context):
|
||||||
|
draw_palette(self.layout, context)
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""SE Paletteのプロパティ定義。"""
|
||||||
|
|
||||||
|
import bpy
|
||||||
|
from bpy.props import (
|
||||||
|
BoolProperty,
|
||||||
|
CollectionProperty,
|
||||||
|
FloatProperty,
|
||||||
|
IntProperty,
|
||||||
|
StringProperty,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SEPaletteItem(bpy.types.PropertyGroup):
|
||||||
|
filename: StringProperty(name="Filename")
|
||||||
|
label: StringProperty(name="Label")
|
||||||
|
filepath: StringProperty(name="Audio File", subtype="FILE_PATH")
|
||||||
|
icon: StringProperty(name="Icon", default="PLAY_SOUND")
|
||||||
|
icon_path: StringProperty(name="Custom Icon", subtype="FILE_PATH")
|
||||||
|
|
||||||
|
|
||||||
|
class SEPaletteProperties(bpy.types.PropertyGroup):
|
||||||
|
folder: StringProperty(
|
||||||
|
name="SE Folder",
|
||||||
|
description="効果音ファイルが入っているフォルダ",
|
||||||
|
default="/home/hare/Assets/SE/",
|
||||||
|
subtype="DIR_PATH",
|
||||||
|
)
|
||||||
|
search: StringProperty(
|
||||||
|
name="Search",
|
||||||
|
description="表示名またはファイル名で絞り込み",
|
||||||
|
default="",
|
||||||
|
)
|
||||||
|
channel: IntProperty(
|
||||||
|
name="Channel",
|
||||||
|
description="SEを配置する優先チャンネル。重なる場合は上の空きチャンネルを使用",
|
||||||
|
default=3,
|
||||||
|
min=1,
|
||||||
|
max=128,
|
||||||
|
)
|
||||||
|
volume: FloatProperty(
|
||||||
|
name="Volume",
|
||||||
|
description="挿入するSEストリップの音量",
|
||||||
|
default=1.0,
|
||||||
|
min=0.0,
|
||||||
|
max=4.0,
|
||||||
|
)
|
||||||
|
advance_playhead: BoolProperty(
|
||||||
|
name="Advance Playhead",
|
||||||
|
description="挿入後にシークバーをSEの末尾へ移動",
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
compact: BoolProperty(
|
||||||
|
name="Compact",
|
||||||
|
description="ボタンをコンパクト表示",
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
grid_columns: IntProperty(
|
||||||
|
name="Columns",
|
||||||
|
description="SEカードを並べる列数",
|
||||||
|
default=4,
|
||||||
|
min=1,
|
||||||
|
max=10,
|
||||||
|
)
|
||||||
|
items: CollectionProperty(type=SEPaletteItem)
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""Run with: blender --background --factory-startup --python tests/blender_smoke.py"""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import bpy
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
PACKAGE_NAME = "blender_se_palette"
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
PACKAGE_NAME,
|
||||||
|
ROOT / "__init__.py",
|
||||||
|
submodule_search_locations=[str(ROOT)],
|
||||||
|
)
|
||||||
|
addon = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules[PACKAGE_NAME] = addon
|
||||||
|
spec.loader.exec_module(addon)
|
||||||
|
|
||||||
|
addon.register()
|
||||||
|
try:
|
||||||
|
assert hasattr(addon.panels, "SEPALETTE_PT_main")
|
||||||
|
scene = bpy.context.scene
|
||||||
|
props = scene.se_palette
|
||||||
|
props.folder = "/home/hare/Assets/SE/"
|
||||||
|
count = addon.operators.populate_items(props)
|
||||||
|
assert count == 42, f"Expected 42 sounds, got {count}"
|
||||||
|
|
||||||
|
valid_icons = bpy.types.UILayout.bl_rna.functions["operator"].parameters["icon"].enum_items.keys()
|
||||||
|
for icon, *_rest in addon.operators.ICON_ITEMS:
|
||||||
|
assert icon in valid_icons, f"Unknown Blender icon: {icon}"
|
||||||
|
|
||||||
|
# Headless Blender has no OpenAL output device, but the same decoder used by
|
||||||
|
# the preview operator must still be able to load the selected file.
|
||||||
|
import aud
|
||||||
|
|
||||||
|
aud.Sound(props.items[0].filepath)
|
||||||
|
|
||||||
|
# Repeated previews must reuse a single output device instead of leaking
|
||||||
|
# one OpenAL device per button press.
|
||||||
|
real_aud = sys.modules["aud"]
|
||||||
|
created_devices = []
|
||||||
|
|
||||||
|
class FakeHandle:
|
||||||
|
status = 1
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.status = 3
|
||||||
|
|
||||||
|
class FakeDevice:
|
||||||
|
def __init__(self):
|
||||||
|
created_devices.append(self)
|
||||||
|
|
||||||
|
def play(self, sound):
|
||||||
|
return FakeHandle()
|
||||||
|
|
||||||
|
def stopAll(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
sys.modules["aud"] = types.SimpleNamespace(
|
||||||
|
Device=FakeDevice,
|
||||||
|
Sound=lambda filepath: filepath,
|
||||||
|
STATUS_PLAYING=1,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
addon.operators.stop_preview(release_device=True)
|
||||||
|
addon.operators._play_preview(props.items[0].filepath)
|
||||||
|
first_device = addon.operators._preview_device
|
||||||
|
addon.operators.stop_preview()
|
||||||
|
addon.operators._play_preview(props.items[1].filepath)
|
||||||
|
assert addon.operators._preview_device is first_device
|
||||||
|
assert len(created_devices) == 1
|
||||||
|
finally:
|
||||||
|
addon.operators.stop_preview(release_device=True)
|
||||||
|
sys.modules["aud"] = real_aud
|
||||||
|
|
||||||
|
result = bpy.ops.se_palette.insert(filepath=props.items[0].filepath)
|
||||||
|
assert result == {"FINISHED"}, result
|
||||||
|
assert len(scene.sequence_editor.strips_all) == 1
|
||||||
|
strip = scene.sequence_editor.active_strip
|
||||||
|
assert strip.type == "SOUND"
|
||||||
|
assert strip.channel == props.channel
|
||||||
|
assert Path(strip.sound.filepath).name == props.items[0].filename
|
||||||
|
finally:
|
||||||
|
addon.unregister()
|
||||||
|
|
||||||
|
print("SE_PALETTE_BLENDER_SMOKE_OK")
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from library import default_label, load_config, save_config, scan_audio_files
|
||||||
|
|
||||||
|
|
||||||
|
class LibraryTests(unittest.TestCase):
|
||||||
|
def test_scan_filters_and_sorts_supported_audio(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
for filename in ("02_b.WAV", "01_a.mp3", "memo.txt"):
|
||||||
|
(root / filename).touch()
|
||||||
|
self.assertEqual(
|
||||||
|
[path.name for path in scan_audio_files(directory)],
|
||||||
|
["01_a.mp3", "02_b.WAV"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_default_label_removes_number_prefix(self):
|
||||||
|
self.assertEqual(default_label(Path("01_和太鼓でドン.mp3")), "和太鼓でドン")
|
||||||
|
|
||||||
|
def test_config_round_trip(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = save_config(
|
||||||
|
directory,
|
||||||
|
[{"filename": "se.mp3", "label": "SE", "icon": "INFO", "icon_path": ""}],
|
||||||
|
)
|
||||||
|
self.assertTrue(path.is_file())
|
||||||
|
self.assertEqual(load_config(directory)["items"]["se.mp3"]["label"], "SE")
|
||||||
|
json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user