Skip to content

Commit 78224af

Browse files
authoredJan 17, 2025
Merge pull request #231 from onyedikachi-david/feat/add-export-metadata-display
Add video metadata display
2 parents a02e9ea + 1980d65 commit 78224af

File tree

7 files changed

+290
-70
lines changed

7 files changed

+290
-70
lines changed
 

‎apps/desktop/src-tauri/src/export.rs

+84
Original file line numberDiff line numberDiff line change
@@ -105,3 +105,87 @@ pub async fn export_video(
105105
}
106106
}
107107
}
108+
109+
#[derive(Debug, serde::Serialize, specta::Type)]
110+
pub struct ExportEstimates {
111+
pub duration_seconds: f64,
112+
pub estimated_time_seconds: f64,
113+
pub estimated_size_mb: f64,
114+
}
115+
116+
// This will need to be refactored at some point to be more accurate.
117+
#[tauri::command]
118+
#[specta::specta]
119+
pub async fn get_export_estimates(
120+
app: AppHandle,
121+
video_id: String,
122+
resolution: XY<u32>,
123+
fps: u32,
124+
) -> Result<ExportEstimates, String> {
125+
let screen_metadata =
126+
get_video_metadata(app.clone(), video_id.clone(), Some(VideoType::Screen)).await?;
127+
let camera_metadata =
128+
get_video_metadata(app.clone(), video_id.clone(), Some(VideoType::Camera))
129+
.await
130+
.ok();
131+
132+
let editor_instance = upsert_editor_instance(&app, video_id.clone()).await;
133+
let total_frames = editor_instance.get_total_frames(fps);
134+
135+
let raw_duration = screen_metadata.duration.max(
136+
camera_metadata
137+
.map(|m| m.duration)
138+
.unwrap_or(screen_metadata.duration),
139+
);
140+
141+
let meta = editor_instance.meta();
142+
let project_config = meta.project_config();
143+
let duration_seconds = if let Some(timeline) = &project_config.timeline {
144+
timeline
145+
.segments
146+
.iter()
147+
.map(|s| (s.end - s.start) / s.timescale)
148+
.sum()
149+
} else {
150+
raw_duration
151+
};
152+
153+
let (width, height) = (resolution.x, resolution.y);
154+
155+
let base_bitrate = if width <= 1280 && height <= 720 {
156+
4_000_000.0
157+
} else if width <= 1920 && height <= 1080 {
158+
8_000_000.0
159+
} else if width <= 2560 && height <= 1440 {
160+
14_000_000.0
161+
} else {
162+
20_000_000.0
163+
};
164+
165+
let fps_factor = (fps as f64) / 30.0;
166+
let video_bitrate = base_bitrate * fps_factor;
167+
168+
let audio_bitrate = 192_000.0;
169+
170+
let total_bitrate = video_bitrate + audio_bitrate;
171+
172+
let estimated_size_mb = (total_bitrate * duration_seconds) / (8.0 * 1024.0 * 1024.0);
173+
174+
let base_factor = match (width, height) {
175+
(w, h) if w <= 1280 && h <= 720 => 0.43,
176+
(w, h) if w <= 1920 && h <= 1080 => 0.64,
177+
(w, h) if w <= 2560 && h <= 1440 => 0.75,
178+
_ => 0.86,
179+
};
180+
181+
let processing_time = duration_seconds * base_factor * fps_factor;
182+
let overhead_time = 0.0;
183+
184+
let estimated_time_seconds = processing_time + overhead_time;
185+
186+
Ok(ExportEstimates {
187+
duration_seconds,
188+
estimated_time_seconds,
189+
estimated_size_mb,
190+
})
191+
}

‎apps/desktop/src-tauri/src/lib.rs

+65-53
Original file line numberDiff line numberDiff line change
@@ -965,6 +965,32 @@ async fn get_video_metadata(
965965

966966
let meta = RecordingMeta::load_for_project(&project_path)?;
967967

968+
fn get_duration_for_paths(paths: Vec<PathBuf>) -> Result<f64, String> {
969+
let mut max_duration: f64 = 0.0;
970+
for path in paths {
971+
let reader = BufReader::new(
972+
File::open(&path).map_err(|e| format!("Failed to open video file: {}", e))?,
973+
);
974+
let file_size = path
975+
.metadata()
976+
.map_err(|e| format!("Failed to get file metadata: {}", e))?
977+
.len();
978+
979+
let current_duration = match Mp4Reader::read_header(reader, file_size) {
980+
Ok(mp4) => mp4.duration().as_secs_f64(),
981+
Err(e) => {
982+
println!(
983+
"Failed to read MP4 header: {}. Falling back to default duration.",
984+
e
985+
);
986+
0.0_f64
987+
}
988+
};
989+
max_duration = max_duration.max(current_duration);
990+
}
991+
Ok(max_duration)
992+
}
993+
968994
fn content_paths(project_path: &PathBuf, meta: &RecordingMeta) -> Vec<PathBuf> {
969995
match &meta.content {
970996
Content::SingleSegment { segment } => {
@@ -978,65 +1004,50 @@ async fn get_video_metadata(
9781004
}
9791005
}
9801006

981-
let paths = match video_type {
982-
Some(VideoType::Screen) => content_paths(&project_path, &meta),
983-
Some(VideoType::Camera) => match &meta.content {
984-
Content::SingleSegment { segment } => segment
985-
.camera
986-
.as_ref()
987-
.map_or(vec![], |c| vec![segment.path(&meta, &c.path)]),
988-
Content::MultipleSegments { inner } => inner
989-
.segments
990-
.iter()
991-
.filter_map(|s| s.camera.as_ref().map(|c| inner.path(&meta, &c.path)))
992-
.collect(),
993-
},
994-
Some(VideoType::Output) | None => {
995-
let output_video_path = project_path.join("output").join("result.mp4");
996-
println!("Using output video path: {:?}", output_video_path);
997-
if output_video_path.exists() {
998-
vec![output_video_path]
999-
} else {
1000-
println!("Output video not found, falling back to screen paths");
1001-
content_paths(&project_path, &meta)
1002-
}
1003-
}
1004-
};
1005-
1006-
let mut ret = VideoRecordingMetadata {
1007-
size: 0.0,
1008-
duration: 0.0,
1007+
// Get display duration
1008+
let display_duration = get_duration_for_paths(content_paths(&project_path, &meta))?;
1009+
1010+
// Get camera duration
1011+
let camera_paths = match &meta.content {
1012+
Content::SingleSegment { segment } => segment
1013+
.camera
1014+
.as_ref()
1015+
.map_or(vec![], |c| vec![segment.path(&meta, &c.path)]),
1016+
Content::MultipleSegments { inner } => inner
1017+
.segments
1018+
.iter()
1019+
.filter_map(|s| s.camera.as_ref().map(|c| inner.path(&meta, &c.path)))
1020+
.collect(),
10091021
};
1022+
let camera_duration = get_duration_for_paths(camera_paths)?;
10101023

1011-
for path in paths {
1012-
let file = File::open(&path).map_err(|e| format!("Failed to open video file: {}", e))?;
1024+
// Use the longer duration
1025+
let duration = display_duration.max(camera_duration);
10131026

1014-
ret.size += (file
1015-
.metadata()
1016-
.map_err(|e| format!("Failed to get file metadata: {}", e))?
1017-
.len() as f64)
1018-
/ (1024.0 * 1024.0);
1027+
// Calculate estimated size using same logic as get_export_estimates
1028+
let (width, height) = (1920, 1080); // Default to 1080p
1029+
let fps = 30; // Default to 30fps
10191030

1020-
let reader = BufReader::new(file);
1021-
let file_size = path
1022-
.metadata()
1023-
.map_err(|e| format!("Failed to get file metadata: {}", e))?
1024-
.len();
1031+
let base_bitrate = if width <= 1280 && height <= 720 {
1032+
4_000_000.0
1033+
} else if width <= 1920 && height <= 1080 {
1034+
8_000_000.0
1035+
} else if width <= 2560 && height <= 1440 {
1036+
14_000_000.0
1037+
} else {
1038+
20_000_000.0
1039+
};
10251040

1026-
ret.duration += match Mp4Reader::read_header(reader, file_size) {
1027-
Ok(mp4) => mp4.duration().as_secs_f64(),
1028-
Err(e) => {
1029-
println!(
1030-
"Failed to read MP4 header: {}. Falling back to default duration.",
1031-
e
1032-
);
1033-
// Return a default duration (e.g., 0.0) or try to estimate it based on file size
1034-
0.0 // or some estimated value
1035-
}
1036-
};
1037-
}
1041+
let fps_factor = (fps as f64) / 30.0;
1042+
let video_bitrate = base_bitrate * fps_factor;
1043+
let audio_bitrate = 192_000.0;
1044+
let total_bitrate = video_bitrate + audio_bitrate;
1045+
let estimated_size_mb = (total_bitrate * duration) / (8.0 * 1024.0 * 1024.0);
10381046

1039-
Ok(ret)
1047+
Ok(VideoRecordingMetadata {
1048+
size: estimated_size_mb,
1049+
duration,
1050+
})
10401051
}
10411052

10421053
#[tauri::command(async)]
@@ -1851,6 +1862,7 @@ pub async fn run() {
18511862
focus_captures_panel,
18521863
get_current_recording,
18531864
export::export_video,
1865+
export::get_export_estimates,
18541866
copy_file_to_path,
18551867
copy_video_to_clipboard,
18561868
copy_screenshot_to_clipboard,

‎apps/desktop/src/routes/editor/Header.tsx

+96-6
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,12 @@ const FPS_OPTIONS = [
6363
{ label: "60 FPS", value: 60 },
6464
] satisfies Array<{ label: string; value: number }>;
6565

66+
export interface ExportEstimates {
67+
duration_seconds: number;
68+
estimated_time_seconds: number;
69+
estimated_size_mb: number;
70+
}
71+
6672
export function Header() {
6773
const currentWindow = getCurrentWindow();
6874
const { videoId, project, prettyName } = useEditorContext();
@@ -78,6 +84,24 @@ export function Header() {
7884
) || RESOLUTION_OPTIONS[0]
7985
);
8086

87+
const [exportEstimates] = createResource(
88+
() => ({
89+
videoId,
90+
resolution: {
91+
x: selectedResolution()?.width || RESOLUTION_OPTIONS[0].width,
92+
y: selectedResolution()?.height || RESOLUTION_OPTIONS[0].height,
93+
},
94+
fps: selectedFps(),
95+
}),
96+
async (params) => {
97+
return commands.getExportEstimates(
98+
params.videoId,
99+
params.resolution,
100+
params.fps
101+
);
102+
}
103+
);
104+
81105
let unlistenTitlebar: UnlistenFn | undefined;
82106
onMount(async () => {
83107
unlistenTitlebar = await initializeTitlebar();
@@ -175,8 +199,8 @@ export function Header() {
175199
true,
176200
selectedFps(),
177201
{
178-
x: selectedResolution().width,
179-
y: selectedResolution().height,
202+
x: selectedResolution()?.width || RESOLUTION_OPTIONS[0].width,
203+
y: selectedResolution()?.height || RESOLUTION_OPTIONS[0].height,
180204
}
181205
);
182206
await commands.copyFileToPath(videoPath, path);
@@ -273,9 +297,9 @@ export function Header() {
273297
</div>
274298
<div>
275299
<label class="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
276-
Frame Rate
300+
FPS
277301
</label>
278-
<KSelect<(typeof FPS_OPTIONS)[number]>
302+
<KSelect
279303
options={FPS_OPTIONS}
280304
optionValue="value"
281305
optionTextValue="label"
@@ -327,6 +351,70 @@ export function Header() {
327351
>
328352
Export Video
329353
</Button>
354+
<Show when={exportEstimates()}>
355+
{(est) => (
356+
<div
357+
class={cx(
358+
"font-medium z-40 flex justify-between items-center pointer-events-none transition-all max-w-full overflow-hidden text-xs"
359+
)}
360+
>
361+
<p class="flex items-center gap-4">
362+
<span class="flex items-center text-[--gray-500]">
363+
<IconCapCamera class="w-[14px] h-[14px] mr-1.5 text-[--gray-500]" />
364+
{(() => {
365+
const totalSeconds = Math.round(
366+
est().duration_seconds
367+
);
368+
const hours = Math.floor(totalSeconds / 3600);
369+
const minutes = Math.floor(
370+
(totalSeconds % 3600) / 60
371+
);
372+
const seconds = totalSeconds % 60;
373+
374+
if (hours > 0) {
375+
return `${hours}:${minutes
376+
.toString()
377+
.padStart(2, "0")}:${seconds
378+
.toString()
379+
.padStart(2, "0")}`;
380+
}
381+
return `${minutes}:${seconds
382+
.toString()
383+
.padStart(2, "0")}`;
384+
})()}
385+
</span>
386+
<span class="flex items-center text-[--gray-500]">
387+
<IconLucideHardDrive class="w-[14px] h-[14px] mr-1.5 text-[--gray-500]" />
388+
{est().estimated_size_mb.toFixed(2)} MB
389+
</span>
390+
<span class="flex items-center text-[--gray-500]">
391+
<IconLucideClock class="w-[14px] h-[14px] mr-1.5 text-[--gray-500]" />
392+
{(() => {
393+
const totalSeconds = Math.round(
394+
est().estimated_time_seconds
395+
);
396+
const hours = Math.floor(totalSeconds / 3600);
397+
const minutes = Math.floor(
398+
(totalSeconds % 3600) / 60
399+
);
400+
const seconds = totalSeconds % 60;
401+
402+
if (hours > 0) {
403+
return `~${hours}:${minutes
404+
.toString()
405+
.padStart(2, "0")}:${seconds
406+
.toString()
407+
.padStart(2, "0")}`;
408+
}
409+
return `~${minutes}:${seconds
410+
.toString()
411+
.padStart(2, "0")}`;
412+
})()}
413+
</span>
414+
</p>
415+
</div>
416+
)}
417+
</Show>
330418
</div>
331419
</div>
332420
</Show>
@@ -608,8 +696,10 @@ function ShareButton(props: ShareButtonProps) {
608696
true,
609697
props.selectedFps(),
610698
{
611-
x: props.selectedResolution().width,
612-
y: props.selectedResolution().height,
699+
x: props.selectedResolution()?.width || RESOLUTION_OPTIONS[0].width,
700+
y:
701+
props.selectedResolution()?.height ||
702+
RESOLUTION_OPTIONS[0].height,
613703
}
614704
);
615705

0 commit comments

Comments
 (0)