From dc5dc4bff4fd99b923c39335b83f03646ff90d0d Mon Sep 17 00:00:00 2001 From: Amatsugu Date: Wed, 28 Jan 2026 21:30:47 -0500 Subject: [PATCH] timeline rendering --- AZKiServer/RPC/ConversionExtensions.cs | 11 ++++ AZKiServer/Services/FileScannerService.cs | 23 +++++--- AZKiServer/Services/MediaService.cs | 4 +- client/assets/styling/player.scss | 40 +++++++++++++ client/src/components/playback/player.rs | 17 +++--- client/src/components/playback/timeline.rs | 69 +++++++++++++++++++++- 6 files changed, 143 insertions(+), 21 deletions(-) diff --git a/AZKiServer/RPC/ConversionExtensions.cs b/AZKiServer/RPC/ConversionExtensions.cs index 76c111f..5e39831 100644 --- a/AZKiServer/RPC/ConversionExtensions.cs +++ b/AZKiServer/RPC/ConversionExtensions.cs @@ -33,9 +33,20 @@ public static class ConversionExtensions Version = entry.Version, CameraId = entry.CameraId, Date = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime(entry.Date), + Metadata = entry.Metadata.ToRpc(), FilePath = entry.Filepath, Id = entry.Id.ToString(), Type = entry.Type.ToRpc() }; } + + public static MediaMetadata ToRpc(this Models.MediaMetadata metadata) + { + return new MediaMetadata + { + Duration = metadata.Duration, + Height = metadata.Height, + Width = metadata.Width + }; + } } diff --git a/AZKiServer/Services/FileScannerService.cs b/AZKiServer/Services/FileScannerService.cs index 047d71a..e4eefaa 100644 --- a/AZKiServer/Services/FileScannerService.cs +++ b/AZKiServer/Services/FileScannerService.cs @@ -32,8 +32,10 @@ public class FileScannerService(MediaService mediaService, IConfiguration config { if (_isRunning) return; + logger.LogInformation("Starting file scan"); _isRunning = true; ScanFilesAsync(path).Wait(); + logger.LogInformation("File scan complete"); _isRunning = false; }, null, TimeSpan.FromMinutes(0), TimeSpan.FromHours(1)); return Task.CompletedTask; @@ -57,7 +59,7 @@ public class FileScannerService(MediaService mediaService, IConfiguration config foreach (var chunk in files.Chunk(50)) { total += await ScanFileChunkAsync(path, chunk, existingFiles, cancellationToken); - logger.LogInformation("Added {updated} of {count}", total, existingFiles.Count); + logger.LogInformation("Added {updated} of {count}", total, files.Length); } } catch (Exception ex) @@ -80,7 +82,7 @@ public class FileScannerService(MediaService mediaService, IConfiguration config var isUpgrade = false; if (existingFiles.TryGetValue(relativePath, out var version)) { - if (version < MediaEntry.CUR_VERSION) + if (version <= MediaEntry.CUR_VERSION) continue; isUpgrade = true; } @@ -111,7 +113,7 @@ public class FileScannerService(MediaService mediaService, IConfiguration config { await mediaService.DeleteAllEntriesAsync(upgradeEntries.Select(e => e.Filepath), cancellationToken); await mediaService.AddMediaBulkAsync(upgradeEntries, cancellationToken); - logger.LogInformation("Upgraded {count} file entries", entries.Count); + logger.LogInformation("Upgraded {count} file entries", upgradeEntries.Count); } return entries.Count + upgradeEntries.Count; } @@ -143,9 +145,16 @@ public class FileScannerService(MediaService mediaService, IConfiguration config private static Maybe ReadVideoMetadata(string filePath) { - var info = FFProbe.Analyse(filePath); - if (info.PrimaryVideoStream == null) - return new Error($"Could not find a primirary video stream in file."); - return new MediaMetadata((int)info.Duration.TotalSeconds, info.PrimaryVideoStream.Height, info.PrimaryVideoStream.Width); + try + { + var info = FFProbe.Analyse(filePath); + if (info.PrimaryVideoStream == null) + return new Error($"Could not find a primirary video stream in file."); + return new MediaMetadata((int)info.Duration.TotalSeconds, info.PrimaryVideoStream.Height, info.PrimaryVideoStream.Width); + } + catch(Exception ex) + { + return ex; + } } } diff --git a/AZKiServer/Services/MediaService.cs b/AZKiServer/Services/MediaService.cs index 1aa7e33..d4ff07f 100644 --- a/AZKiServer/Services/MediaService.cs +++ b/AZKiServer/Services/MediaService.cs @@ -13,13 +13,13 @@ public class MediaService(IMongoDatabase db) public readonly IMongoCollection _entries = db.GetCollection("media"); [BsonIgnoreExtraElements] - private record MediaInfo(string FilePath, int Version); + private record class MediaInfo(string Filepath, int Version); public async Task> GetExistingFilePathsAsync(CancellationToken cancellationToken = default) { var files = await _entries.Find("{}").As().ToListAsync(cancellationToken); if (files.Count == 0) return FrozenDictionary.Empty; - return files.ToFrozenDictionary(m => m.FilePath, m => m.Version); + return files.ToFrozenDictionary(m => m.Filepath, m => m.Version); } public async Task AddMediaBulkAsync(List entries, CancellationToken cancellationToken = default) diff --git a/client/assets/styling/player.scss b/client/assets/styling/player.scss index 507f2cb..3ae5cec 100644 --- a/client/assets/styling/player.scss +++ b/client/assets/styling/player.scss @@ -19,4 +19,44 @@ #timeline { grid-area: Timeline; border: 1px solid $lightBackground; + + #tracklist { + display: flex; + flex-direction: column; + overflow: auto; + width: 100%; + gap: 1px; + } + .track { + display: block; + position: relative; + padding: 2px 0; + height: 30px; + background-color: $lightBackground; + /* overflow: hidden; */ + margin-left: 100px; + + .label { + width: 100px; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + position: absolute; + left: -100px; + top: 0px; + background-color: $featureBackround; + } + } + + .clip { + position: absolute; + display: block; + height: 30px; + top: auto; + flex-grow: 0; + flex-shrink: 0; + background-color: $mainAccentDark; + overflow: hidden; + } } diff --git a/client/src/components/playback/player.rs b/client/src/components/playback/player.rs index e469792..876e875 100644 --- a/client/src/components/playback/player.rs +++ b/client/src/components/playback/player.rs @@ -1,4 +1,4 @@ -use chrono::{Datelike, Days, Local}; +use chrono::{Datelike, Local}; use dioxus::prelude::*; use prost_types::Timestamp; @@ -10,10 +10,10 @@ const PLAYER_CSS: Asset = asset!("/assets/styling/player.scss"); #[component] pub fn Player() -> Element { - let entries = use_resource(|| async move { + let playbackResult = use_resource(|| async move { let mut client = get_rpc_client(); let now = Local::now(); - let from = Timestamp::date(now.year() as i64, now.month() as u8, now.day() as u8).unwrap(); + let from = Timestamp::date(now.year() as i64, now.month() as u8, now.day() as u8 - 4).unwrap(); let result = client .get_media_playback(MediaPlaybackRequest { date: Some(from) }) .await; @@ -26,12 +26,12 @@ pub fn Player() -> Element { return Err(format!("Failed to load results: {msg}")); } }); - let len = match entries.cloned() { + let info = match playbackResult.cloned() { Some(value) => match value { - Ok(result) => result.channels.len().to_string(), - Err(err) => err, + Ok(result) => Some(result), + Err(_) => None, }, - _ => "Not Loaded".to_string(), + _ => None, }; rsx! { document::Link { rel: "stylesheet", href: PLAYER_CSS } @@ -39,10 +39,9 @@ pub fn Player() -> Element { id: "player", div { id: "head", - "r {len}" } Viewport { } - Timeline { } + Timeline { playbackInfo: info } } } } diff --git a/client/src/components/playback/timeline.rs b/client/src/components/playback/timeline.rs index 1236cd1..2f0a345 100644 --- a/client/src/components/playback/timeline.rs +++ b/client/src/components/playback/timeline.rs @@ -1,10 +1,73 @@ use dioxus::prelude::*; +use crate::rpc::azki::{MediaChannel, MediaEntry, PlaybackInfo}; + #[component] -pub fn Timeline() -> Element { +pub fn Timeline(playbackInfo: Option) -> Element { + return match playbackInfo { + Some(info) => rsx! { + div{ + id: "timeline", + TrackList { channels: info.channels, start: info.date.unwrap().seconds, zoom: 2.0 } + } + }, + None => rsx! { + div{ + id: "timeline", + TrackList { channels: Vec::new(), start: 0, zoom: 1.0 } + } + }, + }; +} + +#[component] +fn TrackList(channels: Vec, start: i64, zoom: f32) -> Element { rsx! { - div{ - id: "timeline" + div { + id: "tracklist", + {channels.iter().map(|c|rsx!{ + TimelineTrack { channel: c.clone(), start, zoom } + })} + } + } +} + +#[component] +fn TimelineTrack(channel: MediaChannel, start: i64, zoom: f32) -> Element { + rsx! { + div{ + class: "track", + style: "width: calc({zoom * 100.0}% - 100px);", + TrackLabel { channel: channel.clone() }, + {channel.videos.iter().map(|m|rsx!{Clip{media: m.clone(), start}})} + } + } +} + +#[component] +fn TrackLabel(channel: MediaChannel) -> Element { + rsx! { + div{ + class: "label", + "Camera {channel.camera_id}" + } + } +} + +const SEC_PER_DAY: f32 = 86400.0; +#[component] +fn Clip(media: MediaEntry, start: i64) -> Element { + let meta = media.metadata.unwrap(); + let date = media.date.unwrap(); + let timestamp = date.seconds - start; + let duration = (meta.duration as f32 / SEC_PER_DAY) * 100.0; + let offset = (timestamp as f32 / SEC_PER_DAY) * 100.0; + let time = date.to_string(); + rsx! { + div{ + class: "clip", + style: "width: {duration}%; left: {offset}%", + "{timestamp / 60 / 60}" } } }