mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-06-02 13:58:29 +01:00
Write subtitles using SubtitleEdit
We've been using SubtitleEdit to parse since 2021 https://github.com/jellyfin/jellyfin/pull/4984 I think it's time we start using it to write too
This commit is contained in:
@@ -1,57 +0,0 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using MediaBrowser.Model.MediaInfo;
|
||||
|
||||
namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
{
|
||||
/// <summary>
|
||||
/// ASS subtitle writer.
|
||||
/// </summary>
|
||||
public partial class AssWriter : ISubtitleWriter
|
||||
{
|
||||
[GeneratedRegex(@"\n", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex NewLineRegex();
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Write(SubtitleTrackInfo info, Stream stream, CancellationToken cancellationToken)
|
||||
{
|
||||
using (var writer = new StreamWriter(stream, Encoding.UTF8, 1024, true))
|
||||
{
|
||||
var trackEvents = info.TrackEvents;
|
||||
var timeFormat = @"hh\:mm\:ss\.ff";
|
||||
|
||||
// Write ASS header
|
||||
writer.WriteLine("[Script Info]");
|
||||
writer.WriteLine("Title: Jellyfin transcoded ASS subtitle");
|
||||
writer.WriteLine("ScriptType: v4.00+");
|
||||
writer.WriteLine();
|
||||
writer.WriteLine("[V4+ Styles]");
|
||||
writer.WriteLine("Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding");
|
||||
writer.WriteLine("Style: Default,Arial,20,&H00FFFFFF,&H00FFFFFF,&H19333333,&H910E0807,0,0,0,0,100,100,0,0,0,1,0,2,10,10,10,1");
|
||||
writer.WriteLine();
|
||||
writer.WriteLine("[Events]");
|
||||
writer.WriteLine("Format: Layer, Start, End, Style, Text");
|
||||
|
||||
for (int i = 0; i < trackEvents.Count; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var trackEvent = trackEvents[i];
|
||||
var startTime = TimeSpan.FromTicks(trackEvent.StartPositionTicks).ToString(timeFormat, CultureInfo.InvariantCulture);
|
||||
var endTime = TimeSpan.FromTicks(trackEvent.EndPositionTicks).ToString(timeFormat, CultureInfo.InvariantCulture);
|
||||
var text = NewLineRegex().Replace(trackEvent.Text, "\\n");
|
||||
|
||||
writer.WriteLine(
|
||||
"Dialogue: 0,{0},{1},Default,{2}",
|
||||
startTime,
|
||||
endTime,
|
||||
text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using MediaBrowser.Model.MediaInfo;
|
||||
|
||||
namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
{
|
||||
/// <summary>
|
||||
/// Interface ISubtitleWriter.
|
||||
/// </summary>
|
||||
public interface ISubtitleWriter
|
||||
{
|
||||
/// <summary>
|
||||
/// Writes the specified information.
|
||||
/// </summary>
|
||||
/// <param name="info">The information.</param>
|
||||
/// <param name="stream">The stream.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
void Write(SubtitleTrackInfo info, Stream stream, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using MediaBrowser.Model.MediaInfo;
|
||||
|
||||
namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
{
|
||||
/// <summary>
|
||||
/// JSON subtitle writer.
|
||||
/// </summary>
|
||||
public class JsonWriter : ISubtitleWriter
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public void Write(SubtitleTrackInfo info, Stream stream, CancellationToken cancellationToken)
|
||||
{
|
||||
using (var writer = new Utf8JsonWriter(stream))
|
||||
{
|
||||
var trackevents = info.TrackEvents;
|
||||
writer.WriteStartObject();
|
||||
writer.WriteStartArray("TrackEvents");
|
||||
|
||||
for (int i = 0; i < trackevents.Count; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var current = trackevents[i];
|
||||
writer.WriteStartObject();
|
||||
|
||||
writer.WriteString("Id", current.Id);
|
||||
writer.WriteString("Text", current.Text);
|
||||
writer.WriteNumber("StartPositionTicks", current.StartPositionTicks);
|
||||
writer.WriteNumber("EndPositionTicks", current.EndPositionTicks);
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
writer.WriteEndObject();
|
||||
|
||||
writer.Flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using MediaBrowser.Model.MediaInfo;
|
||||
|
||||
namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
{
|
||||
/// <summary>
|
||||
/// SRT subtitle writer.
|
||||
/// </summary>
|
||||
public partial class SrtWriter : ISubtitleWriter
|
||||
{
|
||||
[GeneratedRegex(@"\\n", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex NewLineEscapedRegex();
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Write(SubtitleTrackInfo info, Stream stream, CancellationToken cancellationToken)
|
||||
{
|
||||
using (var writer = new StreamWriter(stream, Encoding.UTF8, 1024, true))
|
||||
{
|
||||
var trackEvents = info.TrackEvents;
|
||||
|
||||
for (int i = 0; i < trackEvents.Count; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var trackEvent = trackEvents[i];
|
||||
|
||||
writer.WriteLine((i + 1).ToString(CultureInfo.InvariantCulture));
|
||||
writer.WriteLine(
|
||||
@"{0:hh\:mm\:ss\,fff} --> {1:hh\:mm\:ss\,fff}",
|
||||
TimeSpan.FromTicks(trackEvent.StartPositionTicks),
|
||||
TimeSpan.FromTicks(trackEvent.EndPositionTicks));
|
||||
|
||||
var text = trackEvent.Text;
|
||||
|
||||
// TODO: Not sure how to handle these
|
||||
text = NewLineEscapedRegex().Replace(text, " ");
|
||||
|
||||
writer.WriteLine(text);
|
||||
writer.WriteLine();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using MediaBrowser.Model.MediaInfo;
|
||||
|
||||
namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
{
|
||||
/// <summary>
|
||||
/// SSA subtitle writer.
|
||||
/// </summary>
|
||||
public partial class SsaWriter : ISubtitleWriter
|
||||
{
|
||||
[GeneratedRegex(@"\n", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex NewLineRegex();
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Write(SubtitleTrackInfo info, Stream stream, CancellationToken cancellationToken)
|
||||
{
|
||||
using (var writer = new StreamWriter(stream, Encoding.UTF8, 1024, true))
|
||||
{
|
||||
var trackEvents = info.TrackEvents;
|
||||
var timeFormat = @"hh\:mm\:ss\.ff";
|
||||
|
||||
// Write SSA header
|
||||
writer.WriteLine("[Script Info]");
|
||||
writer.WriteLine("Title: Jellyfin transcoded SSA subtitle");
|
||||
writer.WriteLine("ScriptType: v4.00");
|
||||
writer.WriteLine();
|
||||
writer.WriteLine("[V4 Styles]");
|
||||
writer.WriteLine("Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, TertiaryColour, BackColour, Bold, Italic, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, AlphaLevel, Encoding");
|
||||
writer.WriteLine("Style: Default,Arial,20,&H00FFFFFF,&H00FFFFFF,&H19333333,&H19333333,0,0,0,1,0,2,10,10,10,0,1");
|
||||
writer.WriteLine();
|
||||
writer.WriteLine("[Events]");
|
||||
writer.WriteLine("Format: Layer, Start, End, Style, Text");
|
||||
|
||||
for (int i = 0; i < trackEvents.Count; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var trackEvent = trackEvents[i];
|
||||
var startTime = TimeSpan.FromTicks(trackEvent.StartPositionTicks).ToString(timeFormat, CultureInfo.InvariantCulture);
|
||||
var endTime = TimeSpan.FromTicks(trackEvent.EndPositionTicks).ToString(timeFormat, CultureInfo.InvariantCulture);
|
||||
var text = NewLineRegex().Replace(trackEvent.Text, "\\n");
|
||||
|
||||
writer.WriteLine(
|
||||
"Dialogue: 0,{0},{1},Default,{2}",
|
||||
startTime,
|
||||
endTime,
|
||||
text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,10 @@ using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.IO;
|
||||
using MediaBrowser.Model.MediaInfo;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Nikse.SubtitleEdit.Core.Common;
|
||||
using Nikse.SubtitleEdit.Core.SubtitleFormats;
|
||||
using UtfUnknown;
|
||||
using SubtitleFormat = MediaBrowser.Model.MediaInfo.SubtitleFormat;
|
||||
|
||||
namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
{
|
||||
@@ -72,7 +75,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
|
||||
private MemoryStream ConvertSubtitles(
|
||||
Stream stream,
|
||||
string inputFormat,
|
||||
SubtitleInfo inputInfo,
|
||||
string outputFormat,
|
||||
long startTimeTicks,
|
||||
long endTimeTicks,
|
||||
@@ -83,13 +86,18 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
|
||||
try
|
||||
{
|
||||
var trackInfo = _subtitleParser.Parse(stream, inputFormat);
|
||||
var subtitle = Subtitle.Parse(stream, Path.GetExtension(inputInfo.Path));
|
||||
|
||||
FilterEvents(trackInfo, startTimeTicks, endTimeTicks, preserveOriginalTimestamps);
|
||||
FilterEvents(subtitle, startTimeTicks, endTimeTicks, preserveOriginalTimestamps);
|
||||
|
||||
var writer = GetWriter(outputFormat);
|
||||
var formatter = GetWriter(outputFormat);
|
||||
|
||||
var text = formatter.ToText(subtitle, "untitled");
|
||||
using (var writer = new StreamWriter(stream, Encoding.UTF8, 1024, true))
|
||||
{
|
||||
writer.Write(text);
|
||||
}
|
||||
|
||||
writer.Write(trackInfo, ms, cancellationToken);
|
||||
ms.Position = 0;
|
||||
}
|
||||
catch
|
||||
@@ -101,26 +109,24 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
return ms;
|
||||
}
|
||||
|
||||
internal void FilterEvents(SubtitleTrackInfo track, long startPositionTicks, long endTimeTicks, bool preserveTimestamps)
|
||||
internal void FilterEvents(Subtitle track, long startPositionTicks, long endTimeTicks, bool preserveTimestamps)
|
||||
{
|
||||
// Drop subs that have fully elapsed before the requested start position
|
||||
track.TrackEvents = track.TrackEvents
|
||||
.SkipWhile(i => (i.StartPositionTicks - startPositionTicks) < 0 && (i.EndPositionTicks - startPositionTicks) < 0)
|
||||
.ToArray();
|
||||
track.Paragraphs
|
||||
.RemoveAll(i => (i.StartTime.TimeSpan.Ticks - startPositionTicks) < 0 && (i.EndTime.TimeSpan.Ticks - startPositionTicks) < 0);
|
||||
|
||||
if (endTimeTicks > 0)
|
||||
{
|
||||
track.TrackEvents = track.TrackEvents
|
||||
.TakeWhile(i => i.StartPositionTicks <= endTimeTicks)
|
||||
.ToArray();
|
||||
track.Paragraphs
|
||||
.RemoveAll(i => i.StartTime.TimeSpan.Ticks > endTimeTicks);
|
||||
}
|
||||
|
||||
if (!preserveTimestamps)
|
||||
{
|
||||
foreach (var trackEvent in track.TrackEvents)
|
||||
foreach (var trackEvent in track.Paragraphs)
|
||||
{
|
||||
trackEvent.EndPositionTicks = Math.Max(0, trackEvent.EndPositionTicks - startPositionTicks);
|
||||
trackEvent.StartPositionTicks = Math.Max(0, trackEvent.StartPositionTicks - startPositionTicks);
|
||||
trackEvent.StartTime = new TimeCode(TimeSpan.FromTicks(Math.Max(0, trackEvent.StartTime.TimeSpan.Ticks - startPositionTicks)));
|
||||
trackEvent.EndTime = new TimeCode(TimeSpan.FromTicks(Math.Max(0, trackEvent.EndTime.TimeSpan.Ticks - startPositionTicks)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -142,14 +148,14 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
var subtitleStream = mediaSource.MediaStreams
|
||||
.First(i => i.Type == MediaStreamType.Subtitle && i.Index == subtitleStreamIndex);
|
||||
|
||||
var (stream, inputFormat) = await GetSubtitleStream(mediaSource, subtitleStream, cancellationToken)
|
||||
var (stream, info) = await GetSubtitleStream(mediaSource, subtitleStream, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Return the original if the same format is being requested
|
||||
// Character encoding was already handled in GetSubtitleStream
|
||||
// ASS is a superset of SSA, skipping the conversion and preserving the styles
|
||||
if (string.Equals(inputFormat, outputFormat, StringComparison.OrdinalIgnoreCase)
|
||||
|| (string.Equals(inputFormat, SubtitleFormat.SSA, StringComparison.OrdinalIgnoreCase)
|
||||
if (string.Equals(info.Format, outputFormat, StringComparison.OrdinalIgnoreCase)
|
||||
|| (string.Equals(info.Format, SubtitleFormat.SSA, StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(outputFormat, SubtitleFormat.ASS, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return stream;
|
||||
@@ -157,11 +163,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
|
||||
using (stream)
|
||||
{
|
||||
return ConvertSubtitles(stream, inputFormat, outputFormat, startTimeTicks, endTimeTicks, preserveOriginalTimestamps, cancellationToken);
|
||||
return ConvertSubtitles(stream, info, outputFormat, startTimeTicks, endTimeTicks, preserveOriginalTimestamps, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(Stream Stream, string Format)> GetSubtitleStream(
|
||||
private async Task<(Stream Stream, SubtitleInfo Info)> GetSubtitleStream(
|
||||
MediaSourceInfo mediaSource,
|
||||
MediaStream subtitleStream,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -170,7 +176,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
|
||||
var stream = await GetSubtitleStream(fileInfo, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return (stream, fileInfo.Format);
|
||||
return (stream, fileInfo);
|
||||
}
|
||||
|
||||
private async Task<Stream> GetSubtitleStream(SubtitleInfo fileInfo, CancellationToken cancellationToken)
|
||||
@@ -267,43 +273,42 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
};
|
||||
}
|
||||
|
||||
private bool TryGetWriter(string format, [NotNullWhen(true)] out ISubtitleWriter? value)
|
||||
private bool TryGetWriter(string format, [NotNullWhen(true)] out Nikse.SubtitleEdit.Core.SubtitleFormats.SubtitleFormat? value)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(format);
|
||||
|
||||
if (string.Equals(format, SubtitleFormat.ASS, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = new AssWriter();
|
||||
value = new AdvancedSubStationAlpha();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (string.Equals(format, "json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = new JsonWriter();
|
||||
return true;
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
if (string.Equals(format, SubtitleFormat.SRT, StringComparison.OrdinalIgnoreCase) || string.Equals(format, SubtitleFormat.SUBRIP, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = new SrtWriter();
|
||||
value = new SubRip();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (string.Equals(format, SubtitleFormat.SSA, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = new SsaWriter();
|
||||
value = new SubStationAlpha();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (string.Equals(format, SubtitleFormat.VTT, StringComparison.OrdinalIgnoreCase) || string.Equals(format, SubtitleFormat.WEBVTT, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = new VttWriter();
|
||||
value = new WebVTT();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (string.Equals(format, SubtitleFormat.TTML, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = new TtmlWriter();
|
||||
value = new TimedText10();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -311,7 +316,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
return false;
|
||||
}
|
||||
|
||||
private ISubtitleWriter GetWriter(string format)
|
||||
private Nikse.SubtitleEdit.Core.SubtitleFormats.SubtitleFormat GetWriter(string format)
|
||||
{
|
||||
if (TryGetWriter(format, out var writer))
|
||||
{
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using MediaBrowser.Model.MediaInfo;
|
||||
|
||||
namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
{
|
||||
/// <summary>
|
||||
/// TTML subtitle writer.
|
||||
/// </summary>
|
||||
public partial class TtmlWriter : ISubtitleWriter
|
||||
{
|
||||
[GeneratedRegex(@"\\n", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex NewLineEscapeRegex();
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Write(SubtitleTrackInfo info, Stream stream, CancellationToken cancellationToken)
|
||||
{
|
||||
// Example: https://github.com/zmalltalker/ttml2vtt/blob/master/data/sample.xml
|
||||
// Parser example: https://github.com/mozilla/popcorn-js/blob/master/parsers/parserTTML/popcorn.parserTTML.js
|
||||
|
||||
using (var writer = new StreamWriter(stream, Encoding.UTF8, 1024, true))
|
||||
{
|
||||
writer.WriteLine("<?xml version=\"1.0\" encoding=\"utf-8\"?>");
|
||||
writer.WriteLine("<tt xmlns=\"http://www.w3.org/ns/ttml\" xmlns:tts=\"http://www.w3.org/2006/04/ttaf1#styling\" lang=\"no\">");
|
||||
|
||||
writer.WriteLine("<head>");
|
||||
writer.WriteLine("<styling>");
|
||||
writer.WriteLine("<style id=\"italic\" tts:fontStyle=\"italic\" />");
|
||||
writer.WriteLine("<style id=\"left\" tts:textAlign=\"left\" />");
|
||||
writer.WriteLine("<style id=\"center\" tts:textAlign=\"center\" />");
|
||||
writer.WriteLine("<style id=\"right\" tts:textAlign=\"right\" />");
|
||||
writer.WriteLine("</styling>");
|
||||
writer.WriteLine("</head>");
|
||||
|
||||
writer.WriteLine("<body>");
|
||||
writer.WriteLine("<div>");
|
||||
|
||||
foreach (var trackEvent in info.TrackEvents)
|
||||
{
|
||||
var text = trackEvent.Text;
|
||||
|
||||
text = NewLineEscapeRegex().Replace(text, "<br/>");
|
||||
|
||||
writer.WriteLine(
|
||||
"<p begin=\"{0}\" dur=\"{1}\">{2}</p>",
|
||||
trackEvent.StartPositionTicks,
|
||||
trackEvent.EndPositionTicks - trackEvent.StartPositionTicks,
|
||||
text);
|
||||
}
|
||||
|
||||
writer.WriteLine("</div>");
|
||||
writer.WriteLine("</body>");
|
||||
|
||||
writer.WriteLine("</tt>");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using MediaBrowser.Model.MediaInfo;
|
||||
|
||||
namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
{
|
||||
/// <summary>
|
||||
/// Subtitle writer for the WebVTT format.
|
||||
/// </summary>
|
||||
public partial class VttWriter : ISubtitleWriter
|
||||
{
|
||||
[GeneratedRegex(@"\\n", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex NewlineEscapeRegex();
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Write(SubtitleTrackInfo info, Stream stream, CancellationToken cancellationToken)
|
||||
{
|
||||
using (var writer = new StreamWriter(stream, Encoding.UTF8, 1024, true))
|
||||
{
|
||||
writer.WriteLine("WEBVTT");
|
||||
writer.WriteLine();
|
||||
writer.WriteLine("Region: id:subtitle width:80% lines:3 regionanchor:50%,100% viewportanchor:50%,90%");
|
||||
writer.WriteLine();
|
||||
foreach (var trackEvent in info.TrackEvents)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var startTime = TimeSpan.FromTicks(trackEvent.StartPositionTicks);
|
||||
var endTime = TimeSpan.FromTicks(trackEvent.EndPositionTicks);
|
||||
|
||||
// make sure the start and end times are different and sequential
|
||||
if (endTime.TotalMilliseconds <= startTime.TotalMilliseconds)
|
||||
{
|
||||
endTime = startTime.Add(TimeSpan.FromMilliseconds(1));
|
||||
}
|
||||
|
||||
writer.WriteLine(@"{0:hh\:mm\:ss\.fff} --> {1:hh\:mm\:ss\.fff} region:subtitle line:90%", startTime, endTime);
|
||||
|
||||
var text = trackEvent.Text;
|
||||
|
||||
// TODO: Not sure how to handle these
|
||||
text = NewlineEscapeRegex().Replace(text, " ");
|
||||
|
||||
writer.WriteLine(text);
|
||||
writer.WriteLine();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
using System;
|
||||
using AutoFixture;
|
||||
using AutoFixture.AutoMoq;
|
||||
using MediaBrowser.MediaEncoding.Subtitles;
|
||||
using MediaBrowser.Model.MediaInfo;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.MediaEncoding.Subtitles.Tests
|
||||
{
|
||||
public class FilterEventsTests
|
||||
{
|
||||
private readonly SubtitleEncoder _encoder;
|
||||
|
||||
public FilterEventsTests()
|
||||
{
|
||||
var fixture = new Fixture().Customize(new AutoMoqCustomization { ConfigureMembers = true });
|
||||
_encoder = fixture.Create<SubtitleEncoder>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilterEvents_SubtitleSpanningSegmentBoundary_IsRetained()
|
||||
{
|
||||
// Subtitle starts at 5s, ends at 15s.
|
||||
// Segment requested from 10s to 20s.
|
||||
// The subtitle is still on screen at 10s and should NOT be dropped.
|
||||
var track = new SubtitleTrackInfo
|
||||
{
|
||||
TrackEvents = new[]
|
||||
{
|
||||
new SubtitleTrackEvent("1", "Still on screen")
|
||||
{
|
||||
StartPositionTicks = TimeSpan.FromSeconds(5).Ticks,
|
||||
EndPositionTicks = TimeSpan.FromSeconds(15).Ticks
|
||||
},
|
||||
new SubtitleTrackEvent("2", "Next subtitle")
|
||||
{
|
||||
StartPositionTicks = TimeSpan.FromSeconds(12).Ticks,
|
||||
EndPositionTicks = TimeSpan.FromSeconds(17).Ticks
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_encoder.FilterEvents(
|
||||
track,
|
||||
startPositionTicks: TimeSpan.FromSeconds(10).Ticks,
|
||||
endTimeTicks: TimeSpan.FromSeconds(20).Ticks,
|
||||
preserveTimestamps: true);
|
||||
|
||||
Assert.Equal(2, track.TrackEvents.Count);
|
||||
Assert.Equal("1", track.TrackEvents[0].Id);
|
||||
Assert.Equal("2", track.TrackEvents[1].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilterEvents_SubtitleFullyBeforeSegment_IsDropped()
|
||||
{
|
||||
// Subtitle starts at 2s, ends at 5s.
|
||||
// Segment requested from 10s.
|
||||
// The subtitle ended before the segment — should be dropped.
|
||||
var track = new SubtitleTrackInfo
|
||||
{
|
||||
TrackEvents = new[]
|
||||
{
|
||||
new SubtitleTrackEvent("1", "Already gone")
|
||||
{
|
||||
StartPositionTicks = TimeSpan.FromSeconds(2).Ticks,
|
||||
EndPositionTicks = TimeSpan.FromSeconds(5).Ticks
|
||||
},
|
||||
new SubtitleTrackEvent("2", "Visible")
|
||||
{
|
||||
StartPositionTicks = TimeSpan.FromSeconds(12).Ticks,
|
||||
EndPositionTicks = TimeSpan.FromSeconds(17).Ticks
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_encoder.FilterEvents(
|
||||
track,
|
||||
startPositionTicks: TimeSpan.FromSeconds(10).Ticks,
|
||||
endTimeTicks: TimeSpan.FromSeconds(20).Ticks,
|
||||
preserveTimestamps: true);
|
||||
|
||||
Assert.Single(track.TrackEvents);
|
||||
Assert.Equal("2", track.TrackEvents[0].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilterEvents_SubtitleAfterSegment_IsDropped()
|
||||
{
|
||||
// Segment is 10s-20s, subtitle starts at 25s.
|
||||
var track = new SubtitleTrackInfo
|
||||
{
|
||||
TrackEvents = new[]
|
||||
{
|
||||
new SubtitleTrackEvent("1", "In range")
|
||||
{
|
||||
StartPositionTicks = TimeSpan.FromSeconds(12).Ticks,
|
||||
EndPositionTicks = TimeSpan.FromSeconds(15).Ticks
|
||||
},
|
||||
new SubtitleTrackEvent("2", "After segment")
|
||||
{
|
||||
StartPositionTicks = TimeSpan.FromSeconds(25).Ticks,
|
||||
EndPositionTicks = TimeSpan.FromSeconds(30).Ticks
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_encoder.FilterEvents(
|
||||
track,
|
||||
startPositionTicks: TimeSpan.FromSeconds(10).Ticks,
|
||||
endTimeTicks: TimeSpan.FromSeconds(20).Ticks,
|
||||
preserveTimestamps: true);
|
||||
|
||||
Assert.Single(track.TrackEvents);
|
||||
Assert.Equal("1", track.TrackEvents[0].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilterEvents_PreserveTimestampsFalse_AdjustsTimestamps()
|
||||
{
|
||||
var track = new SubtitleTrackInfo
|
||||
{
|
||||
TrackEvents = new[]
|
||||
{
|
||||
new SubtitleTrackEvent("1", "Subtitle")
|
||||
{
|
||||
StartPositionTicks = TimeSpan.FromSeconds(15).Ticks,
|
||||
EndPositionTicks = TimeSpan.FromSeconds(20).Ticks
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_encoder.FilterEvents(
|
||||
track,
|
||||
startPositionTicks: TimeSpan.FromSeconds(10).Ticks,
|
||||
endTimeTicks: TimeSpan.FromSeconds(30).Ticks,
|
||||
preserveTimestamps: false);
|
||||
|
||||
Assert.Single(track.TrackEvents);
|
||||
// Timestamps should be shifted back by 10s
|
||||
Assert.Equal(TimeSpan.FromSeconds(5).Ticks, track.TrackEvents[0].StartPositionTicks);
|
||||
Assert.Equal(TimeSpan.FromSeconds(10).Ticks, track.TrackEvents[0].EndPositionTicks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilterEvents_PreserveTimestampsTrue_KeepsOriginalTimestamps()
|
||||
{
|
||||
var startTicks = TimeSpan.FromSeconds(15).Ticks;
|
||||
var endTicks = TimeSpan.FromSeconds(20).Ticks;
|
||||
|
||||
var track = new SubtitleTrackInfo
|
||||
{
|
||||
TrackEvents = new[]
|
||||
{
|
||||
new SubtitleTrackEvent("1", "Subtitle")
|
||||
{
|
||||
StartPositionTicks = startTicks,
|
||||
EndPositionTicks = endTicks
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_encoder.FilterEvents(
|
||||
track,
|
||||
startPositionTicks: TimeSpan.FromSeconds(10).Ticks,
|
||||
endTimeTicks: TimeSpan.FromSeconds(30).Ticks,
|
||||
preserveTimestamps: true);
|
||||
|
||||
Assert.Single(track.TrackEvents);
|
||||
Assert.Equal(startTicks, track.TrackEvents[0].StartPositionTicks);
|
||||
Assert.Equal(endTicks, track.TrackEvents[0].EndPositionTicks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilterEvents_SubtitleEndingExactlyAtSegmentStart_IsRetained()
|
||||
{
|
||||
// Subtitle ends exactly when the segment begins.
|
||||
// EndPositionTicks == startPositionTicks means (end - start) == 0, not < 0,
|
||||
// so SkipWhile stops and the subtitle is retained.
|
||||
var track = new SubtitleTrackInfo
|
||||
{
|
||||
TrackEvents = new[]
|
||||
{
|
||||
new SubtitleTrackEvent("1", "Boundary subtitle")
|
||||
{
|
||||
StartPositionTicks = TimeSpan.FromSeconds(5).Ticks,
|
||||
EndPositionTicks = TimeSpan.FromSeconds(10).Ticks
|
||||
},
|
||||
new SubtitleTrackEvent("2", "In range")
|
||||
{
|
||||
StartPositionTicks = TimeSpan.FromSeconds(12).Ticks,
|
||||
EndPositionTicks = TimeSpan.FromSeconds(15).Ticks
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_encoder.FilterEvents(
|
||||
track,
|
||||
startPositionTicks: TimeSpan.FromSeconds(10).Ticks,
|
||||
endTimeTicks: TimeSpan.FromSeconds(20).Ticks,
|
||||
preserveTimestamps: true);
|
||||
|
||||
Assert.Equal(2, track.TrackEvents.Count);
|
||||
Assert.Equal("1", track.TrackEvents[0].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilterEvents_SpanningBoundaryWithTimestampAdjustment_DoesNotProduceNegativeTimestamps()
|
||||
{
|
||||
// Subtitle starts at 5s, ends at 15s.
|
||||
// Segment requested from 10s to 20s, preserveTimestamps = false.
|
||||
// The subtitle spans the boundary and is retained, but shifting
|
||||
// StartPositionTicks by -10s would produce -5s (negative).
|
||||
var track = new SubtitleTrackInfo
|
||||
{
|
||||
TrackEvents = new[]
|
||||
{
|
||||
new SubtitleTrackEvent("1", "Spans boundary")
|
||||
{
|
||||
StartPositionTicks = TimeSpan.FromSeconds(5).Ticks,
|
||||
EndPositionTicks = TimeSpan.FromSeconds(15).Ticks
|
||||
},
|
||||
new SubtitleTrackEvent("2", "Fully in range")
|
||||
{
|
||||
StartPositionTicks = TimeSpan.FromSeconds(12).Ticks,
|
||||
EndPositionTicks = TimeSpan.FromSeconds(17).Ticks
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_encoder.FilterEvents(
|
||||
track,
|
||||
startPositionTicks: TimeSpan.FromSeconds(10).Ticks,
|
||||
endTimeTicks: TimeSpan.FromSeconds(20).Ticks,
|
||||
preserveTimestamps: false);
|
||||
|
||||
Assert.Equal(2, track.TrackEvents.Count);
|
||||
// Subtitle 1: start should be clamped to 0, not -5s
|
||||
Assert.True(track.TrackEvents[0].StartPositionTicks >= 0, "StartPositionTicks must not be negative");
|
||||
Assert.Equal(TimeSpan.FromSeconds(5).Ticks, track.TrackEvents[0].EndPositionTicks);
|
||||
// Subtitle 2: normal shift (12s - 10s = 2s, 17s - 10s = 7s)
|
||||
Assert.Equal(TimeSpan.FromSeconds(2).Ticks, track.TrackEvents[1].StartPositionTicks);
|
||||
Assert.Equal(TimeSpan.FromSeconds(7).Ticks, track.TrackEvents[1].EndPositionTicks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilterEvents_NoEndTimeTicks_ReturnsAllFromStartPosition()
|
||||
{
|
||||
var track = new SubtitleTrackInfo
|
||||
{
|
||||
TrackEvents = new[]
|
||||
{
|
||||
new SubtitleTrackEvent("1", "Before")
|
||||
{
|
||||
StartPositionTicks = TimeSpan.FromSeconds(2).Ticks,
|
||||
EndPositionTicks = TimeSpan.FromSeconds(4).Ticks
|
||||
},
|
||||
new SubtitleTrackEvent("2", "After")
|
||||
{
|
||||
StartPositionTicks = TimeSpan.FromSeconds(12).Ticks,
|
||||
EndPositionTicks = TimeSpan.FromSeconds(15).Ticks
|
||||
},
|
||||
new SubtitleTrackEvent("3", "Much later")
|
||||
{
|
||||
StartPositionTicks = TimeSpan.FromSeconds(500).Ticks,
|
||||
EndPositionTicks = TimeSpan.FromSeconds(505).Ticks
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_encoder.FilterEvents(
|
||||
track,
|
||||
startPositionTicks: TimeSpan.FromSeconds(10).Ticks,
|
||||
endTimeTicks: 0,
|
||||
preserveTimestamps: true);
|
||||
|
||||
Assert.Equal(2, track.TrackEvents.Count);
|
||||
Assert.Equal("2", track.TrackEvents[0].Id);
|
||||
Assert.Equal("3", track.TrackEvents[1].Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user