Skip to content

Commit

Permalink
Merge pull request #231 from onyedikachi-david/feat/add-export-metada…
Browse files Browse the repository at this point in the history
…ta-display

Add video metadata display
  • Loading branch information
richiemcilroy authored Jan 17, 2025
2 parents a02e9ea + 1980d65 commit 78224af
Show file tree
Hide file tree
Showing 7 changed files with 290 additions and 70 deletions.
84 changes: 84 additions & 0 deletions apps/desktop/src-tauri/src/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,87 @@ pub async fn export_video(
}
}
}

#[derive(Debug, serde::Serialize, specta::Type)]
pub struct ExportEstimates {
pub duration_seconds: f64,
pub estimated_time_seconds: f64,
pub estimated_size_mb: f64,
}

// This will need to be refactored at some point to be more accurate.
#[tauri::command]
#[specta::specta]
pub async fn get_export_estimates(
app: AppHandle,
video_id: String,
resolution: XY<u32>,
fps: u32,
) -> Result<ExportEstimates, String> {
let screen_metadata =
get_video_metadata(app.clone(), video_id.clone(), Some(VideoType::Screen)).await?;
let camera_metadata =
get_video_metadata(app.clone(), video_id.clone(), Some(VideoType::Camera))
.await
.ok();

let editor_instance = upsert_editor_instance(&app, video_id.clone()).await;
let total_frames = editor_instance.get_total_frames(fps);

let raw_duration = screen_metadata.duration.max(
camera_metadata
.map(|m| m.duration)
.unwrap_or(screen_metadata.duration),
);

let meta = editor_instance.meta();
let project_config = meta.project_config();
let duration_seconds = if let Some(timeline) = &project_config.timeline {
timeline
.segments
.iter()
.map(|s| (s.end - s.start) / s.timescale)
.sum()
} else {
raw_duration
};

let (width, height) = (resolution.x, resolution.y);

let base_bitrate = if width <= 1280 && height <= 720 {
4_000_000.0
} else if width <= 1920 && height <= 1080 {
8_000_000.0
} else if width <= 2560 && height <= 1440 {
14_000_000.0
} else {
20_000_000.0
};

let fps_factor = (fps as f64) / 30.0;
let video_bitrate = base_bitrate * fps_factor;

let audio_bitrate = 192_000.0;

let total_bitrate = video_bitrate + audio_bitrate;

let estimated_size_mb = (total_bitrate * duration_seconds) / (8.0 * 1024.0 * 1024.0);

let base_factor = match (width, height) {
(w, h) if w <= 1280 && h <= 720 => 0.43,
(w, h) if w <= 1920 && h <= 1080 => 0.64,
(w, h) if w <= 2560 && h <= 1440 => 0.75,
_ => 0.86,
};

let processing_time = duration_seconds * base_factor * fps_factor;
let overhead_time = 0.0;

let estimated_time_seconds = processing_time + overhead_time;

Ok(ExportEstimates {
duration_seconds,
estimated_time_seconds,
estimated_size_mb,
})
}
118 changes: 65 additions & 53 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -965,6 +965,32 @@ async fn get_video_metadata(

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

fn get_duration_for_paths(paths: Vec<PathBuf>) -> Result<f64, String> {
let mut max_duration: f64 = 0.0;
for path in paths {
let reader = BufReader::new(
File::open(&path).map_err(|e| format!("Failed to open video file: {}", e))?,
);
let file_size = path
.metadata()
.map_err(|e| format!("Failed to get file metadata: {}", e))?
.len();

let current_duration = match Mp4Reader::read_header(reader, file_size) {
Ok(mp4) => mp4.duration().as_secs_f64(),
Err(e) => {
println!(
"Failed to read MP4 header: {}. Falling back to default duration.",
e
);
0.0_f64
}
};
max_duration = max_duration.max(current_duration);
}
Ok(max_duration)
}

fn content_paths(project_path: &PathBuf, meta: &RecordingMeta) -> Vec<PathBuf> {
match &meta.content {
Content::SingleSegment { segment } => {
Expand All @@ -978,65 +1004,50 @@ async fn get_video_metadata(
}
}

let paths = match video_type {
Some(VideoType::Screen) => content_paths(&project_path, &meta),
Some(VideoType::Camera) => match &meta.content {
Content::SingleSegment { segment } => segment
.camera
.as_ref()
.map_or(vec![], |c| vec![segment.path(&meta, &c.path)]),
Content::MultipleSegments { inner } => inner
.segments
.iter()
.filter_map(|s| s.camera.as_ref().map(|c| inner.path(&meta, &c.path)))
.collect(),
},
Some(VideoType::Output) | None => {
let output_video_path = project_path.join("output").join("result.mp4");
println!("Using output video path: {:?}", output_video_path);
if output_video_path.exists() {
vec![output_video_path]
} else {
println!("Output video not found, falling back to screen paths");
content_paths(&project_path, &meta)
}
}
};

let mut ret = VideoRecordingMetadata {
size: 0.0,
duration: 0.0,
// Get display duration
let display_duration = get_duration_for_paths(content_paths(&project_path, &meta))?;

// Get camera duration
let camera_paths = match &meta.content {
Content::SingleSegment { segment } => segment
.camera
.as_ref()
.map_or(vec![], |c| vec![segment.path(&meta, &c.path)]),
Content::MultipleSegments { inner } => inner
.segments
.iter()
.filter_map(|s| s.camera.as_ref().map(|c| inner.path(&meta, &c.path)))
.collect(),
};
let camera_duration = get_duration_for_paths(camera_paths)?;

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

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

let reader = BufReader::new(file);
let file_size = path
.metadata()
.map_err(|e| format!("Failed to get file metadata: {}", e))?
.len();
let base_bitrate = if width <= 1280 && height <= 720 {
4_000_000.0
} else if width <= 1920 && height <= 1080 {
8_000_000.0
} else if width <= 2560 && height <= 1440 {
14_000_000.0
} else {
20_000_000.0
};

ret.duration += match Mp4Reader::read_header(reader, file_size) {
Ok(mp4) => mp4.duration().as_secs_f64(),
Err(e) => {
println!(
"Failed to read MP4 header: {}. Falling back to default duration.",
e
);
// Return a default duration (e.g., 0.0) or try to estimate it based on file size
0.0 // or some estimated value
}
};
}
let fps_factor = (fps as f64) / 30.0;
let video_bitrate = base_bitrate * fps_factor;
let audio_bitrate = 192_000.0;
let total_bitrate = video_bitrate + audio_bitrate;
let estimated_size_mb = (total_bitrate * duration) / (8.0 * 1024.0 * 1024.0);

Ok(ret)
Ok(VideoRecordingMetadata {
size: estimated_size_mb,
duration,
})
}

#[tauri::command(async)]
Expand Down Expand Up @@ -1851,6 +1862,7 @@ pub async fn run() {
focus_captures_panel,
get_current_recording,
export::export_video,
export::get_export_estimates,
copy_file_to_path,
copy_video_to_clipboard,
copy_screenshot_to_clipboard,
Expand Down
102 changes: 96 additions & 6 deletions apps/desktop/src/routes/editor/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ const FPS_OPTIONS = [
{ label: "60 FPS", value: 60 },
] satisfies Array<{ label: string; value: number }>;

export interface ExportEstimates {
duration_seconds: number;
estimated_time_seconds: number;
estimated_size_mb: number;
}

export function Header() {
const currentWindow = getCurrentWindow();
const { videoId, project, prettyName } = useEditorContext();
Expand All @@ -78,6 +84,24 @@ export function Header() {
) || RESOLUTION_OPTIONS[0]
);

const [exportEstimates] = createResource(
() => ({
videoId,
resolution: {
x: selectedResolution()?.width || RESOLUTION_OPTIONS[0].width,
y: selectedResolution()?.height || RESOLUTION_OPTIONS[0].height,
},
fps: selectedFps(),
}),
async (params) => {
return commands.getExportEstimates(
params.videoId,
params.resolution,
params.fps
);
}
);

let unlistenTitlebar: UnlistenFn | undefined;
onMount(async () => {
unlistenTitlebar = await initializeTitlebar();
Expand Down Expand Up @@ -175,8 +199,8 @@ export function Header() {
true,
selectedFps(),
{
x: selectedResolution().width,
y: selectedResolution().height,
x: selectedResolution()?.width || RESOLUTION_OPTIONS[0].width,
y: selectedResolution()?.height || RESOLUTION_OPTIONS[0].height,
}
);
await commands.copyFileToPath(videoPath, path);
Expand Down Expand Up @@ -273,9 +297,9 @@ export function Header() {
</div>
<div>
<label class="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
Frame Rate
FPS
</label>
<KSelect<(typeof FPS_OPTIONS)[number]>
<KSelect
options={FPS_OPTIONS}
optionValue="value"
optionTextValue="label"
Expand Down Expand Up @@ -327,6 +351,70 @@ export function Header() {
>
Export Video
</Button>
<Show when={exportEstimates()}>
{(est) => (
<div
class={cx(
"font-medium z-40 flex justify-between items-center pointer-events-none transition-all max-w-full overflow-hidden text-xs"
)}
>
<p class="flex items-center gap-4">
<span class="flex items-center text-[--gray-500]">
<IconCapCamera class="w-[14px] h-[14px] mr-1.5 text-[--gray-500]" />
{(() => {
const totalSeconds = Math.round(
est().duration_seconds
);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor(
(totalSeconds % 3600) / 60
);
const seconds = totalSeconds % 60;

if (hours > 0) {
return `${hours}:${minutes
.toString()
.padStart(2, "0")}:${seconds
.toString()
.padStart(2, "0")}`;
}
return `${minutes}:${seconds
.toString()
.padStart(2, "0")}`;
})()}
</span>
<span class="flex items-center text-[--gray-500]">
<IconLucideHardDrive class="w-[14px] h-[14px] mr-1.5 text-[--gray-500]" />
{est().estimated_size_mb.toFixed(2)} MB
</span>
<span class="flex items-center text-[--gray-500]">
<IconLucideClock class="w-[14px] h-[14px] mr-1.5 text-[--gray-500]" />
{(() => {
const totalSeconds = Math.round(
est().estimated_time_seconds
);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor(
(totalSeconds % 3600) / 60
);
const seconds = totalSeconds % 60;

if (hours > 0) {
return `~${hours}:${minutes
.toString()
.padStart(2, "0")}:${seconds
.toString()
.padStart(2, "0")}`;
}
return `~${minutes}:${seconds
.toString()
.padStart(2, "0")}`;
})()}
</span>
</p>
</div>
)}
</Show>
</div>
</div>
</Show>
Expand Down Expand Up @@ -608,8 +696,10 @@ function ShareButton(props: ShareButtonProps) {
true,
props.selectedFps(),
{
x: props.selectedResolution().width,
y: props.selectedResolution().height,
x: props.selectedResolution()?.width || RESOLUTION_OPTIONS[0].width,
y:
props.selectedResolution()?.height ||
RESOLUTION_OPTIONS[0].height,
}
);

Expand Down
Loading

1 comment on commit 78224af

@vercel
Copy link

@vercel vercel bot commented on 78224af Jan 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.