Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add video metadata display #231

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading