189 lines
7.4 KiB
C#
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; }
|
|
}
|
|
}
|
|
}
|