"""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"}