better clips
This commit is contained in:
@ -173,19 +173,17 @@ public class NostrController : Controller
|
|||||||
|
|
||||||
return Json(new
|
return Json(new
|
||||||
{
|
{
|
||||||
id,
|
id = clip.Id,
|
||||||
segments = clip.Select(a => new
|
length = clip.Length
|
||||||
{
|
|
||||||
idx = a.Index
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("clip/{id:guid}")]
|
[HttpPost("clip/{streamId:guid}/{tempClipId:guid}")]
|
||||||
public async Task<IActionResult> MakeClip([FromRoute] Guid id, [FromBody] MakeClipReq req)
|
public async Task<IActionResult> MakeClip([FromRoute] Guid streamId, [FromRoute] Guid tempClipId, [FromQuery] float start,
|
||||||
|
[FromQuery] float length)
|
||||||
{
|
{
|
||||||
var pk = GetPubKey();
|
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);
|
if (clip == default) return StatusCode(500);
|
||||||
|
|
||||||
return Json(new
|
return Json(new
|
||||||
@ -194,17 +192,18 @@ public class NostrController : Controller
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("clip/{id:guid}/{idx:int}.ts")]
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> GetClipSegment([FromRoute] Guid id, [FromRoute] int idx)
|
[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()))
|
if (!System.IO.File.Exists(seg.GetPath()))
|
||||||
{
|
{
|
||||||
return NotFound();
|
return NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
await using var fs = new FileStream(seg.GetPath(), FileMode.Open, FileAccess.Read);
|
var fs = new FileStream(seg.GetPath(), FileMode.Open, FileAccess.Read);
|
||||||
return File(fs, "video/mp2t");
|
return File(fs, "video/mp4", enableRangeProcessing: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<User?> GetUser()
|
private async Task<User?> GetUser()
|
||||||
|
@ -17,11 +17,24 @@ public class ClipGenerator
|
|||||||
_client = client;
|
_client = client;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string> CreateClipFromSegments(List<ClipSegment> segments, float start, float length)
|
public async Task<string> CreateClipFromSegments(List<ClipSegment> segments)
|
||||||
{
|
{
|
||||||
var path = Path.ChangeExtension(Path.GetTempFileName(), ".mp4");
|
var path = Path.ChangeExtension(Path.GetTempFileName(), ".mp4");
|
||||||
var cmd = FFMpegArguments
|
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<string> 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)); })
|
inOpt => { inOpt.Seek(TimeSpan.FromSeconds(start)); })
|
||||||
.OutputToFile(path, true, o => { o.WithDuration(TimeSpan.FromSeconds(length)); })
|
.OutputToFile(path, true, o => { o.WithDuration(TimeSpan.FromSeconds(length)); })
|
||||||
.CancellableThrough(new CancellationTokenSource(TimeSpan.FromSeconds(60)).Token);
|
.CancellableThrough(new CancellationTokenSource(TimeSpan.FromSeconds(60)).Token);
|
||||||
@ -34,7 +47,8 @@ public class ClipGenerator
|
|||||||
public async Task<List<ClipSegment>> GetClipSegments(UserStream stream)
|
public async Task<List<ClipSegment>> GetClipSegments(UserStream stream)
|
||||||
{
|
{
|
||||||
var ret = new List<ClipSegment>();
|
var ret = new List<ClipSegment>();
|
||||||
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 ub = new Uri(_config.SrsHttpHost, path);
|
||||||
|
|
||||||
var rsp = await _client.GetStreamAsync(ub);
|
var rsp = await _client.GetStreamAsync(ub);
|
||||||
@ -44,14 +58,25 @@ public class ClipGenerator
|
|||||||
if (line.StartsWith("#EXTINF"))
|
if (line.StartsWith("#EXTINF"))
|
||||||
{
|
{
|
||||||
var trackPath = await sr.ReadLineAsync();
|
var trackPath = await sr.ReadLineAsync();
|
||||||
|
var segLen = Regex.Match(line, @"#EXTINF:([\d\.]+)");
|
||||||
var seg = Regex.Match(trackPath!, @"-(\d+)\.ts");
|
var seg = Regex.Match(trackPath!, @"-(\d+)\.ts");
|
||||||
var idx = int.Parse(seg.Groups[1].Value);
|
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 outPath = clipSeg.GetPath();
|
||||||
|
var outDir = Path.GetDirectoryName(outPath);
|
||||||
|
if (!Directory.Exists(outDir))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(outDir!);
|
||||||
|
}
|
||||||
|
|
||||||
if (!File.Exists(outPath))
|
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 using var fsOut = new FileStream(outPath, FileMode.Create, FileAccess.ReadWrite);
|
||||||
|
|
||||||
await segStream.CopyToAsync(fsOut);
|
await segStream.CopyToAsync(fsOut);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -61,4 +86,33 @@ public class ClipGenerator
|
|||||||
|
|
||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<string?> 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,17 +2,25 @@ namespace NostrStreamer.Services.Clips;
|
|||||||
|
|
||||||
public interface IClipService
|
public interface IClipService
|
||||||
{
|
{
|
||||||
Task<List<ClipSegment>?> PrepareClip(Guid streamId);
|
Task<TempClip?> PrepareClip(Guid streamId);
|
||||||
|
|
||||||
Task<ClipResult?> MakeClip(string takenBy, List<ClipSegment> segments, float start, float length);
|
Task<ClipResult?> 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()
|
public string GetPath()
|
||||||
{
|
{
|
||||||
return Path.Join(Path.GetTempPath(), Id.ToString(), $"{Index}.ts");
|
return Path.Join(Path.GetTempPath(), Id.ToString(), $"{Index}.ts");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public record TempClip(Guid StreamId, Guid Id, float Length)
|
||||||
|
{
|
||||||
|
public string GetPath()
|
||||||
|
{
|
||||||
|
return Path.Join(Path.GetTempPath(), $"{Id}.mp4");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -15,12 +15,11 @@ public class S3ClipService : IClipService
|
|||||||
{
|
{
|
||||||
_generator = generator;
|
_generator = generator;
|
||||||
_client = config.S3Store.CreateClient();
|
_client = config.S3Store.CreateClient();
|
||||||
;
|
|
||||||
_config = config;
|
_config = config;
|
||||||
_context = context;
|
_context = context;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<ClipSegment>?> PrepareClip(Guid streamId)
|
public async Task<TempClip?> PrepareClip(Guid streamId)
|
||||||
{
|
{
|
||||||
var stream = await _context.Streams
|
var stream = await _context.Streams
|
||||||
.Include(a => a.User)
|
.Include(a => a.User)
|
||||||
@ -32,16 +31,18 @@ public class S3ClipService : IClipService
|
|||||||
return default;
|
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<ClipResult?> MakeClip(string takenBy, List<ClipSegment> segments, float start, float length)
|
public async Task<ClipResult?> MakeClip(string takenBy, Guid streamId, Guid clipId, float start, float length)
|
||||||
{
|
{
|
||||||
if (segments.Count == 0) return default;
|
var clip = await _generator.SliceTempClip(new(streamId, clipId, 0), start, length);
|
||||||
|
|
||||||
var streamId = segments.First().Id;
|
|
||||||
var clip = await _generator.CreateClipFromSegments(segments, start, length);
|
|
||||||
var clipId = Guid.NewGuid();
|
|
||||||
var s3Path = $"{streamId}/clips/{clipId}.mp4";
|
var s3Path = $"{streamId}/clips/{clipId}.mp4";
|
||||||
|
|
||||||
await using var fs = new FileStream(clip, FileMode.Open, FileAccess.Read);
|
await using var fs = new FileStream(clip, FileMode.Open, FileAccess.Read);
|
||||||
@ -82,6 +83,6 @@ public class S3ClipService : IClipService
|
|||||||
_context.Clips.Add(clipObj);
|
_context.Clips.Add(clipObj);
|
||||||
await _context.SaveChangesAsync();
|
await _context.SaveChangesAsync();
|
||||||
|
|
||||||
return new(ub.Uri);
|
return new(ub.Uri, length);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user