// timeline.json を読み、現在アクティブなタイムラインへアイテムを一括配置する。 // スパイク方針: コンパイル時に確実な型(Timeline/BaseItem/各アイテムの空ctor)だけ静的に使い、 // シグネチャが不確かな箇所(プロパティ型・AddItems)はリフレクションで解決してログに残す。 using System.IO; using System.Reflection; using System.Text.Json; using System.Windows; using YukkuriMovieMaker.Project; using YukkuriMovieMaker.Project.Items; namespace KakiniwaYmm4Import; public static class Importer { public static void Run(string packDir, Action log) { var jsonPath = Path.Combine(packDir, "timeline.json"); if (!File.Exists(jsonPath)) { log($"timeline.json がありません: {jsonPath}"); return; } var pack = JsonSerializer.Deserialize( File.ReadAllText(jsonPath), new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); if (pack is null) { log("timeline.json の解析に失敗"); return; } log($"読み込みOK: {pack.Title}『{pack.Episode}』 events={pack.Events.Count} chars={pack.Characters.Count}"); var timeline = FindActiveTimeline(log); if (timeline is null) return; var fps = ReadFps(timeline, log); int F(double sec) => (int)Math.Round(sec * fps); var evs = pack.Events; var totalEnd = evs.Count == 0 ? 0 : evs.Max(e => e.Start + e.Seconds) + 1.0; // レイヤー割り当て: 0=背景 1=BGM 2=SE 3=ト書き 4以降=キャラごとに(立ち絵, ボイス) var charLayer = new Dictionary(); var nextCharLayer = 4; int LayerOf(string charId, bool voice) { if (!charLayer.TryGetValue(charId, out var baseLayer)) { baseLayer = nextCharLayer; nextCharLayer += 2; charLayer[charId] = baseLayer; } return voice ? baseLayer + 1 : baseLayer; } double NextStartOfType(int i, params string[] 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 is null) break; var item = new ImageItem(); SetProps(item, log, ("FilePath", path), ("Frame", F(ev.Start)), ("Layer", 0), ("Length", F(NextStartOfType(i, "bg") - ev.Start)), ("Label", $"背景: {ev.Asset!.Name}")); items.Add(item); break; } case "bgm": { var path = ResolveAsset(packDir, ev.Asset, skipped, ev); if (path is null) break; var item = new AudioItem(); SetProps(item, log, ("FilePath", path), ("Frame", F(ev.Start)), ("Layer", 1), ("Length", F(NextStartOfType(i, "bgm", "bgm_stop") - ev.Start)), ("Label", $"BGM: {ev.Asset!.Name}")); items.Add(item); break; } case "se": { var path = ResolveAsset(packDir, ev.Asset, skipped, ev); if (path is null) break; var item = new AudioItem(); SetProps(item, log, ("FilePath", path), ("Frame", F(ev.Start)), ("Layer", 2), ("Length", F(1.0)), ("Label", $"SE: {ev.Asset!.Name}")); items.Add(item); break; } case "stage": { var item = new TextItem(); SetProps(item, log, ("Text", $"({ev.Text})"), ("Frame", F(ev.Start)), ("Layer", 3), ("Length", F(ev.Seconds)), ("IsHidden", true), ("Label", "ト書き")); items.Add(item); break; } case "serif": { var chara = pack.Characters.FirstOrDefault(c => c.Id == ev.Speaker); var ymm4Name = chara?.Ymm4Name ?? chara?.Name ?? ev.SpeakerName ?? ""; var voice = new VoiceItem(); SetProps(voice, log, ("CharacterName", ymm4Name), ("Serif", ev.Text ?? ""), ("Frame", F(ev.Start)), ("Layer", LayerOf(ev.Speaker ?? "?", voice: true)), ("Length", F(ev.Seconds))); if (!string.IsNullOrEmpty(ev.Reading)) SetProps(voice, log, ("Pronounce", ev.Reading!)); items.Add(voice); // 立ち絵(スパイク版): 表情の画像/PSDを ImageItem としてセリフ区間に置く。 // 本来は TachieItem + TachieFaceParameter(残調査)。 var portrait = ResolvePortrait(packDir, chara, ev.Expression); if (portrait is not null) { var img = new ImageItem(); SetProps(img, log, ("FilePath", portrait), ("Frame", F(ev.Start)), ("Layer", LayerOf(ev.Speaker ?? "?", voice: false)), ("Length", F(ev.Seconds)), ("Label", $"{chara?.Name}({ev.Expression ?? "既定"})")); items.Add(img); } foreach (var se in ev.Se) { var sePath = ResolveAsset(packDir, se, skipped, ev); if (sePath is null) continue; var item = new AudioItem(); SetProps(item, log, ("FilePath", sePath), ("Frame", F(ev.Start)), ("Layer", 2), ("Length", F(1.0)), ("Label", $"SE: {se.Name}")); items.Add(item); } break; } case "bgm_stop": case "custom": break; // アイテム化しない(bgm_stop は bgm の終端計算で消費済み) default: skipped.Add($"no.{ev.No}: 未知の type '{ev.Type}'"); break; } } log($"アイテム生成: {items.Count}件 (fps={fps})"); foreach (var s in skipped) log($" スキップ: {s}"); AddItems(timeline, items, log); } /// MainWindow.DataContext → ActiveTimelineViewModel → 内部の Timeline モデルを辿る static Timeline? FindActiveTimeline(Action log) { var mainVm = Application.Current.MainWindow?.DataContext; if (mainVm is null) { log("NG: MainWindow.DataContext が null"); return null; } log($"MainViewModel: {mainVm.GetType().FullName}"); var tlVm = mainVm.GetType().GetProperty("ActiveTimelineViewModel")?.GetValue(mainVm); if (tlVm is null) { log("NG: ActiveTimelineViewModel が null(プロジェクトが開かれていない?)"); return null; } log($"TimelineViewModel: {tlVm.GetType().FullName}"); // ViewModel が抱えている Timeline モデルをフィールド/プロパティから探す for (var t = tlVm.GetType(); t is not null; t = t.BaseType) { foreach (var f in t.GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)) { if (typeof(Timeline).IsAssignableFrom(f.FieldType) && f.GetValue(tlVm) is Timeline tl) { log($"Timeline 取得: フィールド {t.Name}.{f.Name}"); return tl; } } foreach (var p in t.GetProperties(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)) { if (typeof(Timeline).IsAssignableFrom(p.PropertyType) && p.GetIndexParameters().Length == 0 && p.GetValue(tlVm) is Timeline tl) { log($"Timeline 取得: プロパティ {t.Name}.{p.Name}"); return tl; } } } log("NG: TimelineViewModel から Timeline モデルを見つけられなかった"); return null; } static double ReadFps(Timeline timeline, Action log) { try { var vi = timeline.GetType().GetProperty("VideoInfo")?.GetValue(timeline); var fpsObj = vi?.GetType().GetProperty("FPS")?.GetValue(vi); if (fpsObj is not null) { var fps = Convert.ToDouble(fpsObj); if (fps > 0) return fps; } } catch (Exception ex) { log($"FPS取得失敗({ex.Message})→ 30 とみなす"); } return 30; } static string? ResolveAsset(string packDir, PackAssetRef? asset, List skipped, PackEvent ev) { if (asset?.Path is null) { skipped.Add($"no.{ev.No}: 素材不足 '{asset?.Name ?? "?"}'(path=null)"); return null; } var full = Path.GetFullPath(Path.Combine(packDir, asset.Path)); if (!File.Exists(full)) { skipped.Add($"no.{ev.No}: 素材ファイルが実在しない {asset.Path}"); return null; } return full; } static string? ResolvePortrait(string packDir, PackCharacter? chara, string? expression) { if (chara is null) return null; var expr = expression is null ? chara.Expressions.FirstOrDefault(e => e.Default) ?? chara.Expressions.FirstOrDefault() : chara.Expressions.FirstOrDefault(e => e.Id == expression || e.Label == expression); if (expr is null) return null; string? rel = expr.Psd?.Path ?? (expr.File is not null && chara.PortraitDir is not null ? $"{chara.PortraitDir}/{expr.File}" : null); if (rel is null) return null; var full = Path.GetFullPath(Path.Combine(packDir, rel)); return File.Exists(full) ? full : null; } /// プロパティ型の食い違いに耐えるためリフレクションで設定(int/double/bool/enum は変換) static void SetProps(object item, Action log, params (string Name, object Value)[] props) { foreach (var (name, value) in props) { try { var p = item.GetType().GetProperty(name); if (p is null || !p.CanWrite) { log($" [{item.GetType().Name}] プロパティ {name} が無い/書けない"); continue; } var target = Nullable.GetUnderlyingType(p.PropertyType) ?? p.PropertyType; object converted = value; if (!target.IsInstanceOfType(value)) converted = target.IsEnum ? Enum.ToObject(target, Convert.ToInt32(value)) : Convert.ChangeType(value, target); p.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 is "AddItems" or "TryAddItems") .ToArray(); foreach (var m in candidates) log($"候補メソッド: {m.Name}({string.Join(", ", m.GetParameters().Select(p => $"{p.ParameterType.Name} {p.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 is null ? "完了" : result)}"); log($"配置完了: {items.Count}アイテム"); return; } catch (Exception ex) { log($"{m.Name} 失敗: {ex.InnerException?.Message ?? ex.Message}"); } } log("NG: 適合する AddItems を呼べなかった(上の候補ログを見て次の一手を考える)"); } }