// SDKレスビルド用の単一ファイル版(XAML不使用・UIはコード構築)。 // pwsh の Roslyn(Add-Type)で YMM4 同梱の .NET 10 アセンブリを参照してコンパイルする。 // 内容は Pack.cs / Importer.cs / Plugin.cs / View / ViewModel の合本。 using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; using System.Text.Json; using System.Windows; using System.Windows.Controls; using Microsoft.Win32; using YukkuriMovieMaker.Plugin; using YukkuriMovieMaker.Project; using YukkuriMovieMaker.Project.Items; namespace KakiniwaYmm4Import { // ---------- timeline.json DTO(スキーマ v1: docs/ymm4-bridge.md) ---------- public class Pack { public int Version { get; set; } public string Title { get; set; } = ""; public string Episode { get; set; } = ""; public PackTiming Timing { get; set; } = new PackTiming(); public List Characters { get; set; } = new List(); public List Events { get; set; } = new List(); } public class PackTiming { public double CharsPerSecond { get; set; } = 6; public double SerifGap { get; set; } = 0.2; public double StageSeconds { get; set; } = 2; } public class PackCharacter { public string Id { get; set; } = ""; public string Name { get; set; } = ""; public string? Ymm4Name { get; set; } public string? PortraitDir { get; set; } public List Expressions { get; set; } = new List(); } public class PackExpression { public string Id { get; set; } = ""; public string? Label { get; set; } public bool Default { get; set; } public string? File { get; set; } public PackPsd? Psd { get; set; } } public class PackPsd { public string Path { get; set; } = ""; public List Layers { get; set; } = new List(); } public class PackEvent { public int No { get; set; } public string Scene { get; set; } = ""; public string Type { get; set; } = ""; public double Start { get; set; } public double Seconds { get; set; } public string? Speaker { get; set; } public string? SpeakerName { get; set; } public string? Expression { get; set; } public string? Text { get; set; } public string? Reading { get; set; } public List Se { get; set; } = new List(); public PackAssetRef? Asset { get; set; } public string? Name { get; set; } public string? Arg { get; set; } /// type:"layout" のみ: 立ち位置の指定(以降のイベントに適用) public List? Positions { get; set; } } public class PackPosition { public string Speaker { get; set; } = ""; /// 画面中央からの横位置(画面幅比%・-50〜50・負=左) public double X { get; set; } /// 立ち絵の高さ(画面高さ比%)。null=既定値 public double? Height { get; set; } } public class PackAssetRef { public string Name { get; set; } = ""; public string? Path { get; set; } } // ---------- プラグイン本体 ---------- public class KakiniwaImportPlugin : IToolPlugin { public string Name { get { return "書き庭 台本インポート(スパイク)"; } } public Type ViewModelType { get { return typeof(ImportToolViewModel); } } public Type ViewType { get { return typeof(ImportToolView); } } } public class ImportToolViewModel : IToolViewModel { public string Title { get { return "書き庭 台本インポート"; } } public bool CanSuspend { get { return true; } } public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged { add { } remove { } } public event EventHandler? CreateNewToolViewRequested { add { } remove { } } public ToolState SaveState() { return new ToolState(); } public void LoadState(ToolState state) { } } // ---------- 設定の記憶(話者割り当て・立ち絵レイアウト) ---------- public class ImportSettings { /// パックのキャラid → 前回選んだYMM4キャラ名 public Dictionary SpeakerMap { get; set; } = new Dictionary(); /// 立ち絵の高さ(画面高さ比)。手編集可 public double PortraitHeightRatio { get; set; } = 0.85; /// 立ち絵の左右オフセット(画面幅比)。手編集可 public double PortraitXRatio { get; set; } = 0.25; /// 配置先の開始レイヤー(全アイテムをこの分だけ下へシフト) public int BaseLayer { get; set; } = 0; /// 開始位置: "zero"=先頭(0秒) / "cursor"=タイムラインのカーソル位置 public string StartAt { get; set; } = "zero"; } public static class SettingsStore { static string PathOf() { // プラグインDLLの場所(\user\plugin\KakiniwaYmm4Import)から user\setting へ var dll = Assembly.GetExecutingAssembly().Location; var userDir = Path.GetDirectoryName(Path.GetDirectoryName(Path.GetDirectoryName(dll)!)!)!; return Path.Combine(userDir, "setting", "kakiniwa-import.json"); } public static ImportSettings Load(Action? log) { try { var path = PathOf(); if (File.Exists(path)) { var s = JsonSerializer.Deserialize(File.ReadAllText(path), new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); if (s != null) return s; } } catch (Exception ex) { if (log != null) log("設定読み込み失敗: " + ex.Message); } return new ImportSettings(); } public static void Save(ImportSettings settings, Action log) { try { var path = PathOf(); Directory.CreateDirectory(Path.GetDirectoryName(path)!); File.WriteAllText(path, JsonSerializer.Serialize(settings, new JsonSerializerOptions { WriteIndented = true, Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, })); log("設定を保存: " + path); } catch (Exception ex) { log("設定保存失敗: " + ex.Message); } } } // ---------- View(コード構築・XAML不使用) ---------- /// 話者割り当ての選択肢(YMM4登録キャラ or 名前そのまま) public class CharacterChoice { public string Display = ""; public string Name = ""; // VoiceItem.CharacterName に入れる値 public object? Model; // YMM4 の Character モデル(取れた場合のみ) public override string ToString() { return Display; } } public class ImportToolView : UserControl { readonly TextBox logBox; readonly StackPanel mappingPanel; readonly Button placeButton; readonly TextBox baseLayerBox; readonly ComboBox startAtCombo; readonly List> combos = new List>(); readonly ImportSettings settings = SettingsStore.Load(null); Pack? pack; string packDir = ""; public ImportToolView() { MinWidth = 360; MinHeight = 300; var root = new DockPanel { Margin = new Thickness(8) }; var top = new StackPanel(); top.Children.Add(new TextBlock { TextWrapping = TextWrapping.Wrap, Text = "実験スパイク版。実行前にプロジェクトを保存してください(元に戻す動作は未確認)。", }); var selectButton = new Button { Content = "1. 受け渡しパックのフォルダを選ぶ", Margin = new Thickness(0, 8, 0, 4), Padding = new Thickness(10, 6, 10, 6), HorizontalAlignment = HorizontalAlignment.Left, }; selectButton.Click += OnSelectClick; top.Children.Add(selectButton); top.Children.Add(new TextBlock { Text = "2. 話者の割り当て(パックのキャラ → YMM4のキャラクター)", Margin = new Thickness(0, 4, 0, 2), }); mappingPanel = new StackPanel { Margin = new Thickness(12, 0, 0, 4) }; top.Children.Add(mappingPanel); var destRow = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 2, 0, 2), }; destRow.Children.Add(new TextBlock { Text = "配置先: 開始レイヤー ", VerticalAlignment = VerticalAlignment.Center, }); baseLayerBox = new TextBox { Width = 44, Text = settings.BaseLayer.ToString() }; destRow.Children.Add(baseLayerBox); destRow.Children.Add(new TextBlock { Text = " 開始位置 ", VerticalAlignment = VerticalAlignment.Center, }); startAtCombo = new ComboBox(); startAtCombo.Items.Add("先頭(0秒)"); startAtCombo.Items.Add("カーソル位置から"); startAtCombo.SelectedIndex = settings.StartAt == "cursor" ? 1 : 0; destRow.Children.Add(startAtCombo); top.Children.Add(destRow); placeButton = new Button { Content = "3. タイムラインへ配置", Margin = new Thickness(0, 4, 0, 8), Padding = new Thickness(10, 6, 10, 6), HorizontalAlignment = HorizontalAlignment.Left, IsEnabled = false, }; placeButton.Click += OnPlaceClick; top.Children.Add(placeButton); DockPanel.SetDock(top, Dock.Top); root.Children.Add(top); logBox = new TextBox { IsReadOnly = true, AcceptsReturn = true, TextWrapping = TextWrapping.Wrap, VerticalScrollBarVisibility = ScrollBarVisibility.Auto, }; root.Children.Add(logBox); Content = root; } void Log(string message) { logBox.AppendText(message + Environment.NewLine); logBox.ScrollToEnd(); } void OnSelectClick(object sender, RoutedEventArgs e) { var dialog = new OpenFolderDialog { Title = "書き庭の受け渡しパック(timeline.json のあるフォルダ)を選択", }; if (dialog.ShowDialog() != true) return; logBox.Clear(); try { packDir = dialog.FolderName; pack = Importer.LoadPack(packDir, Log); if (pack == null) return; BuildMappingRows(); } catch (Exception ex) { Log("例外: " + ex); } } void BuildMappingRows() { mappingPanel.Children.Clear(); combos.Clear(); if (pack == null) return; var options = Importer.GetYmm4Characters(Log); Log("YMM4登録キャラ: " + (options.Count == 0 ? "(取得できず)" : string.Join(", ", options.Select(o => o.Name)))); foreach (var c in pack.Characters) { var row = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 2, 0, 2) }; row.Children.Add(new TextBlock { Text = c.Name + " → ", VerticalAlignment = VerticalAlignment.Center, MinWidth = 90, }); var combo = new ComboBox { MinWidth = 200 }; var fallbackName = c.Ymm4Name ?? c.Name; combo.Items.Add(new CharacterChoice { Display = "(名前そのまま: " + fallbackName + ")", Name = fallbackName, Model = null, }); foreach (var opt in options) combo.Items.Add(opt); // 既定選択: ①前回保存した割り当て → ②ymm4Name(無ければ name)との完全一致 string? saved; settings.SpeakerMap.TryGetValue(c.Id, out saved); combo.SelectedIndex = 0; var found = false; if (saved != null) { for (var i = 1; i < combo.Items.Count; i++) { var cc = (CharacterChoice)combo.Items[i]!; if (cc.Name == saved) { combo.SelectedIndex = i; found = true; break; } } } if (!found) { for (var i = 1; i < combo.Items.Count; i++) { var cc = (CharacterChoice)combo.Items[i]!; if (cc.Name == fallbackName) { combo.SelectedIndex = i; break; } } } row.Children.Add(combo); var warn = new TextBlock { Text = " ⚠ 未割り当て(ボイスが付きません)", Foreground = System.Windows.Media.Brushes.OrangeRed, VerticalAlignment = VerticalAlignment.Center, }; var updateWarn = (Action)(() => { var sel = combo.SelectedItem as CharacterChoice; warn.Visibility = sel != null && sel.Model != null ? Visibility.Collapsed : Visibility.Visible; }); combo.SelectionChanged += (_, __) => updateWarn(); updateWarn(); row.Children.Add(warn); mappingPanel.Children.Add(row); combos.Add(Tuple.Create(c.Id, combo)); } placeButton.IsEnabled = true; } async void OnPlaceClick(object sender, RoutedEventArgs e) { if (pack == null) return; try { var map = new Dictionary(); foreach (var pair in combos) { var choice = pair.Item2.SelectedItem as CharacterChoice; if (choice != null) map[pair.Item1] = choice; } int parsedLayer; settings.BaseLayer = int.TryParse(baseLayerBox.Text, out parsedLayer) ? Math.Max(0, parsedLayer) : 0; settings.StartAt = startAtCombo.SelectedIndex == 1 ? "cursor" : "zero"; await Importer.Run(pack, packDir, map, settings, Log); // 今回の割り当てを記憶(次回は最初から選択済みにする)。 // 「名前そのまま」(未割り当て)は記憶しない=次回また選び直せる foreach (var pair in map) { if (pair.Value.Model != null) settings.SpeakerMap[pair.Key] = pair.Value.Name; else settings.SpeakerMap.Remove(pair.Key); } SettingsStore.Save(settings, Log); } catch (Exception ex) { Log("例外: " + ex); } } } // ---------- 配置ロジック ---------- public static class Importer { public static Pack? LoadPack(string packDir, Action log) { var jsonPath = Path.Combine(packDir, "timeline.json"); if (!File.Exists(jsonPath)) { log("timeline.json がありません: " + jsonPath); return null; } var pack = JsonSerializer.Deserialize( File.ReadAllText(jsonPath), new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); if (pack == null) { log("timeline.json の解析に失敗"); return null; } log(string.Format("読み込みOK: {0}『{1}』 events={2} chars={3}", pack.Title, pack.Episode, pack.Events.Count, pack.Characters.Count)); return pack; } /// YMM4に登録済みのキャラクター一覧(TimelineViewModel.Characters をリフレクションで辿る) public static List GetYmm4Characters(Action log) { var result = new List(); try { var mainWindow = Application.Current != null ? Application.Current.MainWindow : null; var mainVm = mainWindow != null ? mainWindow.DataContext : null; var tlProp = mainVm != null ? mainVm.GetType().GetProperty("ActiveTimelineViewModel") : null; var tlVm = tlProp != null ? tlProp.GetValue(mainVm) : null; var chProp = tlVm != null ? tlVm.GetType().GetProperty("Characters") : null; var list = chProp != null ? chProp.GetValue(tlVm) as System.Collections.IEnumerable : null; if (list == null) { log("Characters 一覧が取得できない"); return result; } foreach (var entry in list) { if (entry == null) continue; // entry がキャラモデルそのものか、キャラを抱えるVMかの両対応 object model = entry; var nameProp = entry.GetType().GetProperty("Name"); if (nameProp == null) { var inner = entry.GetType().GetProperty("Character"); var innerVal = inner != null ? inner.GetValue(entry) : null; if (innerVal != null) { model = innerVal; nameProp = innerVal.GetType().GetProperty("Name"); } } var name = nameProp != null ? nameProp.GetValue(model) as string : null; if (string.IsNullOrEmpty(name)) continue; result.Add(new CharacterChoice { Display = name!, Name = name!, Model = model }); } } catch (Exception ex) { log("キャラ一覧取得失敗: " + ex.Message); } return result; } public static async System.Threading.Tasks.Task Run(Pack pack, string packDir, Dictionary speakerMap, ImportSettings settings, Action log) { var timeline = FindActiveTimeline(log); if (timeline == null) return; var mainModel = FindMainModel(log); var fps = ReadFps(timeline, log); var projW = ReadVideoInfoValue(timeline, "Width", 1920, log); var projH = ReadVideoInfoValue(timeline, "Height", 1080, log); Func F = sec => (int)Math.Round(sec * fps); // 尺(長さ)用 // 配置先(設定): 開始レイヤーのシフト+開始フレーム(先頭 or カーソル位置) var layerOffset = Math.Max(0, settings.BaseLayer); var frameOffset = 0; if (settings.StartAt == "cursor") { try { var cf = FindProp(timeline.GetType(), "CurrentFrame"); if (cf != null) frameOffset = Convert.ToInt32(cf.GetValue(timeline)); } catch { } } if (layerOffset > 0 || frameOffset > 0) log("配置先: レイヤー" + layerOffset + "から / " + (frameOffset > 0 ? frameOffset + "フレーム目から" : "先頭から")); Func FrameAt = sec => F(sec) + frameOffset; // 開始位置用 Func L = n => n + layerOffset; var evs = pack.Events; var totalEnd = evs.Count == 0 ? 0 : evs.Max(ev2 => ev2.Start + ev2.Seconds) + 1.0; // レイヤー割り当て: 0=背景 1=BGM 2=SE 3=ト書き // 4以降=キャラごとに3本(立ち絵, 表情, ボイス) var charLayer = new Dictionary(); var nextCharLayer = 4; Func baseLayerOf = charId => { int baseLayer; if (!charLayer.TryGetValue(charId, out baseLayer)) { baseLayer = nextCharLayer; nextCharLayer += 3; charLayer[charId] = baseLayer; } return baseLayer; }; // 現在の立ち位置(layout イベントで更新され、以降の配置に適用) var currentLayout = new Dictionary(); Func xOf = (charId, slotIndex) => { PackPosition? pos; if (currentLayout.TryGetValue(charId, out pos) && pos != null) return projW * pos.X / 100.0; return (slotIndex % 2 == 0 ? -1 : 1) * projW * settings.PortraitXRatio; }; Func heightRatioOf = charId => { PackPosition? pos; if (currentLayout.TryGetValue(charId, out pos) && pos != null && pos.Height != null) return pos.Height.Value / 100.0; return settings.PortraitHeightRatio; }; var tachiePlaced = new HashSet(); var tachieUnavailable = new HashSet(); var warnedUnmapped = new HashSet(); Func nextStartOfType = (i, types) => { for (var j = i + 1; j < evs.Count; j++) if (types.Contains(evs[j].Type)) return evs[j].Start; return totalEnd; }; var items = new List(); var skipped = new List(); for (var i = 0; i < evs.Count; i++) { var ev = evs[i]; switch (ev.Type) { case "bg": { var path = ResolveAsset(packDir, ev.Asset, skipped, ev); if (path == null) break; var item = new ImageItem(); SetProps(item, log, P("FilePath", path), P("Frame", FrameAt(ev.Start)), P("Layer", L(0)), P("Length", F(nextStartOfType(i, new[] { "bg" }) - ev.Start)), P("Remark", "背景: " + ev.Asset!.Name)); items.Add(item); break; } case "bgm": { var path = ResolveAsset(packDir, ev.Asset, skipped, ev); if (path == null) break; var item = new AudioItem(); SetProps(item, log, P("FilePath", path), P("Frame", FrameAt(ev.Start)), P("Layer", L(1)), P("Length", F(nextStartOfType(i, new[] { "bgm", "bgm_stop" }) - ev.Start)), P("Remark", "BGM: " + ev.Asset!.Name)); items.Add(item); break; } case "se": { var path = ResolveAsset(packDir, ev.Asset, skipped, ev); if (path == null) break; var item = new AudioItem(); SetProps(item, log, P("FilePath", path), P("Frame", FrameAt(ev.Start)), P("Layer", L(2)), P("Length", F(1.0)), P("Remark", "SE: " + ev.Asset!.Name)); items.Add(item); break; } case "stage": { var item = new TextItem(); SetProps(item, log, P("Text", "(" + ev.Text + ")"), P("Frame", FrameAt(ev.Start)), P("Layer", L(3)), P("Length", F(ev.Seconds)), P("IsHidden", true), P("Remark", "ト書き")); items.Add(item); break; } case "layout": { if (ev.Positions != null) { foreach (var pos in ev.Positions) { currentLayout[pos.Speaker] = pos; log(string.Format(" 配置更新: {0} x={1}% height={2}", pos.Speaker, pos.X, pos.Height != null ? pos.Height + "%" : "既定")); } } break; } case "serif": { var chara = pack.Characters.FirstOrDefault(c => c.Id == ev.Speaker); var charId = ev.Speaker ?? "?"; var baseLayer = baseLayerOf(charId); var slotIndex = (baseLayer - 4) / 3; // 登場順: 0=左,1=右,… // 話者割り当て(UIで選択済み)。無ければ ymm4Name → name → 台本表記の順 CharacterChoice? choice = null; if (ev.Speaker != null) speakerMap.TryGetValue(ev.Speaker, out choice); var ymm4Name = (choice != null ? choice.Name : null) ?? (chara != null ? (chara.Ymm4Name ?? chara.Name) : null) ?? ev.SpeakerName ?? ""; // 本命: MainModel.AddVoiceItemAsync(UIのセリフ追加の完全経路。 // タイムライン追加+ボイス生成トリガーまでYMM4自身がやる) VoiceItem? voice = null; var addedByModel = false; if (choice != null && choice.Model != null && mainModel != null) { var r = await AddVoiceViaMainModel(mainModel, FrameAt(ev.Start), L(baseLayer + 2), choice.Model, ev.Text ?? "", log); addedByModel = r.Item1; voice = r.Item2; } if (!addedByModel) { // 保険1: 正規ファクトリで生成して自前配置 if (choice != null && choice.Model != null) voice = await CreateVoiceItemViaFactory(choice.Model, ev.Text ?? "", log); if (voice == null) { // 保険2(未割り当て含む): 手組みプレースホルダ voice = new VoiceItem(); SetProps(voice, log, P("CharacterName", ymm4Name), P("Serif", ev.Text ?? ""), P("Length", F(ev.Seconds))); if (choice != null && choice.Model != null) { TrySetInstance(voice, "Character", choice.Model, log); CopyCharacterDefaults(voice, choice.Model, log); } } SetProps(voice, log, P("Frame", FrameAt(ev.Start)), P("Layer", L(baseLayer + 2))); items.Add(voice); } if (voice != null && !string.IsNullOrEmpty(ev.Reading)) SetProps(voice, log, P("Pronounce", ev.Reading!)); var mapped = choice != null && choice.Model != null; if (mapped) { // YMM4登録キャラ: 立ち絵アイテム1本+表情アイテムで切り替え(YMM4の流儀) if (!tachiePlaced.Contains(charId) && !tachieUnavailable.Contains(charId)) { var tachie = new TachieItem(); SetProps(tachie, log, P("CharacterName", ymm4Name), P("Frame", FrameAt(ev.Start)), P("Layer", L(baseLayer)), P("Length", F(totalEnd - ev.Start)), P("Remark", "立ち絵: " + ymm4Name)); TrySetInstance(tachie, "Character", choice!.Model!, log); if (SetTachieParameter(tachie, choice!.Model!, log)) { tachiePlaced.Add(charId); // サイズ・位置: パックの立ち絵実寸が分かればフィット、無ければX位置のみ var defaultPortrait = ResolvePortrait(packDir, chara, null); if (defaultPortrait != null) ApplyPortraitLayout(tachie, defaultPortrait, xOf(charId, slotIndex), projH, heightRatioOf(charId), log); else SetAnim(tachie, "X", xOf(charId, slotIndex), log); items.Add(tachie); } else { tachieUnavailable.Add(charId); log(" → 「" + ymm4Name + "」はYMM4側に立ち絵が無いので、パックの画像で仮置きします"); } } if (tachieUnavailable.Contains(charId)) { // 立ち絵未設定キャラ: パックの表情画像/PSDをセリフ区間に仮置き var portrait = ResolvePortrait(packDir, chara, ev.Expression); if (portrait != null) { var img = new ImageItem(); SetProps(img, log, P("FilePath", portrait), P("Frame", FrameAt(ev.Start)), P("Layer", L(baseLayer)), P("Length", F(ev.Seconds)), P("Remark", (chara != null ? chara.Name : "?") + "(" + (ev.Expression ?? "既定") + ")")); ApplyPortraitLayout(img, portrait, xOf(charId, slotIndex), projH, heightRatioOf(charId), log); items.Add(img); } } else if (ev.Expression != null) { // パックの表情プリセット(PSDレイヤーパス)を探す var packExpr = chara?.Expressions.FirstOrDefault( x => x.Id == ev.Expression || x.Label == ev.Expression); var layers = packExpr?.Psd?.Layers; var face = new TachieFaceItem(); SetProps(face, log, P("CharacterName", ymm4Name), P("Frame", FrameAt(ev.Start)), P("Layer", L(baseLayer + 1)), P("Length", F(ev.Seconds)), P("Remark", "表情: " + ev.Expression)); TrySetInstance(face, "Character", choice!.Model!, log); SetFaceParameter(face, choice!.Model!, layers, log); items.Add(face); } } else { if (warnedUnmapped.Add(charId)) log("⚠ 「" + (chara != null ? chara.Name : ev.SpeakerName ?? charId) + "」→「" + ymm4Name + "」はYMM4未登録の名前です。ボイス・立ち絵は仮配置になります" + "(話者の割り当てドロップダウンで登録キャラを選ぶと本配置)"); // 未割り当てキャラ: パック内の画像/PSDを ImageItem で仮置き(従来動作) var portrait = ResolvePortrait(packDir, chara, ev.Expression); if (portrait != null) { var img = new ImageItem(); SetProps(img, log, P("FilePath", portrait), P("Frame", FrameAt(ev.Start)), P("Layer", L(baseLayer)), P("Length", F(ev.Seconds)), P("Remark", (chara != null ? chara.Name : "?") + "(" + (ev.Expression ?? "既定") + ")")); ApplyPortraitLayout(img, portrait, xOf(charId, slotIndex), projH, heightRatioOf(charId), log); items.Add(img); } } foreach (var se in ev.Se) { var sePath = ResolveAsset(packDir, se, skipped, ev); if (sePath == null) continue; var item = new AudioItem(); SetProps(item, log, P("FilePath", sePath), P("Frame", FrameAt(ev.Start)), P("Layer", L(2)), P("Length", F(1.0)), P("Remark", "SE: " + se.Name)); items.Add(item); } break; } case "bgm_stop": case "custom": break; // アイテム化しない(bgm_stop は bgm の終端計算で消費済み) default: skipped.Add(string.Format("no.{0}: 未知の type '{1}'", ev.No, ev.Type)); break; } } log(string.Format("アイテム生成: {0}件 (fps={1})", items.Count, fps)); foreach (var s in skipped) log(" スキップ: " + s); AddItems(timeline, items, log); // 診断: ボイスアイテムがキャラへ解決できているか(ボイス割り当て問題の切り分け) var firstVoice = items.OfType().FirstOrDefault(); if (firstVoice != null) { try { var cp = FindProp(firstVoice.GetType(), "Character"); var c = cp != null ? cp.GetValue(firstVoice) : null; var np = c != null ? c.GetType().GetProperty("Name") : null; log("診断: 先頭ボイスの Character = " + (c == null ? "null(この名前のキャラがYMM4に未登録)" : (np != null ? np.GetValue(c) : c))); var vp = c != null ? c.GetType().GetProperty("Voice") : null; var v = vp != null && c != null ? vp.GetValue(c) : null; if (c != null) log("診断: キャラの声設定 = " + (v == null ? "null(キャラ設定で声が未設定)" : v.GetType().Name)); } catch (Exception ex) { log("診断失敗: " + ex.Message); } } try { var resolve = typeof(Timeline).GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) .FirstOrDefault(m2 => m2.Name == "ResolveAllItemsCollision" && m2.GetParameters().Length == 0); if (resolve != null) { resolve.Invoke(timeline, null); log("衝突解決を実行(実尺で重なった分を整理)"); } } catch (Exception ex) { log("衝突解決失敗: " + ex.Message); } } /// MainViewModel が抱えている MainModel(アプリ中枢モデル)を探す static object? FindMainModel(Action log) { var mainWindow = Application.Current != null ? Application.Current.MainWindow : null; var mainVm = mainWindow != null ? mainWindow.DataContext : null; if (mainVm == null) return null; const string TargetType = "YukkuriMovieMaker.Project.MainModel"; for (var t = mainVm.GetType(); t != null; t = t.BaseType) { foreach (var f in t.GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)) { if (f.FieldType.FullName != TargetType) continue; var v = f.GetValue(mainVm); if (v != null) { log("MainModel 取得: フィールド " + t.Name + "." + f.Name); return v; } } } foreach (var p in mainVm.GetType().GetProperties(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)) { if (p.PropertyType.FullName != TargetType || p.GetIndexParameters().Length > 0) continue; var v = p.GetValue(mainVm); if (v != null) { log("MainModel 取得: プロパティ " + p.Name); return v; } } log("MainModel が見つからない → ボイスはファクトリ経路で配置"); return null; } /// UIのセリフ追加と同じ完全経路 MainModel.AddVoiceItemAsync(frame, layer, character, serif, decorations)。 /// タイムラインへの追加までやるので、成功時は items へ入れないこと static async System.Threading.Tasks.Task> AddVoiceViaMainModel( object mainModel, int frame, int layer, object character, string serif, Action log) { var shortSerif = serif.Length > 12 ? serif.Substring(0, 12) + "…" : serif; try { var m = mainModel.GetType() .GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) .FirstOrDefault(x => x.Name == "AddVoiceItemAsync" && x.GetParameters().Length == 5); if (m == null) { log(" ボイス: MainModel.AddVoiceItemAsync(5引数) が見つからない"); return Tuple.Create(false, (VoiceItem?)null); } var taskObj = m.Invoke(mainModel, new object?[] { frame, layer, character, serif, null }); VoiceItem? item = null; if (taskObj is System.Threading.Tasks.Task task) { await task; var rp = taskObj.GetType().GetProperty("Result"); item = rp != null ? rp.GetValue(taskObj) as VoiceItem : null; } if (item != null) { Func read = name => { var p2 = FindProp(item.GetType(), name); return p2 != null ? p2.GetValue(item) : null; }; log(" ボイス(UI経路で追加)「" + shortSerif + "」 VoiceLength=" + DumpValue(read("VoiceLength")) + " VoiceCache=" + (read("VoiceCache") == null ? "null" : "あり")); } else { log(" ボイス(UI経路で追加)「" + shortSerif + "」(戻り値からアイテム参照は取れず)"); } return Tuple.Create(true, item); } catch (Exception ex) { log(" ボイス: UI経路での追加失敗「" + shortSerif + "」: " + (ex.InnerException != null ? ex.InnerException.Message : ex.Message)); return Tuple.Create(false, (VoiceItem?)null); } } /// YMM4の正規ファクトリ static VoiceItem.CreateVoiceItemAsync(character, serif, …) で /// ボイスアイテムを生成する(UIのセリフ追加と同じ経路)。生成結果の実態もログに出す static async System.Threading.Tasks.Task CreateVoiceItemViaFactory( object character, string serif, Action log) { try { var m = typeof(VoiceItem) .GetMethods(BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic) .FirstOrDefault(x => x.Name == "CreateVoiceItemAsync"); if (m == null) { log(" ボイス: 正規ファクトリが見つからない(YMM4更新で変わった?)"); return null; } var ps = m.GetParameters(); var args = new object?[ps.Length]; args[0] = character; args[1] = serif; for (var i = 2; i < ps.Length; i++) args[i] = ps[i].HasDefaultValue ? ps[i].DefaultValue : (ps[i].ParameterType.IsValueType ? Activator.CreateInstance(ps[i].ParameterType) : null); var taskObj = m.Invoke(null, args); VoiceItem? item = null; if (taskObj is System.Threading.Tasks.Task task) { await task; var rp = taskObj.GetType().GetProperty("Result"); item = rp != null ? rp.GetValue(taskObj) as VoiceItem : null; } else item = taskObj as VoiceItem; if (item == null) { log(" ボイス: 正規ファクトリが null を返した"); return null; } var shortSerif = serif.Length > 12 ? serif.Substring(0, 12) + "…" : serif; Func read = name => { var p2 = FindProp(item.GetType(), name); return p2 != null ? p2.GetValue(item) : null; }; log(" ボイス(正規生成)「" + shortSerif + "」 VoiceLength=" + DumpValue(read("VoiceLength")) + " VoiceCache=" + (read("VoiceCache") == null ? "null" : "あり") + " Length=" + DumpValue(read("Length"))); return item; } catch (Exception ex) { log(" ボイス: 正規ファクトリでの生成失敗: " + (ex.InnerException != null ? ex.InnerException.Message : ex.Message)); return null; } } static Tuple P(string name, object value) { return Tuple.Create(name, value); } /// MainWindow.DataContext → ActiveTimelineViewModel → 内部の Timeline モデルを辿る static Timeline? FindActiveTimeline(Action log) { var mainWindow = Application.Current != null ? Application.Current.MainWindow : null; var mainVm = mainWindow != null ? mainWindow.DataContext : null; if (mainVm == null) { log("NG: MainWindow.DataContext が null"); return null; } log("MainViewModel: " + mainVm.GetType().FullName); var tlProp = mainVm.GetType().GetProperty("ActiveTimelineViewModel"); var tlVm = tlProp != null ? tlProp.GetValue(mainVm) : null; if (tlVm == null) { log("NG: ActiveTimelineViewModel が null(プロジェクトが開かれていない?)"); return null; } log("TimelineViewModel: " + tlVm.GetType().FullName); for (var t = tlVm.GetType(); t != null; t = t.BaseType) { foreach (var f in t.GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)) { if (typeof(Timeline).IsAssignableFrom(f.FieldType)) { var tl = f.GetValue(tlVm) as Timeline; if (tl != null) { log("Timeline 取得: フィールド " + t.Name + "." + f.Name); return tl; } } } foreach (var pr in t.GetProperties(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)) { if (typeof(Timeline).IsAssignableFrom(pr.PropertyType) && pr.GetIndexParameters().Length == 0) { var tl = pr.GetValue(tlVm) as Timeline; if (tl != null) { log("Timeline 取得: プロパティ " + t.Name + "." + pr.Name); return tl; } } } } log("NG: TimelineViewModel から Timeline モデルを見つけられなかった"); return null; } static double ReadFps(Timeline timeline, Action log) { return ReadVideoInfoValue(timeline, "FPS", 30, log); } static double ReadVideoInfoValue(Timeline timeline, string name, double fallback, Action log) { try { var viProp = timeline.GetType().GetProperty("VideoInfo"); var vi = viProp != null ? viProp.GetValue(timeline) : null; var p = vi != null ? vi.GetType().GetProperty(name) : null; var obj = p != null ? p.GetValue(vi) : null; if (obj != null) { var v = Convert.ToDouble(obj); if (v > 0) return v; } } catch (Exception ex) { log(name + "取得失敗(" + ex.Message + ")→ " + fallback); } return fallback; } // ---------- 立ち絵の自動レイアウト ---------- /// 立ち絵をフィット(高さ比指定)+横位置指定+下端揃えにする static void ApplyPortraitLayout(object img, string path, double x, double projH, double heightRatio, Action log) { var size = ReadImageSize(path); if (size == null) { log(" 立ち絵サイズ不明のためレイアウト省略: " + Path.GetFileName(path)); return; } var zoom = projH * heightRatio / size.Item2 * 100.0; var scaledH = size.Item2 * zoom / 100.0; var y = projH / 2.0 - scaledH / 2.0; // 下端を画面下に揃える(原点=画面中央) var ok1 = SetAnim(img, "Zoom", zoom, log); var ok2 = SetAnim(img, "X", x, log); var ok3 = SetAnim(img, "Y", y, log); log(string.Format(" 立ち絵レイアウト: {0} zoom={1:F1}% x={2:F0} y={3:F0} ({4})", Path.GetFileName(path), zoom, x, y, (ok1 && ok2 && ok3) ? "OK" : "一部失敗")); } /// キャラ設定の既定値をアイテムへコピーする(YMM4がUI追加時にやることの再現)。 /// 同名・同型の書き込み可能プロパティのうち、値型と文字列のみ(参照の共有事故を避ける) static void CopyCharacterDefaults(object item, object character, Action log) { var skip = new[] { "Name", "GroupName", "KeyGesture", "Layer", "LicenseOverviewDummy" }; var copied = 0; foreach (var cp in character.GetType().GetProperties()) { try { if (skip.Contains(cp.Name)) continue; if (cp.Name.StartsWith("Tachie") || cp.Name.StartsWith("VoiceItem") || cp.Name.StartsWith("CustomVoice")) continue; var ip = item.GetType().GetProperty(cp.Name); if (ip == null || !ip.CanWrite) continue; if (ip.PropertyType != cp.PropertyType) continue; if (!cp.PropertyType.IsValueType && cp.PropertyType != typeof(string)) continue; ip.SetValue(item, cp.GetValue(character)); copied++; } catch { } } log(" キャラ既定値コピー: " + copied + "項目 (" + item.GetType().Name + ")"); } /// オブジェクトの安全なクローン。GetClone() があればそれを、 /// 無ければ空ctor+書き込み可能プロパティの浅いコピー。失敗時は null(共有参照は返さない) static object? CloneObject(object source, Action log) { var type = source.GetType(); try { var cloneMethod = type.GetMethods() .FirstOrDefault(m2 => m2.Name == "GetClone" && m2.GetParameters().Length == 0); if (cloneMethod != null) return cloneMethod.Invoke(source, null); var clone = Activator.CreateInstance(type); if (clone == null) return null; foreach (var p2 in type.GetProperties()) { if (!p2.CanRead || !p2.CanWrite || p2.GetIndexParameters().Length > 0) continue; try { p2.SetValue(clone, p2.GetValue(source)); } catch { } } return clone; } catch (Exception ex) { log(" クローン失敗(" + type.Name + "): " + ex.Message); return null; } } static string DumpValue(object? v) { if (v == null) return "null"; if (v is string s) return s; if (v is System.Collections.IEnumerable list) { var parts = new List(); foreach (var x in list) { parts.Add(x?.ToString() ?? "null"); if (parts.Count >= 10) { parts.Add("…"); break; } } return "[" + string.Join(", ", parts) + "]"; } return v.ToString() ?? "null"; } /// 立ち絵アイテムに、キャラ設定の既定立ち絵パラメータ(PSDファイル指定等)のクローンを入れる。 /// これが無いと TachieItem は描くものが無く何も表示されない。成功したら true static bool SetTachieParameter(object tachieItem, object character, Action log) { try { var dp = character.GetType().GetProperty("TachieDefaultItemParameter"); var def = dp != null ? dp.GetValue(character) : null; if (def == null) { log(" 立ち絵: キャラの TachieDefaultItemParameter が null(立ち絵未設定?)"); return false; } var clone = CloneObject(def, log) ?? def; // 書き換えないので共有でも実害はないがクローン優先 var tp = FindProp(tachieItem.GetType(), "TachieItemParameter"); if (tp == null || !tp.CanWrite) { log(" 立ち絵: TachieItemParameter が書けない"); return false; } if (!tp.PropertyType.IsInstanceOfType(clone)) { log(" 立ち絵: 型不一致 " + clone.GetType().Name + " → " + tp.PropertyType.Name); return false; } tp.SetValue(tachieItem, clone); log(" 立ち絵: パラメータ設定OK(" + clone.GetType().Name + ")"); return true; } catch (Exception ex) { log(" 立ち絵パラメータ設定失敗: " + ex.Message); return false; } } /// YMM4のPSDレイヤーパスの区切り文字を、キャラ既定パラメータの実サンプルから検出する。 /// (実機ログでは各階層の後ろに区切りが付く形式。文字の正体もログに出す) static char? DetectLayerPathSeparator(object character, Action log) { try { var dp = character.GetType().GetProperty("TachieDefaultItemParameter"); var def = dp != null ? dp.GetValue(character) : null; var pp = def != null ? def.GetType().GetProperty("EnableLayerPaths") : null; var list = pp != null ? pp.GetValue(def) as System.Collections.IEnumerable : null; if (list == null) return null; var counts = new Dictionary(); var total = 0; foreach (var o in list) { var s = o as string; if (string.IsNullOrEmpty(s)) continue; var c = s[s.Length - 1]; int n; counts.TryGetValue(c, out n); counts[c] = n + 1; total++; if (total >= 20) break; } if (total == 0) return null; var best = counts.OrderByDescending(kv => kv.Value).First(); if (best.Value < total * 0.8) { log(" 表情: 区切り文字を特定できず(末尾がバラバラ)"); return null; } log(" 表情: レイヤーパス区切り文字を検出 = U+" + ((int)best.Key).ToString("X4") + " (表示: '" + best.Key + "')"); return best.Key; } catch { return null; } } /// 表情アイテムに、キャラ既定表情のクローン+表情プリセット(PSDレイヤーパス)を入れる static void SetFaceParameter(object faceItem, object character, List? layers, Action log) { try { var dp = character.GetType().GetProperty("TachieDefaultFaceParameter"); var def = dp != null ? dp.GetValue(character) : null; if (def == null) { log(" 表情: キャラの TachieDefaultFaceParameter が null(立ち絵未設定?)"); return; } var clone = CloneObject(def, log); if (clone == null) { log(" 表情: クローン不可のため既定表情のまま(レイヤーは書き換えない)"); return; } if (layers != null && layers.Count > 0) { // パックは「/」区切り。YMM4の実形式(各階層の後ろに区切り文字)へ変換する var sep = DetectLayerPathSeparator(character, log); var converted = layers; if (sep != null) converted = layers .Select(l => string.Concat(l.Split('/').Select(seg => seg + sep.Value))) .ToList(); else log(" 表情: 区切り文字が検出できず '/' のまま入れる(効かない可能性あり)"); var pathsProp = clone.GetType().GetProperty("EnableLayerPaths"); var namesProp = clone.GetType().GetProperty("EnableLayers"); var paths = System.Collections.Immutable.ImmutableList.CreateRange(converted); // EnableLayers は内部ID(n208等)で外から生成できない。空にしてパス解決に任せる var names = System.Collections.Immutable.ImmutableList.Create(); if (pathsProp != null && pathsProp.CanWrite && pathsProp.PropertyType.IsInstanceOfType(paths)) pathsProp.SetValue(clone, paths); else log(" 表情: EnableLayerPaths を書けない"); if (namesProp != null && namesProp.CanWrite && namesProp.PropertyType.IsInstanceOfType(names)) namesProp.SetValue(clone, names); } var fp = FindProp(faceItem.GetType(), "TachieFaceParameter"); if (fp == null || !fp.CanWrite) { log(" 表情: TachieFaceParameter が書けない"); return; } if (!fp.PropertyType.IsInstanceOfType(clone)) { log(" 表情: 型不一致 " + clone.GetType().Name + " → " + fp.PropertyType.Name); return; } fp.SetValue(faceItem, clone); var lp = clone.GetType().GetProperty("EnableLayerPaths"); log(" 表情: 設定OK layers=" + DumpValue(lp != null ? lp.GetValue(clone) : null)); } catch (Exception ex) { log(" 表情パラメータ設定失敗: " + ex.Message); } } /// 画像のピクセルサイズ。PSDはヘッダ直読み、他はWPFデコーダ static Tuple? ReadImageSize(string path) { try { if (Path.GetExtension(path).Equals(".psd", StringComparison.OrdinalIgnoreCase)) { using (var fs = File.OpenRead(path)) { var header = new byte[26]; if (fs.Read(header, 0, 26) < 26) return null; if (header[0] != (byte)'8' || header[1] != (byte)'B' || header[2] != (byte)'P' || header[3] != (byte)'S') return null; double h = (uint)((header[14] << 24) | (header[15] << 16) | (header[16] << 8) | header[17]); double w = (uint)((header[18] << 24) | (header[19] << 16) | (header[20] << 8) | header[21]); return Tuple.Create(w, h); } } using (var fs = File.OpenRead(path)) { var frame = System.Windows.Media.Imaging.BitmapFrame.Create(fs, System.Windows.Media.Imaging.BitmapCreateOptions.DelayCreation, System.Windows.Media.Imaging.BitmapCacheOption.None); return Tuple.Create((double)frame.PixelWidth, (double)frame.PixelHeight); } } catch { return null; } } /// プロパティを名前で探す(明示的インターフェイス実装 "IVideoItem.X" にも対応) static PropertyInfo? FindProp(Type type, string name) { var p = type.GetProperty(name); if (p != null) return p; return type.GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) .FirstOrDefault(x => x.Name.EndsWith("." + name, StringComparison.Ordinal)); } /// アニメーション型プロパティ(X/Y/Zoom等)に一定値を設定する。 /// From/To 型と Values[].Value 型の両対応 static bool SetAnim(object item, string name, double value, Action log) { try { var pi = FindProp(item.GetType(), name); var anim = pi != null ? pi.GetValue(item) : null; if (anim == null) { // 素の数値プロパティだった場合 if (pi != null && pi.CanWrite) { pi.SetValue(item, Convert.ChangeType(value, Nullable.GetUnderlyingType(pi.PropertyType) ?? pi.PropertyType)); return true; } log(" " + name + ": プロパティが見つからない"); return false; } var fromProp = anim.GetType().GetProperty("From"); var toProp = anim.GetType().GetProperty("To"); if (fromProp != null && fromProp.CanWrite) { fromProp.SetValue(anim, Convert.ChangeType(value, fromProp.PropertyType)); if (toProp != null && toProp.CanWrite) toProp.SetValue(anim, Convert.ChangeType(value, toProp.PropertyType)); return true; } var valuesProp = anim.GetType().GetProperty("Values"); var values = valuesProp != null ? valuesProp.GetValue(anim) as System.Collections.IEnumerable : null; if (values != null) { var any = false; foreach (var v in values) { if (v == null) continue; var vp = v.GetType().GetProperty("Value"); if (vp != null && vp.CanWrite) { vp.SetValue(v, Convert.ChangeType(value, Nullable.GetUnderlyingType(vp.PropertyType) ?? vp.PropertyType)); any = true; } } if (any) return true; } log(" " + name + ": " + anim.GetType().Name + " の設定方法が分からない(From/To も Values も無し)"); return false; } catch (Exception ex) { log(" " + name + " 設定失敗: " + ex.Message); return false; } } static string? ResolveAsset(string packDir, PackAssetRef? asset, List skipped, PackEvent ev) { if (asset == null || asset.Path == null) { skipped.Add(string.Format("no.{0}: 素材不足 '{1}'(path=null)", ev.No, asset != null ? asset.Name : "?")); return null; } var full = Path.GetFullPath(Path.Combine(packDir, asset.Path)); if (!File.Exists(full)) { skipped.Add(string.Format("no.{0}: 素材ファイルが実在しない {1}", ev.No, asset.Path)); return null; } return full; } static string? ResolvePortrait(string packDir, PackCharacter? chara, string? expression) { if (chara == null) return null; var expr = expression == null ? (chara.Expressions.FirstOrDefault(x => x.Default) ?? chara.Expressions.FirstOrDefault()) : chara.Expressions.FirstOrDefault(x => x.Id == expression || x.Label == expression); if (expr == null) return null; string? rel = null; if (expr.Psd != null) rel = expr.Psd.Path; else if (expr.File != null && chara.PortraitDir != null) rel = chara.PortraitDir + "/" + expr.File; if (rel == null) return null; var full = Path.GetFullPath(Path.Combine(packDir, rel)); return File.Exists(full) ? full : null; } /// 型が一致する場合のみインスタンスを設定(YMM4のキャラモデル等・変換なし) static void TrySetInstance(object item, string name, object value, Action log) { try { var pi = item.GetType().GetProperty(name); if (pi == null || !pi.CanWrite) { log(" [" + item.GetType().Name + "] " + name + " が無い/書けない"); return; } if (!pi.PropertyType.IsInstanceOfType(value)) { log(" [" + item.GetType().Name + "] " + name + " 型不一致: " + value.GetType().Name + " → " + pi.PropertyType.Name); return; } pi.SetValue(item, value); } catch (Exception ex) { log(" [" + item.GetType().Name + "] " + name + " 設定失敗: " + ex.Message); } } /// プロパティ型の食い違いに耐えるためリフレクションで設定(int/double/bool/enum は変換) static void SetProps(object item, Action log, params Tuple[] props) { foreach (var prop in props) { var name = prop.Item1; var value = prop.Item2; try { var pi = item.GetType().GetProperty(name); if (pi == null || !pi.CanWrite) { log(" [" + item.GetType().Name + "] プロパティ " + name + " が無い/書けない"); continue; } var target = Nullable.GetUnderlyingType(pi.PropertyType) ?? pi.PropertyType; object converted = value; if (!target.IsInstanceOfType(value)) converted = target.IsEnum ? Enum.ToObject(target, Convert.ToInt32(value)) : Convert.ChangeType(value, target); pi.SetValue(item, converted); } catch (Exception ex) { log(" [" + item.GetType().Name + "] " + name + " 設定失敗: " + ex.Message); } } } /// Timeline.AddItems / TryAddItems をリフレクションで探して呼ぶ(シグネチャをログに残す) static void AddItems(Timeline timeline, List items, Action log) { if (items.Count == 0) { log("追加するアイテムがありません"); return; } var candidates = typeof(Timeline) .GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) .Where(m => m.Name == "AddItems" || m.Name == "TryAddItems") .ToArray(); foreach (var m in candidates) log("候補メソッド: " + m.Name + "(" + string.Join(", ", m.GetParameters().Select(pp => pp.ParameterType.Name + " " + pp.Name)) + ")"); foreach (var m in candidates.OrderBy(m => m.Name == "AddItems" ? 0 : 1) .ThenBy(m => m.GetParameters().Length)) { var ps = m.GetParameters(); if (ps.Length == 0 || !ps[0].ParameterType.IsInstanceOfType(items)) continue; var args = new object?[ps.Length]; args[0] = items; var ok = true; for (var i = 1; i < ps.Length; i++) { if (ps[i].HasDefaultValue) args[i] = ps[i].DefaultValue; else if (ps[i].ParameterType.IsValueType) args[i] = Activator.CreateInstance(ps[i].ParameterType); else { ok = false; break; } } if (!ok) continue; try { var result = m.Invoke(timeline, args); log("実行: " + m.Name + " → " + (result == null ? "完了" : result.ToString())); log("配置完了: " + items.Count + "アイテム"); return; } catch (Exception ex) { var inner = ex.InnerException != null ? ex.InnerException.Message : ex.Message; log(m.Name + " 失敗: " + inner); } } log("NG: 適合する AddItems を呼べなかった(上の候補ログを見て次の一手を考える)"); } } }