Skip to content

Commit b9a0944

Browse files
Merge pull request #1464 from CapSoftware/optimisations
wip: Optimize macOS recording pipeline with M4S muxer and async finalization (+ optimisations)
2 parents 444df9e + d43f7e3 commit b9a0944

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

55 files changed

+6215
-3773
lines changed

.claude/settings.local.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,11 @@
5050
"Bash(cargo build:*)",
5151
"Bash(footprint:*)",
5252
"Bash(RUST_LOG=info,cap_recording=debug ./target/release/examples/memory-leak-detector:*)",
53-
"Bash(git rm:*)"
53+
"Bash(git rm:*)",
54+
"Bash(./target/release/examples/decode-benchmark:*)",
55+
"Bash(RUST_LOG=warn ./target/release/examples/decode-benchmark:*)",
56+
"Bash(git mv:*)",
57+
"Bash(xargs cat:*)"
5458
],
5559
"deny": [],
5660
"ask": []

Cargo.lock

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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

Lines changed: 287 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
1+
use crate::editor_window::WindowEditorInstance;
12
use crate::{FramesRendered, get_video_metadata};
23
use cap_export::ExporterBase;
3-
use cap_project::RecordingMeta;
4-
use serde::Deserialize;
4+
use cap_project::{RecordingMeta, XY};
5+
use cap_rendering::{
6+
FrameRenderer, ProjectRecordingsMeta, ProjectUniforms, RenderSegment, RenderVideoConstants,
7+
RendererLayers,
8+
};
9+
use image::codecs::jpeg::JpegEncoder;
10+
use serde::{Deserialize, Serialize};
511
use specta::Type;
6-
use std::path::PathBuf;
12+
use std::{path::PathBuf, sync::Arc};
713
use tracing::{info, instrument};
814

915
#[derive(Deserialize, Clone, Copy, Debug, Type)]
@@ -164,3 +170,281 @@ pub async fn get_export_estimates(
164170
estimated_size_mb,
165171
})
166172
}
173+
174+
#[derive(Debug, Deserialize, Type)]
175+
pub struct ExportPreviewSettings {
176+
pub fps: u32,
177+
pub resolution_base: XY<u32>,
178+
pub compression_bpp: f32,
179+
}
180+
181+
#[derive(Debug, Serialize, Type)]
182+
pub struct ExportPreviewResult {
183+
pub jpeg_base64: String,
184+
pub estimated_size_mb: f64,
185+
pub actual_width: u32,
186+
pub actual_height: u32,
187+
pub frame_render_time_ms: f64,
188+
pub total_frames: u32,
189+
}
190+
191+
fn bpp_to_jpeg_quality(bpp: f32) -> u8 {
192+
((bpp - 0.04) / (0.3 - 0.04) * (95.0 - 40.0) + 40.0).clamp(40.0, 95.0) as u8
193+
}
194+
195+
#[tauri::command]
196+
#[specta::specta]
197+
#[instrument(skip_all)]
198+
pub async fn generate_export_preview(
199+
project_path: PathBuf,
200+
frame_time: f64,
201+
settings: ExportPreviewSettings,
202+
) -> Result<ExportPreviewResult, String> {
203+
use base64::{Engine, engine::general_purpose::STANDARD};
204+
use cap_editor::create_segments;
205+
use std::time::Instant;
206+
207+
let recording_meta = RecordingMeta::load_for_project(&project_path)
208+
.map_err(|e| format!("Failed to load recording meta: {e}"))?;
209+
210+
let cap_project::RecordingMetaInner::Studio(studio_meta) = &recording_meta.inner else {
211+
return Err("Cannot preview non-studio recordings".to_string());
212+
};
213+
214+
let project_config = recording_meta.project_config();
215+
216+
let recordings = Arc::new(
217+
ProjectRecordingsMeta::new(&recording_meta.project_path, studio_meta)
218+
.map_err(|e| format!("Failed to load recordings: {e}"))?,
219+
);
220+
221+
let render_constants = Arc::new(
222+
RenderVideoConstants::new(
223+
&recordings.segments,
224+
recording_meta.clone(),
225+
studio_meta.clone(),
226+
)
227+
.await
228+
.map_err(|e| format!("Failed to create render constants: {e}"))?,
229+
);
230+
231+
let segments = create_segments(&recording_meta, studio_meta)
232+
.await
233+
.map_err(|e| format!("Failed to create segments: {e}"))?;
234+
235+
let render_segments: Vec<RenderSegment> = segments
236+
.iter()
237+
.map(|s| RenderSegment {
238+
cursor: s.cursor.clone(),
239+
decoders: s.decoders.clone(),
240+
})
241+
.collect();
242+
243+
let Some((segment_time, segment)) = project_config.get_segment_time(frame_time) else {
244+
return Err("Frame time is outside video duration".to_string());
245+
};
246+
247+
let render_segment = &render_segments[segment.recording_clip as usize];
248+
let clip_config = project_config
249+
.clips
250+
.iter()
251+
.find(|v| v.index == segment.recording_clip);
252+
253+
let render_start = Instant::now();
254+
255+
let segment_frames = render_segment
256+
.decoders
257+
.get_frames(
258+
segment_time as f32,
259+
!project_config.camera.hide,
260+
clip_config.map(|v| v.offsets).unwrap_or_default(),
261+
)
262+
.await
263+
.ok_or_else(|| "Failed to decode frame".to_string())?;
264+
265+
let frame_number = (frame_time * settings.fps as f64).floor() as u32;
266+
267+
let uniforms = ProjectUniforms::new(
268+
&render_constants,
269+
&project_config,
270+
frame_number,
271+
settings.fps,
272+
settings.resolution_base,
273+
&render_segment.cursor,
274+
&segment_frames,
275+
);
276+
277+
let mut frame_renderer = FrameRenderer::new(&render_constants);
278+
let mut layers = RendererLayers::new_with_options(
279+
&render_constants.device,
280+
&render_constants.queue,
281+
render_constants.is_software_adapter,
282+
);
283+
284+
let frame = frame_renderer
285+
.render(
286+
segment_frames,
287+
uniforms,
288+
&render_segment.cursor,
289+
&mut layers,
290+
)
291+
.await
292+
.map_err(|e| format!("Failed to render frame: {e}"))?;
293+
294+
let frame_render_time_ms = render_start.elapsed().as_secs_f64() * 1000.0;
295+
296+
let width = frame.width;
297+
let height = frame.height;
298+
299+
let rgb_data: Vec<u8> = frame
300+
.data
301+
.chunks(frame.padded_bytes_per_row as usize)
302+
.flat_map(|row| {
303+
row[0..(frame.width * 4) as usize]
304+
.chunks(4)
305+
.flat_map(|chunk| [chunk[0], chunk[1], chunk[2]])
306+
})
307+
.collect();
308+
309+
let jpeg_quality = bpp_to_jpeg_quality(settings.compression_bpp);
310+
let mut jpeg_buffer = Vec::new();
311+
{
312+
let mut encoder = JpegEncoder::new_with_quality(&mut jpeg_buffer, jpeg_quality);
313+
encoder
314+
.encode(&rgb_data, width, height, image::ExtendedColorType::Rgb8)
315+
.map_err(|e| format!("Failed to encode JPEG: {e}"))?;
316+
}
317+
318+
let jpeg_base64 = STANDARD.encode(&jpeg_buffer);
319+
320+
let total_pixels = (settings.resolution_base.x * settings.resolution_base.y) as f64;
321+
let fps_f64 = settings.fps as f64;
322+
323+
let metadata = get_video_metadata(project_path.clone()).await?;
324+
let duration_seconds = if let Some(timeline) = &project_config.timeline {
325+
timeline.segments.iter().map(|s| s.duration()).sum()
326+
} else {
327+
metadata.duration
328+
};
329+
let total_frames = (duration_seconds * fps_f64).ceil() as u32;
330+
331+
let video_bitrate = total_pixels * settings.compression_bpp as f64 * fps_f64;
332+
let audio_bitrate = 192_000.0;
333+
let total_bitrate = video_bitrate + audio_bitrate;
334+
let estimated_size_mb = (total_bitrate * duration_seconds) / (8.0 * 1024.0 * 1024.0);
335+
336+
Ok(ExportPreviewResult {
337+
jpeg_base64,
338+
estimated_size_mb,
339+
actual_width: width,
340+
actual_height: height,
341+
frame_render_time_ms,
342+
total_frames,
343+
})
344+
}
345+
346+
#[tauri::command]
347+
#[specta::specta]
348+
#[instrument(skip_all)]
349+
pub async fn generate_export_preview_fast(
350+
editor: WindowEditorInstance,
351+
frame_time: f64,
352+
settings: ExportPreviewSettings,
353+
) -> Result<ExportPreviewResult, String> {
354+
use base64::{Engine, engine::general_purpose::STANDARD};
355+
use std::time::Instant;
356+
357+
let project_config = editor.project_config.1.borrow().clone();
358+
359+
let Some((segment_time, segment)) = project_config.get_segment_time(frame_time) else {
360+
return Err("Frame time is outside video duration".to_string());
361+
};
362+
363+
let segment_media = &editor.segment_medias[segment.recording_clip as usize];
364+
let clip_config = project_config
365+
.clips
366+
.iter()
367+
.find(|v| v.index == segment.recording_clip);
368+
369+
let render_start = Instant::now();
370+
371+
let segment_frames = segment_media
372+
.decoders
373+
.get_frames(
374+
segment_time as f32,
375+
!project_config.camera.hide,
376+
clip_config.map(|v| v.offsets).unwrap_or_default(),
377+
)
378+
.await
379+
.ok_or_else(|| "Failed to decode frame".to_string())?;
380+
381+
let frame_number = (frame_time * settings.fps as f64).floor() as u32;
382+
383+
let uniforms = ProjectUniforms::new(
384+
&editor.render_constants,
385+
&project_config,
386+
frame_number,
387+
settings.fps,
388+
settings.resolution_base,
389+
&segment_media.cursor,
390+
&segment_frames,
391+
);
392+
393+
let mut frame_renderer = FrameRenderer::new(&editor.render_constants);
394+
let mut layers = RendererLayers::new_with_options(
395+
&editor.render_constants.device,
396+
&editor.render_constants.queue,
397+
editor.render_constants.is_software_adapter,
398+
);
399+
400+
let frame = frame_renderer
401+
.render(segment_frames, uniforms, &segment_media.cursor, &mut layers)
402+
.await
403+
.map_err(|e| format!("Failed to render frame: {e}"))?;
404+
405+
let frame_render_time_ms = render_start.elapsed().as_secs_f64() * 1000.0;
406+
407+
let width = frame.width;
408+
let height = frame.height;
409+
410+
let rgb_data: Vec<u8> = frame
411+
.data
412+
.chunks(frame.padded_bytes_per_row as usize)
413+
.flat_map(|row| {
414+
row[0..(frame.width * 4) as usize]
415+
.chunks(4)
416+
.flat_map(|chunk| [chunk[0], chunk[1], chunk[2]])
417+
})
418+
.collect();
419+
420+
let jpeg_quality = bpp_to_jpeg_quality(settings.compression_bpp);
421+
let mut jpeg_buffer = Vec::new();
422+
{
423+
let mut encoder = JpegEncoder::new_with_quality(&mut jpeg_buffer, jpeg_quality);
424+
encoder
425+
.encode(&rgb_data, width, height, image::ExtendedColorType::Rgb8)
426+
.map_err(|e| format!("Failed to encode JPEG: {e}"))?;
427+
}
428+
429+
let jpeg_base64 = STANDARD.encode(&jpeg_buffer);
430+
431+
let total_pixels = (settings.resolution_base.x * settings.resolution_base.y) as f64;
432+
let fps_f64 = settings.fps as f64;
433+
434+
let duration_seconds = editor.recordings.duration();
435+
let total_frames = (duration_seconds * fps_f64).ceil() as u32;
436+
437+
let video_bitrate = total_pixels * settings.compression_bpp as f64 * fps_f64;
438+
let audio_bitrate = 192_000.0;
439+
let total_bitrate = video_bitrate + audio_bitrate;
440+
let estimated_size_mb = (total_bitrate * duration_seconds) / (8.0 * 1024.0 * 1024.0);
441+
442+
Ok(ExportPreviewResult {
443+
jpeg_base64,
444+
estimated_size_mb,
445+
actual_width: width,
446+
actual_height: height,
447+
frame_render_time_ms,
448+
total_frames,
449+
})
450+
}

0 commit comments

Comments
 (0)