Skip to content

Commit da6841b

Browse files
committed
Add export preview render time and frame estimates
1 parent e4517f1 commit da6841b

File tree

3 files changed

+119
-61
lines changed

3 files changed

+119
-61
lines changed

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,8 @@ pub struct ExportPreviewResult {
184184
pub estimated_size_mb: f64,
185185
pub actual_width: u32,
186186
pub actual_height: u32,
187+
pub frame_render_time_ms: f64,
188+
pub total_frames: u32,
187189
}
188190

189191
fn bpp_to_jpeg_quality(bpp: f32) -> u8 {
@@ -201,6 +203,7 @@ pub async fn generate_export_preview(
201203
) -> Result<ExportPreviewResult, String> {
202204
use base64::{Engine, engine::general_purpose::STANDARD};
203205
use cap_editor::create_segments;
206+
use std::time::Instant;
204207

205208
let recording_meta = RecordingMeta::load_for_project(&project_path)
206209
.map_err(|e| format!("Failed to load recording meta: {e}"))?;
@@ -248,6 +251,8 @@ pub async fn generate_export_preview(
248251
.iter()
249252
.find(|v| v.index == segment.recording_clip);
250253

254+
let render_start = Instant::now();
255+
251256
let segment_frames = render_segment
252257
.decoders
253258
.get_frames(
@@ -287,6 +292,8 @@ pub async fn generate_export_preview(
287292
.await
288293
.map_err(|e| format!("Failed to render frame: {e}"))?;
289294

295+
let frame_render_time_ms = render_start.elapsed().as_secs_f64() * 1000.0;
296+
290297
let width = frame.width;
291298
let height = frame.height;
292299

@@ -320,6 +327,7 @@ pub async fn generate_export_preview(
320327
} else {
321328
metadata.duration
322329
};
330+
let total_frames = (duration_seconds * fps_f64).ceil() as u32;
323331

324332
let video_bitrate = total_pixels * settings.compression_bpp as f64 * fps_f64;
325333
let audio_bitrate = 192_000.0;
@@ -331,6 +339,8 @@ pub async fn generate_export_preview(
331339
estimated_size_mb,
332340
actual_width: width,
333341
actual_height: height,
342+
frame_render_time_ms,
343+
total_frames,
334344
})
335345
}
336346

@@ -343,6 +353,7 @@ pub async fn generate_export_preview_fast(
343353
settings: ExportPreviewSettings,
344354
) -> Result<ExportPreviewResult, String> {
345355
use base64::{Engine, engine::general_purpose::STANDARD};
356+
use std::time::Instant;
346357

347358
let project_config = editor.project_config.1.borrow().clone();
348359

@@ -356,6 +367,8 @@ pub async fn generate_export_preview_fast(
356367
.iter()
357368
.find(|v| v.index == segment.recording_clip);
358369

370+
let render_start = Instant::now();
371+
359372
let segment_frames = segment_media
360373
.decoders
361374
.get_frames(
@@ -390,6 +403,8 @@ pub async fn generate_export_preview_fast(
390403
.await
391404
.map_err(|e| format!("Failed to render frame: {e}"))?;
392405

406+
let frame_render_time_ms = render_start.elapsed().as_secs_f64() * 1000.0;
407+
393408
let width = frame.width;
394409
let height = frame.height;
395410

@@ -418,6 +433,7 @@ pub async fn generate_export_preview_fast(
418433
let fps_f64 = settings.fps as f64;
419434

420435
let duration_seconds = editor.recordings.duration();
436+
let total_frames = (duration_seconds * fps_f64).ceil() as u32;
421437

422438
let video_bitrate = total_pixels * settings.compression_bpp as f64 * fps_f64;
423439
let audio_bitrate = 192_000.0;
@@ -429,5 +445,7 @@ pub async fn generate_export_preview_fast(
429445
estimated_size_mb,
430446
actual_width: width,
431447
actual_height: height,
448+
frame_render_time_ms,
449+
total_frames,
432450
})
433451
}

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

Lines changed: 100 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,7 @@ import { Button } from "@cap/ui-solid";
22
import { RadioGroup as KRadioGroup } from "@kobalte/core/radio-group";
33
import { debounce } from "@solid-primitives/scheduled";
44
import { makePersisted } from "@solid-primitives/storage";
5-
import {
6-
createMutation,
7-
createQuery,
8-
keepPreviousData,
9-
} from "@tanstack/solid-query";
5+
import { createMutation } from "@tanstack/solid-query";
106
import { Channel } from "@tauri-apps/api/core";
117
import { CheckMenuItem, Menu } from "@tauri-apps/api/menu";
128
import { ask, save as saveDialog } from "@tauri-apps/plugin-dialog";
@@ -102,7 +98,7 @@ export const EXPORT_TO_OPTIONS = [
10298
description: "Copy to paste anywhere",
10399
},
104100
{
105-
label: "Link",
101+
label: "Shareable Link",
106102
value: "link",
107103
icon: IconCapLink,
108104
description: "Share via Cap cloud",
@@ -195,11 +191,33 @@ export function ExportPage() {
195191

196192
const [previewUrl, setPreviewUrl] = createSignal<string | null>(null);
197193
const [previewLoading, setPreviewLoading] = createSignal(false);
198-
199-
const updateSettings: typeof setSettings = (...args) => {
194+
const [renderEstimate, setRenderEstimate] = createSignal<{
195+
frameRenderTimeMs: number;
196+
totalFrames: number;
197+
estimatedSizeMb: number;
198+
} | null>(null);
199+
200+
type EstimateCacheKey = string;
201+
const estimateCache = new Map<
202+
EstimateCacheKey,
203+
{ frameRenderTimeMs: number; totalFrames: number; estimatedSizeMb: number }
204+
>();
205+
206+
const getEstimateCacheKey = (
207+
fps: number,
208+
width: number,
209+
height: number,
210+
bpp: number,
211+
): EstimateCacheKey => `${fps}-${width}-${height}-${bpp}`;
212+
213+
const updateSettings: typeof setSettings = ((
214+
...args: Parameters<typeof setSettings>
215+
) => {
200216
setPreviewLoading(true);
201-
return setSettings(...args);
202-
};
217+
return (setSettings as (...args: Parameters<typeof setSettings>) => void)(
218+
...args,
219+
);
220+
}) as typeof setSettings;
203221
const [previewDialogOpen, setPreviewDialogOpen] = createSignal(false);
204222
const [compressionBpp, setCompressionBpp] = createSignal(
205223
COMPRESSION_TO_BPP[_settings.compression] ?? 0.15,
@@ -223,6 +241,13 @@ export function ExportPage() {
223241
resHeight: number,
224242
bpp: number,
225243
) => {
244+
const cacheKey = getEstimateCacheKey(fps, resWidth, resHeight, bpp);
245+
const cachedEstimate = estimateCache.get(cacheKey);
246+
247+
if (cachedEstimate) {
248+
setRenderEstimate(cachedEstimate);
249+
}
250+
226251
try {
227252
const result = await commands.generateExportPreviewFast(frameTime, {
228253
fps,
@@ -238,6 +263,17 @@ export function ExportPage() {
238263
);
239264
const blob = new Blob([byteArray], { type: "image/jpeg" });
240265
setPreviewUrl(URL.createObjectURL(blob));
266+
267+
const newEstimate = {
268+
frameRenderTimeMs: result.frame_render_time_ms,
269+
totalFrames: result.total_frames,
270+
estimatedSizeMb: result.estimated_size_mb,
271+
};
272+
273+
if (!cachedEstimate) {
274+
estimateCache.set(cacheKey, newEstimate);
275+
}
276+
setRenderEstimate(newEstimate);
241277
} catch (e) {
242278
console.error("Failed to generate preview:", e);
243279
} finally {
@@ -334,39 +370,6 @@ export function ExportPage() {
334370
}
335371
};
336372

337-
const exportEstimates = createQuery(() => ({
338-
placeholderData: keepPreviousData,
339-
queryKey: [
340-
"exportEstimates",
341-
{
342-
format: settings.format,
343-
resolution: {
344-
x: settings.resolution.width,
345-
y: settings.resolution.height,
346-
},
347-
fps: settings.fps,
348-
compression: settings.compression,
349-
},
350-
] as const,
351-
queryFn: ({ queryKey: [_, { format, resolution, fps, compression }] }) => {
352-
const exportSettings =
353-
format === "Mp4"
354-
? {
355-
format: "Mp4" as const,
356-
fps,
357-
resolution_base: resolution,
358-
compression,
359-
}
360-
: {
361-
format: "Gif" as const,
362-
fps,
363-
resolution_base: resolution,
364-
quality: null,
365-
};
366-
return commands.getExportEstimates(projectPath, exportSettings);
367-
},
368-
}));
369-
370373
const copy = createMutation(() => ({
371374
mutationFn: async () => {
372375
setIsCancelled(false);
@@ -653,33 +656,68 @@ export function ExportPage() {
653656
</Show>
654657
</div>
655658

656-
<Suspense>
657-
<Show when={exportEstimates.data}>
658-
{(est) => (
659+
<Show
660+
when={!previewLoading() && renderEstimate()}
661+
fallback={
662+
<div class="flex items-center justify-center gap-4 mt-4 text-xs text-gray-11">
663+
<span class="flex items-center gap-1.5">
664+
<IconLucideClock class="size-3.5" />
665+
<span class="h-3.5 w-8 bg-gray-4 rounded animate-pulse" />
666+
</span>
667+
<span class="flex items-center gap-1.5">
668+
<IconLucideMonitor class="size-3.5" />
669+
<span class="h-3.5 w-16 bg-gray-4 rounded animate-pulse" />
670+
</span>
671+
<span class="flex items-center gap-1.5">
672+
<IconLucideHardDrive class="size-3.5" />
673+
<span class="h-3.5 w-12 bg-gray-4 rounded animate-pulse" />
674+
</span>
675+
<span class="flex items-center gap-1.5">
676+
<IconLucideZap class="size-3.5" />
677+
<span class="h-3.5 w-10 bg-gray-4 rounded animate-pulse" />
678+
</span>
679+
</div>
680+
}
681+
>
682+
{(est) => {
683+
const data = est();
684+
const durationSeconds = data.totalFrames / settings.fps;
685+
686+
const exportSpeedMultiplier =
687+
settings.format === "Gif" ? 4 : 10;
688+
const totalTimeMs =
689+
(data.frameRenderTimeMs * data.totalFrames) /
690+
exportSpeedMultiplier;
691+
const estimatedTimeSeconds = Math.max(1, totalTimeMs / 1000);
692+
693+
const sizeMultiplier = settings.format === "Gif" ? 0.7 : 0.5;
694+
const estimatedSizeMb = data.estimatedSizeMb * sizeMultiplier;
695+
696+
return (
659697
<div class="flex items-center justify-center gap-4 mt-4 text-xs text-gray-11">
660698
<span class="flex items-center gap-1.5">
661699
<IconLucideClock class="size-3.5" />
662-
{formatDuration(Math.round(est().duration_seconds))}
700+
{formatDuration(Math.round(durationSeconds))}
663701
</span>
664702
<span class="flex items-center gap-1.5">
665703
<IconLucideMonitor class="size-3.5" />
666704
{settings.resolution.width}×{settings.resolution.height}
667705
</span>
668706
<span class="flex items-center gap-1.5">
669707
<IconLucideHardDrive class="size-3.5" />~
670-
{est().estimated_size_mb.toFixed(1)} MB
708+
{estimatedSizeMb.toFixed(1)} MB
671709
</span>
672710
<span class="flex items-center gap-1.5">
673711
<IconLucideZap class="size-3.5" />~
674-
{formatDuration(Math.round(est().estimated_time_seconds))}
712+
{formatDuration(Math.round(estimatedTimeSeconds))}
675713
</span>
676714
</div>
677-
)}
678-
</Show>
679-
</Suspense>
715+
);
716+
}}
717+
</Show>
680718
</div>
681719

682-
<div class="w-72 border-l border-gray-3 flex flex-col bg-gray-1 dark:bg-gray-2">
720+
<div class="w-[340px] border-l border-gray-3 flex flex-col bg-gray-1 dark:bg-gray-2">
683721
<div class="flex-1 overflow-y-auto p-4 space-y-5">
684722
<Field name="Destination" icon={<IconCapUpload class="size-4" />}>
685723
<KRadioGroup
@@ -1006,15 +1044,17 @@ export function ExportPage() {
10061044
<span>
10071045
{settings.resolution.width}×{settings.resolution.height}
10081046
</span>
1009-
<Suspense>
1010-
<Show when={exportEstimates.data}>
1011-
{(est) => (
1047+
<Show when={renderEstimate()}>
1048+
{(est) => {
1049+
const sizeMultiplier = settings.format === "Gif" ? 0.7 : 0.5;
1050+
return (
10121051
<span>
1013-
Estimated size: {est().estimated_size_mb.toFixed(1)} MB
1052+
Estimated size:{" "}
1053+
{(est().estimatedSizeMb * sizeMultiplier).toFixed(1)} MB
10141054
</span>
1015-
)}
1016-
</Show>
1017-
</Suspense>
1055+
);
1056+
}}
1057+
</Show>
10181058
</div>
10191059
</div>
10201060
</Dialog.Root>

apps/desktop/src/utils/tauri.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -414,7 +414,7 @@ export type DownloadProgress = { progress: number; message: string }
414414
export type EditorStateChanged = { playhead_position: number }
415415
export type ExportCompression = "Minimal" | "Social" | "Web" | "Potato"
416416
export type ExportEstimates = { duration_seconds: number; estimated_time_seconds: number; estimated_size_mb: number }
417-
export type ExportPreviewResult = { jpeg_base64: string; estimated_size_mb: number; actual_width: number; actual_height: number }
417+
export type ExportPreviewResult = { jpeg_base64: string; estimated_size_mb: number; actual_width: number; actual_height: number; frame_render_time_ms: number; total_frames: number }
418418
export type ExportPreviewSettings = { fps: number; resolution_base: XY<number>; compression_bpp: number }
419419
export type ExportSettings = ({ format: "Mp4" } & Mp4ExportSettings) | ({ format: "Gif" } & GifExportSettings)
420420
export type FileType = "recording" | "screenshot"

0 commit comments

Comments
 (0)