From 493972c816eb90cf83c1ec591f18a4ed78de2993 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Sackers?= Date: Fri, 28 Jul 2023 20:03:52 +0200 Subject: [PATCH] Many things --- .github/workflows/release.yml | 2 + .gitignore | 3 +- README.md | 75 +++++++++++++++---- src/Dockerfile | 10 +-- .../Client/Components/ClipViewer.razor | 21 +++--- .../Client/Components/ClipViewer.razor.cs | 14 +++- .../Client/Components/VideoPlayer.razor | 3 +- .../Client/Models/EventFilterValues.cs | 13 ++-- .../Client/Pages/Index.razor | 2 +- .../TeslaCamPlayer.BlazorHosted.Client.csproj | 1 - .../wwwroot/scss/components/_viewer.scss | 16 +++- .../Server/Controllers/ApiController.cs | 1 - .../Server/Program.cs | 12 +++ .../Server/Properties/launchSettings.json | 9 +-- .../Server/Providers/SettingsProvider.cs | 2 + .../Server/Services/ClipsService.cs | 75 ++++++++++++++++--- .../Server/appsettings.Development.json | 8 -- .../Server/appsettings.json | 3 +- .../Shared/Models/Clip.cs | 4 +- 19 files changed, 202 insertions(+), 72 deletions(-) delete mode 100644 src/TeslaCamPlayer.BlazorHosted/Server/appsettings.Development.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 36657e4..9e4d9bc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -83,8 +83,10 @@ jobs: name: release-sc path: "./publish/${{env.ZIP_FILE_NAME}}" + # Self contained version is larger than self-contained + compressed, no need to build this version windows-dependant: runs-on: ubuntu-latest + if: false needs: version env: VERSION: ${{needs.version.outputs.VERSION}} diff --git a/.gitignore b/.gitignore index 40ea33f..5459212 100644 --- a/.gitignore +++ b/.gitignore @@ -398,4 +398,5 @@ FodyWeavers.xsd *.sln.iml *.css -.idea/ \ No newline at end of file +.idea/ +appsettings.*.json \ No newline at end of file diff --git a/README.md b/README.md index 9953a24..489c715 100644 --- a/README.md +++ b/README.md @@ -12,22 +12,41 @@ First release, still needs some work, but functional and might be just what you ### Implemented -* Infinite scrolling list of events (virtualized) -* Icons to easily identify events (sentry/dashcam/honk/movement detected/manual save) -* Calendar to easily go to a certain date -* Auto scaling viewer -* Supports MCU-1 (3 camera, missing back cam) and/or missing/corrupt angles +- Infinite scrolling list of events (virtualized) +- Icons to easily identify events (sentry/dashcam/honk/movement detected/manual save) +- Calendar to easily go to a certain date +- Auto scaling viewer +- Supports MCU-1 (3 camera, missing back cam) and/or missing/corrupt angles +- Event time marker on timeline +- Filtering events +- RecentClips viewing ### TODO/missing -* Event time marker on timeline -* Mobile viewport support -* Progress bar for loading. Initial load checks the length of each video file, this may take a moment. -* Filtering events -* RecentClips viewing -* Map for event location -* Exporting clips -* General small issues +- Mobile viewport support +- Progress bar for loading. Initial load checks the length of each video file, this may take a moment. +- Map for event location +- Exporting clips +- General small issues + +## Windows + +The Windows build is a self-contained .exe, you do not need to install .NET, as it's compiled into the executable. + +Download the latest version from [releases](https://github.com/Rene-Sackers/TeslaCamPlayer/releases/tag/v2023.7.23.1431) (teslacamplayer-win-x64-\*.zip) and extract the zip. + +Modify `appsettings.json`, change the value for `ClipsRootPath`, set it to the path that contains your TeslaCam videos, and escape the \\ directory character with another \\ For example: + +```json +{ + ... + "AllowedHosts": "*", + "ClipsRootPath": "D:\\Some\\Folder\\TeslaCam" +} + +``` + +Run `TeslaCamPlayer.BlazorHosted.Server.exe` and navigate to `http://localhost:5000` in your browser. ## Docker @@ -41,9 +60,9 @@ docker run \ ### Environment variables -| Variable | Example | Description | -| ------------- | --------- | -------------------------------------------------------- | -| ClipsRootPath | /TeslaCam | The path to the root of the clips mount in the container | +| Variable | Example | Description | +| ------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| ClipsRootPath | /TeslaCam | The path to the root of the clips mount in the container. This is set by default, you do not need to change it if you mount the volume to this path. | ### Volumes @@ -56,3 +75,27 @@ docker run \ | Port | Description | | ---- | --------------------------- | | 80 | The HTTP web interface port | + +# Legal + +pls no sue kthx + +## Tesla + +This software is in no way, shape or form affiliated with Tesla, Inc. (https://www.tesla.com/), or its products. +This software is not: + +- An official Tesla product +- Licensed by Tesla +- Built by or in conjunction with Tesla +- Commisioned by Tesla + +It does not directly impact Tesla products. It is an aftermarket piece of software that processes data produced by Tesla vehicles. + +## FFmpeg + +This software uses libraries from the FFmpeg project under the LGPLv2.1 +The Windows build of this software includes a copy of ffprobe.exe, compiled by: https://github.com/BtbN/FFmpeg-Builds/releases +More info and sources for FFmpeg can be found on: https://ffmpeg.org/ + +[def]: releases diff --git a/src/Dockerfile b/src/Dockerfile index 1db7e7a..a4c96e4 100644 --- a/src/Dockerfile +++ b/src/Dockerfile @@ -1,11 +1,10 @@ -FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build +FROM mcr.microsoft.com/dotnet/sdk:7.0 AS publish COPY . /src WORKDIR "/src/TeslaCamPlayer.BlazorHosted/Server" RUN dotnet restore . -RUN dotnet build *.csproj -c Release -o /app/build /p:DefineConstants=DOCKER - -FROM build AS publish RUN dotnet publish *.csproj -c Release -o /app/publish /p:DefineConstants=DOCKER +# Remove ffprobe.exe from docker builds. It relies on an apt package instead. +RUN rm -r /app/publish/lib/ FROM node:17 AS gulp WORKDIR /src @@ -17,9 +16,10 @@ RUN npm install -g gulp RUN gulp default FROM mcr.microsoft.com/dotnet/aspnet:7.0 AS final -RUN apt-get update && apt-get install -y ffmpeg +RUN apt-get update && apt-get install -y ffmpeg --no-install-recommends WORKDIR /app ENV ClipsRootPath=/TeslaCam +EXPOSE 80/tcp COPY --from=publish /app/publish . COPY --from=gulp /src/wwwroot/css/ ./wwwroot/css/ diff --git a/src/TeslaCamPlayer.BlazorHosted/Client/Components/ClipViewer.razor b/src/TeslaCamPlayer.BlazorHosted/Client/Components/ClipViewer.razor index 9044464..a28bead 100644 --- a/src/TeslaCamPlayer.BlazorHosted/Client/Components/ClipViewer.razor +++ b/src/TeslaCamPlayer.BlazorHosted/Client/Components/ClipViewer.razor @@ -15,14 +15,17 @@ - +
+ +
+
\ No newline at end of file diff --git a/src/TeslaCamPlayer.BlazorHosted/Client/Components/ClipViewer.razor.cs b/src/TeslaCamPlayer.BlazorHosted/Client/Components/ClipViewer.razor.cs index 6c0edc8..5f79c46 100644 --- a/src/TeslaCamPlayer.BlazorHosted/Client/Components/ClipViewer.razor.cs +++ b/src/TeslaCamPlayer.BlazorHosted/Client/Components/ClipViewer.razor.cs @@ -51,7 +51,7 @@ private double TimelineValue protected override void OnInitialized() { - _setVideoTimeDebounceTimer = new(100); + _setVideoTimeDebounceTimer = new(500); _setVideoTimeDebounceTimer.Elapsed += ScrubVideoDebounceTick; } @@ -194,6 +194,16 @@ private async Task ScrubToSliderTime() { // ignore, happens sometimes } - + } + + private string EventMarkerStyle() + { + if (_clip?.Event?.Timestamp == null) + return "display: none"; + + var percentageOfClipAtTimestamp = Math.Round(_clip.Event.Timestamp.Subtract(_clip.StartDate).TotalSeconds / _clip.TotalSeconds * 100, 2); + percentageOfClipAtTimestamp = Math.Clamp(percentageOfClipAtTimestamp, 0, 100); + + return $"left: {percentageOfClipAtTimestamp}%"; } } \ No newline at end of file diff --git a/src/TeslaCamPlayer.BlazorHosted/Client/Components/VideoPlayer.razor b/src/TeslaCamPlayer.BlazorHosted/Client/Components/VideoPlayer.razor index 257dca1..11a4c85 100644 --- a/src/TeslaCamPlayer.BlazorHosted/Client/Components/VideoPlayer.razor +++ b/src/TeslaCamPlayer.BlazorHosted/Client/Components/VideoPlayer.razor @@ -4,7 +4,8 @@ class="@Class @(string.IsNullOrWhiteSpace(Src) ? "d-none" : null)" @onended="@VideoEndedHandler" - @ontimeupdate="@TimeUpdateHandler"> + @ontimeupdate="@TimeUpdateHandler" + disableremoteplayback> @code { [Inject] diff --git a/src/TeslaCamPlayer.BlazorHosted/Client/Models/EventFilterValues.cs b/src/TeslaCamPlayer.BlazorHosted/Client/Models/EventFilterValues.cs index 1311e69..571ab6e 100644 --- a/src/TeslaCamPlayer.BlazorHosted/Client/Models/EventFilterValues.cs +++ b/src/TeslaCamPlayer.BlazorHosted/Client/Models/EventFilterValues.cs @@ -20,25 +20,28 @@ public class EventFilterValues public bool IsInFilter(Clip clip) { - if (DashcamHonk && clip.Event.Reason == CamEvents.UserInteractionHonk) + if (Recent && clip.Type == ClipType.Recent) + return true; + + if (DashcamHonk && clip.Event?.Reason == CamEvents.UserInteractionHonk) return true; - if (DashcamSaved && (clip.Event.Reason == CamEvents.UserInteractionDashcamPanelSave || clip.Event.Reason == CamEvents.UserInteractionDashcamIconTapped)) + if (DashcamSaved && clip.Event?.Reason is CamEvents.UserInteractionDashcamPanelSave or CamEvents.UserInteractionDashcamIconTapped) return true; if (DashcamOther && clip.Type == ClipType.Saved) return true; - if (SentryObjectDetection && clip.Event.Reason == CamEvents.SentryAwareObjectDetection) + if (SentryObjectDetection && clip.Event?.Reason == CamEvents.SentryAwareObjectDetection) return true; - if (SentryAccelerationDetection && clip.Event.Reason.StartsWith(CamEvents.SentryAwareAccelerationPrefix)) + if (SentryAccelerationDetection && clip.Event?.Reason?.StartsWith(CamEvents.SentryAwareAccelerationPrefix) == true) return true; if (SentryOther && clip.Type == ClipType.Sentry) return true; - return Recent && clip.Type == ClipType.Recent; + return false; } } } diff --git a/src/TeslaCamPlayer.BlazorHosted/Client/Pages/Index.razor b/src/TeslaCamPlayer.BlazorHosted/Client/Pages/Index.razor index c486107..d600590 100644 --- a/src/TeslaCamPlayer.BlazorHosted/Client/Pages/Index.razor +++ b/src/TeslaCamPlayer.BlazorHosted/Client/Pages/Index.razor @@ -54,7 +54,7 @@ else }
- @context.Event.Timestamp.ToString("yyyy-MM-dd HH:mm:ss") + @((context.Event?.Timestamp ?? context.StartDate).ToString("yyyy-MM-dd HH:mm:ss"))
diff --git a/src/TeslaCamPlayer.BlazorHosted/Client/TeslaCamPlayer.BlazorHosted.Client.csproj b/src/TeslaCamPlayer.BlazorHosted/Client/TeslaCamPlayer.BlazorHosted.Client.csproj index cf8dff9..08ad67b 100644 --- a/src/TeslaCamPlayer.BlazorHosted/Client/TeslaCamPlayer.BlazorHosted.Client.csproj +++ b/src/TeslaCamPlayer.BlazorHosted/Client/TeslaCamPlayer.BlazorHosted.Client.csproj @@ -23,7 +23,6 @@ - diff --git a/src/TeslaCamPlayer.BlazorHosted/Client/wwwroot/scss/components/_viewer.scss b/src/TeslaCamPlayer.BlazorHosted/Client/wwwroot/scss/components/_viewer.scss index afeaed4..1c218dd 100644 --- a/src/TeslaCamPlayer.BlazorHosted/Client/wwwroot/scss/components/_viewer.scss +++ b/src/TeslaCamPlayer.BlazorHosted/Client/wwwroot/scss/components/_viewer.scss @@ -45,8 +45,22 @@ align-items: center; } - .mud-slider { + .seeker-slider-container { + position: relative; + display: flex; width: 80%; + + .event-marker { + $size: 20px; + width: $size; + height: $size; + border-radius: calc($size / 2); + position: absolute; + top: 50%; + transform: translate(-50%, -50%); + background-color: adjust-color(#f64e62ff, $alpha: -0.5); + pointer-events: none; + } } } } \ No newline at end of file diff --git a/src/TeslaCamPlayer.BlazorHosted/Server/Controllers/ApiController.cs b/src/TeslaCamPlayer.BlazorHosted/Server/Controllers/ApiController.cs index 880024b..e9106c1 100644 --- a/src/TeslaCamPlayer.BlazorHosted/Server/Controllers/ApiController.cs +++ b/src/TeslaCamPlayer.BlazorHosted/Server/Controllers/ApiController.cs @@ -1,6 +1,5 @@ using System.Web; using Microsoft.AspNetCore.Mvc; -using Serilog; using TeslaCamPlayer.BlazorHosted.Server.Providers.Interfaces; using TeslaCamPlayer.BlazorHosted.Server.Services.Interfaces; using TeslaCamPlayer.BlazorHosted.Shared.Models; diff --git a/src/TeslaCamPlayer.BlazorHosted/Server/Program.cs b/src/TeslaCamPlayer.BlazorHosted/Server/Program.cs index 232dad0..eed8bfc 100644 --- a/src/TeslaCamPlayer.BlazorHosted/Server/Program.cs +++ b/src/TeslaCamPlayer.BlazorHosted/Server/Program.cs @@ -24,6 +24,18 @@ var app = builder.Build(); +var clipsRootPath = app.Services.GetService()!.Settings.ClipsRootPath; +try +{ + if (!Directory.Exists(clipsRootPath)) + throw new Exception("Configured clips root path doesn't exist, or no permission to access: " + clipsRootPath); +} +catch (Exception e) +{ + Log.Fatal(e, e.Message); + return; +} + // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { diff --git a/src/TeslaCamPlayer.BlazorHosted/Server/Properties/launchSettings.json b/src/TeslaCamPlayer.BlazorHosted/Server/Properties/launchSettings.json index a960d83..1711492 100644 --- a/src/TeslaCamPlayer.BlazorHosted/Server/Properties/launchSettings.json +++ b/src/TeslaCamPlayer.BlazorHosted/Server/Properties/launchSettings.json @@ -4,8 +4,7 @@ "commandName": "Project", "launchBrowser": false, "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development", - "ClipsRootPath": "D:\\TeslaCam\\MCU-2\\TeslaCam" + "ASPNETCORE_ENVIRONMENT": "Development" }, "dotnetRunMessages": false, "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", @@ -15,8 +14,7 @@ "commandName": "Project", "launchBrowser": false, "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development", - "ClipsRootPath": "D:\\TeslaCam" + "ASPNETCORE_ENVIRONMENT": "Development" }, "dotnetRunMessages": false, "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", @@ -26,8 +24,7 @@ "commandName": "IISExpress", "launchBrowser": true, "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development", - "ClipsRootPath": "D:\\TeslaCam\\MCU-2\\TeslaCam" + "ASPNETCORE_ENVIRONMENT": "Development" }, "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}" } diff --git a/src/TeslaCamPlayer.BlazorHosted/Server/Providers/SettingsProvider.cs b/src/TeslaCamPlayer.BlazorHosted/Server/Providers/SettingsProvider.cs index 760aa4f..8962601 100644 --- a/src/TeslaCamPlayer.BlazorHosted/Server/Providers/SettingsProvider.cs +++ b/src/TeslaCamPlayer.BlazorHosted/Server/Providers/SettingsProvider.cs @@ -11,6 +11,8 @@ public class SettingsProvider : ISettingsProvider private static Settings SettingsValueFactory() => new ConfigurationBuilder() + .AddJsonFile(Path.Combine(AppContext.BaseDirectory, "appsettings.json"), optional: true) + .AddJsonFile(Path.Combine(AppContext.BaseDirectory, "appsettings.Development.json"), optional: true) .AddEnvironmentVariables() .Build() .Get(); diff --git a/src/TeslaCamPlayer.BlazorHosted/Server/Services/ClipsService.cs b/src/TeslaCamPlayer.BlazorHosted/Server/Services/ClipsService.cs index e5a2900..81d175b 100644 --- a/src/TeslaCamPlayer.BlazorHosted/Server/Services/ClipsService.cs +++ b/src/TeslaCamPlayer.BlazorHosted/Server/Services/ClipsService.cs @@ -9,6 +9,8 @@ namespace TeslaCamPlayer.BlazorHosted.Server.Services; public partial class ClipsService : IClipsService { + private const string NoThumbnailImageUrl = "/img/no-thumbnail.png"; + private static readonly string CacheFilePath = Path.Combine(AppContext.BaseDirectory, "clips.json"); private static readonly Regex FileNameRegex = FileNameRegexGenerated(); private static Clip[] _cache; @@ -32,28 +34,27 @@ public async Task GetClipsAsync(bool refreshCache = false) if (!refreshCache && (_cache ??= await GetCachedAsync()) != null) return _cache; - var videoFileInfos = (await Task.WhenAll(Directory + var videoFiles = (await Task.WhenAll(Directory .GetFiles(_settingsProvider.Settings.ClipsRootPath, "*.mp4", SearchOption.AllDirectories) .AsParallel() .Select(path => new { Path = path, RegexMatch = FileNameRegex.Match(path) }) .Where(f => f.RegexMatch.Success) .ToList() - .Select(async f => new { f.Path, f.RegexMatch, VideoFile = await TryParseVideoFileAsync(f.Path, f.RegexMatch) }))) + .Select(async f => await TryParseVideoFileAsync(f.Path, f.RegexMatch)))) .AsParallel() - .Where(vfi => vfi.VideoFile != null) + .Where(vfi => vfi != null) .ToList(); - var videoFiles = videoFileInfos - .Select(vfi => vfi.VideoFile) - .ToArray(); - - var clips = videoFileInfos - .Select(vfi => vfi.VideoFile.EventFolderName) + var recentClips = GetRecentClips(videoFiles + .Where(vfi => vfi.ClipType == ClipType.Recent).ToList()); + + var clips = videoFiles + .Select(vfi => vfi.EventFolderName) .Distinct() .AsParallel() - .Where(e => !string.IsNullOrWhiteSpace(e)) // TODO: Work with RecentClips + .Where(e => !string.IsNullOrWhiteSpace(e)) .Select(e => ParseClip(e, videoFiles)) - .ToArray() + .Concat(recentClips.AsParallel()) .OrderByDescending(c => c.StartDate) .ToArray(); @@ -62,6 +63,56 @@ public async Task GetClipsAsync(bool refreshCache = false) return _cache; } + private static IEnumerable GetRecentClips(List recentVideoFiles) + { + recentVideoFiles = recentVideoFiles.OrderByDescending(f => f.StartDate).ToList(); + + var currentClipSegments = new List(); + for (var i = 0; i < recentVideoFiles.Count;) + { + var currentVideoFile = recentVideoFiles[i]; + var segmentVideos = recentVideoFiles.Where(f => f.StartDate == currentVideoFile.StartDate).ToList(); + var segment = new ClipVideoSegment + { + StartDate = currentVideoFile.StartDate, + EndDate = currentVideoFile.StartDate.Add(currentVideoFile.Duration), + CameraFront = segmentVideos.FirstOrDefault(v => v.Camera == Cameras.Front), + CameraLeftRepeater = segmentVideos.FirstOrDefault(v => v.Camera == Cameras.LeftRepeater), + CameraRightRepeater = segmentVideos.FirstOrDefault(v => v.Camera == Cameras.RightRepeater), + CameraBack = segmentVideos.FirstOrDefault(v => v.Camera == Cameras.Back) + }; + + currentClipSegments.Add(segment); + + // Set i to the video after the last video in this clip segment, ie: the first video of the next segment. + i = i + segmentVideos.Count + 1; + + // No more recent video files + if (i >= recentVideoFiles.Count) + { + yield return new Clip(ClipType.Recent, currentClipSegments.ToArray()) + { + ThumbnailUrl = NoThumbnailImageUrl + }; + currentClipSegments.Clear(); + yield break; + } + + const int segmentVideoGapToleranceInSeconds = 5; + var nextSegmentFirstVideo = recentVideoFiles[i]; + // Next video is within X seconds of last video of current segment, continue building clip segments + if (nextSegmentFirstVideo.StartDate <= segment.EndDate.AddSeconds(segmentVideoGapToleranceInSeconds)) + continue; + + // Next video is more than X seconds, assume it's a new recent video clip + yield return new Clip(ClipType.Recent, currentClipSegments.ToArray()) + { + ThumbnailUrl = NoThumbnailImageUrl + }; + currentClipSegments.Clear(); + } + } + private async Task TryParseVideoFileAsync(string path, Match regexMatch) { try @@ -153,7 +204,7 @@ private static Clip ParseClip(string eventFolderName, IEnumerable vid var expectedEventThumbnailPath = Path.Combine(eventFolderPath, "thumb.png"); var thumbnailUrl = File.Exists(expectedEventThumbnailPath) ? $"/Api/Thumbnail/{Uri.EscapeDataString(expectedEventThumbnailPath)}" - : "/img/no-thumbnail.png"; + : NoThumbnailImageUrl; return new Clip(eventVideoFiles.First().ClipType, segments) { diff --git a/src/TeslaCamPlayer.BlazorHosted/Server/appsettings.Development.json b/src/TeslaCamPlayer.BlazorHosted/Server/appsettings.Development.json deleted file mode 100644 index 6ea7671..0000000 --- a/src/TeslaCamPlayer.BlazorHosted/Server/appsettings.Development.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - } -} diff --git a/src/TeslaCamPlayer.BlazorHosted/Server/appsettings.json b/src/TeslaCamPlayer.BlazorHosted/Server/appsettings.json index 9c3a5de..27653bf 100644 --- a/src/TeslaCamPlayer.BlazorHosted/Server/appsettings.json +++ b/src/TeslaCamPlayer.BlazorHosted/Server/appsettings.json @@ -5,5 +5,6 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "ClipsRootPath": "" } diff --git a/src/TeslaCamPlayer.BlazorHosted/Shared/Models/Clip.cs b/src/TeslaCamPlayer.BlazorHosted/Shared/Models/Clip.cs index d290dbb..8bb7dc6 100644 --- a/src/TeslaCamPlayer.BlazorHosted/Shared/Models/Clip.cs +++ b/src/TeslaCamPlayer.BlazorHosted/Shared/Models/Clip.cs @@ -8,12 +8,12 @@ public class Clip public DateTime StartDate { get; } public DateTime EndDate { get; } public double TotalSeconds { get; } - public string ThumbnailUrl { get; set; } + public string ThumbnailUrl { get; init; } public Clip(ClipType type, ClipVideoSegment[] segments) { Type = type; - Segments = segments; + Segments = segments.OrderBy(s => s.StartDate).ToArray(); StartDate = segments.Min(s => s.StartDate); EndDate = segments.Max(s => s.EndDate); TotalSeconds = EndDate.Subtract(StartDate).TotalSeconds;