■ 要約
■ 前提
以前、ずんだもん動画を制作した。
その際に気になったのが、立ち絵設定作業の工数である。
コピー&ペーストを駆使しても、
適切な表情が連続しているとは限らない。
とはいえ、1キャラクターあたり 10パターン程度あれば十分だ。
これは何とかしたい。
いや、何とかしないと動画1本あたりのコストが大きすぎる。
興味本位もあり、この作業に着手した。
■ AS-IS:現状の制作手順
最初にずんだもん動画を制作した際の手順は以下の通り。
- 台本作成
- VOICEVOXを使用して音声を出力
- YMM4を起動して 2 の音声をインポート
- 音声に合わせて立ち絵を作成
- 動画として出力
- AviUtlに 5 をインポート
- 字幕を制作
- 完成動画として出力
課題点
特に 4. 立ち絵作成 の工数が多い。
インポートした音声に合わせて
- 立ち絵パーツを選択
- 表示幅を調整
という作業を繰り返す必要があり、手数が多い。
- 0から設定する場合:約 50秒
- 継続作業中の場合:約 5〜20秒
■ TO-BE:新規の制作手順
今回、立ち絵設定の自動化を実装した後の手順。
- 台本作成
- 台本をもとにプロジェクトファイルを生成
- 生成したプロジェクトファイルをYMM4で起動
- 動画として出力
- AviUtlに動画と字幕をインポート
- 完成動画として出力
改善点
- 台本作成段階で立ち絵を指定可能
- 台本をもとに YMM4プロジェクトファイルを生成するスクリプト を作成
- YMM4上での手作業がほぼ不要になった
なお、
現時点では 謎のボイスアイテムが生成されるバグ が残っており、
その解消には至っていない。
■ 実装コンセプト
過去に以下のようなことを行っていた。
- 音声認識した文字情報を AviUtl に取り込むため
.exoファイルを生成 -
.exoファイルを.yttファイルに変換
今回の実装は、その延長線上の発想である。
- Excelに記載した情報を読み込み
- YMM4のプロジェクトファイル形式に変換する
ただし、
YMM4のプロジェクトファイル形式は AviUtl よりも複雑で、 実装はなかなか大変だった。
■ 使用例
1. 台本を作成
2. スクリプトを起動
スクリプト内で以下を実施。
3. YMM4でプロジェクトファイルをインポート
以下の状態から作業を開始できる。
配置ルールは次の2点。
- 立ち絵は、切り替え指示があるまで同一
- セリフは、直前のボイスアイテムに連続して配置
■ スクリプト(の一部)
TACHIE_PROTO: Dict[str, Any] = {
"$type": "YukkuriMovieMaker.Project.Items.TachieItem, YukkuriMovieMaker",
"CharacterName": "四国めたん",
"TachieItemParameter": {
"$type": "YukkuriMovieMaker.Plugin.Tachie.Psd.PsdTachieItemParameter, YukkuriMovieMaker.Plugin.Tachie.Psd",
"FilePath": None,
"EnableLayers": [],
"IsHiddenWhenNoSpeech": False
},
"X": {
"Values": [{"Value": -597.0}],
"Span": 0.0,
"AnimationType": "なし",
"Bezier": {
"Points": [
{"Point": {"X": 0.0, "Y": 0.0}, "ControlPoint1": {"X": -0.3, "Y": -0.3}, "ControlPoint2": {"X": 0.3, "Y": 0.3}},
{"Point": {"X": 1.0, "Y": 1.0}, "ControlPoint1": {"X": -0.3, "Y": -0.3}, "ControlPoint2": {"X": 0.3, "Y": 0.3}},
],
"IsQuadratic": False
}
},
"Y": {
"Values": [{"Value": 185.0}],
"Span": 0.0,
"AnimationType": "なし",
"Bezier": {
"Points": [
{"Point": {"X": 0.0, "Y": 0.0}, "ControlPoint1": {"X": -0.3, "Y": -0.3}, "ControlPoint2": {"X": 0.3, "Y": 0.3}},
{"Point": {"X": 1.0, "Y": 1.0}, "ControlPoint1": {"X": -0.3, "Y": -0.3}, "ControlPoint2": {"X": 0.3, "Y": 0.3}},
],
"IsQuadratic": False
}
},
"Z": {
"Values": [{"Value": 0.0}],
"Span": 0.0,
"AnimationType": "なし",
"Bezier": {
"Points": [
{"Point": {"X": 0.0, "Y": 0.0}, "ControlPoint1": {"X": -0.3, "Y": -0.3}, "ControlPoint2": {"X": 0.3, "Y": 0.3}},
{"Point": {"X": 1.0, "Y": 1.0}, "ControlPoint1": {"X": -0.3, "Y": -0.3}, "ControlPoint2": {"X": 0.3, "Y": 0.3}},
],
"IsQuadratic": False
}
},
"Opacity": {
"Values": [{"Value": 100.0}],
"Span": 0.0,
"AnimationType": "なし",
"Bezier": {
"Points": [
{"Point": {"X": 0.0, "Y": 0.0}, "ControlPoint1": {"X": -0.3, "Y": -0.3}, "ControlPoint2": {"X": 0.3, "Y": 0.3}},
{"Point": {"X": 1.0, "Y": 1.0}, "ControlPoint1": {"X": -0.3, "Y": -0.3}, "ControlPoint2": {"X": 0.3, "Y": 0.3}},
],
"IsQuadratic": False
}
},
"Zoom": {
"Values": [{"Value": 66.4}],
"Span": 0.0,
"AnimationType": "なし",
"Bezier": {
"Points": [
{"Point": {"X": 0.0, "Y": 0.0}, "ControlPoint1": {"X": -0.3, "Y": -0.3}, "ControlPoint2": {"X": 0.3, "Y": 0.3}},
{"Point": {"X": 1.0, "Y": 1.0}, "ControlPoint1": {"X": -0.3, "Y": -0.3}, "ControlPoint2": {"X": 0.3, "Y": 0.3}},
],
"IsQuadratic": False
}
},
"Rotation": {
"Values": [{"Value": 0.0}],
"Span": 0.0,
"AnimationType": "なし",
"Bezier": {
"Points": [
{"Point": {"X": 0.0, "Y": 0.0}, "ControlPoint1": {"X": -0.3, "Y": -0.3}, "ControlPoint2": {"X": 0.3, "Y": 0.3}},
{"Point": {"X": 1.0, "Y": 1.0}, "ControlPoint1": {"X": -0.3, "Y": -0.3}, "ControlPoint2": {"X": 0.3, "Y": 0.3}},
],
"IsQuadratic": False
}
},
"FadeIn": 0.0,
"FadeOut": 0.0,
"Blend": "Normal",
"IsInverted": True, # ←左右反転はココ!
"IsClippingWithObjectAbove": False,
"IsAlwaysOnTop": False,
"IsZOrderEnabled": False,
"VideoEffects": [],
"Group": 0,
"Frame": 0,
"Layer": 0,
"KeyFrames": {"Frames": [], "Count": 0},
"Length": 300,
"PlaybackRate": 100.0,
"ContentOffset": "00:00:00",
"Remark": "",
"IsLocked": False,
"IsHidden": False
}
VOICE_PROTO = {
"$type": "YukkuriMovieMaker.Project.Items.VoiceItem, YukkuriMovieMaker",
"CharacterName": "",
"Serif": "",
"Hatsuon": None,
"Volume": {
"Values": [{"Value": 50.0}],
"Span": 0.0,
"AnimationType": "なし",
"Bezier": {"Points": [], "IsQuadratic": False},
},
"Pan": {
"Values": [{"Value": 0.0}],
"Span": 0.0,
"AnimationType": "なし",
"Bezier": {"Points": [], "IsQuadratic": False},
},
"PlaybackRate": 100.0,
"VoiceParameter": {
"$type": "YukkuriMovieMaker.Voice.VOICEVOXVoiceParameter, YukkuriMovieMaker",
"StyleID": 0,
"Speed": 100,
"Pitch": 100.0,
"Intonation": 100.0,
"PrePhonemeLength": 0.1,
"PostPhonemeLength": 0.1,
"PauseLengthScale": 100.0,
"MorphingStyleID": -1,
"MorphingRate": 50.0,
},
"Frame": 0,
"Length": 0,
"Layer": 1,
"Group": 0,
"KeyFrames": {"Frames": [], "Count": 0},
"Remark": "",
"IsLocked": False,
"IsHidden": False,
}
def select_folder_dialog(default_dir: str | Path | None = None, title: str = "フォルダを選択") -> str:
"""
フォルダ選択ダイアログを表示して、選択されたフォルダパスを返す。
- default_dir: 初期表示するフォルダ(未指定ならカレント)
- title: ダイアログタイトル
戻り値:
選択されたフォルダの絶対パス(キャンセル時は例外)
"""
initdir = str(Path(default_dir).expanduser().resolve()) if default_dir else None
root = Tk()
root.withdraw() # 余計なウィンドウを出さない
root.attributes("-topmost", True) # ダイアログを前面に出す
selected = filedialog.askdirectory(
initialdir=initdir,
title=title,
mustexist=True,
)
root.destroy()
if not selected:
raise RuntimeError("フォルダ選択がキャンセルされました。")
return str(Path(selected).resolve())
# キャラクター名とスタイルを指定することで動的に変化するスタイルIDを取得
def get_style_id(character, style):
speakers = requests.get("http://127.0.0.1:50021/speakers").json()
for sp in speakers:
if sp["name"] == character:
for st in sp["styles"]:
if st["name"] == style:
return st["id"]
raise ValueError("style not found")
# パラメータを指定して音声ファイルを出力
def synthesize_voice(
text: str,
speaker: int = 1,
filename: str = "output.wav",
*,
speed: float = 1.00, # 話速
pitch: float = 0.00, # 音高
intonation: float = 1.00, # 抑揚
volume: float = 1.00, # 音量
pause: float = 1.00, # 間の長さ
pre_silence: float = 0.10, # 開始無音
post_silence: float = 0.10 # 終了無音
):
# 1) audio_query
r = requests.post(f"{BASE}/audio_query", params={"text": text, "speaker": speaker})
r.raise_for_status()
query = r.json()
# 2) パラメータを書き換え(ここが肝!)
query["speedScale"] = float(speed)
query["pitchScale"] = float(pitch)
query["intonationScale"] = float(intonation)
query["volumeScale"] = float(volume)
query["pauseLengthScale"] = float(pause)
query["prePhonemeLength"] = float(pre_silence)
query["postPhonemeLength"] = float(post_silence)
# 3) synthesis
r2 = requests.post(f"{BASE}/synthesis", params={"speaker": speaker}, json=query)
r2.raise_for_status()
with open(filename, "wb") as f:
f.write(r2.content)
print(f"✅ Saved: {filename} (speaker={speaker})")
def get_voice_params_from_serif_master(
serif_master,
chara_info: str,
voice_info,
*,
defaults=None,
strict: bool = True,
):
"""
セリフマスタ(serif_master)から、(キャラクター, 話法ID)に一致する行を探し
synthesize_voice に渡すパラメータ(dict)を返す。
serif_master: list[dict] もしくは pandas.DataFrame から to_dict('records') したもの想定
voice_info: Excel由来で "2" や 2.0 などブレるので文字列比較に寄せる
defaults: 見つからない/欠損時のデフォルト(Noneなら標準デフォルト)
strict: Trueなら見つからない時に例外、Falseなら defaults を返す
"""
# デフォルト値(あなたの今の設定をそのまま標準に)
base_defaults = {
"speed": 1.00,
"pitch": -0.06,
"intonation": 1.10,
"volume": 1.00,
"pause": 1.00,
"pre_silence": 0.10,
"post_silence": 0.10,
}
if defaults:
base_defaults.update(defaults)
def _norm(x):
# 2, 2.0, "2" を全部 "2" に寄せる
if x is None:
return ""
s = str(x).strip()
if s.endswith(".0"):
s = s[:-2]
return s
target_chara = str(chara_info).strip()
target_voice = _norm(voice_info)
# まず一致行を探す
matched = None
for r in serif_master:
# セリフマスタ側のキー名はここで吸収(列名が違うならここだけ直せばOK)
r_chara = r["キャラクター"]
r_voice = r["パターンID"]
if r_chara == target_chara and r_voice == target_voice:
matched = r
break
if matched is None:
if strict:
raise ValueError(f"セリフマスタに一致行なし: キャラクター={target_chara}, 話法ID={target_voice}")
return dict(base_defaults)
# マスタ行から値を取り出し(列名はここで吸収)
def _get_float(keys, default):
for k in keys:
if k in matched and matched[k] not in (None, ""):
return float(matched[k])
return float(default)
params = {
"speed": _get_float(["speed", "話速", "Speed"], base_defaults["speed"]),
"pitch": _get_float(["pitch", "声高", "Pitch"], base_defaults["pitch"]),
"intonation": _get_float(["intonation", "抑揚", "Intonation"], base_defaults["intonation"]),
"volume": _get_float(["volume", "音量", "Volume"], base_defaults["volume"]),
"pause": _get_float(["pause", "間", "間の長さ", "Pause"], base_defaults["pause"]),
"pre_silence": _get_float(["pre_silence", "開始無音", "PrePhonemeLength"], base_defaults["pre_silence"]),
"post_silence": _get_float(["post_silence", "終了無音", "PostPhonemeLength"], base_defaults["post_silence"]),
}
return params
def create_voicefile_from_parameter(Edit_Parameters, out_dir):
# .wavファイルの出力先ディレクトリを指定
folder = out_dir
print("Selected:", folder)
# 出力対象のテキスト情報を取得
script_parameter = Edit_Parameters.selected_script_sheet
# セリフマスタ
serif_parameter = Edit_Parameters.serif_master
if not script_parameter:
raise ValueError("serif_table is empty in Edit_Parameters")
# シート情報のループ開始
for i, row in enumerate(script_parameter, start=1):
# ---- Excel側の想定キー(必要ならここをExcelに合わせて直す)----
# serif_id = row.get("serif_id", i)
# character = row["character"] # 例: "四国めたん"
# style = row.get("style", "ノーマル") # 例: "あまあま"
# text = row["text"] # 読み上げ文字
if row["No"] is not None:
text_info = row["セリフ"]
chara_info = row["キャラクター"]
style_info = row["スタイル"]
file_info = row["音声ファイル名"]
voice_info = row["話法ID"]
text = text_info[1:]
voice_params = get_voice_params_from_serif_master(
serif_parameter,
chara_info=chara_info,
voice_info=voice_info,
strict=True, # 見つからない場合に落とす(運用によりFalseでもOK)
)
style_id = get_style_id(chara_info, style_info)
synthesize_voice(
text,
speaker=style_id,
filename=os.path.join(folder, file_info),
**voice_params
# speed=1.00, # 話速
# pitch=-0.06, # 声高
# intonation=1.10, # 抑揚
# volume=1.00, # 音量
# pause=1.00, # 間の長さ
# pre_silence=0.10, # 開始無音
# post_silence=0.10, # 終了無音
)
# ============================================== 音声出力 ==========================================================
# ============================================== 音声時間情報出力 ==========================================================
def get_time_list(Edit_Parameters, out_dir, fps=30):
"""
script_sheet の「音声ファイル名」を元に wav を読み、
再生時間・フレーム数を取得して返す
Returns:
time_list: list[dict]
"""
script_sheet = Edit_Parameters.selected_script_sheet
if not script_sheet:
raise ValueError("selected_script_sheet が空です")
out_dir = Path(out_dir)
if not out_dir.exists():
raise FileNotFoundError(out_dir)
time_list = []
current_frame = 0 # ymmp用:積み上げ開始フレーム
for i, row in enumerate(script_sheet, start=1):
if row["No"] is not None:
wav_name = row["音声ファイル名"]
wav_path = out_dir / wav_name
if not wav_path.exists():
raise FileNotFoundError(f"WAVが見つかりません: {wav_path}")
# ---- WAVの長さ取得 ----
with wave.open(str(wav_path), "rb") as wf:
frame_count = wf.getnframes()
sample_rate = wf.getframerate()
duration_sec = frame_count / float(sample_rate)
# ---- ymmp用フレーム換算 ----
length_frame = math.ceil(duration_sec * fps)
time_info = {
"index": i,
"wav_file": wav_name,
"wav_path": str(wav_path),
"duration_sec": duration_sec,
"length_frame": length_frame,
"start_frame": current_frame,
"end_frame": current_frame + length_frame,
}
time_list.append(time_info)
# 次の開始位置へ
current_frame += length_frame
return time_list
# ============================================== 音声時間情報出力 ==========================================================
# ============================================== ファイル編集 ==========================================================
def load_ymmp(path: Path) -> dict:
# BOMありUTF-8にも対応
text = path.read_text(encoding="utf-8-sig")
return json.loads(text)
def save_ymmp(data: dict, path: Path) -> None:
# 文字化け/差分管理しやすいように整形
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
def find_first_item(items, type_contains: str):
"""
items: list[dict]
type_contains: 例 "Project.Items.TachieItem" や "VoiceItem"
"""
for it in items:
t = it.get("$type", "")
if type_contains in t:
return it
raise ValueError(f"prototype not found (contains): {type_contains}")
def add_tachie_item(data: dict, *, timeline_index: int, proto: dict,
character: str, psd_path: str, enable_layers: list[str],
x: float, y: float, zoom: float, inverted: bool,
frame: int, layer: int, length: int) -> None:
items = data["Timelines"][timeline_index]["Items"]
it = copy.deepcopy(proto)
it["CharacterName"] = character
it["TachieItemParameter"]["FilePath"] = psd_path
it["TachieItemParameter"]["EnableLayers"] = enable_layers
it["X"]["Values"][0]["Value"] = float(x)
it["Y"]["Values"][0]["Value"] = float(y)
it["Zoom"]["Values"][0]["Value"] = float(zoom)
it["IsInverted"] = bool(inverted)
it["Frame"] = int(frame)
it["Layer"] = int(layer)
it["Length"] = int(length)
items.append(it)
def add_voice_item(data: dict, *, timeline_index: int, proto: dict,
character: str, serif: str, wav_path: str,
voice_length_str: str,
style_id: int, speed: int, pitch: float, intonation: float,
pre: float, post: float, pause_scale: float,
frame: int, layer: int, length: int) -> None:
items = data["Timelines"][timeline_index]["Items"]
it = copy.deepcopy(proto)
it["CharacterName"] = character
it["Serif"] = serif
it["Hatsuon"] = wav_path
it["VoiceLength"] = voice_length_str
vp = it["VoiceParameter"]
vp["StyleID"] = int(style_id)
vp["Speed"] = int(speed)
vp["Pitch"] = float(pitch)
vp["Intonation"] = float(intonation)
vp["PrePhonemeLength"] = float(pre)
vp["PostPhonemeLength"] = float(post)
vp["PauseLengthScale"] = float(pause_scale)
it["Frame"] = int(frame)
it["Layer"] = int(layer)
it["Length"] = int(length)
items.append(it)
def build_voice_item(
*,
character_name: str,
serif: str,
wav_path: str,
style_id: int,
frame: int,
length: int,
layer: int,
voice_length_str: str, # 例: "00:00:00.8853333"
) -> Dict[str, Any]:
"""VoiceItemをテンプレから生成して返す(完全版)"""
item = deepcopy(VOICE_PROTO)
item["CharacterName"] = character_name
item["Serif"] = serif
item["Hatsuon"] = wav_path
item["VoiceLength"] = voice_length_str
item["Frame"] = int(frame)
item["Length"] = int(length) # ★ここ絶対int
item["Layer"] = int(layer)
# VOICEVOX系パラメータ(YMM4で“再生成”する用途があるなら意味がある)
item["VoiceParameter"]["StyleID"] = int(style_id)
return item
def build_tachie_item(
*,
character_name: str,
psd_path: str,
enable_layers: List[str],
frame: int,
length: int,
layer: int,
x: float,
y: float,
zoom: float,
inverted: bool,
) -> Dict[str, Any]:
"""TachieItemをテンプレから生成して返す"""
item = deepcopy(TACHIE_PROTO)
item["CharacterName"] = character_name
item["TachieItemParameter"]["FilePath"] = psd_path
item["TachieItemParameter"]["EnableLayers"] = list(enable_layers)
item["Frame"] = int(frame)
item["Length"] = int(length)
item["Layer"] = int(layer)
item["X"]["Values"][0]["Value"] = float(x)
item["Y"]["Values"][0]["Value"] = float(y)
item["Zoom"]["Values"][0]["Value"] = float(zoom)
item["IsInverted"] = bool(inverted)
return item
def get_layers_resolver(
decor_id: str,
parts_master, # pandas.DataFrame 想定
character: Optional[str] = None,
*,
char_col: str = "キャラクター",
serial_col: str = "連番",
) -> List[str]:
"""
parts_master の「decor_id列」が X の行の「連番」を EnableLayers 用に返す。
decor_id は列名(例:めたん解説通常)
"""
# 0) 前提チェック
if not isinstance(parts_master, list) or not parts_master:
raise ValueError("parts_master が空です(list[dict] を想定)")
# 1) ヘッダ(キー)確認:最初の行のキーを採用(必要なら全行チェックにしてもOK)
keys = set(parts_master[0].keys())
if char_col not in keys:
raise ValueError(f"parts_masterに '{char_col}' 列(キー)がありません。")
if serial_col not in keys:
raise ValueError(f"parts_masterに '{serial_col}' 列(キー)がありません。")
if decor_id not in keys:
raise ValueError(f"parts_masterに '{decor_id}' 列(キー)がありません。")
# 2) キャラクターで絞る(指定があれば)
rows = parts_master
if character is not None:
character_s = str(character).strip()
rows = [
r for r in rows
if str(r.get(char_col, "")).strip() == character_s
]
# 3) decor_id列が "X" の行を抽出
hit = [
r for r in rows
if str(r.get(decor_id, "")).strip().upper() == "X"
]
if not hit:
raise ValueError(
f"decor_id='{decor_id}'(character={character})で X が付いた行が0件です。"
)
# 4) 連番を取り出す(そのまま n### を返す)
layers = [str(r.get(serial_col, "")).strip() for r in hit]
layers = [v for v in layers if v] # 空文字除外
# 念のため n で始まる形に統一(n1 / 1 など混在しても吸収)
layers = [v if v.startswith("n") else f"n{v}" for v in layers]
return layers
def get_length_fram(time_info, character_name, i):
# 現在のインデックス以降で同じキャラクター名が出てくるインデックスを取得
targetIndex = [ind["index"] for idx, ind in enumerate(time_info) if character_name in ind["wav_file"] and idx > i]
long_frame = 0
if len(targetIndex) > 0:
for j, tinfo in enumerate(time_info, start=i):
long_frame+=time_info[j]["length_frame"]
if j >= targetIndex[0]-2:
break
else:
long_frame+=time_info[i]["length_frame"]
return long_frame
# 立ち絵オブジェクト用のフレーム取得
def get_length_tachie(serifs, decor_id, time_info, frame, voice_frame_list, character_name, i):
# 現在のインデックス以降で同じキャラクター名が出てくるインデックスを取得
targetIndex = [ind["No"] for idx, ind in enumerate(serifs) if character_name in ind["キャラクター"] and idx > i]
next_idx = 0
next_idx_retrive = 0
for e, serif in enumerate(serifs, start=i):
if len(serifs) > e:
for t in targetIndex:
if serifs[e]["No"] == t:
decor_id_next = serifs[e]["装飾ID"]
if decor_id != decor_id_next:
next_idx = t
break
if next_idx != 0:
break
if next_idx != 0:
next_idx_retrive = 0
for e,serif in enumerate(serifs, start=0):
if serifs[e]["キャラクター"]==character_name:
if serifs[e]["No"] == t:
break
next_idx_retrive += 1
if next_idx_retrive != 0:
for v in range(len(voice_frame_list)):
if voice_frame_list[v][0] == character_name:
times = voice_frame_list[v][1]
break
# long_frame = times[next_idx_retrive][0] - times[next_idx_retrive-1][0]
long_frame = times[next_idx_retrive][0] - frame
# 後続の立ち絵オブジェクトがない場合、最終点から逆算して最後まで維持される立ち絵にする
if next_idx_retrive == 0:
# long_frame = time_info[i]["length_frame"]
long_frame = sum(info["length_frame"] for info in time_info) - frame
return long_frame
# 立ち絵オブジェクトの作成
def append_tachie_items_from_excel(
items: List[Dict[str, Any]],
serifs: List[Dict[str, Any]],
illust_master,
parts_master,
time_info,
voice_frame_list
):
current_frame_list = []
for i, row in enumerate(serifs, start=0):
if row["No"] is not None:
character_name = row["キャラクター"]
if not character_name in current_frame_list:
current_frame_list.append(character_name)
for i, c_list in enumerate(current_frame_list, start=0):
current_frame_list[i] = [current_frame_list[i],0]
for i, row in enumerate(serifs, start=0):
if row["No"] is not None:
character_name = row["キャラクター"]
decor_id = row["装飾ID"]
frame = [c_frame[1] for c_frame in current_frame_list if c_frame[0] == character_name]
long_frame = get_length_tachie(serifs, decor_id, time_info, frame[0], voice_frame_list, character_name, i)
length = long_frame
illust_info = [im for im in illust_master if im["キャラクター"] == character_name]
layer = illust_info[0]["立ち絵レイヤ"]
# 立ち絵設定がなければ立ち絵オブジェクトを作成しない
if layer == "-":
continue
x = illust_info[0]["立ち絵X"]
y = illust_info[0]["立ち絵Y"]
zoom = illust_info[0]["拡大率"]
inverted = illust_info[0]["左右反転"]
if character_name == "ずんだもん":
psd_path = ZUNDA_ILLUST
elif character_name == "四国めたん":
psd_path = METAN_ILLUST
print(i)
enable_layers = get_layers_resolver(decor_id, parts_master, character=character_name)
tachie = build_tachie_item(
character_name=character_name,
psd_path=psd_path,
enable_layers=enable_layers,
frame=frame[0],
length=length,
layer=layer,
x=x,
y=y,
zoom=zoom,
inverted=inverted,
)
for c, c_list in enumerate(current_frame_list, start=0):
if current_frame_list[c][0] == character_name:
current_frame_list[c][1] += length
break
items.append(tachie)
# ボイスオブジェクトの作成
def append_voice_items_from_excel(
items: List[Dict[str, Any]],
serifs: List[Dict[str, Any]],
illust_master: List[Dict[str, Any]],
time_info: List[Dict[str, Any]],
out_dir_wav: str
):
"""
serifs と time_info を元に VoiceItem を items に順番に追加する。
time_info[i] には length_frame がある前提。
serifs[i] には 音声ファイル名 / キャラクター / 話法ID(or style) 等がある想定。
out_dir_wav は wav 出力先フォルダ。
"""
current_frame = 0
voice_frame_list = []
for i, row in enumerate(serifs, start=0):
if row["No"] is not None:
character_name = row["キャラクター"]
if not character_name in voice_frame_list:
voice_frame_list.append(character_name)
for i, c_list in enumerate(voice_frame_list, start=0):
voice_frame_list[i] = [voice_frame_list[i],[]]
for i, row in enumerate(serifs, start=0):
if row["No"] is not None:
length = int(time_info[i]["length_frame"])
# frame = [c_frame[1] for c_frame in current_frame_list if c_frame[0] == character_name]
frame = current_frame
character_name = row["キャラクター"]
wav_name = row["音声ファイル名"] # 例: "001_四国めたん_....wav"
wav_path = rf"{out_dir_wav}\{wav_name}" # Windows想定(pathlibでもOK)
illust_info = [im for im in illust_master if im["キャラクター"] == character_name]
layer = illust_info[0]["ボイスレイヤ"]
voice = build_voice_item(
character_name=character_name,
serif=row["セリフ"],
wav_path=wav_path,
style_id=get_style_id(character_name, row["スタイル"]), # or get_style_id(...)
# frame=frame[0],
frame=frame,
length=length, # ★ int
layer=layer,
voice_length_str=time_info[i]["duration_sec"],
)
# 立ち絵オブジェクト用にタイムを記録
for c, c_list in enumerate(voice_frame_list, start=0):
if voice_frame_list[c][0] == character_name:
voice_frame_list[c][1].append([frame,length])
break
current_frame += length
items.append(voice)
return voice_frame_list
def create_ymmp_file(out_dir, time_info, Edit_Parameters, selected_script_sheet):
# タイムスタンプ取得
timestamp = (datetime.datetime.now()).strftime("%Y-%m-%d %H:%M:%S")
# テンプレート指定
src = Path(TEMPLETE_FILE)
# 出力ファイルパス指定
dst = Path(os.path.join(os.path.dirname(TEMPLETE_FILE),selected_script_sheet+".ymmp"))
# テンプレートファイルデータをロード
data = load_ymmp(src)
items0 = data["Timelines"][0]["Items"]
# tachie_proto = find_first_item(items0, "TachieItem, YukkuriMovieMaker")
# voice_proto = find_first_item(items0, "VoiceItem, YukkuriMovieMaker")
serifs = Edit_Parameters.selected_script_sheet
illust_master = Edit_Parameters.illustration_master
parts_master = Edit_Parameters.parts_master
# print("serif")
items = data["Timelines"][0]["Items"]
# ボイスオブジェクトの追加
voice_frame_list = append_voice_items_from_excel(
items,
serifs,
illust_master,
time_info,
out_dir_wav=out_dir,
)
# 立ち絵オブジェクトの追加
append_tachie_items_from_excel(
items,
serifs,
illust_master,
parts_master,
time_info,
voice_frame_list
)
save_ymmp(data, dst)
print("✅ saved:", dst)
# ============================================== ファイル編集 ==========================================================
# ============================================== エクセルファイルから情報をインポート ==========================================================
# =========================
# 1) UI(ファイル選択/シート選択)
# =========================
def choose_file(default_dir: str) -> Path:
"""
ファイル選択ダイアログを表示してExcelファイルを選ぶ。
"""
import tkinter as tk
from tkinter import filedialog
root = tk.Tk()
root.withdraw()
root.attributes("-topmost", True)
initialdir = default_dir if Path(default_dir).exists() else str(Path.cwd())
file_path = filedialog.askopenfilename(
title="Excelファイルを選択してください",
initialdir=initialdir,
filetypes=[
("Excel files", "*.xlsx *.xlsm *.xltx *.xltm"),
("All files", "*.*"),
],
)
root.destroy()
if not file_path:
raise RuntimeError("ファイルが選択されませんでした。")
return Path(file_path)
def choose_sheet_name(sheet_names: List[str], title: str = "シートを選択") -> str:
"""
シート一覧から1つ選択する簡易ダイアログ(Listbox)。
"""
import tkinter as tk
selected: Dict[str, Optional[str]] = {"name": None}
root = tk.Tk()
root.title(title)
root.geometry("420x320")
root.attributes("-topmost", True)
label = tk.Label(root, text="読み込む台本シートを選択してください")
label.pack(padx=10, pady=10)
listbox = tk.Listbox(root, selectmode=tk.SINGLE)
listbox.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
for name in sheet_names:
listbox.insert(tk.END, name)
# 先頭を選択状態に
if sheet_names:
listbox.selection_set(0)
def on_ok():
sel = listbox.curselection()
if not sel:
return
selected["name"] = listbox.get(sel[0])
root.destroy()
def on_cancel():
root.destroy()
btn_frame = tk.Frame(root)
btn_frame.pack(pady=10)
ok_btn = tk.Button(btn_frame, text="OK", width=12, command=on_ok)
ok_btn.pack(side=tk.LEFT, padx=10)
cancel_btn = tk.Button(btn_frame, text="キャンセル", width=12, command=on_cancel)
cancel_btn.pack(side=tk.LEFT, padx=10)
root.mainloop()
if not selected["name"]:
raise RuntimeError("シートが選択されませんでした。")
return selected["name"]
# =========================
# 2) Excelテーブル読込(1行目ヘッダ想定)
# =========================
def read_table_from_sheet(ws, header_row: int = 3) -> List[Dict[str, Any]]:
"""
指定シートを「1行目=ヘッダ」としてテーブル化して返す。
空行はスキップ。
"""
rows = list(ws.iter_rows(values_only=True))
if not rows or len(rows) < header_row:
return []
header = rows[header_row - 1]
if header is None:
return []
# ヘッダのNoneを空文字にしつつ、末尾の空カラムを削る
header_list = [("" if h is None else str(h).strip()) for h in header]
# 末尾の空を削る
while header_list and header_list[-1] == "":
header_list.pop()
data: List[Dict[str, Any]] = []
for r in rows[header_row:]:
if r is None:
continue
# 同じく末尾をヘッダ長に合わせる
values = list(r[: len(header_list)])
# 全部空ならスキップ
if all(v is None or (isinstance(v, str) and v.strip() == "") for v in values):
continue
record: Dict[str, Any] = {}
for k, v in zip(header_list, values):
if k == "":
# ヘッダが空の列は無視
continue
record[k] = v
data.append(record)
return data
# =========================
# 3) まとめて読込(あなたの要件)
# =========================
@dataclass
class ExcelImportedData:
excel_path: Path
selected_script_sheet: str
template_table: List[Dict[str, Any]]
illustration_master: List[Dict[str, Any]]
serif_master: List[Dict[str, Any]]
parts_master: List[Dict[str, Any]]
def import_from_excel(
default_dir: str,
*,
template_sheet_name: str = "テンプレート",
illustration_master_sheet_name: str = "イラストマスタ",
serif_master_sheet_name: str = "セリフマスタ",
parts_master_sheet_name: str = "パーツマスタ",
header_row: int = 3,
) -> ExcelImportedData:
"""
手順:
1. ファイル選択ダイアログ
2. シート選択ダイアログ(台本シートを選ぶ)
3. 4つのシートから表情報を取得
4. Excelを閉じる
"""
# 1) Excelファイル選択
excel_path = choose_file(default_dir)
# 2) Workbook読み込み
wb = openpyxl.load_workbook(excel_path, data_only=True)
try:
# 3) 台本シート選択(※名前固定ではなく「一覧から選択」)
sheet_names = wb.sheetnames
selected_script_sheet = choose_sheet_name(sheet_names, title="台本シート選択")
# 4) 必須シートの存在チェック
required = {
"template": template_sheet_name,
"illustration_master": illustration_master_sheet_name,
"serif_master": serif_master_sheet_name,
"parts_master": parts_master_sheet_name,
}
missing = [name for name in required.values() if name not in wb.sheetnames]
if missing:
raise KeyError(f"必須シートが見つかりません: {missing}")
# 5) テーブル読込
ws_template = wb[template_sheet_name]
ws_illu = wb[illustration_master_sheet_name]
ws_serif = wb[serif_master_sheet_name]
ws_parts = wb[parts_master_sheet_name]
ws_script_parameter = wb[selected_script_sheet]
template_table = read_table_from_sheet(ws_template, header_row=header_row)
illustration_master = read_table_from_sheet(ws_illu, header_row=header_row)
serif_master = read_table_from_sheet(ws_serif, header_row=header_row)
parts_master = read_table_from_sheet(ws_parts, header_row=header_row)
script_parameter = read_table_from_sheet(ws_script_parameter, header_row=header_row)
return ExcelImportedData(
excel_path=excel_path,
selected_script_sheet=script_parameter,
template_table=template_table,
illustration_master=illustration_master,
serif_master=serif_master,
parts_master=parts_master,
), selected_script_sheet
finally:
# 6) Excelを閉じる
wb.close()
if __name__ == "__main__":
# 1. エクセルファイルから情報をインポート
# 2. 音声ファイルを出力
# 3. 音声ファイルからタイム情報を取得
# 4. 「.ymmp」ファイルを作成
# 1. エクセルファイルから情報をインポート
Edit_Parameters, selected_script_sheet = import_from_excel(DEFAULT_DIR_EXCEL)
# 2. 音声ファイルを出力
out_dir = select_folder_dialog(DEFAULT_DIR_WAV, title="出力先フォルダを選択してください")
timestamp = time.strftime('%Y%m%d_%H%M%S') # ← コロンを使わない
out_dir = os.path.join(out_dir, timestamp)
os.makedirs(out_dir, exist_ok=True)
create_voicefile_from_parameter(Edit_Parameters, out_dir)
# 3. 音声ファイルからタイム情報を取得
time_info = get_time_list(Edit_Parameters, out_dir)
# 4. 「.ymmp」ファイルを作成
create_ymmp_file(out_dir, time_info, Edit_Parameters, selected_script_sheet)
■ 残りの課題
現状のスクリプトを使用すると、
原因不明のゴミオブジェクトが生成される。
これさえ解消できれば、
少なくとも 設定済みのずんだもん・めたん については
かなり楽に動画を量産できる見込みである。



