commit 524d98f620821cb94964d2fbda245a3614d72974 Author: Hare Date: Thu Sep 3 03:17:27 2026 +0900 Initial SE Palette add-on diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..31925ee --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.py[cod] +*.zip +.DS_Store diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bba3cfe --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9f72def --- /dev/null +++ b/README.md @@ -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 diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..f7ce668 --- /dev/null +++ b/__init__.py @@ -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() diff --git a/blender_manifest.toml b/blender_manifest.toml new file mode 100644 index 0000000..0618279 --- /dev/null +++ b/blender_manifest.toml @@ -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 " +type = "add-on" + +tags = ["Sequencer"] +blender_version_min = "5.0.0" + +license = ["SPDX:MIT"] + +[build] +paths_exclude_pattern = [ + "__pycache__/", + "/.git/", + "/tests/", + "*.pyc", + "*.zip", +] diff --git a/library.py b/library.py new file mode 100644 index 0000000..f94809d --- /dev/null +++ b/library.py @@ -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 diff --git a/operators.py b/operators.py new file mode 100644 index 0000000..f2398bf --- /dev/null +++ b/operators.py @@ -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"} diff --git a/panels.py b/panels.py new file mode 100644 index 0000000..c8ed462 --- /dev/null +++ b/panels.py @@ -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) diff --git a/properties.py b/properties.py new file mode 100644 index 0000000..7fdf1d5 --- /dev/null +++ b/properties.py @@ -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) diff --git a/tests/blender_smoke.py b/tests/blender_smoke.py new file mode 100644 index 0000000..babb470 --- /dev/null +++ b/tests/blender_smoke.py @@ -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") diff --git a/tests/test_library.py b/tests/test_library.py new file mode 100644 index 0000000..d21a016 --- /dev/null +++ b/tests/test_library.py @@ -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()