From 60e7307cc55962d4daadb7a59917c31b28be1f66 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Wed, 15 Jan 2025 14:26:25 +0000 Subject: [PATCH 1/8] feat: Export options picker --- apps/desktop/src-tauri/src/recording.rs | 2 + apps/desktop/src/routes/editor/Header.tsx | 327 ++++++++++++++-------- apps/desktop/src/utils/tauri.ts | 2 +- crates/project/src/configuration.rs | 7 +- crates/rendering/src/lib.rs | 75 +++-- 5 files changed, 277 insertions(+), 136 deletions(-) diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index f797af9f..04fed815 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -369,6 +369,8 @@ fn project_config_from_recording( }) .collect(), zoom_segments: generate_zoom_segments_from_clicks(&completed_recording, &recordings), + output_width: None, + output_height: None, }), ..Default::default() } diff --git a/apps/desktop/src/routes/editor/Header.tsx b/apps/desktop/src/routes/editor/Header.tsx index 0b778382..b3fdb13d 100644 --- a/apps/desktop/src/routes/editor/Header.tsx +++ b/apps/desktop/src/routes/editor/Header.tsx @@ -7,31 +7,62 @@ import { batch, createEffect, createResource, + createSignal, onCleanup, onMount, } from "solid-js"; import { type as ostype } from "@tauri-apps/plugin-os"; import { Tooltip } from "@kobalte/core"; +import { createMutation } from "@tanstack/solid-query"; +import { getRequestEvent } from "solid-js/web"; +import { save } from "@tauri-apps/plugin-dialog"; +import { Channel } from "@tauri-apps/api/core"; +import type { UnlistenFn } from "@tauri-apps/api/event"; +import { getCurrentWindow, ProgressBarStatus } from "@tauri-apps/api/window"; import { type RenderProgress, commands } from "~/utils/tauri"; -import { canCreateShareableLink } from "~/utils/plans"; - +import { + canCreateShareableLink, + checkIsUpgradedAndUpdate, +} from "~/utils/plans"; import { FPS, useEditorContext } from "./context"; import { Dialog, DialogContent } from "./ui"; +import { DEFAULT_PROJECT_CONFIG } from "./projectConfig"; import { type ProgressState, progressState, setProgressState, } from "~/store/progress"; - import { events } from "~/utils/tauri"; import Titlebar from "~/components/titlebar/Titlebar"; import { initializeTitlebar, setTitlebar } from "~/utils/titlebar-state"; -import type { UnlistenFn } from "@tauri-apps/api/event"; -import { getCurrentWindow, ProgressBarStatus } from "@tauri-apps/api/window"; + +type ResolutionOption = { + label: string; + value: string; + width: number; + height: number; +}; + +const RESOLUTION_OPTIONS: ResolutionOption[] = [ + { label: "720p", value: "720p", width: 1280, height: 720 }, + { label: "1080p", value: "1080p", width: 1920, height: 1080 }, + { label: "4K", value: "4k", width: 3840, height: 2160 }, +]; + +const FPS_OPTIONS = [ + { label: "30 FPS", value: 30 }, + { label: "60 FPS", value: 60 }, +] as const; export function Header() { const currentWindow = getCurrentWindow(); + const { videoId, project, prettyName } = useEditorContext(); + + const [showExportOptions, setShowExportOptions] = createSignal(false); + const [selectedFps, setSelectedFps] = createSignal(30); + const [selectedResolution, setSelectedResolution] = + createSignal(RESOLUTION_OPTIONS[1]); let unlistenTitlebar: UnlistenFn | undefined; onMount(async () => { @@ -61,6 +92,104 @@ export function Header() { currentWindow.setProgressBar({ progress: Math.round(percentage) }); }); + const exportWithSettings = async () => { + setShowExportOptions(false); + + const path = await save({ + filters: [{ name: "mp4 filter", extensions: ["mp4"] }], + defaultPath: `~/Desktop/${prettyName()}.mp4`, + }); + if (!path) return; + + setProgressState({ + type: "saving", + progress: 0, + renderProgress: 0, + totalFrames: 0, + message: "Preparing to render...", + mediaPath: path, + stage: "rendering", + }); + + const progress = new Channel(); + progress.onmessage = (p) => { + if (p.type === "FrameRendered" && progressState.type === "saving") { + 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" && + progressState.type === "saving" + ) { + setProgressState({ + ...progressState, + totalFrames: p.total_frames, + message: "Starting render...", + }); + } + }; + + try { + const updatedProject = { + ...project, + timeline: project.timeline + ? { + ...project.timeline, + outputWidth: selectedResolution().width, + outputHeight: selectedResolution().height, + } + : { + segments: [], + zoomSegments: [], + outputWidth: selectedResolution().width, + outputHeight: selectedResolution().height, + }, + fps: selectedFps(), + }; + + const videoPath = await commands.exportVideo( + videoId, + updatedProject, + progress, + true, + selectedFps() + ); + await commands.copyFileToPath(videoPath, path); + + setProgressState({ + type: "saving", + progress: 100, + message: "Saved successfully!", + mediaPath: path, + }); + + setTimeout(() => { + setProgressState({ type: "idle" }); + }, 1500); + } catch (error) { + setProgressState({ type: "idle" }); + throw error; + } + }; + batch(() => { setTitlebar("border", false); setTitlebar("height", "4rem"); @@ -76,8 +205,66 @@ export function Header() { >
- - + +
+ + +
+
+
+ + +
+
+ + +
+ +
+
+
+
); @@ -235,111 +422,12 @@ export function Header() { ); } -import { Channel } from "@tauri-apps/api/core"; -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(); - - const exportVideo = createMutation(() => ({ - mutationFn: async (useCustomMuxer: boolean) => { - const path = await save({ - filters: [{ name: "mp4 filter", extensions: ["mp4"] }], - defaultPath: `~/Desktop/${prettyName()}.mp4`, - }); - if (!path) return; - - setProgressState({ - type: "saving", - progress: 0, - renderProgress: 0, - totalFrames: 0, - message: "Preparing to render...", - mediaPath: path, - stage: "rendering", - }); - - const progress = new Channel(); - progress.onmessage = (p) => { - if (p.type === "FrameRendered" && progressState.type === "saving") { - 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" && - progressState.type === "saving" - ) { - setProgressState({ - ...progressState, - totalFrames: p.total_frames, - message: "Starting render...", - }); - } - }; - - try { - const videoPath = await commands.exportVideo( - videoId, - project, - progress, - true, - FPS - ); - await commands.copyFileToPath(videoPath, path); - - setProgressState({ - type: "saving", - progress: 100, - message: "Saved successfully!", - mediaPath: path, - }); +type ShareButtonProps = { + selectedResolution: () => ResolutionOption; + selectedFps: () => number; +}; - setTimeout(() => { - setProgressState({ type: "idle" }); - }, 1500); - } catch (error) { - setProgressState({ type: "idle" }); - throw error; - } - }, - })); - - return ( - - ); -} - -function ShareButton() { +function ShareButton(props: ShareButtonProps) { const { videoId, project, presets } = useEditorContext(); const [recordingMeta, metaActions] = createResource(() => commands.getRecordingMeta(videoId, "recording") @@ -407,8 +495,13 @@ function ShareButton() { }); console.log("Starting actual upload..."); - const projectConfig = - project ?? presets.getDefaultConfig() ?? DEFAULT_PROJECT_CONFIG; + const projectConfig = { + ...(project ?? presets.getDefaultConfig() ?? DEFAULT_PROJECT_CONFIG), + outputResolution: { + width: props.selectedResolution().width, + height: props.selectedResolution().height, + }, + }; setProgressState({ type: "uploading", @@ -449,7 +542,13 @@ function ShareButton() { getRequestEvent()?.nativeEvent; - await commands.exportVideo(videoId, projectConfig, progress, true, FPS); + await commands.exportVideo( + videoId, + projectConfig, + progress, + true, + props.selectedFps() + ); // Now proceed with upload const result = recordingMeta()?.sharing diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index ed4ba54d..2b179e9b 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -282,7 +282,7 @@ export type SerializedEditorInstance = { framesSocketUrl: string; recordingDurat export type SharingMeta = { id: string; link: string } export type ShowCapWindow = "Setup" | "Main" | { Settings: { page: string | null } } | { Editor: { project_id: string } } | "PrevRecordings" | "WindowCaptureOccluder" | { Camera: { ws_port: number } } | { InProgressRecording: { position: [number, number] | null } } | "Upgrade" | "SignIn" export type SingleSegment = { display: Display; camera?: CameraMeta | null; audio?: AudioMeta | null; cursor?: string | null } -export type TimelineConfiguration = { segments: TimelineSegment[]; zoomSegments?: ZoomSegment[] } +export type TimelineConfiguration = { segments: TimelineSegment[]; zoomSegments: ZoomSegment[]; outputWidth?: number | null; outputHeight?: number | null } export type TimelineSegment = { recordingSegment: number | null; timescale: number; start: number; end: number } export type UploadMode = { Initial: { pre_created_video: PreCreatedVideo | null } } | "Reupload" export type UploadProgress = { stage: string; progress: number; message: string } diff --git a/crates/project/src/configuration.rs b/crates/project/src/configuration.rs index 022ceeff..14b4eaee 100644 --- a/crates/project/src/configuration.rs +++ b/crates/project/src/configuration.rs @@ -299,12 +299,15 @@ pub enum ZoomMode { Manual { x: f32, y: f32 }, } -#[derive(Type, Serialize, Deserialize, Clone, Debug, Default)] +#[derive(Type, Serialize, Deserialize, Clone, Debug)] #[serde(rename_all = "camelCase")] pub struct TimelineConfiguration { pub segments: Vec, - #[serde(default)] pub zoom_segments: Vec, + #[serde(default)] + pub output_width: Option, + #[serde(default)] + pub output_height: Option, } impl TimelineConfiguration { diff --git a/crates/rendering/src/lib.rs b/crates/rendering/src/lib.rs index 469dd8ae..714446a0 100644 --- a/crates/rendering/src/lib.rs +++ b/crates/rendering/src/lib.rs @@ -28,7 +28,7 @@ pub mod decoder; mod project_recordings; mod zoom; pub use decoder::DecodedFrame; -pub use project_recordings::{ProjectRecordings, SegmentRecordings}; +pub use project_recordings::{ProjectRecordings, SegmentRecordings, Video}; use zoom::*; @@ -566,34 +566,71 @@ impl ProjectUniforms { pub fn get_output_size(options: &RenderOptions, project: &ProjectConfiguration) -> (u32, u32) { let crop = Self::get_crop(options, project); - let crop_aspect = crop.aspect_ratio(); - let padding = Self::get_padding(options, project) * 2.0; - let aspect = match &project.aspect_ratio { + let (base_width, base_height) = match &project.aspect_ratio { None => { let width = ((crop.size.x as f64 + padding) as u32 + 1) & !1; let height = ((crop.size.y as f64 + padding) as u32 + 1) & !1; - return (width, height); + (width, height) + } + Some(AspectRatio::Square) => { + let size = if crop_aspect > 1.0 { + crop.size.y + } else { + crop.size.x + }; + (size, size) + } + Some(AspectRatio::Wide) => { + if crop_aspect > 16.0 / 9.0 { + (((crop.size.y as f32 * 16.0 / 9.0) as u32), crop.size.y) + } else { + (crop.size.x, ((crop.size.x as f32 * 9.0 / 16.0) as u32)) + } + } + Some(AspectRatio::Vertical) => { + if crop_aspect > 9.0 / 16.0 { + ((crop.size.y as f32 * 9.0 / 16.0) as u32, crop.size.y) + } else { + (crop.size.x, ((crop.size.x as f32 * 16.0 / 9.0) as u32)) + } + } + Some(AspectRatio::Classic) => { + if crop_aspect > 4.0 / 3.0 { + ((crop.size.y as f32 * 4.0 / 3.0) as u32, crop.size.y) + } else { + (crop.size.x, ((crop.size.x as f32 * 3.0 / 4.0) as u32)) + } + } + Some(AspectRatio::Tall) => { + if crop_aspect > 3.0 / 4.0 { + ((crop.size.y as f32 * 3.0 / 4.0) as u32, crop.size.y) + } else { + (crop.size.x, ((crop.size.x as f32 * 4.0 / 3.0) as u32)) + } } - Some(AspectRatio::Square) => 1.0, - Some(AspectRatio::Wide) => 16.0 / 9.0, - Some(AspectRatio::Vertical) => 9.0 / 16.0, - Some(AspectRatio::Classic) => 4.0 / 3.0, - Some(AspectRatio::Tall) => 3.0 / 4.0, }; - let (width, height) = if crop_aspect > aspect { - (crop.size.x, (crop.size.x as f32 / aspect) as u32) - } else if crop_aspect < aspect { - ((crop.size.y as f32 * aspect) as u32, crop.size.y) - } else { - (crop.size.x, crop.size.y) - }; + // Apply timeline output size limits if they exist + if let Some(timeline) = &project.timeline { + if let (Some(max_width), Some(max_height)) = + (timeline.output_width, timeline.output_height) + { + if base_width > max_width || base_height > max_height { + let width_scale = max_width as f32 / base_width as f32; + let height_scale = max_height as f32 / base_height as f32; + let scale = width_scale.min(height_scale); + + let scaled_width = ((base_width as f32 * scale) as u32 + 1) & !1; + let scaled_height = ((base_height as f32 * scale) as u32 + 1) & !1; + return (scaled_width, scaled_height); + } + } + } - // Ensure width and height are divisible by 2 - ((width + 1) & !1, (height + 1) & !1) + ((base_width + 1) & !1, (base_height + 1) & !1) } pub fn get_display_offset( From 1eb580ccaf75c656324da418daf1b2a98dc274ef Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Wed, 15 Jan 2025 14:36:42 +0000 Subject: [PATCH 2/8] feat: use selected export fps --- crates/export/src/lib.rs | 2 +- crates/rendering/src/lib.rs | 48 +++++++++++++++++++++---------------- 2 files changed, 28 insertions(+), 22 deletions(-) diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index ed295c90..1e7385b9 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -228,7 +228,7 @@ where RawVideoFormat::Rgba, self.output_size.0, self.output_size.1, - 30, + self.fps, ) .wrap_frame( &frame.data, diff --git a/crates/rendering/src/lib.rs b/crates/rendering/src/lib.rs index 714446a0..a0080021 100644 --- a/crates/rendering/src/lib.rs +++ b/crates/rendering/src/lib.rs @@ -177,8 +177,8 @@ pub async fn render_video_to_channel( let total_frames = (fps as f64 * duration).ceil() as u32; println!( - "Final export duration: {} seconds ({} frames at 30fps)", - duration, total_frames + "Final export duration: {} seconds ({} frames at {}fps)", + duration, total_frames, fps ); let mut frame_number = 0; @@ -189,23 +189,32 @@ pub async fn render_video_to_channel( break; } - let (time, segment_i) = if let Some(timeline) = &project.timeline { + let source_time = if let Some(timeline) = &project.timeline { match timeline.get_recording_time(frame_number as f64 / fps as f64) { - Some(value) => (value.0, value.1), - None => (frame_number as f64 / fps as f64, Some(0u32)), + Some(value) => value.0, + None => frame_number as f64 / fps as f64, } } else { - (frame_number as f64 / fps as f64, Some(0u32)) + frame_number as f64 / fps as f64 }; - let segment = &segments[segment_i.unwrap() as usize]; - // let frame_time = frame_number; + let segment_i = if let Some(timeline) = &project.timeline { + timeline + .get_recording_time(frame_number as f64 / fps as f64) + .map(|value| value.1) + .flatten() + .unwrap_or(0u32) + } else { + 0u32 + }; + + let segment = &segments[segment_i as usize]; let (screen_frame, camera_frame) = segment .decoders - .get_frames(time as f32, !project.camera.hide) + .get_frames(source_time as f32, !project.camera.hide) .await; - let uniforms = ProjectUniforms::new(&constants, &project, time as f32); + let uniforms = ProjectUniforms::new(&constants, &project, source_time as f32); let frame = produce_frame( &constants, @@ -213,7 +222,7 @@ pub async fn render_video_to_channel( &camera_frame, background, &uniforms, - time as f32, + source_time as f32, total_frames, ) .await?; @@ -613,20 +622,17 @@ impl ProjectUniforms { } }; - // Apply timeline output size limits if they exist if let Some(timeline) = &project.timeline { if let (Some(max_width), Some(max_height)) = (timeline.output_width, timeline.output_height) { - if base_width > max_width || base_height > max_height { - let width_scale = max_width as f32 / base_width as f32; - let height_scale = max_height as f32 / base_height as f32; - let scale = width_scale.min(height_scale); - - let scaled_width = ((base_width as f32 * scale) as u32 + 1) & !1; - let scaled_height = ((base_height as f32 * scale) as u32 + 1) & !1; - return (scaled_width, scaled_height); - } + let width_scale = max_width as f32 / base_width as f32; + let height_scale = max_height as f32 / base_height as f32; + let scale = width_scale.min(height_scale); + + let scaled_width = ((base_width as f32 * scale) as u32 + 1) & !1; + let scaled_height = ((base_height as f32 * scale) as u32 + 1) & !1; + return (scaled_width, scaled_height); } } From e9454923124a0be9bd7385a4a266bf2018dd71c1 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Wed, 15 Jan 2025 14:39:44 +0000 Subject: [PATCH 3/8] feat: Remember previous export selection --- apps/desktop/src/routes/editor/Header.tsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/routes/editor/Header.tsx b/apps/desktop/src/routes/editor/Header.tsx index b3fdb13d..8b6cf788 100644 --- a/apps/desktop/src/routes/editor/Header.tsx +++ b/apps/desktop/src/routes/editor/Header.tsx @@ -60,9 +60,15 @@ export function Header() { const { videoId, project, prettyName } = useEditorContext(); const [showExportOptions, setShowExportOptions] = createSignal(false); - const [selectedFps, setSelectedFps] = createSignal(30); + const [selectedFps, setSelectedFps] = createSignal( + Number(localStorage.getItem("cap-export-fps")) || 30 + ); const [selectedResolution, setSelectedResolution] = - createSignal(RESOLUTION_OPTIONS[1]); + createSignal( + RESOLUTION_OPTIONS.find( + (opt) => opt.value === localStorage.getItem("cap-export-resolution") + ) || RESOLUTION_OPTIONS[0] + ); let unlistenTitlebar: UnlistenFn | undefined; onMount(async () => { @@ -70,6 +76,12 @@ export function Header() { }); onCleanup(() => unlistenTitlebar?.()); + // Save settings when they change + createEffect(() => { + localStorage.setItem("cap-export-fps", selectedFps().toString()); + localStorage.setItem("cap-export-resolution", selectedResolution().value); + }); + createEffect(() => { const state = progressState; if (state === undefined || state.type === "idle") { From a435e1dda8f80277fc5a1152448a9ceaef9a0838 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Wed, 15 Jan 2025 14:49:07 +0000 Subject: [PATCH 4/8] feat: use KSelect for export --- apps/desktop/src/routes/editor/Header.tsx | 119 +++++++++++++++++----- 1 file changed, 93 insertions(+), 26 deletions(-) diff --git a/apps/desktop/src/routes/editor/Header.tsx b/apps/desktop/src/routes/editor/Header.tsx index 8b6cf788..3a16cc59 100644 --- a/apps/desktop/src/routes/editor/Header.tsx +++ b/apps/desktop/src/routes/editor/Header.tsx @@ -13,6 +13,7 @@ import { } from "solid-js"; import { type as ostype } from "@tauri-apps/plugin-os"; import { Tooltip } from "@kobalte/core"; +import { Select as KSelect } from "@kobalte/core/select"; import { createMutation } from "@tanstack/solid-query"; import { getRequestEvent } from "solid-js/web"; import { save } from "@tauri-apps/plugin-dialog"; @@ -26,7 +27,14 @@ import { checkIsUpgradedAndUpdate, } from "~/utils/plans"; import { FPS, useEditorContext } from "./context"; -import { Dialog, DialogContent } from "./ui"; +import { + Dialog, + DialogContent, + MenuItem, + MenuItemList, + PopperContent, + topLeftAnimateClasses, +} from "./ui"; import { DEFAULT_PROJECT_CONFIG } from "./projectConfig"; import { type ProgressState, @@ -53,7 +61,7 @@ const RESOLUTION_OPTIONS: ResolutionOption[] = [ const FPS_OPTIONS = [ { label: "30 FPS", value: 30 }, { label: "60 FPS", value: 60 }, -] as const; +] satisfies Array<{ label: string; value: number }>; export function Header() { const currentWindow = getCurrentWindow(); @@ -229,42 +237,101 @@ export function Header() { Export -
+
- + + class="flex-1 text-sm text-left truncate"> + {(state) => ( + {state.selectedOption()?.label} + )} + + + + + + + + as={KSelect.Content} + class={cx(topLeftAnimateClasses, "z-50")} + > + + class="max-h-32 overflow-y-auto" + as={KSelect.Listbox} + /> + + +
- + + class="flex-1 text-sm text-left truncate"> + {(state) => ( + {state.selectedOption()?.label} + )} + + + + + + + + as={KSelect.Content} + class={cx(topLeftAnimateClasses, "z-50")} + > + + class="max-h-32 overflow-y-auto" + as={KSelect.Listbox} + /> + + +