feat: racing line analysis, frame trimming, persistence fix
- Add analyze_racing_line(): lap-by-lap path extraction with speed data, downsampled to 400 pts/lap. Spline-sorted in spline mode for clean paths. - Add complete_lap_frames() + split_by_lap() utilities: filters to laps whose spline spans >= 0.9 of [0,1], discarding partial out/in laps. - Fix complete_lap_frames logic: was checking if segment STARTS at spline~0, but a full lap starting mid-track (joined before start/finish) also qualifies. Now checks spline span coverage instead. - WOT window trimming in analyze_shift_points(): trims to first/last frame with throttle >= 0.9, filters rev-limiter plateau (acc_g < 0.01). - Fix frame persistence race condition: persist_session now writes frames synchronously instead of spawning a thread. Multiple concurrent calls could race on the same temp file, with earlier calls (empty frames) overwriting later calls with correct data. - atomic_write uses UUID-based temp file names as defense-in-depth. - PositionMode now derives Deserialize for RacingLineAnalysis. - Empty game/track_name validation in build_track() endpoint. - Wired RacingLine use case into analysis dispatch.
This commit is contained in:
parent
d86a87ab7d
commit
bcacd4b8a7
3 changed files with 315 additions and 16 deletions
300
src/analysis.rs
300
src/analysis.rs
|
|
@ -25,6 +25,74 @@ use crate::recording::UseCase;
|
||||||
use crate::telemetry_frame::{gear_display, TelemetryFrame};
|
use crate::telemetry_frame::{gear_display, TelemetryFrame};
|
||||||
use crate::track_model::{Corner, Sector, TrackModel, TrackModelSource};
|
use crate::track_model::{Corner, Sector, TrackModel, TrackModelSource};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Frame trimming utilities
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Split a run's frames into segments by lap_count transitions.
|
||||||
|
fn split_by_lap(frames: &[TelemetryFrame]) -> Vec<&[TelemetryFrame]> {
|
||||||
|
if frames.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let mut segments = Vec::new();
|
||||||
|
let mut seg_start = 0;
|
||||||
|
let mut prev_lap = frames[0].lap_count;
|
||||||
|
for (i, f) in frames.iter().enumerate() {
|
||||||
|
if f.lap_count != prev_lap {
|
||||||
|
segments.push(&frames[seg_start..i]);
|
||||||
|
seg_start = i;
|
||||||
|
prev_lap = f.lap_count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
segments.push(&frames[seg_start..]);
|
||||||
|
segments
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return frames belonging to complete laps only.
|
||||||
|
///
|
||||||
|
/// A complete lap covers most of the track (spline spans ≥ 0.9 of [0, 1]).
|
||||||
|
/// The first segment may start mid-track (driver joined before start/finish)
|
||||||
|
/// but still complete a full revolution — spline wraps from the join point
|
||||||
|
/// through 1.0 back to near 0. The last segment is often a partial out-lap
|
||||||
|
/// (driver stopped before completing the lap).
|
||||||
|
fn complete_lap_frames(frames: &[TelemetryFrame]) -> Vec<TelemetryFrame> {
|
||||||
|
let segments = split_by_lap(frames);
|
||||||
|
if segments.is_empty() {
|
||||||
|
return frames.to_vec();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut result = Vec::new();
|
||||||
|
for seg in &segments {
|
||||||
|
let splines: Vec<f32> = seg.iter().filter(|f| f.spline_position > 0.0).map(|f| f.spline_position).collect();
|
||||||
|
if splines.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let min_s = splines.iter().cloned().fold(f32::INFINITY, f32::min);
|
||||||
|
let max_s = splines.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
|
||||||
|
let span = max_s - min_s;
|
||||||
|
// Complete lap: spline covers ≥ 0.9 of the track, or wraps around
|
||||||
|
// (min near 0, max near 1).
|
||||||
|
let wraps = min_s < 0.1 && max_s > 0.9;
|
||||||
|
if span >= 0.9 || wraps {
|
||||||
|
result.extend_from_slice(seg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no segment qualifies (e.g., no spline data), fall back to longest.
|
||||||
|
if result.is_empty() {
|
||||||
|
let longest = segments.iter().max_by_key(|s| s.len());
|
||||||
|
if let Some(seg) = longest {
|
||||||
|
return seg.to_vec();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.is_empty() {
|
||||||
|
frames.to_vec()
|
||||||
|
} else {
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Shift Point Analysis
|
// Shift Point Analysis
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -80,9 +148,28 @@ pub fn analyze_shift_points(
|
||||||
for (run_idx, run) in runs.iter().enumerate() {
|
for (run_idx, run) in runs.iter().enumerate() {
|
||||||
let label = run_labels.get(run_idx).cloned().unwrap_or_default();
|
let label = run_labels.get(run_idx).cloned().unwrap_or_default();
|
||||||
|
|
||||||
let valid: Vec<&TelemetryFrame> = run
|
// Trim to the WOT window: find the first and last frames where
|
||||||
|
// throttle is near 100%. This removes pre-launch and post-lift data.
|
||||||
|
let wot_start = run.iter().position(|f| f.throttle >= 0.9);
|
||||||
|
let wot_end = run.iter().rposition(|f| f.throttle >= 0.9);
|
||||||
|
let trimmed: &[TelemetryFrame] = match (wot_start, wot_end) {
|
||||||
|
(Some(s), Some(e)) if e > s => &run[s..=e],
|
||||||
|
_ => run.as_slice(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter for valid WOT acceleration frames.
|
||||||
|
// Exclude rev-limiter plateau: when speed stops increasing (delta < 0.5 km/h
|
||||||
|
// over the window), the engine is bouncing off the limiter and acceleration
|
||||||
|
// data is meaningless.
|
||||||
|
let valid: Vec<&TelemetryFrame> = trimmed
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|f| f.throttle >= 0.9 && f.gear >= 2 && f.speed_kmh > 1.0 && f.engine_rpm > 500.0)
|
.filter(|f| {
|
||||||
|
f.throttle >= 0.9
|
||||||
|
&& f.gear >= 2
|
||||||
|
&& f.speed_kmh > 1.0
|
||||||
|
&& f.engine_rpm > 500.0
|
||||||
|
&& f.acc_g_frontal > 0.01
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if valid.len() < 10 {
|
if valid.len() < 10 {
|
||||||
|
|
@ -292,7 +379,7 @@ fn lerp_search<T>(
|
||||||
// Track Map Analysis
|
// Track Map Analysis
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, serde::Deserialize, PartialEq)]
|
||||||
pub enum PositionMode {
|
pub enum PositionMode {
|
||||||
Spline,
|
Spline,
|
||||||
Coordinate,
|
Coordinate,
|
||||||
|
|
@ -362,9 +449,15 @@ pub fn analyze_track_map(
|
||||||
let mut boundaries: Vec<TrackBoundary> = Vec::new();
|
let mut boundaries: Vec<TrackBoundary> = Vec::new();
|
||||||
|
|
||||||
for (run, label) in runs.iter().zip(labels.iter()) {
|
for (run, label) in runs.iter().zip(labels.iter()) {
|
||||||
|
// Trim to complete laps only — partial laps at the start/end of
|
||||||
|
// the recording produce artifacts when spline-sorted (the car
|
||||||
|
// was not at the boundary edge during those segments).
|
||||||
|
let trimmed = complete_lap_frames(run);
|
||||||
|
|
||||||
let mut points: Vec<TrackPoint> = match &mode {
|
let mut points: Vec<TrackPoint> = match &mode {
|
||||||
PositionMode::Spline => {
|
PositionMode::Spline => {
|
||||||
run.iter()
|
trimmed
|
||||||
|
.iter()
|
||||||
.filter(|f| f.spline_position > 0.0)
|
.filter(|f| f.spline_position > 0.0)
|
||||||
.map(|f| TrackPoint {
|
.map(|f| TrackPoint {
|
||||||
x: f.world_x,
|
x: f.world_x,
|
||||||
|
|
@ -375,7 +468,7 @@ pub fn analyze_track_map(
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
PositionMode::Coordinate => {
|
PositionMode::Coordinate => {
|
||||||
let mut pts: Vec<TrackPoint> = run
|
let mut pts: Vec<TrackPoint> = trimmed
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|f| f.world_x != 0.0 || f.world_z != 0.0)
|
.filter(|f| f.world_x != 0.0 || f.world_z != 0.0)
|
||||||
.map(|f| TrackPoint {
|
.map(|f| TrackPoint {
|
||||||
|
|
@ -909,6 +1002,195 @@ fn downsample<T: Clone>(items: Vec<T>, target: usize) -> Vec<T> {
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Racing Line Analysis
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, serde::Deserialize)]
|
||||||
|
pub struct RacingLinePoint {
|
||||||
|
pub x: f32,
|
||||||
|
pub y: f32,
|
||||||
|
pub z: f32,
|
||||||
|
pub speed_kmh: f32,
|
||||||
|
pub spline: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, serde::Deserialize)]
|
||||||
|
pub struct RacingLap {
|
||||||
|
pub label: String,
|
||||||
|
pub lap_index: usize,
|
||||||
|
pub point_count: usize,
|
||||||
|
pub points: Vec<RacingLinePoint>,
|
||||||
|
pub lap_time_ms: i32,
|
||||||
|
pub length_m: f32,
|
||||||
|
pub avg_speed_kmh: f32,
|
||||||
|
pub max_speed_kmh: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, serde::Deserialize)]
|
||||||
|
pub struct RacingLineAnalysis {
|
||||||
|
pub laps: Vec<RacingLap>,
|
||||||
|
pub position_mode: PositionMode,
|
||||||
|
pub total_laps: usize,
|
||||||
|
pub total_frames_analyzed: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split a run's frames into individual laps based on lap_count transitions.
|
||||||
|
fn split_into_laps(frames: &[TelemetryFrame]) -> Vec<Vec<TelemetryFrame>> {
|
||||||
|
if frames.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut laps: Vec<Vec<TelemetryFrame>> = Vec::new();
|
||||||
|
let mut current: Vec<TelemetryFrame> = Vec::new();
|
||||||
|
let mut prev_lap = frames[0].lap_count;
|
||||||
|
|
||||||
|
for f in frames {
|
||||||
|
if f.lap_count != prev_lap && !current.is_empty() {
|
||||||
|
laps.push(std::mem::take(&mut current));
|
||||||
|
}
|
||||||
|
current.push(f.clone());
|
||||||
|
prev_lap = f.lap_count;
|
||||||
|
}
|
||||||
|
if !current.is_empty() {
|
||||||
|
laps.push(current);
|
||||||
|
}
|
||||||
|
laps
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn analyze_racing_line(
|
||||||
|
runs: &[Vec<TelemetryFrame>],
|
||||||
|
labels: &[String],
|
||||||
|
) -> RacingLineAnalysis {
|
||||||
|
let all_frames: Vec<TelemetryFrame> = runs.iter().flat_map(|r| r.iter()).cloned().collect();
|
||||||
|
let mode = detect_position_mode(&all_frames);
|
||||||
|
let mut laps_out: Vec<RacingLap> = Vec::new();
|
||||||
|
let mut total_frames = 0usize;
|
||||||
|
|
||||||
|
for (run, label) in runs.iter().zip(labels.iter()) {
|
||||||
|
let laps = split_into_laps(run);
|
||||||
|
|
||||||
|
for (lap_idx, lap_frames) in laps.iter().enumerate() {
|
||||||
|
if lap_frames.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip partial laps: check spline coverage. A complete lap's
|
||||||
|
// spline values span ≥ 0.9 of [0, 1] (the car went almost all
|
||||||
|
// the way around). Partial laps at the start (joined mid-track)
|
||||||
|
// and end (session ended early) are discarded.
|
||||||
|
if laps.len() > 1 {
|
||||||
|
let splines: Vec<f32> = lap_frames.iter()
|
||||||
|
.filter(|f| f.spline_position > 0.0)
|
||||||
|
.map(|f| f.spline_position)
|
||||||
|
.collect();
|
||||||
|
if !splines.is_empty() {
|
||||||
|
let min_s = splines.iter().cloned().fold(f32::INFINITY, f32::min);
|
||||||
|
let max_s = splines.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
|
||||||
|
let span = max_s - min_s;
|
||||||
|
let wraps = min_s < 0.1 && max_s > 0.9;
|
||||||
|
if span < 0.9 && !wraps {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut points: Vec<RacingLinePoint> = lap_frames
|
||||||
|
.iter()
|
||||||
|
.filter(|f| {
|
||||||
|
if matches!(mode, PositionMode::Spline) {
|
||||||
|
f.spline_position > 0.0
|
||||||
|
} else {
|
||||||
|
f.world_x != 0.0 || f.world_z != 0.0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map(|f| RacingLinePoint {
|
||||||
|
x: f.world_x,
|
||||||
|
y: f.world_y,
|
||||||
|
z: f.world_z,
|
||||||
|
speed_kmh: f.speed_kmh,
|
||||||
|
spline: f.spline_position,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if points.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
total_frames += lap_frames.len();
|
||||||
|
|
||||||
|
// Compute track length from consecutive point distances
|
||||||
|
let mut length_m = 0.0f32;
|
||||||
|
for i in 1..points.len() {
|
||||||
|
let dx = points[i].x - points[i - 1].x;
|
||||||
|
let dz = points[i].z - points[i - 1].z;
|
||||||
|
length_m += (dx * dx + dz * dz).sqrt();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by spline if in spline mode for a clean path
|
||||||
|
if matches!(mode, PositionMode::Spline) {
|
||||||
|
points.sort_by(|a, b| {
|
||||||
|
a.spline
|
||||||
|
.partial_cmp(&b.spline)
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
});
|
||||||
|
points.dedup_by(|a, b| (a.spline - b.spline).abs() < 0.0001);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Downsample for efficient rendering (target ~400 points/lap)
|
||||||
|
let point_count_before = points.len();
|
||||||
|
points = downsample(points, 400);
|
||||||
|
|
||||||
|
let max_speed = lap_frames
|
||||||
|
.iter()
|
||||||
|
.map(|f| f.speed_kmh)
|
||||||
|
.fold(0.0f32, f32::max);
|
||||||
|
|
||||||
|
let avg_speed = if length_m > 0.0 {
|
||||||
|
let best_lap_s = lap_frames
|
||||||
|
.last()
|
||||||
|
.map(|f| f.lap_time_ms)
|
||||||
|
.unwrap_or(0)
|
||||||
|
.max(0) as f32
|
||||||
|
/ 1000.0;
|
||||||
|
if best_lap_s > 0.0 {
|
||||||
|
(length_m / best_lap_s) * 3.6
|
||||||
|
} else {
|
||||||
|
let frame_count = lap_frames.len() as f32;
|
||||||
|
lap_frames.iter().map(|f| f.speed_kmh).sum::<f32>() / frame_count
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
let lap_time = lap_frames
|
||||||
|
.last()
|
||||||
|
.map(|f| f.lap_time_ms)
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
laps_out.push(RacingLap {
|
||||||
|
label: label.clone(),
|
||||||
|
lap_index: lap_idx,
|
||||||
|
point_count: point_count_before,
|
||||||
|
points,
|
||||||
|
lap_time_ms: lap_time,
|
||||||
|
length_m,
|
||||||
|
avg_speed_kmh: avg_speed,
|
||||||
|
max_speed_kmh: max_speed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let total_laps = laps_out.len();
|
||||||
|
|
||||||
|
RacingLineAnalysis {
|
||||||
|
laps: laps_out,
|
||||||
|
position_mode: mode,
|
||||||
|
total_laps,
|
||||||
|
total_frames_analyzed: total_frames,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Dispatch
|
// Dispatch
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -939,6 +1221,14 @@ pub fn analyze(
|
||||||
serde_json::to_value(analyze_track_map(frames_by_run, run_labels))
|
serde_json::to_value(analyze_track_map(frames_by_run, run_labels))
|
||||||
.unwrap_or_else(|_| serde_json::json!({"error": "serialization failed"}))
|
.unwrap_or_else(|_| serde_json::json!({"error": "serialization failed"}))
|
||||||
}
|
}
|
||||||
|
UseCase::RacingLine => {
|
||||||
|
if frames_by_run.is_empty() || frames_by_run.iter().all(|r| r.is_empty()) {
|
||||||
|
serde_json::json!({"error": "no frames provided"})
|
||||||
|
} else {
|
||||||
|
serde_json::to_value(analyze_racing_line(frames_by_run, run_labels))
|
||||||
|
.unwrap_or_else(|_| serde_json::json!({"error": "serialization failed"}))
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => serde_json::json!({
|
_ => serde_json::json!({
|
||||||
"status": "not_implemented",
|
"status": "not_implemented",
|
||||||
"use_case": format!("{:?}", use_case),
|
"use_case": format!("{:?}", use_case),
|
||||||
|
|
|
||||||
|
|
@ -636,6 +636,10 @@ async fn build_track(
|
||||||
let game = req.game.unwrap_or_else(|| manifest.game.to_track_dir_name().to_string());
|
let game = req.game.unwrap_or_else(|| manifest.game.to_track_dir_name().to_string());
|
||||||
let track_name = req.track_name.unwrap_or(manifest.track);
|
let track_name = req.track_name.unwrap_or(manifest.track);
|
||||||
|
|
||||||
|
if game.is_empty() || track_name.is_empty() {
|
||||||
|
return err(StatusCode::BAD_REQUEST, "Game and track name are required. Specify them in the request or set them when starting the recording.");
|
||||||
|
}
|
||||||
|
|
||||||
let boundaries = {
|
let boundaries = {
|
||||||
let mgr = lock_sessions(&state);
|
let mgr = lock_sessions(&state);
|
||||||
let session = match mgr.get_session(&req.session_id) {
|
let session = match mgr.get_session(&req.session_id) {
|
||||||
|
|
|
||||||
|
|
@ -161,8 +161,13 @@ impl SessionManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Atomically write bytes to a path via temp file + rename (same directory).
|
/// Atomically write bytes to a path via temp file + rename (same directory).
|
||||||
|
/// Uses a unique temp file name to avoid collisions between concurrent writes.
|
||||||
fn atomic_write(path: &Path, bytes: &[u8]) {
|
fn atomic_write(path: &Path, bytes: &[u8]) {
|
||||||
let tmp = path.with_extension("json.tmp");
|
let tmp = path.with_file_name(format!(
|
||||||
|
".{}.{}.tmp",
|
||||||
|
path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(),
|
||||||
|
uuid::Uuid::new_v4()
|
||||||
|
));
|
||||||
if let Err(e) = std::fs::write(&tmp, bytes) {
|
if let Err(e) = std::fs::write(&tmp, bytes) {
|
||||||
eprintln!("[WARN] Failed to write temp file {}: {}", tmp.display(), e);
|
eprintln!("[WARN] Failed to write temp file {}: {}", tmp.display(), e);
|
||||||
return;
|
return;
|
||||||
|
|
@ -173,8 +178,8 @@ impl SessionManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persist session metadata (compact JSON, excludes frames) synchronously,
|
/// Persist session metadata (compact JSON, excludes frames) and frame data
|
||||||
/// then write frame data off-thread to a separate file.
|
/// synchronously. Both files use atomic write (temp file + rename).
|
||||||
fn persist_session(&self, id: &str) {
|
fn persist_session(&self, id: &str) {
|
||||||
let Some(session) = self.sessions.get(id) else {
|
let Some(session) = self.sessions.get(id) else {
|
||||||
return;
|
return;
|
||||||
|
|
@ -188,17 +193,17 @@ impl SessionManager {
|
||||||
Err(e) => eprintln!("[WARN] Failed to serialize session metadata {}: {}", id, e),
|
Err(e) => eprintln!("[WARN] Failed to serialize session metadata {}: {}", id, e),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Frames are large — clone under lock (memcpy) then serialize + write off-thread.
|
// Frames — clone under lock, then serialize + write synchronously.
|
||||||
|
// Must be synchronous: off-thread writes race on the same file,
|
||||||
|
// and an earlier call (with empty frames) can overwrite a later one.
|
||||||
let runs_frames: Vec<Vec<TelemetryFrame>> =
|
let runs_frames: Vec<Vec<TelemetryFrame>> =
|
||||||
session.runs.iter().map(|r| r.frames.clone()).collect();
|
session.runs.iter().map(|r| r.frames.clone()).collect();
|
||||||
let frames_path = self.frames_file_path(&id);
|
let frames_path = self.frames_file_path(&id);
|
||||||
std::thread::spawn(move || {
|
let frames_file = SessionFramesFile { runs: runs_frames };
|
||||||
let frames_file = SessionFramesFile { runs: runs_frames };
|
match serde_json::to_vec(&frames_file) {
|
||||||
match serde_json::to_vec(&frames_file) {
|
Ok(bytes) => Self::atomic_write(&frames_path, &bytes),
|
||||||
Ok(bytes) => Self::atomic_write(&frames_path, &bytes),
|
Err(e) => eprintln!("[WARN] Failed to serialize frames for session {}: {}", id, e),
|
||||||
Err(e) => eprintln!("[WARN] Failed to serialize frames for session {}: {}", id, e),
|
}
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn delete_session_file(&self, id: &str) {
|
fn delete_session_file(&self, id: &str) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue