WARNING: THIS SITE IS A MIRROR OF GITHUB.COM / IT CANNOT LOGIN OR REGISTER ACCOUNTS / THE CONTENTS ARE PROVIDED AS-IS / THIS SITE ASSUMES NO RESPONSIBILITY FOR ANY DISPLAYED CONTENT OR LINKS / IF YOU FOUND SOMETHING MAY NOT GOOD FOR EVERYONE, CONTACT ADMIN AT ilovescratch@foxmail.com
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
2663374
Improve video frame handling and YUV conversion performance
richiemcilroy Dec 21, 2025
636f5b3
Improve audio-video sync and adjust playback buffer sizes
richiemcilroy Dec 21, 2025
b7011d8
Remove fragmented_mp4, add segmented_stream encoder
richiemcilroy Dec 21, 2025
a122436
Remove segmented MP4 encoder and enhance MP4 encoder settings
richiemcilroy Dec 21, 2025
84cba3c
Replace segmented muxers with M4S muxer on macOS
richiemcilroy Dec 21, 2025
b33d74c
Add async finalization for fragmented recordings
richiemcilroy Dec 21, 2025
04066dd
Refactor macOS memory usage reporting to use libproc
richiemcilroy Dec 21, 2025
3bfdbbc
Remove backward stale frame handling in AVAssetReaderDecoder
richiemcilroy Dec 22, 2025
9b03f4e
Fix audio sync in fragmented m4s
richiemcilroy Dec 22, 2025
94ad34c
Add decode-benchmark example to editor crate
richiemcilroy Dec 22, 2025
ef81bbc
Refactor CameraLayer::prepare argument structure
richiemcilroy Dec 22, 2025
4ff10db
Improve video decoding performance and add keyframe indexing
richiemcilroy Dec 22, 2025
cf96969
Add multi-position decoder pool for AVAssetReader
richiemcilroy Dec 22, 2025
9232b71
Update audio playhead logic in MP4 export
richiemcilroy Dec 22, 2025
89bb3a1
Remove debug and trace logging statements
richiemcilroy Dec 22, 2025
c44ad76
Fix audio sample calculation for MP4 export
richiemcilroy Dec 22, 2025
cdb9b15
clippy
richiemcilroy Dec 22, 2025
962df11
clippy
richiemcilroy Dec 22, 2025
c79f8de
coderabbit bits
richiemcilroy Dec 22, 2025
c1c98f1
coderabbit
richiemcilroy Dec 22, 2025
5fb8232
Add EditorSkeleton loading component
richiemcilroy Dec 22, 2025
8df37a9
Refactor crop dialog to use canvas frame instead of video
richiemcilroy Dec 22, 2025
f32f3a2
Add export preview generation for video exports
richiemcilroy Dec 23, 2025
b15ae5d
Replace ExportDialog with ExportPage in editor
richiemcilroy Dec 23, 2025
9ecd43e
Redesign export page UI and improve export options
richiemcilroy Dec 23, 2025
e4517f1
Add shimmer animation to Tailwind config
richiemcilroy Dec 23, 2025
da6841b
Add export preview render time and frame estimates
richiemcilroy Dec 23, 2025
e9809d1
Refactor ExportPage layout and add new animations
richiemcilroy Dec 23, 2025
1f1fb45
Update export and header button styles and labels
richiemcilroy Dec 23, 2025
d7edd42
coderabbit bits
richiemcilroy Dec 24, 2025
4a07659
clippy
richiemcilroy Dec 24, 2025
bbd80f3
clippy
richiemcilroy Dec 24, 2025
599c380
fmt
richiemcilroy Dec 24, 2025
9285e02
clippy
richiemcilroy Dec 24, 2025
3ff5712
clippy
richiemcilroy Dec 26, 2025
340f7f8
greptile
richiemcilroy Dec 26, 2025
d43f7e3
Add preview label and tooltip to export page
richiemcilroy Dec 26, 2025
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
6 changes: 5 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,11 @@
"Bash(cargo build:*)",
"Bash(footprint:*)",
"Bash(RUST_LOG=info,cap_recording=debug ./target/release/examples/memory-leak-detector:*)",
"Bash(git rm:*)"
"Bash(git rm:*)",
"Bash(./target/release/examples/decode-benchmark:*)",
"Bash(RUST_LOG=warn ./target/release/examples/decode-benchmark:*)",
"Bash(git mv:*)",
"Bash(xargs cat:*)"
],
"deny": [],
"ask": []
Expand Down
13 changes: 13 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

290 changes: 287 additions & 3 deletions apps/desktop/src-tauri/src/export.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
use crate::editor_window::WindowEditorInstance;
use crate::{FramesRendered, get_video_metadata};
use cap_export::ExporterBase;
use cap_project::RecordingMeta;
use serde::Deserialize;
use cap_project::{RecordingMeta, XY};
use cap_rendering::{
FrameRenderer, ProjectRecordingsMeta, ProjectUniforms, RenderSegment, RenderVideoConstants,
RendererLayers,
};
use image::codecs::jpeg::JpegEncoder;
use serde::{Deserialize, Serialize};
use specta::Type;
use std::path::PathBuf;
use std::{path::PathBuf, sync::Arc};
use tracing::{info, instrument};

#[derive(Deserialize, Clone, Copy, Debug, Type)]
Expand Down Expand Up @@ -164,3 +170,281 @@ pub async fn get_export_estimates(
estimated_size_mb,
})
}

#[derive(Debug, Deserialize, Type)]
pub struct ExportPreviewSettings {
pub fps: u32,
pub resolution_base: XY<u32>,
pub compression_bpp: f32,
}

#[derive(Debug, Serialize, Type)]
pub struct ExportPreviewResult {
pub jpeg_base64: String,
pub estimated_size_mb: f64,
pub actual_width: u32,
pub actual_height: u32,
pub frame_render_time_ms: f64,
pub total_frames: u32,
}

fn bpp_to_jpeg_quality(bpp: f32) -> u8 {
((bpp - 0.04) / (0.3 - 0.04) * (95.0 - 40.0) + 40.0).clamp(40.0, 95.0) as u8
}

#[tauri::command]
#[specta::specta]
#[instrument(skip_all)]
pub async fn generate_export_preview(
project_path: PathBuf,
frame_time: f64,
settings: ExportPreviewSettings,
) -> Result<ExportPreviewResult, String> {
use base64::{Engine, engine::general_purpose::STANDARD};
use cap_editor::create_segments;
use std::time::Instant;

let recording_meta = RecordingMeta::load_for_project(&project_path)
.map_err(|e| format!("Failed to load recording meta: {e}"))?;

let cap_project::RecordingMetaInner::Studio(studio_meta) = &recording_meta.inner else {
return Err("Cannot preview non-studio recordings".to_string());
};

let project_config = recording_meta.project_config();

let recordings = Arc::new(
ProjectRecordingsMeta::new(&recording_meta.project_path, studio_meta)
.map_err(|e| format!("Failed to load recordings: {e}"))?,
);

let render_constants = Arc::new(
RenderVideoConstants::new(
&recordings.segments,
recording_meta.clone(),
studio_meta.clone(),
)
.await
.map_err(|e| format!("Failed to create render constants: {e}"))?,
);

let segments = create_segments(&recording_meta, studio_meta)
.await
.map_err(|e| format!("Failed to create segments: {e}"))?;

let render_segments: Vec<RenderSegment> = segments
.iter()
.map(|s| RenderSegment {
cursor: s.cursor.clone(),
decoders: s.decoders.clone(),
})
.collect();

let Some((segment_time, segment)) = project_config.get_segment_time(frame_time) else {
return Err("Frame time is outside video duration".to_string());
};

let render_segment = &render_segments[segment.recording_clip as usize];
Comment on lines +243 to +247
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Verify array bounds before indexing.

The indexing at line 247 (&render_segments[segment.recording_clip as usize]) could panic if segment.recording_clip is out of bounds. While get_segment_time validates the segment exists, it doesn't guarantee the clip index is within render_segments.len().

🔎 Proposed bounds check
 let Some((segment_time, segment)) = project_config.get_segment_time(frame_time) else {
     return Err("Frame time is outside video duration".to_string());
 };
 
+let clip_index = segment.recording_clip as usize;
+if clip_index >= render_segments.len() {
+    return Err(format!("Invalid clip index: {}", clip_index));
+}
+
-let render_segment = &render_segments[segment.recording_clip as usize];
+let render_segment = &render_segments[clip_index];
🤖 Prompt for AI Agents
In apps/desktop/src-tauri/src/export.rs around lines 243 to 247, the code
indexes render_segments with segment.recording_clip as usize without verifying
bounds which can panic; before indexing, convert segment.recording_clip to usize
and check it is < render_segments.len(), returning an Err with a clear message
if out of range, otherwise safely use render_segments[index] (or
render_segments.get(index).ok_or(...)?) to avoid panics.

let clip_config = project_config
.clips
.iter()
.find(|v| v.index == segment.recording_clip);

let render_start = Instant::now();

let segment_frames = render_segment
.decoders
.get_frames(
segment_time as f32,
!project_config.camera.hide,
clip_config.map(|v| v.offsets).unwrap_or_default(),
)
.await
.ok_or_else(|| "Failed to decode frame".to_string())?;

let frame_number = (frame_time * settings.fps as f64).floor() as u32;

let uniforms = ProjectUniforms::new(
&render_constants,
&project_config,
frame_number,
settings.fps,
settings.resolution_base,
&render_segment.cursor,
&segment_frames,
);

let mut frame_renderer = FrameRenderer::new(&render_constants);
let mut layers = RendererLayers::new_with_options(
&render_constants.device,
&render_constants.queue,
render_constants.is_software_adapter,
);

let frame = frame_renderer
.render(
segment_frames,
uniforms,
&render_segment.cursor,
&mut layers,
)
.await
.map_err(|e| format!("Failed to render frame: {e}"))?;

let frame_render_time_ms = render_start.elapsed().as_secs_f64() * 1000.0;

let width = frame.width;
let height = frame.height;

let rgb_data: Vec<u8> = frame
.data
.chunks(frame.padded_bytes_per_row as usize)
.flat_map(|row| {
row[0..(frame.width * 4) as usize]
.chunks(4)
.flat_map(|chunk| [chunk[0], chunk[1], chunk[2]])
})
.collect();

let jpeg_quality = bpp_to_jpeg_quality(settings.compression_bpp);
let mut jpeg_buffer = Vec::new();
{
let mut encoder = JpegEncoder::new_with_quality(&mut jpeg_buffer, jpeg_quality);
encoder
.encode(&rgb_data, width, height, image::ExtendedColorType::Rgb8)
.map_err(|e| format!("Failed to encode JPEG: {e}"))?;
}
Comment on lines +299 to +316
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Extract duplicated RGB conversion and JPEG encoding into helper functions.

The RGB data conversion (lines 299-307, 410-418) and JPEG encoding logic (lines 309-316, 420-427) are identical in both preview functions, violating DRY principles. This increases maintenance burden and the risk of inconsistent fixes.

🔎 Proposed helper functions

Add these helper functions before generate_export_preview:

fn frame_to_rgb(frame: &cap_rendering::FrameData) -> Vec<u8> {
    frame
        .data
        .chunks(frame.padded_bytes_per_row as usize)
        .flat_map(|row| {
            row[0..(frame.width * 4) as usize]
                .chunks(4)
                .flat_map(|chunk| [chunk[0], chunk[1], chunk[2]])
        })
        .collect()
}

fn encode_jpeg_base64(rgb_data: &[u8], width: u32, height: u32, quality: u8) -> Result<String, String> {
    use base64::{Engine, engine::general_purpose::STANDARD};
    
    let mut jpeg_buffer = Vec::new();
    {
        let mut encoder = JpegEncoder::new_with_quality(&mut jpeg_buffer, quality);
        encoder
            .encode(rgb_data, width, height, image::ExtendedColorType::Rgb8)
            .map_err(|e| format!("Failed to encode JPEG: {e}"))?;
    }
    Ok(STANDARD.encode(&jpeg_buffer))
}

Then replace the duplicated code with:

-let rgb_data: Vec<u8> = frame
-    .data
-    .chunks(frame.padded_bytes_per_row as usize)
-    .flat_map(|row| {
-        row[0..(frame.width * 4) as usize]
-            .chunks(4)
-            .flat_map(|chunk| [chunk[0], chunk[1], chunk[2]])
-    })
-    .collect();
+let rgb_data = frame_to_rgb(&frame);

 let jpeg_quality = bpp_to_jpeg_quality(settings.compression_bpp);
-let mut jpeg_buffer = Vec::new();
-{
-    let mut encoder = JpegEncoder::new_with_quality(&mut jpeg_buffer, jpeg_quality);
-    encoder
-        .encode(&rgb_data, width, height, image::ExtendedColorType::Rgb8)
-        .map_err(|e| format!("Failed to encode JPEG: {e}"))?;
-}
-
-let jpeg_base64 = STANDARD.encode(&jpeg_buffer);
+let jpeg_base64 = encode_jpeg_base64(&rgb_data, width, height, jpeg_quality)?;

Also applies to: 410-427

🤖 Prompt for AI Agents
In apps/desktop/src-tauri/src/export.rs around lines 299-316 (and similarly
410-427), extract the duplicated RGB conversion and JPEG encoding into two
helpers: one fn frame_to_rgb(frame: &cap_rendering::FrameData) -> Vec<u8> that
performs the padded-row -> RGB byte vector extraction, and another fn
encode_jpeg_base64(rgb_data: &[u8], width: u32, height: u32, quality: u8) ->
Result<String, String> that creates a JpegEncoder, writes the JPEG into a
Vec<u8>, and returns the base64 string or an error; then replace the duplicated
blocks in both preview functions with calls to frame_to_rgb and
encode_jpeg_base64, passing settings.compression_bpp converted to the encoder
quality, and propagate errors as before.


let jpeg_base64 = STANDARD.encode(&jpeg_buffer);

let total_pixels = (settings.resolution_base.x * settings.resolution_base.y) as f64;
let fps_f64 = settings.fps as f64;

let metadata = get_video_metadata(project_path.clone()).await?;
let duration_seconds = if let Some(timeline) = &project_config.timeline {
timeline.segments.iter().map(|s| s.duration()).sum()
} else {
metadata.duration
};
let total_frames = (duration_seconds * fps_f64).ceil() as u32;

let video_bitrate = total_pixels * settings.compression_bpp as f64 * fps_f64;
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);

Ok(ExportPreviewResult {
jpeg_base64,
estimated_size_mb,
actual_width: width,
actual_height: height,
frame_render_time_ms,
total_frames,
})
}

#[tauri::command]
#[specta::specta]
#[instrument(skip_all)]
pub async fn generate_export_preview_fast(
editor: WindowEditorInstance,
frame_time: f64,
settings: ExportPreviewSettings,
) -> Result<ExportPreviewResult, String> {
use base64::{Engine, engine::general_purpose::STANDARD};
use std::time::Instant;

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

let Some((segment_time, segment)) = project_config.get_segment_time(frame_time) else {
return Err("Frame time is outside video duration".to_string());
};

let segment_media = &editor.segment_medias[segment.recording_clip as usize];
Comment on lines +359 to +363
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Verify array bounds before indexing.

Similar to the issue at line 247, the indexing &editor.segment_medias[segment.recording_clip as usize] could panic if the clip index exceeds the segment_medias length.

🔎 Proposed bounds check
 let Some((segment_time, segment)) = project_config.get_segment_time(frame_time) else {
     return Err("Frame time is outside video duration".to_string());
 };
 
+let clip_index = segment.recording_clip as usize;
+if clip_index >= editor.segment_medias.len() {
+    return Err(format!("Invalid clip index: {}", clip_index));
+}
+
-let segment_media = &editor.segment_medias[segment.recording_clip as usize];
+let segment_media = &editor.segment_medias[clip_index];
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let Some((segment_time, segment)) = project_config.get_segment_time(frame_time) else {
return Err("Frame time is outside video duration".to_string());
};
let segment_media = &editor.segment_medias[segment.recording_clip as usize];
let Some((segment_time, segment)) = project_config.get_segment_time(frame_time) else {
return Err("Frame time is outside video duration".to_string());
};
let clip_index = segment.recording_clip as usize;
if clip_index >= editor.segment_medias.len() {
return Err(format!("Invalid clip index: {}", clip_index));
}
let segment_media = &editor.segment_medias[clip_index];
🤖 Prompt for AI Agents
In apps/desktop/src-tauri/src/export.rs around lines 359 to 363, the code
indexes editor.segment_medias using segment.recording_clip as usize without
verifying the index is within bounds; add a safe bounds check (convert/check the
recording_clip value to a usize and ensure it is < editor.segment_medias.len())
and return an Err with a clear message if it is out of range, before using the
reference, to avoid potential panics.

let clip_config = project_config
.clips
.iter()
.find(|v| v.index == segment.recording_clip);

let render_start = Instant::now();

let segment_frames = segment_media
.decoders
.get_frames(
segment_time as f32,
!project_config.camera.hide,
clip_config.map(|v| v.offsets).unwrap_or_default(),
)
.await
.ok_or_else(|| "Failed to decode frame".to_string())?;

let frame_number = (frame_time * settings.fps as f64).floor() as u32;

let uniforms = ProjectUniforms::new(
&editor.render_constants,
&project_config,
frame_number,
settings.fps,
settings.resolution_base,
&segment_media.cursor,
&segment_frames,
);

let mut frame_renderer = FrameRenderer::new(&editor.render_constants);
let mut layers = RendererLayers::new_with_options(
&editor.render_constants.device,
&editor.render_constants.queue,
editor.render_constants.is_software_adapter,
);

let frame = frame_renderer
.render(segment_frames, uniforms, &segment_media.cursor, &mut layers)
.await
.map_err(|e| format!("Failed to render frame: {e}"))?;

let frame_render_time_ms = render_start.elapsed().as_secs_f64() * 1000.0;

let width = frame.width;
let height = frame.height;

let rgb_data: Vec<u8> = frame
.data
.chunks(frame.padded_bytes_per_row as usize)
.flat_map(|row| {
row[0..(frame.width * 4) as usize]
.chunks(4)
.flat_map(|chunk| [chunk[0], chunk[1], chunk[2]])
})
.collect();

let jpeg_quality = bpp_to_jpeg_quality(settings.compression_bpp);
let mut jpeg_buffer = Vec::new();
{
let mut encoder = JpegEncoder::new_with_quality(&mut jpeg_buffer, jpeg_quality);
encoder
.encode(&rgb_data, width, height, image::ExtendedColorType::Rgb8)
.map_err(|e| format!("Failed to encode JPEG: {e}"))?;
}

let jpeg_base64 = STANDARD.encode(&jpeg_buffer);

let total_pixels = (settings.resolution_base.x * settings.resolution_base.y) as f64;
let fps_f64 = settings.fps as f64;

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

let video_bitrate = total_pixels * settings.compression_bpp as f64 * fps_f64;
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);

Ok(ExportPreviewResult {
jpeg_base64,
estimated_size_mb,
actual_width: width,
actual_height: height,
frame_render_time_ms,
total_frames,
})
}
Loading