189 lines
7.4 KiB
C#

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(
@"^(?<sh>\d+):(?<sm>\d{2}):(?<ss>\d{2}),(?<sms>\d{3})\s*-->\s*(?<eh>\d+):(?<em>\d{2}):(?<es>\d{2}),(?<ems>\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<SubtitleTrack>(null, GeneratedTrackName);
foreach (var cue in cues)
{
var start = Math.Max(0d, cue.StartSeconds + offsetSeconds);
var clip = track.CreateClip<SubtitleClip>();
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<SrtCue> Parse(string srtContents)
{
if (string.IsNullOrWhiteSpace(srtContents))
return Array.Empty<SrtCue>();
var normalized = srtContents.Replace("\r\n", "\n").Replace('\r', '\n').Trim();
var blocks = Regex.Split(normalized, @"\n{2,}");
var cues = new List<SrtCue>(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<SubtitleTrack>()
.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; }
}
}
}