Skip to content

Commit

Permalink
Merge pull request #232 from onyedikachi-david/fix/228-video-export-p…
Browse files Browse the repository at this point in the history
…rogress

fix(export): prevent export progress from freezing at 102%
  • Loading branch information
richiemcilroy authored Jan 6, 2025
2 parents 28d2abe + 1e91c64 commit ac2f956
Show file tree
Hide file tree
Showing 11 changed files with 551 additions and 152 deletions.
75 changes: 56 additions & 19 deletions apps/desktop/src-tauri/src/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,42 @@ pub async fn export_video(
force: bool,
use_custom_muxer: bool,
) -> Result<PathBuf, String> {
let VideoRecordingMetadata { duration, .. } =
get_video_metadata(app.clone(), video_id.clone(), Some(VideoType::Screen))
let screen_metadata =
match get_video_metadata(app.clone(), video_id.clone(), Some(VideoType::Screen)).await {
Ok(meta) => meta,
Err(e) => {
sentry::capture_message(
&format!("Failed to get video metadata: {}", e),
sentry::Level::Error,
);
return Err(
"Failed to read video metadata. The recording may be from an incompatible version."
.to_string(),
);
}
};

// Get camera metadata if it exists
let camera_metadata =
get_video_metadata(app.clone(), video_id.clone(), Some(VideoType::Camera))
.await
.unwrap();
.ok();

// Use the longer duration between screen and camera
let duration = screen_metadata.duration.max(
camera_metadata
.map(|m| m.duration)
.unwrap_or(screen_metadata.duration),
);

// 30 FPS (calculated for output video)
let total_frames = (duration * 30.0).round() as u32;
// Calculate total frames with ceiling to ensure we don't exceed 100%
let total_frames = ((duration * 30.0).ceil() as u32).max(1);

let editor_instance = upsert_editor_instance(&app, video_id.clone()).await;

let output_path = editor_instance.meta().output_path();

// If the file exists, return it immediately
// If the file exists and we're not forcing a re-render, return it
if output_path.exists() && !force {
return Ok(output_path);
}
Expand All @@ -37,14 +60,25 @@ pub async fn export_video(
.send(RenderProgress::EstimatedTotalFrames { total_frames })
.ok();

// Create a modified project configuration that accounts for different video lengths
let mut modified_project = project.clone();
if let Some(timeline) = &mut modified_project.timeline {
// Ensure timeline duration matches the longest video
for segment in timeline.segments.iter_mut() {
if segment.end > duration {
segment.end = duration;
}
}
}

let exporter = cap_export::Exporter::new(
project,
modified_project,
output_path.clone(),
move |frame_index| {
// Ensure progress never exceeds total frames
let current_frame = (frame_index + 1).min(total_frames);
progress
.send(RenderProgress::FrameRendered {
current_frame: frame_index + 1,
})
.send(RenderProgress::FrameRendered { current_frame })
.ok();
},
editor_instance.project_path.clone(),
Expand All @@ -57,17 +91,20 @@ pub async fn export_video(
e.to_string()
})?;

if use_custom_muxer {
let result = if use_custom_muxer {
exporter.export_with_custom_muxer().await
} else {
exporter.export_with_ffmpeg_cli().await
}
.map_err(|e| {
sentry::capture_message(&e.to_string(), sentry::Level::Error);
e.to_string()
})?;
};

ShowCapWindow::PrevRecordings.show(&app).ok();

Ok(output_path)
match result {
Ok(_) => {
ShowCapWindow::PrevRecordings.show(&app).ok();
Ok(output_path)
}
Err(e) => {
sentry::capture_message(&e.to_string(), sentry::Level::Error);
Err(e.to_string())
}
}
}
14 changes: 13 additions & 1 deletion apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ pub struct App {
pub enum VideoType {
Screen,
Output,
Camera,
}

#[derive(Serialize, Deserialize, specta::Type)]
Expand Down Expand Up @@ -952,6 +953,17 @@ 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);
Expand Down Expand Up @@ -1039,7 +1051,7 @@ fn focus_captures_panel(app: AppHandle) {

#[derive(Serialize, Deserialize, specta::Type, Clone)]
#[serde(tag = "type")]
enum RenderProgress {
pub enum RenderProgress {
Starting { total_frames: u32 },
EstimatedTotalFrames { total_frames: u32 },
FrameRendered { current_frame: u32 },
Expand Down
61 changes: 43 additions & 18 deletions apps/desktop/src/routes/editor/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ import { save } from "@tauri-apps/plugin-dialog";
import { DEFAULT_PROJECT_CONFIG } from "./projectConfig";
import { createMutation } from "@tanstack/solid-query";
import { getRequestEvent } from "solid-js/web";
import { checkIsUpgradedAndUpdate } from "~/utils/plans";

function ExportButton() {
const { videoId, project, prettyName } = useEditorContext();
Expand All @@ -264,14 +265,26 @@ function ExportButton() {
const progress = new Channel<RenderProgress>();
progress.onmessage = (p) => {
if (p.type === "FrameRendered" && progressState.type === "saving") {
const percentComplete = Math.round(
(p.current_frame / (progressState.totalFrames || 1)) * 100
const percentComplete = Math.min(
Math.round(
(p.current_frame / (progressState.totalFrames || 1)) * 100
),
100
);

setProgressState({
...progressState,
renderProgress: p.current_frame,
message: `Rendering video - ${percentComplete}%`,
});

// If rendering is complete, update to finalizing state
if (percentComplete === 100) {
setProgressState({
...progressState,
message: "Finalizing export...",
});
}
}
if (
p.type === "EstimatedTotalFrames" &&
Expand All @@ -285,25 +298,30 @@ function ExportButton() {
}
};

const videoPath = await commands.exportVideo(
videoId,
project,
progress,
true,
useCustomMuxer
);
await commands.copyFileToPath(videoPath, path);
try {
const videoPath = await commands.exportVideo(
videoId,
project,
progress,
true,
useCustomMuxer
);
await commands.copyFileToPath(videoPath, path);

setProgressState({
type: "saving",
progress: 100,
message: "Saved successfully!",
mediaPath: path,
});
setProgressState({
type: "saving",
progress: 100,
message: "Saved successfully!",
mediaPath: path,
});

setTimeout(() => {
setTimeout(() => {
setProgressState({ type: "idle" });
}, 1500);
} catch (error) {
setProgressState({ type: "idle" });
}, 1500);
throw error;
}
},
}));

Expand Down Expand Up @@ -334,6 +352,13 @@ function ShareButton() {
throw new Error("Recording metadata not available");
}

// Check for pro access first before starting the export
const isUpgraded = await checkIsUpgradedAndUpdate();
if (!isUpgraded) {
await commands.showWindow("Upgrade");
throw new Error("Upgrade required to share recordings");
}

let unlisten: (() => void) | undefined;

try {
Expand Down
16 changes: 14 additions & 2 deletions apps/desktop/src/routes/recordings-overlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -375,14 +375,26 @@ export default function () {
undefined &&
progressState.totalFrames
) {
return `${Math.min(
const progress = Math.min(
Math.round(
(progressState.renderProgress /
progressState.totalFrames) *
100
),
100
)}%`;
);

// If we hit 100%, transition to the next stage
if (progress === 100 && progressState.type === "uploading") {
setProgressState({
...progressState,
stage: "uploading",
message: "Starting upload...",
uploadProgress: 0
});
}

return `${progress}%`;
}

return progressState.message;
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/utils/tauri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ export type UploadProgress = { stage: string; progress: number; message: string
export type UploadResult = { Success: string } | "NotAuthenticated" | "PlanCheckFailed" | "UpgradeRequired"
export type Video = { duration: number; width: number; height: number }
export type VideoRecordingMetadata = { duration: number; size: number }
export type VideoType = "screen" | "output"
export type VideoType = "screen" | "output" | "camera"
export type XY<T> = { x: T; y: T }
export type ZoomMode = "auto" | { manual: { x: number; y: number } }
export type ZoomSegment = { start: number; end: number; amount: number; mode: ZoomMode }
Expand Down
26 changes: 23 additions & 3 deletions crates/editor/src/editor.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
use std::{sync::Arc, time::Instant};

use cap_media::frame_ws::WSFrame;
use cap_project::{BackgroundSource, ProjectConfiguration};
use cap_rendering::{decoder::DecodedFrame, produce_frame, ProjectUniforms, RenderVideoConstants};
use cap_project::{BackgroundSource, ProjectConfiguration, RecordingMeta};
use cap_rendering::{
decoder::DecodedFrame, produce_frame, ProjectRecordings, ProjectUniforms, RenderVideoConstants,
};
use tokio::{
sync::{mpsc, oneshot},
task::JoinHandle,
Expand All @@ -26,6 +28,7 @@ pub struct Renderer {
rx: mpsc::Receiver<RendererMessage>,
frame_tx: flume::Sender<WSFrame>,
render_constants: Arc<RenderVideoConstants>,
total_frames: u32,
}

pub struct RendererHandle {
Expand All @@ -36,13 +39,28 @@ impl Renderer {
pub fn spawn(
render_constants: Arc<RenderVideoConstants>,
frame_tx: flume::Sender<WSFrame>,
meta: &RecordingMeta,
) -> RendererHandle {
let recordings = ProjectRecordings::new(meta);
let mut max_duration = recordings.duration();

// Check camera duration if it exists
if let Some(camera_path) = meta.content.camera_path() {
if let Ok(camera_duration) = recordings.get_source_duration(&camera_path) {
max_duration = max_duration.max(camera_duration);
}
}

let total_frames = (30_f64 * max_duration).ceil() as u32;
println!("Editor total frames: {total_frames}");

let (tx, rx) = mpsc::channel(4);

let this = Self {
rx,
frame_tx,
render_constants,
total_frames,
};

tokio::spawn(this.run());
Expand All @@ -61,7 +79,7 @@ impl Renderer {
camera_frame,
background,
uniforms,
time, // Add this
time,
finished,
} => {
if let Some(task) = frame_task.as_ref() {
Expand All @@ -74,6 +92,7 @@ impl Renderer {

let render_constants = self.render_constants.clone();
let frame_tx = self.frame_tx.clone();
let total_frames = self.total_frames;

frame_task = Some(tokio::spawn(async move {
let frame = produce_frame(
Expand All @@ -83,6 +102,7 @@ impl Renderer {
cap_rendering::Background::from(background),
&uniforms,
time,
total_frames,
)
.await
.unwrap();
Expand Down
6 changes: 5 additions & 1 deletion crates/editor/src/editor_instance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,11 @@ impl EditorInstance {
.unwrap(),
);

let renderer = Arc::new(editor::Renderer::spawn(render_constants.clone(), frame_tx));
let renderer = Arc::new(editor::Renderer::spawn(
render_constants.clone(),
frame_tx,
&meta,
));

let (preview_tx, preview_rx) = watch::channel(None);

Expand Down
12 changes: 12 additions & 0 deletions crates/project/src/meta.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,18 @@ pub enum Content {
},
}

impl Content {
pub fn camera_path(&self) -> Option<PathBuf> {
match self {
Content::SingleSegment { segment } => segment.camera.as_ref().map(|c| c.path.clone()),
Content::MultipleSegments { inner } => inner
.segments
.first()
.and_then(|s| s.camera.as_ref().map(|c| c.path.clone())),
}
}
}

#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct SingleSegment {
pub display: Display,
Expand Down
Loading

1 comment on commit ac2f956

@vercel
Copy link

@vercel vercel bot commented on ac2f956 Jan 6, 2025

Choose a reason for hiding this comment

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

Please sign in to comment.