diff --git a/Cargo.lock b/Cargo.lock index f993a72b..3ef14003 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -886,6 +886,7 @@ dependencies = [ "futures-intrusive", "image 0.25.5", "nix 0.29.0", + "pretty_assertions", "serde", "specta", "thiserror 1.0.63", @@ -897,6 +898,7 @@ dependencies = [ name = "cap-utils" version = "0.1.0" dependencies = [ + "futures", "nix 0.29.0", "tokio", "uuid", @@ -1808,6 +1810,12 @@ dependencies = [ "x11", ] +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "digest" version = "0.10.7" @@ -4987,6 +4995,16 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -8783,6 +8801,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "539a77ee7c0de333dcc6da69b177380a0b81e0dacfa4f7344c465a36871ee601" +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yansi-term" version = "0.1.2" diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index da093dfa..42b3f03e 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -311,35 +311,35 @@ fn generate_zoom_segments_from_clicks( const ZOOM_SEGMENT_AFTER_CLICK_PADDING: f64 = 1.5; // single-segment only - for click in &recording.cursor_data.clicks { - let time = click.process_time_ms / 1000.0; - - if segments.last().is_none() { - segments.push(ZoomSegment { - start: (click.process_time_ms / 1000.0 - (ZOOM_DURATION + 0.2)).max(0.0), - end: click.process_time_ms / 1000.0 + ZOOM_SEGMENT_AFTER_CLICK_PADDING, - amount: 2.0, - }); - } else { - let last_segment = segments.last_mut().unwrap(); - - if click.down { - if last_segment.end > time { - last_segment.end = - (time + ZOOM_SEGMENT_AFTER_CLICK_PADDING).min(recordings.duration()); - } else if time < max_duration - ZOOM_DURATION { - segments.push(ZoomSegment { - start: (time - ZOOM_DURATION).max(0.0), - end: time + ZOOM_SEGMENT_AFTER_CLICK_PADDING, - amount: 2.0, - }); - } - } else { - last_segment.end = - (time + ZOOM_SEGMENT_AFTER_CLICK_PADDING).min(recordings.duration()); - } - } - } + // for click in &recording.cursor_data.clicks { + // let time = click.process_time_ms / 1000.0; + + // if segments.last().is_none() { + // segments.push(ZoomSegment { + // start: (click.process_time_ms / 1000.0 - (ZOOM_DURATION + 0.2)).max(0.0), + // end: click.process_time_ms / 1000.0 + ZOOM_SEGMENT_AFTER_CLICK_PADDING, + // amount: 2.0, + // }); + // } else { + // let last_segment = segments.last_mut().unwrap(); + + // if click.down { + // if last_segment.end > time { + // last_segment.end = + // (time + ZOOM_SEGMENT_AFTER_CLICK_PADDING).min(recordings.duration()); + // } else if time < max_duration - ZOOM_DURATION { + // segments.push(ZoomSegment { + // start: (time - ZOOM_DURATION).max(0.0), + // end: time + ZOOM_SEGMENT_AFTER_CLICK_PADDING, + // amount: 2.0, + // }); + // } + // } else { + // last_segment.end = + // (time + ZOOM_SEGMENT_AFTER_CLICK_PADDING).min(recordings.duration()); + // } + // } + // } segments } diff --git a/apps/desktop/src/routes/camera.tsx b/apps/desktop/src/routes/camera.tsx index 9b7c52d6..176ecfb3 100644 --- a/apps/desktop/src/routes/camera.tsx +++ b/apps/desktop/src/routes/camera.tsx @@ -47,7 +47,10 @@ export default function () { { name: "cameraWindowState" } ); - const [latestFrame, setLatestFrame] = createLazySignal(); + const [latestFrame, setLatestFrame] = createLazySignal<{ + width: number; + data: ImageData; + } | null>(); const [isLoading, setIsLoading] = createSignal(true); const [error, setError] = createSignal(null); @@ -56,7 +59,7 @@ export default function () { (imageData) => { setLatestFrame(imageData); const ctx = cameraCanvasRef?.getContext("2d"); - ctx?.putImageData(imageData, 0, 0); + ctx?.putImageData(imageData.data, 0, 0); setIsLoading(false); } ); @@ -81,7 +84,7 @@ export default function () { (imageData) => { setLatestFrame(imageData); const ctx = cameraCanvasRef?.getContext("2d"); - ctx?.putImageData(imageData, 0, 0); + ctx?.putImageData(imageData.data, 0, 0); setIsLoading(false); } ); @@ -196,7 +199,7 @@ export default function () { {(latestFrame) => { const style = () => { const aspectRatio = - latestFrame().width / latestFrame().height; + latestFrame().data.width / latestFrame().data.height; const windowWidth = windowSize.latest?.size ?? 0; @@ -232,8 +235,8 @@ export default function () { data-tauri-drag-region class={cx("absolute")} style={style()} - width={latestFrame().width} - height={latestFrame().height} + width={latestFrame().data.width} + height={latestFrame().data.height} ref={cameraCanvasRef!} /> ); diff --git a/apps/desktop/src/routes/editor/ConfigSidebar.tsx b/apps/desktop/src/routes/editor/ConfigSidebar.tsx index 8cc05bf3..9a390f60 100644 --- a/apps/desktop/src/routes/editor/ConfigSidebar.tsx +++ b/apps/desktop/src/routes/editor/ConfigSidebar.tsx @@ -626,7 +626,7 @@ export function ConfigSidebar() { return (
@@ -638,6 +638,7 @@ export function ConfigSidebar() {
{ const index = value().selection.index; @@ -678,6 +679,87 @@ export function ConfigSidebar() { step={0.001} /> + }> + + + setSelectedTab(item.id)} + disabled + > + Auto + + setSelectedTab(item.id)} + > + Manual + + +
+ + + + { + const m = value().segment.mode; + if (m === "auto") return; + return m.manual; + })()} + > + {(mode) => ( +
+
{ + if (e.buttons === 1) { + const bounds = + e.currentTarget.getBoundingClientRect(); + + setProject( + "timeline", + "zoomSegments", + value().selection.index, + "mode", + "manual", + { + x: Math.max( + Math.min( + (e.clientX - bounds.left) / + bounds.width, + 1 + ), + 0 + ), + y: Math.max( + Math.min( + (e.clientY - bounds.top) / + bounds.height, + 1 + ), + 0 + ), + } + ); + } + }} + > +
+
+
+ )} + + + +
); }} diff --git a/apps/desktop/src/routes/editor/Player.tsx b/apps/desktop/src/routes/editor/Player.tsx index 9981dbba..a9bfafae 100644 --- a/apps/desktop/src/routes/editor/Player.tsx +++ b/apps/desktop/src/routes/editor/Player.tsx @@ -44,7 +44,7 @@ export function Player() { const frame = latestFrame(); if (!frame) return; const ctx = canvasRef.getContext("2d"); - ctx?.putImageData(frame, 0, 0); + ctx?.putImageData(frame.data, 0, 0); }); const [canvasContainerRef, setCanvasContainerRef] = @@ -172,7 +172,7 @@ export function Player() { }; const frameAspect = () => - currentFrame().width / currentFrame().height; + currentFrame().width / currentFrame().data.height; const size = () => { if (frameAspect() < containerAspect()) { @@ -210,7 +210,7 @@ export function Player() { ref={canvasRef} id="canvas" width={currentFrame().width} - height={currentFrame().height} + height={currentFrame().data.height} /> ); }} diff --git a/apps/desktop/src/routes/editor/Timeline.tsx b/apps/desktop/src/routes/editor/Timeline.tsx index d4255331..11f0e41c 100644 --- a/apps/desktop/src/routes/editor/Timeline.tsx +++ b/apps/desktop/src/routes/editor/Timeline.tsx @@ -6,6 +6,7 @@ import { For, Show, createContext, + batch, createRoot, createSignal, onMount, @@ -404,18 +405,28 @@ export function Timeline() { if (time === undefined) return; e.stopPropagation(); - setProject( - "timeline", - "zoomSegments", - produce((zoomSegments) => { - zoomSegments ??= []; - zoomSegments.push({ - start: time, - end: time + 1, - amount: 1.5, - }); - }) - ); + batch(() => { + setProject("timeline", "zoomSegments", (v) => v ?? []); + setProject( + "timeline", + "zoomSegments", + produce((zoomSegments) => { + zoomSegments ??= []; + zoomSegments.push({ + start: time, + end: time + 1, + amount: 1.5, + mode: { + manual: { + x: 0.5, + y: 0.5, + }, + }, + }); + console.log(zoomSegments); + }) + ); + }); }} > { - const [latestFrame, setLatestFrame] = createLazySignal(); + const [latestFrame, setLatestFrame] = createLazySignal<{ + width: number; + data: ImageData; + }>(); const [editorInstance] = createResource(async () => { const instance = await commands.createEditorInstance(props.videoId); diff --git a/apps/desktop/src/routes/editor/ui.tsx b/apps/desktop/src/routes/editor/ui.tsx index 1703a6b8..36fc74a5 100644 --- a/apps/desktop/src/routes/editor/ui.tsx +++ b/apps/desktop/src/routes/editor/ui.tsx @@ -16,7 +16,9 @@ import { } from "solid-js"; import { useEditorContext } from "./context"; -export function Field(props: ParentProps<{ name: string; icon: JSX.Element }>) { +export function Field( + props: ParentProps<{ name: string; icon?: JSX.Element }> +) { return (
diff --git a/apps/desktop/src/utils/socket.ts b/apps/desktop/src/utils/socket.ts index fc2bbe5f..fc1c5d64 100644 --- a/apps/desktop/src/utils/socket.ts +++ b/apps/desktop/src/utils/socket.ts @@ -3,7 +3,7 @@ import { createResource, createSignal } from "solid-js"; export function createImageDataWS( url: string, - onmessage: (data: ImageData) => void + onmessage: (data: { width: number; data: ImageData }) => void ): [Omit, () => boolean] { const [isConnected, setIsConnected] = createSignal(false); const ws = createWS(url); @@ -23,13 +23,19 @@ export function createImageDataWS( setIsConnected(false); }); + // let lastTime = Date.now(); ws.binaryType = "arraybuffer"; ws.onmessage = (event) => { + // console.log(Date.now() - lastTime); + + // onmessage(new ImageData(new Uint8ClampedArray([0, 0, 0, 0]), 1, 1)); + const buffer = event.data as ArrayBuffer; const clamped = new Uint8ClampedArray(buffer); const widthArr = clamped.slice(clamped.length - 4); const heightArr = clamped.slice(clamped.length - 8, clamped.length - 4); + const strideArr = clamped.slice(clamped.length - 12, clamped.length - 8); const width = widthArr[0] + @@ -41,14 +47,24 @@ export function createImageDataWS( (heightArr[1] << 8) + (heightArr[2] << 16) + (heightArr[3] << 24); + const stride = + (strideArr[0] + + (strideArr[1] << 8) + + (strideArr[2] << 16) + + (strideArr[3] << 24)) / + 4; + + console.log({ stride, width, height }); const imageData = new ImageData( - clamped.slice(0, clamped.length - 8), - width, + clamped.slice(0, clamped.length - 12), + stride, height ); - onmessage(imageData); + // lastTime = Date.now(); + + onmessage({ width, data: imageData }); }; return [ws, isConnected]; diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index a1be7e26..a8183d8b 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -286,7 +286,8 @@ export type Video = { duration: number; width: number; height: number } export type VideoRecordingMetadata = { duration: number; size: number } export type VideoType = "screen" | "output" export type XY = { x: T; y: T } -export type ZoomSegment = { start: number; end: number; amount: number } +export type ZoomMode = "auto" | { manual: { x: number; y: number } } +export type ZoomSegment = { start: number; end: number; amount: number; mode: ZoomMode } /** tauri-specta globals **/ diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 03b6f381..cae9659f 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -165,8 +165,8 @@ impl Renderer { let frame_tx = self.frame_tx.clone(); frame_task = Some(tokio::spawn(async move { - let time_instant = Instant::now(); - let frame = produce_frame( + let now = Instant::now(); + let (frame, stride) = produce_frame( &render_constants, &screen_frame, &camera_frame, @@ -176,13 +176,13 @@ impl Renderer { ) .await .unwrap(); - // println!("produced frame in {:?}", time_instant.elapsed()); frame_tx .try_send(SocketMessage::Frame { data: frame, width: uniforms.output_size.0, height: uniforms.output_size.1, + stride, }) .ok(); finished.send(()).ok(); diff --git a/crates/editor/src/editor_instance.rs b/crates/editor/src/editor_instance.rs index db9b969e..2dde4823 100644 --- a/crates/editor/src/editor_instance.rs +++ b/crates/editor/src/editor_instance.rs @@ -8,6 +8,7 @@ use cap_rendering::{ }; use std::ops::Deref; use std::sync::Mutex as StdMutex; +use std::time::Instant; use std::{path::PathBuf, sync::Arc}; use tokio::sync::{mpsc, watch, Mutex}; @@ -277,6 +278,8 @@ impl EditorInstance { let project = self.project_config.1.borrow().clone(); + let now = Instant::now(); + let Some((time, segment)) = project .timeline .as_ref() @@ -288,6 +291,8 @@ impl EditorInstance { let segment = &self.segments[segment.unwrap_or(0) as usize]; + let now = Instant::now(); + let Some((screen_frame, camera_frame)) = segment .decoders .get_frames((time * FPS as f64) as u32) @@ -355,8 +360,9 @@ async fn create_frames_ws(frame_rx: mpsc::Receiver) -> (u16, mpsc }; match chunk { - SocketMessage::Frame { width, height, mut data } => { - data.extend_from_slice(&height.to_le_bytes()); + SocketMessage::Frame { width, height, mut data, stride } => { + data.extend_from_slice(&stride.to_le_bytes()); + data.extend_from_slice(&height.to_le_bytes()); data.extend_from_slice(&width.to_le_bytes()); socket.send(Message::Binary(data)).await.unwrap(); @@ -406,6 +412,7 @@ pub enum SocketMessage { data: Vec, width: u32, height: u32, + stride: u32, }, } diff --git a/crates/ffmpeg-cli/src/lib.rs b/crates/ffmpeg-cli/src/lib.rs index 6a8bc532..686b0705 100644 --- a/crates/ffmpeg-cli/src/lib.rs +++ b/crates/ffmpeg-cli/src/lib.rs @@ -13,7 +13,7 @@ use tokio::{ pub struct FFmpegProcess { pub ffmpeg_stdin: ChildStdin, - pub ffmpeg_stderr: ChildStderr, + // pub ffmpeg_stderr: ChildStderr, cmd: Child, } @@ -28,7 +28,7 @@ impl FFmpegProcess { let mut cmd = command .stdin(Stdio::piped()) - .stderr(Stdio::piped()) + // .stderr(Stdio::piped()) .spawn() .unwrap_or_else(|e| { println!("Failed to start FFmpeg: {}", e); @@ -41,14 +41,14 @@ impl FFmpegProcess { panic!("Failed to capture FFmpeg stdin"); }); - let ffmpeg_stderr = cmd.stderr.take().unwrap_or_else(|| { - println!("Failed to capture FFmpeg stderr"); - panic!("Failed to capture FFmpeg stderr"); - }); + // let ffmpeg_stderr = cmd.stderr.take().unwrap_or_else(|| { + // println!("Failed to capture FFmpeg stderr"); + // panic!("Failed to capture FFmpeg stderr"); + // }); Self { ffmpeg_stdin, - ffmpeg_stderr, + // ffmpeg_stderr, cmd, } } @@ -69,7 +69,7 @@ impl FFmpegProcess { pub async fn read_stderr(&mut self) -> std::io::Result { let mut err = String::new(); - self.ffmpeg_stderr.read_to_string(&mut err).await?; + // self.ffmpeg_stderr.read_to_string(&mut err).await?; Ok(err) } @@ -279,7 +279,7 @@ impl Default for FFmpeg { impl FFmpeg { pub fn new() -> Self { let mut command = Command::new(relative_command_path("ffmpeg").unwrap()); - command.arg("-hide_banner"); + // command.arg("-hide_banner"); Self { command, diff --git a/crates/project/src/configuration.rs b/crates/project/src/configuration.rs index 3197b0d8..022ceeff 100644 --- a/crates/project/src/configuration.rs +++ b/crates/project/src/configuration.rs @@ -283,13 +283,20 @@ impl TimelineSegment { } } -#[derive(Type, Serialize, Deserialize, Clone, Debug, Default)] +#[derive(Type, Serialize, Deserialize, Clone, Debug)] #[serde(rename_all = "camelCase")] pub struct ZoomSegment { pub start: f64, pub end: f64, pub amount: f64, - // pub mode: Z + pub mode: ZoomMode, +} + +#[derive(Type, Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub enum ZoomMode { + Auto, + Manual { x: f32, y: f32 }, } #[derive(Type, Serialize, Deserialize, Clone, Debug, Default)] diff --git a/crates/rendering/Cargo.toml b/crates/rendering/Cargo.toml index 383bc8be..5cf21305 100644 --- a/crates/rendering/Cargo.toml +++ b/crates/rendering/Cargo.toml @@ -22,3 +22,6 @@ thiserror.workspace = true [target.'cfg(unix)'.dependencies] nix = { version = "0.29.0", features = ["fs"] } + +[dev-dependencies] +pretty_assertions = "1.4.1" diff --git a/crates/rendering/src/lib.rs b/crates/rendering/src/lib.rs index b344ac22..0faafba6 100644 --- a/crates/rendering/src/lib.rs +++ b/crates/rendering/src/lib.rs @@ -15,9 +15,9 @@ use wgpu::{CommandEncoder, COPY_BYTES_PER_ROW_ALIGNMENT}; use cap_project::{ AspectRatio, BackgroundSource, CameraXPosition, CameraYPosition, Content, Crop, CursorAnimationStyle, CursorClickEvent, CursorData, CursorEvents, CursorMoveEvent, - ProjectConfiguration, RecordingMeta, FAST_SMOOTHING_SAMPLES, FAST_VELOCITY_THRESHOLD, - REGULAR_SMOOTHING_SAMPLES, REGULAR_VELOCITY_THRESHOLD, SLOW_SMOOTHING_SAMPLES, - SLOW_VELOCITY_THRESHOLD, XY, + ProjectConfiguration, RecordingMeta, ZoomSegment, FAST_SMOOTHING_SAMPLES, + FAST_VELOCITY_THRESHOLD, REGULAR_SMOOTHING_SAMPLES, REGULAR_VELOCITY_THRESHOLD, + SLOW_SMOOTHING_SAMPLES, SLOW_VELOCITY_THRESHOLD, XY, }; use image::GenericImageView; @@ -147,8 +147,6 @@ pub async fn render_video_to_channel( let constants = RenderVideoConstants::new(options, meta).await?; let recordings = ProjectRecordings::new(meta); - println!("Setting up FFmpeg input for screen recording..."); - ffmpeg::init().unwrap(); let start_time = Instant::now(); @@ -158,6 +156,9 @@ pub async fn render_video_to_channel( .map(|t| t.duration()) .unwrap_or(recordings.duration()); + println!("export duration: {duration}"); + println!("export duration: {duration}"); + let mut frame_number = 0; let background = Background::from(project.background.source.clone()); @@ -186,6 +187,8 @@ pub async fn render_video_to_channel( if let Some((screen_frame, camera_frame)) = segment.decoders.get_frames((time * 30.0) as u32).await { + println!("frame {frame_number} decoded"); + let frame = produce_frame( &constants, &screen_frame, @@ -196,7 +199,11 @@ pub async fn render_video_to_channel( ) .await?; - sender.send(frame).await?; + println!("frame {frame_number} produced"); + + sender.send(frame.0).await?; + + println!("frame {frame_number} sent"); } else { println!("no decoder frames: {:?}", (time, segment_i)); }; @@ -247,7 +254,13 @@ impl RenderVideoConstants { .await .ok_or(RenderingError::NoAdapter)?; let (device, queue) = adapter - .request_device(&wgpu::DeviceDescriptor::default(), None) + .request_device( + &wgpu::DeviceDescriptor { + required_features: wgpu::Features::MAPPABLE_PRIMARY_BUFFERS, + ..Default::default() + }, + None, + ) .await?; // Pass project_path to load_cursor_textures @@ -499,11 +512,11 @@ impl ProjectUniforms { ); let zoom_keyframes = ZoomKeyframes::new(project); - let current_zoom = zoom_keyframes.get_amount(time as f64); - let prev_zoom = zoom_keyframes.get_amount((time - 1.0 / 30.0) as f64); + let current_zoom = zoom_keyframes.interpolate(time as f64); + let prev_zoom = zoom_keyframes.interpolate((time - 1.0 / 30.0) as f64); let velocity = if current_zoom != prev_zoom { - let scale_change = (current_zoom - prev_zoom) as f32; + let scale_change = (current_zoom.0 - prev_zoom.0) as f32; // Reduce the velocity scale from 0.05 to 0.02 [ (scale_change * output_size.0 as f32) * 0.02, // Reduced from 0.05 @@ -519,24 +532,35 @@ impl ProjectUniforms { 0.0 }; - let zoom_origin_uv = if let Some(cursor_position) = cursor_position { - (zoom_keyframes.get_amount(time as f64), cursor_position) - } else { - (1.0, Coord::new(XY { x: 0.0, y: 0.0 })) - }; - let crop = Self::get_crop(options, project); - let zoom_origin = if let Some(cursor_position) = cursor_position { - cursor_position + let (zoom_amount, zoom_origin) = { + let (amount, position) = zoom_keyframes.interpolate(time as f64); + + let origin = match position { + ZoomPosition::Manual { x, y } => Coord::::new(XY { + x: x as f64, + y: y as f64, + }) .to_raw_display_space(options) - .to_cropped_display_space(options, project) - } else { - let center = XY::new( - options.screen_size.x as f64 / 2.0, - options.screen_size.y as f64 / 2.0, - ); - Coord::::new(center).to_cropped_display_space(options, project) + .to_cropped_display_space(options, project), + ZoomPosition::Cursor => { + if let Some(cursor_position) = cursor_position { + cursor_position + .to_raw_display_space(options) + .to_cropped_display_space(options, project) + } else { + let center = XY::new( + options.screen_size.x as f64 / 2.0, + options.screen_size.y as f64 / 2.0, + ); + Coord::::new(center) + .to_cropped_display_space(options, project) + } + } + }; + + (amount, origin) }; let (display, zoom) = { @@ -561,7 +585,7 @@ impl ProjectUniforms { .clamp(display_offset.coord, end.coord); let zoom = Zoom { - amount: zoom_keyframes.get_amount(time as f64), + amount: zoom_amount, zoom_origin: screen_scale_origin, // padding: screen_scale_origin, }; @@ -606,7 +630,7 @@ impl ProjectUniforms { // Calculate camera size based on zoom let base_size = project.camera.size / 100.0; - let zoom_amount = zoom_keyframes.get_amount(time as f64) as f32; + let zoom_amount = zoom_amount as f32; let zoomed_size = if zoom_amount > 1.0 { // Get the zoom size as a percentage (0-1 range) let zoom_size = project.camera.zoom_size.unwrap_or(20.0) / 100.0; @@ -650,7 +674,7 @@ impl ProjectUniforms { // Calculate camera motion blur based on zoom transition let camera_motion_blur = { let base_blur = project.motion_blur.unwrap_or(0.2); - let zoom_delta = (current_zoom - prev_zoom).abs() as f32; + let zoom_delta = (current_zoom.0 - prev_zoom.0).abs() as f32; // Calculate a smooth transition factor let transition_speed = 30.0f32; // Frames per second @@ -694,53 +718,183 @@ impl ProjectUniforms { } } -#[derive(Debug)] +#[derive(Debug, PartialEq)] pub struct ZoomKeyframe { time: f64, - amount: f64, + scale: f64, + position: ZoomPosition, } -#[derive(Debug)] +#[derive(Debug, PartialEq, Clone, Copy)] +pub enum ZoomPosition { + Cursor, + Manual { x: f32, y: f32 }, +} +#[derive(Debug, PartialEq)] pub struct ZoomKeyframes(Vec); pub const ZOOM_DURATION: f64 = 0.6; +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn zoom_keyframes() { + let segments = [ZoomSegment { + start: 0.5, + end: 1.5, + amount: 1.5, + mode: cap_project::ZoomMode::Manual { x: 0.2, y: 0.2 }, + }]; + + let keyframes = ZoomKeyframes::from_zoom_segments(&segments); + + pretty_assertions::assert_eq!( + keyframes, + ZoomKeyframes(vec![ + ZoomKeyframe { + time: 0.0, + scale: 1.0, + position: ZoomPosition::Manual { x: 0.0, y: 0.0 } + }, + ZoomKeyframe { + time: 0.5, + scale: 1.0, + position: ZoomPosition::Manual { x: 0.2, y: 0.2 } + }, + ZoomKeyframe { + time: 0.5 + ZOOM_DURATION, + scale: 1.5, + position: ZoomPosition::Manual { x: 0.2, y: 0.2 } + }, + ZoomKeyframe { + time: 1.5, + scale: 1.5, + position: ZoomPosition::Manual { x: 0.2, y: 0.2 } + }, + ZoomKeyframe { + time: 1.5 + ZOOM_DURATION, + scale: 1.0, + position: ZoomPosition::Manual { x: 0.2, y: 0.2 } + } + ]) + ); + + let segments = [ + ZoomSegment { + start: 0.5, + end: 1.5, + amount: 1.5, + mode: cap_project::ZoomMode::Manual { x: 0.2, y: 0.2 }, + }, + ZoomSegment { + start: 1.5, + end: 2.5, + amount: 1.5, + mode: cap_project::ZoomMode::Manual { x: 0.8, y: 0.8 }, + }, + ]; + + let keyframes = ZoomKeyframes::from_zoom_segments(&segments); + + pretty_assertions::assert_eq!( + keyframes, + ZoomKeyframes(vec![ + ZoomKeyframe { + time: 0.0, + scale: 1.0, + position: ZoomPosition::Manual { x: 0.0, y: 0.0 } + }, + ZoomKeyframe { + time: 0.5, + scale: 1.0, + position: ZoomPosition::Manual { x: 0.2, y: 0.2 } + }, + ZoomKeyframe { + time: 0.5 + ZOOM_DURATION, + scale: 1.5, + position: ZoomPosition::Manual { x: 0.2, y: 0.2 } + }, + ZoomKeyframe { + time: 1.5, + scale: 1.5, + position: ZoomPosition::Manual { x: 0.2, y: 0.2 } + }, + ZoomKeyframe { + time: 1.5 + ZOOM_DURATION, + scale: 1.5, + position: ZoomPosition::Manual { x: 0.8, y: 0.8 } + }, + ZoomKeyframe { + time: 2.5, + scale: 1.5, + position: ZoomPosition::Manual { x: 0.8, y: 0.8 } + } + ]) + ); + } +} + impl ZoomKeyframes { pub fn new(config: &ProjectConfiguration) -> Self { let Some(zoom_segments) = config.timeline().map(|t| &t.zoom_segments) else { return Self(vec![]); }; - if zoom_segments.is_empty() { + Self::from_zoom_segments(zoom_segments) + } + + fn from_zoom_segments(segments: &[ZoomSegment]) -> Self { + if segments.is_empty() { return Self(vec![]); } let mut keyframes = vec![]; - for segment in zoom_segments { + if segments[0].start != 0.0 { + keyframes.push(ZoomKeyframe { + time: 0.0, + scale: 1.0, + position: ZoomPosition::Manual { x: 0.0, y: 0.0 }, + }); + } + + for segment in segments { + let position = match segment.mode { + cap_project::ZoomMode::Auto => ZoomPosition::Cursor, + cap_project::ZoomMode::Manual { x, y } => ZoomPosition::Manual { x, y }, + }; + keyframes.push(ZoomKeyframe { time: segment.start, - amount: 1.0, + scale: 1.0, + position, }); keyframes.push(ZoomKeyframe { time: segment.start + ZOOM_DURATION, - amount: segment.amount, + scale: segment.amount, + position, }); keyframes.push(ZoomKeyframe { time: segment.end, - amount: segment.amount, + scale: segment.amount, + position, }); keyframes.push(ZoomKeyframe { time: segment.end + ZOOM_DURATION, - amount: 1.0, + scale: 1.0, + position, }); } Self(keyframes) } - pub fn get_amount(&self, time: f64) -> f64 { + pub fn interpolate(&self, time: f64) -> (f64, ZoomPosition) { + let default = (1.0, ZoomPosition::Manual { x: 0.0, y: 0.0 }); + if !FLAGS.zoom { - return 1.0; + return default; } let prev_index = self @@ -751,13 +905,13 @@ impl ZoomKeyframes { .map(|p| self.0.len() - 1 - p); let Some(prev_index) = prev_index else { - return 1.0; + return default; }; let next_index = prev_index + 1; let Some((prev, next)) = self.0.get(prev_index).zip(self.0.get(next_index)) else { - return 1.0; + return default; }; let keyframe_length = next.time - prev.time; @@ -766,7 +920,17 @@ impl ZoomKeyframes { let t = delta_time / keyframe_length; let t = t.powf(0.5); - prev.amount + (next.amount - prev.amount) * t + let position = match (&prev.position, &next.position) { + (ZoomPosition::Manual { x: x1, y: y1 }, ZoomPosition::Manual { x: x2, y: y2 }) => { + ZoomPosition::Manual { + x: x1 + (x2 - x1) * t as f32, + y: y1 + (y2 - y1) * t as f32, + } + } + _ => ZoomPosition::Manual { x: 0.0, y: 0.0 }, + }; + + (prev.scale + (next.scale - prev.scale) * t, position) } } @@ -777,7 +941,7 @@ pub async fn produce_frame( background: Background, uniforms: &ProjectUniforms, time: f32, -) -> Result, RenderingError> { +) -> Result<(Vec, u32), RenderingError> { let mut encoder = constants.device.create_command_encoder( &(wgpu::CommandEncoderDescriptor { label: Some("Render Encoder"), @@ -900,16 +1064,16 @@ pub async fn produce_frame( output_is_left = !output_is_left; } - // if FLAGS.zoom { - // Then render the cursor - draw_cursor( - constants, - uniforms, - time, - &mut encoder, - get_either(texture_views, !output_is_left), - ); - // } + if FLAGS.zoom { + // Then render the cursor + draw_cursor( + constants, + uniforms, + time, + &mut encoder, + get_either(texture_views, !output_is_left), + ); + } // camera if let (Some(camera_size), Some(camera_frame), Some(uniforms)) = ( @@ -1041,18 +1205,14 @@ pub async fn produce_frame( .ok_or(RenderingError::BufferMapWaitingFailed)??; let data = buffer_slice.get_mapped_range(); - let padded_data: Vec = data.to_vec(); // Ensure the type is Vec - let mut image_data = - Vec::with_capacity((uniforms.output_size.0 * uniforms.output_size.1 * 4) as usize); - for chunk in padded_data.chunks(padded_bytes_per_row as usize) { - image_data.extend_from_slice(&chunk[..unpadded_bytes_per_row as usize]); - } + + let image_data = data.to_vec(); // Unmap the buffer drop(data); output_buffer.unmap(); - Ok(image_data) + Ok((image_data, padded_bytes_per_row)) } fn draw_cursor( diff --git a/crates/utils/Cargo.toml b/crates/utils/Cargo.toml index 644dab0f..668d7971 100644 --- a/crates/utils/Cargo.toml +++ b/crates/utils/Cargo.toml @@ -19,5 +19,6 @@ windows = { version = "0.58.0", features = [ ] } [dependencies] +futures = "0.3.31" tokio = { workspace = true, features = ["net"] } uuid = "1.11.0" diff --git a/crates/utils/src/lib.rs b/crates/utils/src/lib.rs index 567b04fd..d9138bfe 100644 --- a/crates/utils/src/lib.rs +++ b/crates/utils/src/lib.rs @@ -1,4 +1,6 @@ -use std::{ffi::OsString, fs::OpenOptions, path::PathBuf}; +use futures::FutureExt; +use std::{ffi::OsString, fs::OpenOptions, io::Write, path::PathBuf}; +// use tokio::{fs::OpenOptions, io::AsyncWriteExt}; #[cfg(windows)] pub fn get_last_win32_error_formatted() -> String { @@ -46,26 +48,38 @@ pub fn create_channel_named_pipe( ) -> OsString { #[cfg(unix)] { - use std::io::Write; - create_named_pipe(&unix_path).unwrap(); let path = unix_path.clone(); - tokio::spawn(async move { - let mut file = OpenOptions::new() - .write(true) - .create(false) - .truncate(true) - .open(&path)?; - println!("video pipe opened"); - - while let Some(bytes) = rx.recv().await { - file.write_all(&bytes)?; - } + tokio::spawn( + async move { + let mut file = OpenOptions::new() + .write(true) + .create(false) + .truncate(true) + .open(&path) + // .await + .unwrap(); + println!("video pipe opened"); - println!("done writing to video pipe"); - Ok::<(), std::io::Error>(()) - }); + println!("receiving frame for channel"); + while let Some(bytes) = rx.recv().await { + println!("received frame, writing bytes"); + file.write_all(&bytes) + // .await + .unwrap(); + println!("bytes written"); + } + + println!("done writing to video pipe"); + Ok::<(), std::io::Error>(()) + } + .then(|result| async { + if let Err(e) = result { + eprintln!("error writing to video pipe: {}", e); + } + }), + ); unix_path.into_os_string() }