timeline rendering

This commit is contained in:
2026-01-28 21:30:47 -05:00
parent c3ddcf16bf
commit dc5dc4bff4
6 changed files with 143 additions and 21 deletions

View File

@@ -33,9 +33,20 @@ public static class ConversionExtensions
Version = entry.Version, Version = entry.Version,
CameraId = entry.CameraId, CameraId = entry.CameraId,
Date = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime(entry.Date), Date = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime(entry.Date),
Metadata = entry.Metadata.ToRpc(),
FilePath = entry.Filepath, FilePath = entry.Filepath,
Id = entry.Id.ToString(), Id = entry.Id.ToString(),
Type = entry.Type.ToRpc() Type = entry.Type.ToRpc()
}; };
} }
public static MediaMetadata ToRpc(this Models.MediaMetadata metadata)
{
return new MediaMetadata
{
Duration = metadata.Duration,
Height = metadata.Height,
Width = metadata.Width
};
}
} }

View File

@@ -32,8 +32,10 @@ public class FileScannerService(MediaService mediaService, IConfiguration config
{ {
if (_isRunning) if (_isRunning)
return; return;
logger.LogInformation("Starting file scan");
_isRunning = true; _isRunning = true;
ScanFilesAsync(path).Wait(); ScanFilesAsync(path).Wait();
logger.LogInformation("File scan complete");
_isRunning = false; _isRunning = false;
}, null, TimeSpan.FromMinutes(0), TimeSpan.FromHours(1)); }, null, TimeSpan.FromMinutes(0), TimeSpan.FromHours(1));
return Task.CompletedTask; return Task.CompletedTask;
@@ -57,7 +59,7 @@ public class FileScannerService(MediaService mediaService, IConfiguration config
foreach (var chunk in files.Chunk(50)) foreach (var chunk in files.Chunk(50))
{ {
total += await ScanFileChunkAsync(path, chunk, existingFiles, cancellationToken); 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) catch (Exception ex)
@@ -80,7 +82,7 @@ public class FileScannerService(MediaService mediaService, IConfiguration config
var isUpgrade = false; var isUpgrade = false;
if (existingFiles.TryGetValue(relativePath, out var version)) if (existingFiles.TryGetValue(relativePath, out var version))
{ {
if (version < MediaEntry.CUR_VERSION) if (version <= MediaEntry.CUR_VERSION)
continue; continue;
isUpgrade = true; isUpgrade = true;
} }
@@ -111,7 +113,7 @@ public class FileScannerService(MediaService mediaService, IConfiguration config
{ {
await mediaService.DeleteAllEntriesAsync(upgradeEntries.Select(e => e.Filepath), cancellationToken); await mediaService.DeleteAllEntriesAsync(upgradeEntries.Select(e => e.Filepath), cancellationToken);
await mediaService.AddMediaBulkAsync(upgradeEntries, 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; return entries.Count + upgradeEntries.Count;
} }
@@ -143,9 +145,16 @@ public class FileScannerService(MediaService mediaService, IConfiguration config
private static Maybe<MediaMetadata> ReadVideoMetadata(string filePath) private static Maybe<MediaMetadata> ReadVideoMetadata(string filePath)
{ {
var info = FFProbe.Analyse(filePath); try
if (info.PrimaryVideoStream == null) {
return new Error($"Could not find a primirary video stream in file."); var info = FFProbe.Analyse(filePath);
return new MediaMetadata((int)info.Duration.TotalSeconds, info.PrimaryVideoStream.Height, info.PrimaryVideoStream.Width); 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;
}
} }
} }

View File

@@ -13,13 +13,13 @@ public class MediaService(IMongoDatabase db)
public readonly IMongoCollection<MediaEntry> _entries = db.GetCollection<MediaEntry>("media"); public readonly IMongoCollection<MediaEntry> _entries = db.GetCollection<MediaEntry>("media");
[BsonIgnoreExtraElements] [BsonIgnoreExtraElements]
private record MediaInfo(string FilePath, int Version); private record class MediaInfo(string Filepath, int Version);
public async Task<FrozenDictionary<string, int>> GetExistingFilePathsAsync(CancellationToken cancellationToken = default) public async Task<FrozenDictionary<string, int>> GetExistingFilePathsAsync(CancellationToken cancellationToken = default)
{ {
var files = await _entries.Find("{}").As<MediaInfo>().ToListAsync(cancellationToken); var files = await _entries.Find("{}").As<MediaInfo>().ToListAsync(cancellationToken);
if (files.Count == 0) if (files.Count == 0)
return FrozenDictionary<string, int>.Empty; return FrozenDictionary<string, int>.Empty;
return files.ToFrozenDictionary(m => m.FilePath, m => m.Version); return files.ToFrozenDictionary(m => m.Filepath, m => m.Version);
} }
public async Task AddMediaBulkAsync(List<MediaEntry> entries, CancellationToken cancellationToken = default) public async Task AddMediaBulkAsync(List<MediaEntry> entries, CancellationToken cancellationToken = default)

View File

@@ -19,4 +19,44 @@
#timeline { #timeline {
grid-area: Timeline; grid-area: Timeline;
border: 1px solid $lightBackground; 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;
}
} }

View File

@@ -1,4 +1,4 @@
use chrono::{Datelike, Days, Local}; use chrono::{Datelike, Local};
use dioxus::prelude::*; use dioxus::prelude::*;
use prost_types::Timestamp; use prost_types::Timestamp;
@@ -10,10 +10,10 @@ const PLAYER_CSS: Asset = asset!("/assets/styling/player.scss");
#[component] #[component]
pub fn Player() -> Element { pub fn Player() -> Element {
let entries = use_resource(|| async move { let playbackResult = use_resource(|| async move {
let mut client = get_rpc_client(); let mut client = get_rpc_client();
let now = Local::now(); 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 let result = client
.get_media_playback(MediaPlaybackRequest { date: Some(from) }) .get_media_playback(MediaPlaybackRequest { date: Some(from) })
.await; .await;
@@ -26,12 +26,12 @@ pub fn Player() -> Element {
return Err(format!("Failed to load results: {msg}")); return Err(format!("Failed to load results: {msg}"));
} }
}); });
let len = match entries.cloned() { let info = match playbackResult.cloned() {
Some(value) => match value { Some(value) => match value {
Ok(result) => result.channels.len().to_string(), Ok(result) => Some(result),
Err(err) => err, Err(_) => None,
}, },
_ => "Not Loaded".to_string(), _ => None,
}; };
rsx! { rsx! {
document::Link { rel: "stylesheet", href: PLAYER_CSS } document::Link { rel: "stylesheet", href: PLAYER_CSS }
@@ -39,10 +39,9 @@ pub fn Player() -> Element {
id: "player", id: "player",
div { div {
id: "head", id: "head",
"r {len}"
} }
Viewport { } Viewport { }
Timeline { } Timeline { playbackInfo: info }
} }
} }
} }

View File

@@ -1,10 +1,73 @@
use dioxus::prelude::*; use dioxus::prelude::*;
use crate::rpc::azki::{MediaChannel, MediaEntry, PlaybackInfo};
#[component] #[component]
pub fn Timeline() -> Element { pub fn Timeline(playbackInfo: Option<PlaybackInfo>) -> 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<MediaChannel>, start: i64, zoom: f32) -> Element {
rsx! { rsx! {
div{ div {
id: "timeline" 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}"
} }
} }
} }