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
+4
View File
@@ -0,0 +1,4 @@
__pycache__/
*.py[cod]
*.zip
.DS_Store
+21
View File
@@ -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.
+46
View File
@@ -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
View File
@@ -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()
+22
View File
@@ -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
View File
@@ -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
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"}
+148
View File
@@ -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)
+64
View File
@@ -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)
+90
View File
@@ -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")
+35
View File
@@ -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()