1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ずんだもん動画、立ち絵設定が、ツライ

1
Last updated at Posted at 2025-12-22

■ 要約

ずんだもん動画の立ち絵設定を自動化した。
image.png


■ 前提

以前、ずんだもん動画を制作した。
その際に気になったのが、立ち絵設定作業の工数である。

コピー&ペーストを駆使しても、
適切な表情が連続しているとは限らない。

とはいえ、1キャラクターあたり 10パターン程度あれば十分だ。

これは何とかしたい。
いや、何とかしないと動画1本あたりのコストが大きすぎる

興味本位もあり、この作業に着手した。


■ AS-IS:現状の制作手順

最初にずんだもん動画を制作した際の手順は以下の通り。

  1. 台本作成
  2. VOICEVOXを使用して音声を出力
  3. YMM4を起動して 2 の音声をインポート
  4. 音声に合わせて立ち絵を作成
  5. 動画として出力
  6. AviUtlに 5 をインポート
  7. 字幕を制作
  8. 完成動画として出力

課題点

特に 4. 立ち絵作成 の工数が多い。

インポートした音声に合わせて

  • 立ち絵パーツを選択
  • 表示幅を調整

という作業を繰り返す必要があり、手数が多い。

  • 0から設定する場合:約 50秒
  • 継続作業中の場合:約 5〜20秒

■ TO-BE:新規の制作手順

今回、立ち絵設定の自動化を実装した後の手順。

  1. 台本作成
  2. 台本をもとにプロジェクトファイルを生成
  3. 生成したプロジェクトファイルをYMM4で起動
  4. 動画として出力
  5. AviUtlに動画と字幕をインポート
  6. 完成動画として出力

改善点

  • 台本作成段階で立ち絵を指定可能
  • 台本をもとに YMM4プロジェクトファイルを生成するスクリプト を作成
  • YMM4上での手作業がほぼ不要になった

なお、
現時点では 謎のボイスアイテムが生成されるバグ が残っており、
その解消には至っていない。


■ 実装コンセプト

過去に以下のようなことを行っていた。

  • 音声認識した文字情報を AviUtl に取り込むため .exo ファイルを生成
  • .exo ファイルを .ytt ファイルに変換

今回の実装は、その延長線上の発想である。

  • Excelに記載した情報を読み込み
  • YMM4のプロジェクトファイル形式に変換する

ただし、
YMM4のプロジェクトファイル形式は AviUtl よりも複雑で、 実装はなかなか大変だった。


■ 使用例

1. 台本を作成

  • 立ち絵パターンは別シートで定義
  • 台本側ではその定義を参照して指定
    image.png

2. スクリプトを起動

スクリプト内で以下を実施。

  • VOICEVOXを通したボイスアイテムの作成
  • ボイスアイテムを用いたプロジェクトファイルの作成
    image.png

3. YMM4でプロジェクトファイルをインポート

以下の状態から作業を開始できる。

  • 立ち絵が配置済み
  • ボイスアイテムが配置済み
    image.png

配置ルールは次の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)


 

■ 残りの課題

現状のスクリプトを使用すると、
原因不明のゴミオブジェクトが生成される

これさえ解消できれば、
少なくとも 設定済みのずんだもん・めたん については
かなり楽に動画を量産できる見込みである。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?