feat: reference text strip

This commit is contained in:
Keisuke Hirata 2026-02-04 21:11:17 +09:00
parent aead0ff136
commit 04e62ba030
9 changed files with 410 additions and 97 deletions

View File

@ -17,6 +17,8 @@ bl_info = {
import bpy
from . import operators
from . import operators_reference
from . import operators_speaker
from . import panels
from . import properties
@ -26,6 +28,9 @@ classes = (
operators.VOICEVOX_OT_generate_speech,
operators.VOICEVOX_OT_add_subtitle,
operators.VOICEVOX_OT_test_connection,
operators_reference.VOICEVOX_OT_set_reference_text,
operators_reference.VOICEVOX_OT_clear_reference_text,
operators_speaker.VOICEVOX_OT_refresh_speakers,
panels.VOICEVOX_PT_main_panel,
)
@ -38,6 +43,12 @@ def register():
# シーンにプロパティを追加
bpy.types.Scene.voicevox = bpy.props.PointerProperty(type=properties.VoiceVoxProperties)
# 初回起動時に話者リストを取得
try:
properties.update_speaker_cache()
except:
pass # 初回はVoiceVoxが起動していない可能性があるのでスキップ
print("VoiceVox Plugin registered")

View File

@ -8,7 +8,6 @@
outputs =
{
self,
nixpkgs,
flake-utils,
}:
@ -20,21 +19,14 @@
{
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
# Python開発環境
python311
python311Packages.pip
python311Packages.requests
python311Packages.pillow
# Blender (開発・テスト用)
blender
# 開発ツール
git
# VoiceVoxエンジン(オプション)
# voicevox-engine は Nix パッケージとして提供されていない可能性があるため
# 別途インストールが必要
voicevox-core
voicevox-engine
];
shellHook = ''

View File

@ -9,6 +9,93 @@ from .voicevox import VoiceVoxAPI
from .subtitles import SubtitleManager
def get_text_strip_settings(context):
"""
リファレンスまたは選択されているテキストストリップから設定を抽出
Returns:
dict: 抽出された設定見つからない場合はNone
"""
if not context.scene.sequence_editor:
return None
seq_editor = context.scene.sequence_editor
props = context.scene.voicevox
text_strip = None
# 1. まずリファレンステキストストリップをチェック
if props.reference_text_strip:
for strip in seq_editor.strips_all:
if strip.name == props.reference_text_strip and strip.type == 'TEXT':
text_strip = strip
print(f"[VoiceVox] リファレンステキスト '{strip.name}' を使用")
break
# 2. リファレンスが見つからない場合は選択されているストリップを使用
if not text_strip:
if seq_editor.active_strip and seq_editor.active_strip.type == 'TEXT':
text_strip = seq_editor.active_strip
print(f"[VoiceVox] アクティブなテキスト '{text_strip.name}' を使用")
else:
# 選択されているストリップからテキストを探す
for strip in seq_editor.strips_all:
if strip.select and strip.type == 'TEXT':
text_strip = strip
print(f"[VoiceVox] 選択されたテキスト '{text_strip.name}' を使用")
break
if not text_strip:
return None
# すべてのプロパティを動的に抽出
settings = {}
# ストリップ固有のプロパティ(コピーしないもの)
skip_properties = {
'name', 'type', 'frame_start', 'frame_final_start',
'frame_final_end', 'frame_final_duration', 'frame_duration',
'frame_offset_start', 'frame_offset_end', 'frame_still_start',
'frame_still_end', 'select', 'select_left_handle',
'select_right_handle', 'mute', 'lock', 'text', # textは別途設定
'channel', # channelはUIの設定を優先
}
# bl_rna.propertiesから全プロパティを取得
if hasattr(text_strip, 'bl_rna') and hasattr(text_strip.bl_rna, 'properties'):
for prop in text_strip.bl_rna.properties:
prop_name = prop.identifier
# スキップするプロパティ
if prop_name in skip_properties:
continue
# 読み取り専用プロパティはスキップ
if prop.is_readonly:
continue
# プロパティの値を取得
try:
value = getattr(text_strip, prop_name)
# locationは特別処理
if prop_name == 'location':
try:
settings['position_x'] = value[0]
settings['position_y'] = value[1]
except (TypeError, IndexError):
pass
else:
settings[prop_name] = value
print(f"[VoiceVox] コピー: {prop_name} = {value}")
except Exception as e:
print(f"[VoiceVox] {prop_name} の取得をスキップ: {e}")
print(f"[VoiceVox] テキストストリップ '{text_strip.name}' から {len(settings)} 個のプロパティを抽出")
return settings
class VOICEVOX_OT_test_connection(Operator):
"""VoiceVoxエンジンへの接続をテスト"""
bl_idname = "voicevox.test_connection"
@ -52,9 +139,12 @@ class VOICEVOX_OT_generate_speech(Operator):
output_dir = bpy.path.abspath(props.output_directory)
print(f"[VoiceVox] 出力先: {output_dir}")
# speakerプロパティからIDを取得
speaker_id = int(props.speaker) if props.speaker else 0
audio_file = api.generate_speech(
text=props.text,
speaker_id=props.speaker_id,
speaker_id=speaker_id,
speed_scale=props.speed_scale,
pitch_scale=props.pitch_scale,
intonation_scale=props.intonation_scale,
@ -93,6 +183,7 @@ class VOICEVOX_OT_generate_speech(Operator):
seq_editor = context.scene.sequence_editor
frame_current = context.scene.frame_current
props = context.scene.voicevox
# 音声を追加
audio_strip = seq_editor.strips.new_sound(
@ -106,17 +197,36 @@ class VOICEVOX_OT_generate_speech(Operator):
audio_duration_frames = audio_strip.frame_final_duration
print(f"[VoiceVox] 音声の長さ: {audio_duration_frames} フレーム")
# 選択されているテキストストリップから設定を抽出(あれば)
text_settings = get_text_strip_settings(context)
# 字幕を追加(音声の長さに合わせる)
props = context.scene.voicevox
subtitle_mgr = SubtitleManager()
# テキストストリップから設定を取得できた場合はそれを優先
if text_settings:
font_size = text_settings.get('font_size', 48) # デフォルト48
position_y = text_settings.get('position_y', 0.1) # デフォルト0.1
channel = text_settings.get('channel', props.subtitle_channel)
# その他の設定も渡す
extra_settings = {k: v for k, v in text_settings.items()
if k not in ['font_size', 'position_y', 'channel']}
else:
# リファレンスがない場合のデフォルト値
font_size = 48
position_y = 0.1
channel = props.subtitle_channel
extra_settings = {}
subtitle_mgr.add_subtitle(
context=context,
text=text,
frame_start=frame_current,
duration_frames=audio_duration_frames, # 音声の長さに合わせる
font_size=props.subtitle_font_size,
position_y=props.subtitle_position_y,
channel=props.subtitle_channel,
font_size=font_size,
position_y=position_y,
channel=channel,
**extra_settings,
)
@ -143,15 +253,35 @@ class VOICEVOX_OT_add_subtitle(Operator):
default_duration_sec = 3.0
duration_frames = int(default_duration_sec * context.scene.render.fps)
# 選択されているテキストストリップから設定を抽出(あれば)
text_settings = get_text_strip_settings(context)
subtitle_mgr = SubtitleManager()
# テキストストリップから設定を取得できた場合はそれを優先
if text_settings:
font_size = text_settings.get('font_size', 48) # デフォルト48
position_y = text_settings.get('position_y', 0.1) # デフォルト0.1
channel = text_settings.get('channel', props.subtitle_channel)
# その他の設定も渡す
extra_settings = {k: v for k, v in text_settings.items()
if k not in ['font_size', 'position_y', 'channel']}
else:
# リファレンスがない場合のデフォルト値
font_size = 48
position_y = 0.1
channel = props.subtitle_channel
extra_settings = {}
subtitle_mgr.add_subtitle(
context=context,
text=props.text,
frame_start=frame_current,
duration_frames=duration_frames,
font_size=props.subtitle_font_size,
position_y=props.subtitle_position_y,
channel=props.subtitle_channel,
font_size=font_size,
position_y=position_y,
channel=channel,
**extra_settings,
)
self.report({'INFO'}, "字幕を追加しました")

60
operators_reference.py Normal file
View File

@ -0,0 +1,60 @@
"""
リファレンステキストストリップ管理用オペレーター
"""
import bpy
from bpy.types import Operator
class VOICEVOX_OT_set_reference_text(Operator):
"""選択したテキストストリップをリファレンスとして設定"""
bl_idname = "voicevox.set_reference_text"
bl_label = "Set as Reference"
bl_description = "選択したテキストストリップをリファレンスとして設定します"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
if not context.scene.sequence_editor:
self.report({'ERROR'}, "シーケンスエディタがありません")
return {'CANCELLED'}
seq_editor = context.scene.sequence_editor
props = context.scene.voicevox
# 選択されているテキストストリップを探す
text_strip = None
if seq_editor.active_strip and seq_editor.active_strip.type == 'TEXT':
text_strip = seq_editor.active_strip
else:
for strip in seq_editor.strips_all:
if strip.select and strip.type == 'TEXT':
text_strip = strip
break
if not text_strip:
self.report({'ERROR'}, "テキストストリップが選択されていません")
return {'CANCELLED'}
# リファレンスとして設定
props.reference_text_strip = text_strip.name
self.report({'INFO'}, f"リファレンステキスト: '{text_strip.name}'")
print(f"[VoiceVox] リファレンステキストを設定: {text_strip.name}")
return {'FINISHED'}
class VOICEVOX_OT_clear_reference_text(Operator):
"""リファレンステキストストリップをクリア"""
bl_idname = "voicevox.clear_reference_text"
bl_label = "Clear Reference"
bl_description = "リファレンステキストストリップをクリアします"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
props = context.scene.voicevox
props.reference_text_strip = ""
self.report({'INFO'}, "リファレンステキストをクリアしました")
print(f"[VoiceVox] リファレンステキストをクリア")
return {'FINISHED'}

36
operators_speaker.py Normal file
View File

@ -0,0 +1,36 @@
"""
話者リスト更新用オペレーター
"""
import bpy
from bpy.types import Operator
from .properties import update_speaker_cache
class VOICEVOX_OT_refresh_speakers(Operator):
"""VoiceVoxから話者リストを再取得"""
bl_idname = "voicevox.refresh_speakers"
bl_label = "Refresh Speakers"
bl_description = "VoiceVoxエンジンから話者リストを再取得します"
bl_options = {'REGISTER'}
def execute(self, context):
props = context.scene.voicevox
# VoiceVox APIから話者リストを取得してキャッシュを更新
count = update_speaker_cache(props.voicevox_host, props.voicevox_port)
if count > 0:
self.report({'INFO'}, f"{count}人の話者を取得しました")
else:
self.report({'WARNING'}, "話者の取得に失敗しました")
# EnumPropertyを強制的に再評価
current = props.speaker
try:
props.speaker = '0'
props.speaker = current
except:
pass
return {'FINISHED'}

View File

@ -39,7 +39,9 @@ class VOICEVOX_PT_main_panel(Panel):
# 音声設定
box = layout.box()
box.label(text="Voice Settings", icon='SPEAKER')
box.prop(props, "speaker_id")
row = box.row()
row.prop(props, "speaker", text="")
row.operator("voicevox.refresh_speakers", text="", icon='FILE_REFRESH')
box.prop(props, "speed_scale", slider=True)
box.prop(props, "pitch_scale", slider=True)
box.prop(props, "intonation_scale", slider=True)
@ -50,8 +52,14 @@ class VOICEVOX_PT_main_panel(Panel):
# 字幕設定
box = layout.box()
box.label(text="Subtitle Settings", icon='FONT_DATA')
box.prop(props, "subtitle_font_size")
box.prop(props, "subtitle_position_y", slider=True)
# リファレンステキスト
if props.reference_text_strip:
row = box.row()
row.label(text=f"Ref: {props.reference_text_strip}", icon='LINKED')
row.operator("voicevox.clear_reference_text", text="", icon='X')
else:
box.operator("voicevox.set_reference_text", icon='EYEDROPPER')
layout.separator()

View File

@ -12,6 +12,63 @@ from bpy.props import (
EnumProperty,
)
# グローバル変数で話者リストをキャッシュ
_speaker_cache = []
def get_speaker_items(self, context):
"""キャッシュされた話者一覧を返す"""
global _speaker_cache
if not _speaker_cache:
# キャッシュが空の場合はデフォルトを返す
return [('0', 'リロードボタンを押してください', 'VoiceVoxから話者リストを取得')]
return _speaker_cache
def update_speaker_cache(host: str = "127.0.0.1", port: int = 50021):
"""VoiceVox APIから話者リストを取得してキャッシュを更新"""
global _speaker_cache
from .voicevox import VoiceVoxAPI
items = []
try:
api = VoiceVoxAPI(host, port)
speakers = api.get_speakers()
if speakers:
for speaker in speakers:
name = speaker.get('name', 'Unknown')
styles = speaker.get('styles', [])
for style in styles:
style_name = style.get('name', '')
style_id = style.get('id', 0)
# 表示名: "キャラクター名 (スタイル名)"
display_name = f"{name} ({style_name})" if style_name else name
items.append((
str(style_id),
display_name,
f"Speaker ID: {style_id}",
))
_speaker_cache = items
return len(items)
else:
_speaker_cache = [('0', 'VoiceVoxに接続できません', '')]
return 0
except Exception as e:
print(f"[VoiceVox] 話者一覧の取得に失敗: {e}")
import traceback
traceback.print_exc()
_speaker_cache = [('0', '接続エラー', str(e))]
return 0
class VoiceVoxProperties(bpy.types.PropertyGroup):
"""VoiceVoxプラグインのプロパティ"""
@ -38,6 +95,13 @@ class VoiceVoxProperties(bpy.types.PropertyGroup):
default="",
)
speaker: EnumProperty(
name="Speaker",
description="話者を選択",
items=get_speaker_items,
)
# 後方互換性のため残す内部的にはspeakerを使用
speaker_id: IntProperty(
name="Speaker ID",
description="話者ID (VoiceVoxのキャラクター)",
@ -77,23 +141,6 @@ class VoiceVoxProperties(bpy.types.PropertyGroup):
max=2.0,
)
# 字幕の設定
subtitle_font_size: IntProperty(
name="Font Size",
description="字幕のフォントサイズ",
default=48,
min=12,
max=200,
)
subtitle_position_y: FloatProperty(
name="Position Y",
description="字幕の垂直位置 (0.0 = 下, 1.0 = 上)",
default=0.1,
min=0.0,
max=1.0,
)
# チャンネル設定
audio_channel: IntProperty(
name="Audio Channel",
@ -111,6 +158,13 @@ class VoiceVoxProperties(bpy.types.PropertyGroup):
max=128,
)
# リファレンステキストストリップ
reference_text_strip: StringProperty(
name="Reference Text Strip",
description="設定を流用するリファレンステキストストリップの名前",
default="",
)
# 出力設定
output_directory: StringProperty(
name="Output Directory",

View File

@ -18,6 +18,7 @@ class SubtitleManager:
font_size: int = 48,
position_y: float = 0.1,
channel: int = 2,
**extra_settings,
):
"""
シーケンサーに字幕を追加
@ -50,44 +51,45 @@ class SubtitleManager:
# テキスト内容を設定
text_strip.text = text
# Blender 5.0で利用可能なプロパティのみ設定
try:
text_strip.font_size = font_size
except AttributeError:
print(f"[Subtitle] font_size プロパティは利用できません")
# デフォルト設定
default_settings = {
'use_shadow': True,
'shadow_color': (0.0, 0.0, 0.0, 0.8),
'color': (1.0, 1.0, 1.0, 1.0),
'blend_type': 'ALPHA_OVER',
'align_x': 'CENTER',
'align_y': 'BOTTOM',
}
try:
text_strip.use_shadow = True
text_strip.shadow_color = (0.0, 0.0, 0.0, 0.8)
except AttributeError:
print(f"[Subtitle] shadow プロパティは利用できません")
# デフォルト設定から開始し、extra_settingsで上書き
all_settings = default_settings.copy()
if extra_settings:
all_settings.update(extra_settings)
# font_sizeは常にパラメータの値を使用
all_settings['font_size'] = font_size
# 位置の処理
position_x = all_settings.pop('position_x', 0.5)
position_y_val = all_settings.pop('position_y', position_y)
# 全てのプロパティを設定
for prop, value in all_settings.items():
try:
setattr(text_strip, prop, value)
print(f"[Subtitle] 設定: {prop} = {value}")
except AttributeError:
print(f"[Subtitle] {prop} プロパティは利用できません(スキップ)")
except Exception as e:
print(f"[Subtitle] {prop} の設定に失敗: {e}")
# 位置を設定
try:
text_strip.location[0] = 0.5 # 水平中央
text_strip.location[1] = position_y # 垂直位置
except (AttributeError, TypeError):
print(f"[Subtitle] location プロパティは利用できません")
# テキストの配置Blender 5.0では変更された可能性あり)
try:
text_strip.align_x = 'CENTER'
text_strip.align_y = 'BOTTOM'
except AttributeError:
# Blender 5.0では align_x/align_y が削除または変更された
print(f"[Subtitle] align_x/align_y プロパティは利用できません")
# テキストの色(白)
try:
text_strip.color = (1.0, 1.0, 1.0, 1.0)
except AttributeError:
print(f"[Subtitle] color プロパティは利用できません")
# ブレンドモード
try:
text_strip.blend_type = 'ALPHA_OVER'
except AttributeError:
print(f"[Subtitle] blend_type プロパティは利用できません")
text_strip.location[0] = position_x
text_strip.location[1] = position_y_val
print(f"[Subtitle] location = ({position_x}, {position_y_val})")
except (AttributeError, TypeError) as e:
print(f"[Subtitle] location プロパティは利用できません: {e}")
print(f"Subtitle added: '{text}' at frame {frame_start}")

View File

@ -3,12 +3,12 @@ VoiceVox API連携
VoiceVoxエンジンとの通信を担当
"""
import os
import json
import hashlib
from typing import Optional
import urllib.request
import json
import os
import urllib.error
import urllib.request
from typing import Optional
class VoiceVoxAPI:
@ -34,9 +34,16 @@ class VoiceVoxAPI:
url = f"{self.base_url}/speakers"
with urllib.request.urlopen(url, timeout=10) as response:
data = response.read()
return json.loads(data)
# UTF-8として明示的にデコード
text = data.decode("utf-8")
speakers = json.loads(text)
print(f"[VoiceVox API] 話者を {len(speakers)} 人取得")
return speakers
except Exception as e:
print(f"Failed to get speakers: {e}")
import traceback
traceback.print_exc()
return None
def generate_speech(
@ -62,45 +69,51 @@ class VoiceVoxAPI:
# 1. 音声合成用のクエリを作成
print(f"[VoiceVox API] Step 2: 音声クエリを生成中...")
query_url = f"{self.base_url}/audio_query"
query_params = urllib.parse.urlencode({
'text': text,
'speaker': speaker_id,
})
query_params = urllib.parse.urlencode(
{
"text": text,
"speaker": speaker_id,
}
)
full_query_url = f"{query_url}?{query_params}"
print(f"[VoiceVox API] リクエストURL: {full_query_url}")
try:
# POSTリクエストにするため空のdataを渡す
req = urllib.request.Request(full_query_url, data=b'', method='POST')
req = urllib.request.Request(full_query_url, data=b"", method="POST")
with urllib.request.urlopen(req, timeout=10) as response:
query_data = json.loads(response.read())
print(f"[VoiceVox API] クエリ生成成功")
except urllib.error.HTTPError as e:
print(f"[VoiceVox API Error] HTTPエラー: {e.code} {e.reason}")
print(f"[VoiceVox API Error] レスポンス: {e.read().decode('utf-8', errors='replace')}")
print(
f"[VoiceVox API Error] レスポンス: {e.read().decode('utf-8', errors='replace')}"
)
raise
except urllib.error.URLError as e:
print(f"[VoiceVox API Error] 接続エラー: {e.reason}")
print(f"[VoiceVox API Error] VoiceVoxエンジンが起動しているか確認してください")
raise
print(
f"[VoiceVox API Error] VoiceVoxエンジンが起動しているか確認してください"
)
raisee
# パラメータを適用
query_data['speedScale'] = speed_scale
query_data['pitchScale'] = pitch_scale
query_data['intonationScale'] = intonation_scale
query_data['volumeScale'] = volume_scale
query_data["speedScale"] = speed_scale
query_data["pitchScale"] = pitch_scale
query_data["intonationScale"] = intonation_scale
query_data["volumeScale"] = volume_scale
# 2. 音声合成を実行
print(f"[VoiceVox API] Step 3: 音声合成を実行中...")
synthesis_url = f"{self.base_url}/synthesis"
synthesis_params = urllib.parse.urlencode({'speaker': speaker_id})
synthesis_params = urllib.parse.urlencode({"speaker": speaker_id})
req = urllib.request.Request(
f"{synthesis_url}?{synthesis_params}",
data=json.dumps(query_data).encode('utf-8'),
headers={'Content-Type': 'application/json'},
method='POST',
data=json.dumps(query_data).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
try:
@ -109,27 +122,32 @@ class VoiceVoxAPI:
print(f"[VoiceVox API] 音声合成成功 ({len(audio_data)} bytes)")
except urllib.error.HTTPError as e:
print(f"[VoiceVox API Error] 合成HTTPエラー: {e.code} {e.reason}")
print(f"[VoiceVox API Error] レスポンス: {e.read().decode('utf-8', errors='replace')}")
print(
f"[VoiceVox API Error] レスポンス: {e.read().decode('utf-8', errors='replace')}"
)
raise
# 3. 音声ファイルを保存
print(f"[VoiceVox API] Step 4: 音声ファイルを保存中...")
text_hash = hashlib.md5(text.encode('utf-8')).hexdigest()[:8]
text_hash = hashlib.md5(text.encode("utf-8")).hexdigest()[:8]
filename = f"voicevox_{text_hash}_{speaker_id}.wav"
filepath = os.path.join(output_dir, filename)
with open(filepath, 'wb') as f:
with open(filepath, "wb") as f:
f.write(audio_data)
print(f"[VoiceVox API] 音声保存完了: {filepath}")
return filepath
except urllib.error.URLError as e:
error_msg = f"接続エラー: VoiceVoxエンジンに接続できません ({self.base_url})"
error_msg = (
f"接続エラー: VoiceVoxエンジンに接続できません ({self.base_url})"
)
print(f"[VoiceVox API Error] {error_msg}")
print(f"[VoiceVox API Error] 詳細: {e}")
self.last_error = error_msg
import traceback
traceback.print_exc()
return None
except urllib.error.HTTPError as e:
@ -137,6 +155,7 @@ class VoiceVoxAPI:
print(f"[VoiceVox API Error] {error_msg}")
self.last_error = error_msg
import traceback
traceback.print_exc()
return None
except Exception as e:
@ -144,5 +163,6 @@ class VoiceVoxAPI:
print(f"[VoiceVox API Error] 音声生成失敗: {error_msg}")
self.last_error = error_msg
import traceback
traceback.print_exc()
return None