diff --git a/NostrStreamer/ApiModel/MakeClipReq.cs b/NostrStreamer/ApiModel/MakeClipReq.cs new file mode 100644 index 0000000..5982bb2 --- /dev/null +++ b/NostrStreamer/ApiModel/MakeClipReq.cs @@ -0,0 +1,16 @@ + +using Newtonsoft.Json; + +namespace NostrStreamer.ApiModel; + +public class MakeClipReq +{ + [JsonProperty("segments")] + public List Segments { get; init; } = null!; + + [JsonProperty("start")] + public float Start { get; init; } + + [JsonProperty("length")] + public float Length { get; init; } +} diff --git a/NostrStreamer/Controllers/NostrController.cs b/NostrStreamer/Controllers/NostrController.cs index 0ea47d3..6048268 100644 --- a/NostrStreamer/Controllers/NostrController.cs +++ b/NostrStreamer/Controllers/NostrController.cs @@ -165,11 +165,27 @@ public class NostrController : Controller return Ok(); } + [HttpGet("clip/{id:guid}")] + public async Task GetClipSegments([FromRoute] Guid id) + { + var clip = await _clipService.PrepareClip(id); + if (clip == default) return StatusCode(500); + + return Json(new + { + id, + segments = clip.Select(a => new + { + idx = a.Index + }) + }); + } + [HttpPost("clip/{id:guid}")] - public async Task CreateClip([FromRoute] Guid id) + public async Task MakeClip([FromRoute] Guid id, [FromBody] MakeClipReq req) { var pk = GetPubKey(); - var clip = await _clipService.CreateClip(id, pk); + var clip = await _clipService.MakeClip(pk, req.Segments.Select(a => new ClipSegment(id, a)).ToList(), req.Start, req.Length); if (clip == default) return StatusCode(500); return Json(new @@ -178,6 +194,19 @@ public class NostrController : Controller }); } + [HttpGet("clip/{id:guid}/{idx:int}.ts")] + public async Task GetClipSegment([FromRoute] Guid id, [FromRoute] int idx) + { + var seg = new ClipSegment(id, idx); + 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"); + } + private async Task GetUser() { var pk = GetPubKey(); diff --git a/NostrStreamer/Services/Clips/ClipGenerator.cs b/NostrStreamer/Services/Clips/ClipGenerator.cs index 7f7bb99..b2704cf 100644 --- a/NostrStreamer/Services/Clips/ClipGenerator.cs +++ b/NostrStreamer/Services/Clips/ClipGenerator.cs @@ -1,5 +1,5 @@ +using System.Text.RegularExpressions; using FFMpegCore; -using NostrStreamer.Database; namespace NostrStreamer.Services.Clips; @@ -7,28 +7,56 @@ public class ClipGenerator { private readonly ILogger _logger; private readonly Config _config; + private readonly HttpClient _client; - public ClipGenerator(ILogger logger, Config config) + public ClipGenerator(ILogger logger, Config config, HttpClient client) { _logger = logger; _config = config; + _client = client; } - public async Task GenerateClip(UserStream stream) + public async Task CreateClipFromSegments(List segments, float start, float length) { - const int clipLength = 20; var path = Path.ChangeExtension(Path.GetTempFileName(), ".mp4"); var cmd = FFMpegArguments - .FromUrlInput(new Uri(_config.DataHost, $"stream/{stream.Id}.m3u8"), - inOpt => - { - inOpt.WithCustomArgument($"-ss -{clipLength}"); - }) - .OutputToFile(path, true, o => { o.WithDuration(TimeSpan.FromSeconds(clipLength)); }) + .FromConcatInput(segments.Select(a => a.GetPath()), + inOpt => { inOpt.Seek(TimeSpan.FromSeconds(start)); }) + .OutputToFile(path, true, o => { o.WithDuration(TimeSpan.FromSeconds(length)); }) .CancellableThrough(new CancellationTokenSource(TimeSpan.FromSeconds(60)).Token); - + _logger.LogInformation("Running command {cmd}", cmd.Arguments); await cmd.ProcessAsynchronously(); return path; } + + public async Task> GetClipSegments(Guid id) + { + var ret = new List(); + var playlist = new Uri(_config.DataHost, $"stream/{id}.m3u8"); + + var rsp = await _client.GetStreamAsync(playlist); + using var sr = new StreamReader(rsp); + while (await sr.ReadLineAsync() is { } line) + { + if (line.StartsWith("#EXTINF")) + { + var trackPath = await sr.ReadLineAsync(); + var seg = Regex.Match(trackPath!, @"-(\d+)\.ts"); + var idx = int.Parse(seg.Groups[1].Value); + var clipSeg = new ClipSegment(id, idx); + var outPath = clipSeg.GetPath(); + if (!File.Exists(outPath)) + { + var segStream = await _client.GetStreamAsync(new Uri(_config.DataHost, trackPath)); + await using var fsOut = new FileStream(outPath, FileMode.Create, FileAccess.ReadWrite); + await segStream.CopyToAsync(fsOut); + } + + ret.Add(clipSeg); + } + } + + return ret; + } } diff --git a/NostrStreamer/Services/Clips/IClipService.cs b/NostrStreamer/Services/Clips/IClipService.cs index 5ca8885..96c2c6d 100644 --- a/NostrStreamer/Services/Clips/IClipService.cs +++ b/NostrStreamer/Services/Clips/IClipService.cs @@ -2,7 +2,17 @@ namespace NostrStreamer.Services.Clips; public interface IClipService { - Task CreateClip(Guid streamId, string takenBy); + Task?> PrepareClip(Guid streamId); + + Task MakeClip(string takenBy, List segments, float start, float length); } public record ClipResult(Uri Url); + +public record ClipSegment(Guid Id, int Index) +{ + public string GetPath() + { + return Path.Join(Path.GetTempPath(), Id.ToString(), $"{Index}.ts"); + } +} \ No newline at end of file diff --git a/NostrStreamer/Services/Clips/S3ClipService.cs b/NostrStreamer/Services/Clips/S3ClipService.cs index 0491d31..04919a1 100644 --- a/NostrStreamer/Services/Clips/S3ClipService.cs +++ b/NostrStreamer/Services/Clips/S3ClipService.cs @@ -20,7 +20,7 @@ public class S3ClipService : IClipService _context = context; } - public async Task CreateClip(Guid streamId, string takenBy) + public async Task?> PrepareClip(Guid streamId) { var stream = await _context.Streams .Include(a => a.User) @@ -32,12 +32,19 @@ public class S3ClipService : IClipService return default; } - var tmpClip = await _generator.GenerateClip(stream); + return await _generator.GetClipSegments(streamId); + } + public async Task MakeClip(string takenBy, List segments, 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 s3Path = $"{stream.Id}/clips/{clipId}.mp4"; + var s3Path = $"{streamId}/clips/{clipId}.mp4"; - await using var fs = new FileStream(tmpClip, FileMode.Open, FileAccess.Read); + await using var fs = new FileStream(clip, FileMode.Open, FileAccess.Read); await _client.PutObjectAsync(new() { BucketName = _config.S3Store.BucketName, @@ -67,7 +74,7 @@ public class S3ClipService : IClipService var clipObj = new UserStreamClip() { Id = clipId, - UserStreamId = stream.Id, + UserStreamId = streamId, TakenByPubkey = takenBy, Url = ub.Uri.ToString() };