From d087850f69c008d1428c7cb5234c5d8f2f79be97 Mon Sep 17 00:00:00 2001 From: Kieran Date: Wed, 13 Dec 2023 13:46:30 +0000 Subject: [PATCH] better clips --- NostrStreamer/Controllers/NostrController.cs | 25 ++++---- NostrStreamer/Services/Clips/ClipGenerator.cs | 64 +++++++++++++++++-- NostrStreamer/Services/Clips/IClipService.cs | 18 ++++-- NostrStreamer/Services/Clips/S3ClipService.cs | 21 +++--- 4 files changed, 95 insertions(+), 33 deletions(-) diff --git a/NostrStreamer/Controllers/NostrController.cs b/NostrStreamer/Controllers/NostrController.cs index 6048268..f95b798 100644 --- a/NostrStreamer/Controllers/NostrController.cs +++ b/NostrStreamer/Controllers/NostrController.cs @@ -173,19 +173,17 @@ public class NostrController : Controller return Json(new { - id, - segments = clip.Select(a => new - { - idx = a.Index - }) + id = clip.Id, + length = clip.Length }); } - [HttpPost("clip/{id:guid}")] - public async Task MakeClip([FromRoute] Guid id, [FromBody] MakeClipReq req) + [HttpPost("clip/{streamId:guid}/{tempClipId:guid}")] + public async Task MakeClip([FromRoute] Guid streamId, [FromRoute] Guid tempClipId, [FromQuery] float start, + [FromQuery] float length) { var pk = GetPubKey(); - var clip = await _clipService.MakeClip(pk, req.Segments.Select(a => new ClipSegment(id, a)).ToList(), req.Start, req.Length); + var clip = await _clipService.MakeClip(pk, streamId, tempClipId, start, length); if (clip == default) return StatusCode(500); return Json(new @@ -194,17 +192,18 @@ public class NostrController : Controller }); } - [HttpGet("clip/{id:guid}/{idx:int}.ts")] - public async Task GetClipSegment([FromRoute] Guid id, [FromRoute] int idx) + [AllowAnonymous] + [HttpGet("clip/{streamId:guid}/{clipId:guid}")] + public IActionResult GetClipSegment([FromRoute] Guid streamId, [FromRoute] Guid clipId) { - var seg = new ClipSegment(id, idx); + var seg = new TempClip(streamId, clipId, 0); if (!System.IO.File.Exists(seg.GetPath())) { return NotFound(); } - await using var fs = new FileStream(seg.GetPath(), FileMode.Open, FileAccess.Read); - return File(fs, "video/mp2t"); + var fs = new FileStream(seg.GetPath(), FileMode.Open, FileAccess.Read); + return File(fs, "video/mp4", enableRangeProcessing: true); } private async Task GetUser() diff --git a/NostrStreamer/Services/Clips/ClipGenerator.cs b/NostrStreamer/Services/Clips/ClipGenerator.cs index 57084bf..25c5987 100644 --- a/NostrStreamer/Services/Clips/ClipGenerator.cs +++ b/NostrStreamer/Services/Clips/ClipGenerator.cs @@ -17,11 +17,24 @@ public class ClipGenerator _client = client; } - public async Task CreateClipFromSegments(List segments, float start, float length) + public async Task CreateClipFromSegments(List segments) { var path = Path.ChangeExtension(Path.GetTempFileName(), ".mp4"); var cmd = FFMpegArguments - .FromConcatInput(segments.Select(a => a.GetPath()), + .FromConcatInput(segments.Select(a => a.GetPath())) + .OutputToFile(path) + .CancellableThrough(new CancellationTokenSource(TimeSpan.FromSeconds(60)).Token); + + _logger.LogInformation("Running command {cmd}", cmd.Arguments); + await cmd.ProcessAsynchronously(); + return path; + } + + public async Task SliceTempClip(TempClip tempClip, float start, float length) + { + var path = Path.ChangeExtension(Path.GetTempFileName(), ".mp4"); + var cmd = FFMpegArguments + .FromFileInput(tempClip.GetPath(), true, inOpt => { inOpt.Seek(TimeSpan.FromSeconds(start)); }) .OutputToFile(path, true, o => { o.WithDuration(TimeSpan.FromSeconds(length)); }) .CancellableThrough(new CancellationTokenSource(TimeSpan.FromSeconds(60)).Token); @@ -34,7 +47,8 @@ public class ClipGenerator public async Task> GetClipSegments(UserStream stream) { var ret = new List(); - var path = $"/{stream.Endpoint.App}/source/{stream.User.StreamKey}.m3u8"; + var ctx = await GetHlsCtx(stream); + var path = $"/{stream.Endpoint.App}/source/{stream.User.StreamKey}.m3u8?hls_ctx={ctx}"; var ub = new Uri(_config.SrsHttpHost, path); var rsp = await _client.GetStreamAsync(ub); @@ -44,14 +58,25 @@ public class ClipGenerator if (line.StartsWith("#EXTINF")) { var trackPath = await sr.ReadLineAsync(); + var segLen = Regex.Match(line, @"#EXTINF:([\d\.]+)"); var seg = Regex.Match(trackPath!, @"-(\d+)\.ts"); var idx = int.Parse(seg.Groups[1].Value); - var clipSeg = new ClipSegment(stream.Id, idx); + var len = float.Parse(segLen.Groups[1].Value); + var clipSeg = new ClipSegment(stream.Id, idx, len); var outPath = clipSeg.GetPath(); + var outDir = Path.GetDirectoryName(outPath); + if (!Directory.Exists(outDir)) + { + Directory.CreateDirectory(outDir!); + } + if (!File.Exists(outPath)) { - var segStream = await _client.GetStreamAsync(new Uri(_config.DataHost, trackPath)); + var segStream = await _client.GetStreamAsync(new Uri(_config.SrsHttpHost, + $"/{stream.Endpoint.App}/source/{stream.User.StreamKey}-{idx}.ts")); + await using var fsOut = new FileStream(outPath, FileMode.Create, FileAccess.ReadWrite); + await segStream.CopyToAsync(fsOut); } @@ -61,4 +86,33 @@ public class ClipGenerator return ret; } + + private async Task GetHlsCtx(UserStream stream) + { + var path = $"/{stream.Endpoint.App}/source/{stream.User.StreamKey}.m3u8"; + var ub = new Uri(_config.SrsHttpHost, path); + var req = new HttpRequestMessage(HttpMethod.Get, ub); + using var rsp = await _client.SendAsync(req); + if (!rsp.IsSuccessStatusCode) + { + return default; + } + + using var sr = new StreamReader(await rsp.Content.ReadAsStreamAsync()); + while (await sr.ReadLineAsync() is { } line) + { + if (line.StartsWith("#EXT-X-STREAM-INF")) + { + var trackLine = await sr.ReadLineAsync(); + var rx = new Regex("\\?hls_ctx=(\\w+)$"); + var match = rx.Match(trackLine!); + if (match.Success) + { + return match.Groups[1].Value; + } + } + } + + return default; + } } diff --git a/NostrStreamer/Services/Clips/IClipService.cs b/NostrStreamer/Services/Clips/IClipService.cs index 96c2c6d..fbdbdfa 100644 --- a/NostrStreamer/Services/Clips/IClipService.cs +++ b/NostrStreamer/Services/Clips/IClipService.cs @@ -2,17 +2,25 @@ namespace NostrStreamer.Services.Clips; public interface IClipService { - Task?> PrepareClip(Guid streamId); + Task PrepareClip(Guid streamId); - Task MakeClip(string takenBy, List segments, float start, float length); + Task MakeClip(string takenBy, Guid streamId, Guid clipId, float start, float length); } -public record ClipResult(Uri Url); +public record ClipResult(Uri Url, float Length); -public record ClipSegment(Guid Id, int Index) +public record ClipSegment(Guid Id, int Index, float Length) { public string GetPath() { return Path.Join(Path.GetTempPath(), Id.ToString(), $"{Index}.ts"); } -} \ No newline at end of file +} + +public record TempClip(Guid StreamId, Guid Id, float Length) +{ + public string GetPath() + { + return Path.Join(Path.GetTempPath(), $"{Id}.mp4"); + } +} diff --git a/NostrStreamer/Services/Clips/S3ClipService.cs b/NostrStreamer/Services/Clips/S3ClipService.cs index 1f2fc26..e05e18e 100644 --- a/NostrStreamer/Services/Clips/S3ClipService.cs +++ b/NostrStreamer/Services/Clips/S3ClipService.cs @@ -15,12 +15,11 @@ public class S3ClipService : IClipService { _generator = generator; _client = config.S3Store.CreateClient(); - ; _config = config; _context = context; } - public async Task?> PrepareClip(Guid streamId) + public async Task PrepareClip(Guid streamId) { var stream = await _context.Streams .Include(a => a.User) @@ -32,16 +31,18 @@ public class S3ClipService : IClipService return default; } - return await _generator.GetClipSegments(stream); + var segments = await _generator.GetClipSegments(stream); + var clip = await _generator.CreateClipFromSegments(segments); + if (string.IsNullOrEmpty(clip)) return default; + + var ret = new TempClip(streamId, Guid.NewGuid(), segments.Sum(a => a.Length)); + File.Move(clip, ret.GetPath()); + return ret; } - public async Task MakeClip(string takenBy, List segments, float start, float length) + public async Task MakeClip(string takenBy, Guid streamId, Guid clipId, float start, float length) { - if (segments.Count == 0) return default; - - var streamId = segments.First().Id; - var clip = await _generator.CreateClipFromSegments(segments, start, length); - var clipId = Guid.NewGuid(); + var clip = await _generator.SliceTempClip(new(streamId, clipId, 0), start, length); var s3Path = $"{streamId}/clips/{clipId}.mp4"; await using var fs = new FileStream(clip, FileMode.Open, FileAccess.Read); @@ -82,6 +83,6 @@ public class S3ClipService : IClipService _context.Clips.Add(clipObj); await _context.SaveChangesAsync(); - return new(ub.Uri); + return new(ub.Uri, length); } }