Skip to content

Commit 1980d65

Browse files
committed
feat: Better export estimates
1 parent 2bddf21 commit 1980d65

File tree

4 files changed

+189
-54
lines changed

4 files changed

+189
-54
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

+24-23
Original file line numberDiff line numberDiff line change
@@ -943,25 +943,6 @@ async fn copy_video_to_clipboard(
943943
Ok(())
944944
}
945945

946-
947-
948-
// Helper function to estimate rendered file size based on duration and quality
949-
fn estimate_rendered_size(duration: f64) -> f64 {
950-
// Use actual encoder bitrates:
951-
// - Video: 8-10 Mbps (from H264 encoder implementations)
952-
// - Audio: 128 Kbps (fixed in all encoders)
953-
#[cfg(target_os = "macos")]
954-
let video_bitrate = 10_000_000.0; // 10 Mbps (AVAssetWriter)
955-
#[cfg(not(target_os = "macos"))]
956-
let video_bitrate = 8_000_000.0; // 8 Mbps (libx264)
957-
958-
let audio_bitrate = 128_000.0; // 128 Kbps (fixed in encoders)
959-
960-
// Total data = (video_bitrate + audio_bitrate) * duration / 8 bits per byte
961-
// Convert to MB by dividing by (1024 * 1024)
962-
((video_bitrate + audio_bitrate) * duration) / (8.0 * 1024.0 * 1024.0)
963-
}
964-
965946
#[tauri::command]
966947
#[specta::specta]
967948
async fn get_video_metadata(
@@ -987,7 +968,9 @@ async fn get_video_metadata(
987968
fn get_duration_for_paths(paths: Vec<PathBuf>) -> Result<f64, String> {
988969
let mut max_duration: f64 = 0.0;
989970
for path in paths {
990-
let reader = BufReader::new(File::open(&path).map_err(|e| format!("Failed to open video file: {}", e))?);
971+
let reader = BufReader::new(
972+
File::open(&path).map_err(|e| format!("Failed to open video file: {}", e))?,
973+
);
991974
let file_size = path
992975
.metadata()
993976
.map_err(|e| format!("Failed to get file metadata: {}", e))?
@@ -1041,11 +1024,28 @@ async fn get_video_metadata(
10411024
// Use the longer duration
10421025
let duration = display_duration.max(camera_duration);
10431026

1044-
// Estimate the rendered file size based on the duration
1045-
let estimated_size = estimate_rendered_size(duration);
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
1030+
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+
};
1040+
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);
10461046

10471047
Ok(VideoRecordingMetadata {
1048-
size: estimated_size,
1048+
size: estimated_size_mb,
10491049
duration,
10501050
})
10511051
}
@@ -1862,6 +1862,7 @@ pub async fn run() {
18621862
focus_captures_panel,
18631863
get_current_recording,
18641864
export::export_video,
1865+
export::get_export_estimates,
18651866
copy_file_to_path,
18661867
copy_video_to_clipboard,
18671868
copy_screenshot_to_clipboard,

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

+77-31
Original file line numberDiff line numberDiff line change
@@ -63,24 +63,16 @@ 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();
6975

70-
const [metadata] = createResource(async () => {
71-
const result = await commands.getVideoMetadata(videoId, null).catch((e) => {
72-
console.error(`Failed to get metadata: ${e}`);
73-
});
74-
if (!result) return;
75-
76-
const { duration, size } = result;
77-
const estimatedExportTime = Math.ceil(duration * 1.5);
78-
console.log(
79-
`Metadata for video: duration=${duration}, size=${size}, estimatedExport=${estimatedExportTime}`
80-
);
81-
return { duration, size, estimatedExportTime };
82-
});
83-
8476
const [showExportOptions, setShowExportOptions] = createSignal(false);
8577
const [selectedFps, setSelectedFps] = createSignal(
8678
Number(localStorage.getItem("cap-export-fps")) || 30
@@ -92,6 +84,24 @@ export function Header() {
9284
) || RESOLUTION_OPTIONS[0]
9385
);
9486

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+
95105
let unlistenTitlebar: UnlistenFn | undefined;
96106
onMount(async () => {
97107
unlistenTitlebar = await initializeTitlebar();
@@ -189,8 +199,8 @@ export function Header() {
189199
true,
190200
selectedFps(),
191201
{
192-
x: selectedResolution().width,
193-
y: selectedResolution().height,
202+
x: selectedResolution()?.width || RESOLUTION_OPTIONS[0].width,
203+
y: selectedResolution()?.height || RESOLUTION_OPTIONS[0].height,
194204
}
195205
);
196206
await commands.copyFileToPath(videoPath, path);
@@ -287,9 +297,9 @@ export function Header() {
287297
</div>
288298
<div>
289299
<label class="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
290-
Frame Rate
300+
FPS
291301
</label>
292-
<KSelect<(typeof FPS_OPTIONS)[number]>
302+
<KSelect
293303
options={FPS_OPTIONS}
294304
optionValue="value"
295305
optionTextValue="label"
@@ -341,8 +351,8 @@ export function Header() {
341351
>
342352
Export Video
343353
</Button>
344-
<Show when={metadata()}>
345-
{(metadata) => (
354+
<Show when={exportEstimates()}>
355+
{(est) => (
346356
<div
347357
class={cx(
348358
"font-medium z-40 flex justify-between items-center pointer-events-none transition-all max-w-full overflow-hidden text-xs"
@@ -351,21 +361,55 @@ export function Header() {
351361
<p class="flex items-center gap-4">
352362
<span class="flex items-center text-[--gray-500]">
353363
<IconCapCamera class="w-[14px] h-[14px] mr-1.5 text-[--gray-500]" />
354-
{Math.floor(metadata().duration / 60)}:
355-
{Math.floor(metadata().duration % 60)
356-
.toString()
357-
.padStart(2, "0")}
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+
})()}
358385
</span>
359386
<span class="flex items-center text-[--gray-500]">
360387
<IconLucideHardDrive class="w-[14px] h-[14px] mr-1.5 text-[--gray-500]" />
361-
{metadata().size.toFixed(2)} MB
388+
{est().estimated_size_mb.toFixed(2)} MB
362389
</span>
363390
<span class="flex items-center text-[--gray-500]">
364391
<IconLucideClock class="w-[14px] h-[14px] mr-1.5 text-[--gray-500]" />
365-
~{Math.floor(metadata().estimatedExportTime / 60)}:
366-
{Math.floor(metadata().estimatedExportTime % 60)
367-
.toString()
368-
.padStart(2, "0")}
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+
})()}
369413
</span>
370414
</p>
371415
</div>
@@ -652,8 +696,10 @@ function ShareButton(props: ShareButtonProps) {
652696
true,
653697
props.selectedFps(),
654698
{
655-
x: props.selectedResolution().width,
656-
y: props.selectedResolution().height,
699+
x: props.selectedResolution()?.width || RESOLUTION_OPTIONS[0].width,
700+
y:
701+
props.selectedResolution()?.height ||
702+
RESOLUTION_OPTIONS[0].height,
657703
}
658704
);
659705

apps/desktop/src/utils/tauri.ts

+4
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ async getCurrentRecording() : Promise<JsonValue<RecordingInfo | null>> {
5656
async exportVideo(videoId: string, project: ProjectConfiguration, progress: TAURI_CHANNEL<RenderProgress>, force: boolean, fps: number, resolutionBase: XY<number>) : Promise<string> {
5757
return await TAURI_INVOKE("export_video", { videoId, project, progress, force, fps, resolutionBase });
5858
},
59+
async getExportEstimates(videoId: string, resolution: XY<number>, fps: number) : Promise<ExportEstimates> {
60+
return await TAURI_INVOKE("get_export_estimates", { videoId, resolution, fps });
61+
},
5962
async copyFileToPath(src: string, dst: string) : Promise<null> {
6063
return await TAURI_INVOKE("copy_file_to_path", { src, dst });
6164
},
@@ -240,6 +243,7 @@ export type CursorConfiguration = { hideWhenIdle: boolean; size: number; type: C
240243
export type CursorType = "pointer" | "circle"
241244
export type Display = { path: string; fps?: number }
242245
export type EditorStateChanged = { playhead_position: number }
246+
export type ExportEstimates = { duration_seconds: number; estimated_time_seconds: number; estimated_size_mb: number }
243247
export type Flags = { recordMouse: boolean; split: boolean; pauseResume: boolean; zoom: boolean }
244248
export type GeneralSettingsStore = { uploadIndividualFiles?: boolean; openEditorAfterRecording?: boolean; hideDockIcon?: boolean; autoCreateShareableLink?: boolean; enableNotifications?: boolean; disableAutoOpenLinks?: boolean; hasCompletedStartup?: boolean; theme?: AppTheme; recordingConfig?: RecordingConfig | null }
245249
export type Hotkey = { code: string; meta: boolean; ctrl: boolean; alt: boolean; shift: boolean }

0 commit comments

Comments
 (0)