using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.RegularExpressions; using TMPro; using UnityEditor; using UnityEditor.SceneManagement; using UnityEngine; using UnityEngine.Playables; using UnityEngine.Timeline; namespace Streamingle.Subtitles.Editor { public static class SrtSubtitleInstaller { public const string GeneratedTrackName = "Subtitles [Generated]"; private static readonly Regex TimestampPattern = new Regex( @"^(?\d+):(?\d{2}):(?\d{2}),(?\d{3})\s*-->\s*(?\d+):(?\d{2}):(?\d{2}),(?\d{3})$", RegexOptions.Compiled | RegexOptions.CultureInvariant); public static void Install( PlayableDirector director, TMP_Text target, string srtAssetPath, double offsetSeconds) { if (director == null) throw new ArgumentNullException(nameof(director)); if (target == null) throw new ArgumentNullException(nameof(target)); if (director.playableAsset is not TimelineAsset timeline) throw new InvalidOperationException("The PlayableDirector must reference a TimelineAsset."); if (double.IsNaN(offsetSeconds) || double.IsInfinity(offsetSeconds)) throw new ArgumentOutOfRangeException(nameof(offsetSeconds)); var fullSrtPath = ResolveProjectPath(srtAssetPath); if (!File.Exists(fullSrtPath)) throw new FileNotFoundException("SRT file was not found.", fullSrtPath); var cues = Parse(File.ReadAllText(fullSrtPath)); if (cues.Count == 0) throw new InvalidDataException("The SRT file contains no subtitle cues."); var undoGroup = Undo.GetCurrentGroup(); Undo.SetCurrentGroupName("Install generated subtitle track"); try { Undo.RegisterCompleteObjectUndo(timeline, "Replace generated subtitle track"); Undo.RecordObject(director, "Bind generated subtitle track"); RemoveGeneratedTracks(timeline, director); var track = timeline.CreateTrack(null, GeneratedTrackName); foreach (var cue in cues) { var start = Math.Max(0d, cue.StartSeconds + offsetSeconds); var clip = track.CreateClip(); clip.start = start; clip.duration = cue.EndSeconds - cue.StartSeconds; clip.displayName = CreateDisplayName(cue.Text); if (clip.asset is SubtitleClip subtitleAsset) { subtitleAsset.Text = cue.Text; EditorUtility.SetDirty(subtitleAsset); } } director.SetGenericBinding(track, target); EditorUtility.SetDirty(track); EditorUtility.SetDirty(timeline); EditorUtility.SetDirty(director); EditorSceneManager.MarkSceneDirty(director.gameObject.scene); AssetDatabase.SaveAssetIfDirty(timeline); director.RebuildGraph(); director.Evaluate(); SceneView.RepaintAll(); } finally { Undo.CollapseUndoOperations(undoGroup); } } public static IReadOnlyList Parse(string srtContents) { if (string.IsNullOrWhiteSpace(srtContents)) return Array.Empty(); var normalized = srtContents.Replace("\r\n", "\n").Replace('\r', '\n').Trim(); var blocks = Regex.Split(normalized, @"\n{2,}"); var cues = new List(blocks.Length); for (var blockIndex = 0; blockIndex < blocks.Length; blockIndex++) { var lines = blocks[blockIndex] .Split('\n') .Select(line => line.TrimEnd()) .ToArray(); var timestampLineIndex = Array.FindIndex(lines, line => line.Contains("-->")); if (timestampLineIndex < 0 || timestampLineIndex + 1 >= lines.Length) throw new FormatException($"Invalid SRT cue at block {blockIndex + 1}."); var match = TimestampPattern.Match(lines[timestampLineIndex].Trim()); if (!match.Success) throw new FormatException($"Invalid SRT timestamp at block {blockIndex + 1}."); var start = ParseTimestamp(match, "s"); var end = ParseTimestamp(match, "e"); if (end <= start) throw new FormatException($"SRT cue {blockIndex + 1} must end after it starts."); var text = string.Join("\n", lines.Skip(timestampLineIndex + 1)).Trim(); if (text.Length == 0) continue; if (cues.Count > 0 && start < cues[cues.Count - 1].EndSeconds) throw new FormatException($"SRT cue {blockIndex + 1} overlaps the previous cue."); cues.Add(new SrtCue(start, end, text)); } return cues; } private static void RemoveGeneratedTracks(TimelineAsset timeline, PlayableDirector director) { var generatedTracks = timeline.GetRootTracks() .OfType() .Where(track => track.name == GeneratedTrackName) .ToArray(); foreach (var track in generatedTracks) { director.ClearGenericBinding(track); timeline.DeleteTrack(track); } } private static string ResolveProjectPath(string path) { if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("An SRT asset path is required.", nameof(path)); if (Path.IsPathRooted(path)) return Path.GetFullPath(path); var projectRoot = Directory.GetParent(Application.dataPath)?.FullName ?? throw new InvalidOperationException("Could not resolve the Unity project root."); return Path.GetFullPath(Path.Combine(projectRoot, path)); } private static double ParseTimestamp(Match match, string prefix) { var hours = int.Parse(match.Groups[prefix + "h"].Value); var minutes = int.Parse(match.Groups[prefix + "m"].Value); var seconds = int.Parse(match.Groups[prefix + "s"].Value); var milliseconds = int.Parse(match.Groups[prefix + "ms"].Value); return hours * 3600d + minutes * 60d + seconds + milliseconds / 1000d; } private static string CreateDisplayName(string text) { var singleLine = Regex.Replace(text ?? string.Empty, @"\s+", " ").Trim(); return singleLine.Length <= 36 ? singleLine : singleLine.Substring(0, 35) + "…"; } public readonly struct SrtCue { public SrtCue(double startSeconds, double endSeconds, string text) { StartSeconds = startSeconds; EndSeconds = endSeconds; Text = text; } public double StartSeconds { get; } public double EndSeconds { get; } public string Text { get; } } } }