Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
09844dd
chore: add target-agent and tmp to gitignore
richiemcilroy Mar 21, 2026
4642b5a
fix(lint): rename unchecked_duration_subtraction to unchecked_time_su…
richiemcilroy Mar 21, 2026
8412033
feat(web): add posthog-growth analytics script
richiemcilroy Mar 21, 2026
2b8ac90
feat(desktop): associate posthog events with authenticated user
richiemcilroy Mar 21, 2026
09eb901
feat(web): add auth_surface tracking to login and signup forms
richiemcilroy Mar 21, 2026
d92b0c1
feat(web): track auth events from share overlay
richiemcilroy Mar 21, 2026
66369fd
fix(web): add 7-day window and safer result parsing for signup tracking
richiemcilroy Mar 21, 2026
8273138
fix(web): use proper platform enum in stripe webhook
richiemcilroy Mar 21, 2026
98094b8
refactor(rendering): rewrite cursor interpolation with fixed-timestep…
richiemcilroy Mar 21, 2026
a2326c8
feat(rendering): anticipatory click animation and idle fade lookahead
richiemcilroy Mar 21, 2026
dcd31ef
perf(rendering): simplify cursor and display motion blur shaders
richiemcilroy Mar 21, 2026
4718c18
feat(project): tune cursor spring, motion blur, and rotation defaults
richiemcilroy Mar 21, 2026
f680366
feat(desktop): add cursor tilt slider to config sidebar
richiemcilroy Mar 21, 2026
2cd5d57
fix(desktop): remove unused variables and switch webgpu render to loa…
richiemcilroy Mar 21, 2026
0f964dd
feat(rendering): add edge snapping and continuous zoom-out animation
richiemcilroy Mar 21, 2026
05e12de
refactor(rendering): lazy precomputation and cluster-based zoom focus
richiemcilroy Mar 21, 2026
48e0b5d
perf(rendering): forward-only decoder fallback and pool bounds safety
richiemcilroy Mar 21, 2026
db7a216
refactor(recording): extract blocking thread finish and mux error hel…
richiemcilroy Mar 21, 2026
a759889
refactor(recording): use shared finish helpers across all muxer imple…
richiemcilroy Mar 21, 2026
dc5f9e3
feat(recording): track optional pipeline failures with diagnostics si…
richiemcilroy Mar 21, 2026
4132529
fix(encoding): rebuild video sample buffer for UYVY camera frames
richiemcilroy Mar 21, 2026
b9b1a1c
perf(editor): improve playback warmup, buffer drain, and frame skip r…
richiemcilroy Mar 21, 2026
bc6abd6
perf(editor): increase renderer channel and add drain-flush-blocking …
richiemcilroy Mar 21, 2026
8d7a032
feat(rendering): integrate cursor tilt, zoom focus, and NV12 startup …
richiemcilroy Mar 21, 2026
48f2edc
refactor: update ZoomFocusInterpolator callers with click_spring and …
richiemcilroy Mar 21, 2026
b305a4f
feat(export): add first-frame benchmark with NV12 startup breakdown
richiemcilroy Mar 21, 2026
6959c29
feat(export): add export startup time benchmark example
richiemcilroy Mar 21, 2026
d3d7c74
feat(recording): add camera-writer-repro diagnostic example
richiemcilroy Mar 21, 2026
bada32f
refactor(recording): extract FinishableEncoderState trait to deduplic…
richiemcilroy Mar 21, 2026
08e7e51
perf(enc-avfoundation): prefer copy_with_new_timing over full sample …
richiemcilroy Mar 21, 2026
baf66fb
perf(rendering): use binary search for click lookups in cursor interp…
richiemcilroy Mar 21, 2026
8932583
perf(rendering): use binary search for cursor idle opacity move lookup
richiemcilroy Mar 21, 2026
3e89f9b
fix(rendering): include segment start time in zoom focus interpolatio…
richiemcilroy Mar 21, 2026
f6fcbb2
fix(desktop): remove unnecessary type cast on cursor rotationAmount
richiemcilroy Mar 21, 2026
a440a32
fix(web): handle array videoId param and use safer array access in Au…
richiemcilroy Mar 21, 2026
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
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ dist-ssr
.env.development
.env.test
target
target-agent
.cursorrules
.github/hooks

Expand Down Expand Up @@ -68,4 +69,5 @@ scripts/releases-backfill-data.txt
.opencode/
analysis/
analysis/plans/
.ralphy
.ralphy
tmp/
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ All Rust code must respect these workspace-level lints defined in `Cargo.toml`:
**Clippy lints (all denied):**
- `dbg_macro` — Never use `dbg!()` in code; use proper logging instead.
- `let_underscore_future` — Never write `let _ = async_fn()` which silently drops futures; await or explicitly handle them.
- `unchecked_duration_subtraction` — Use `saturating_sub` instead of `-` for `Duration` to avoid panics.
- `unchecked_time_subtraction` — Use `saturating_sub` instead of `-` for `Duration` to avoid panics.
- `collapsible_if` — Merge nested `if` statements: use `if a && b { }` instead of `if a { if b { } }`.
- `clone_on_copy` — Don't call `.clone()` on `Copy` types; just copy them directly.
- `redundant_closure` — Use function references directly: `iter.map(foo)` instead of `iter.map(|x| foo(x))`.
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,7 @@ All Rust code must respect these workspace-level lints defined in `Cargo.toml`.
**Clippy lints (all denied — code MUST NOT contain these patterns):**
- `dbg_macro` — Never use `dbg!()` in code; use proper logging (`tracing::debug!`, etc.) instead.
- `let_underscore_future` — Never write `let _ = async_fn()` which silently drops futures; await or explicitly handle them.
- `unchecked_duration_subtraction` — Use `duration.saturating_sub(other)` instead of `duration - other` to avoid panics on underflow.
- `unchecked_time_subtraction` — Use `duration.saturating_sub(other)` instead of `duration - other` to avoid panics on underflow.
- `collapsible_if` — Merge nested `if` statements: write `if a && b { }` instead of `if a { if b { } }`.
- `clone_on_copy` — Don't call `.clone()` on `Copy` types (integers, bools, etc.); just copy them directly.
- `redundant_closure` — Use function references directly: `iter.map(foo)` instead of `iter.map(|x| foo(x))`.
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ unused_must_use = "deny"
[workspace.lints.clippy]
dbg_macro = "deny"
let_underscore_future = "deny"
unchecked_duration_subtraction = "deny"
unchecked_time_subtraction = "deny"
collapsible_if = "deny"
clone_on_copy = "deny"
redundant_closure = "deny"
Expand Down
12 changes: 12 additions & 0 deletions apps/desktop/src-tauri/src/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -336,8 +336,14 @@ pub async fn generate_export_preview(
let zoom_focus_interpolator = ZoomFocusInterpolator::new(
&render_segment.cursor,
cursor_smoothing,
project_config.cursor.click_spring_config(),
project_config.screen_movement_spring,
total_duration,
project_config
.timeline
.as_ref()
.map(|t| t.zoom_segments.as_slice())
.unwrap_or(&[]),
);

let uniforms = ProjectUniforms::new(
Expand Down Expand Up @@ -482,8 +488,14 @@ pub async fn generate_export_preview_fast(
let zoom_focus_interpolator = ZoomFocusInterpolator::new(
&segment_media.cursor,
cursor_smoothing,
project_config.cursor.click_spring_config(),
project_config.screen_movement_spring,
total_duration,
project_config
.timeline
.as_ref()
.map(|t| t.zoom_segments.as_slice())
.unwrap_or(&[]),
);

let uniforms = ProjectUniforms::new(
Expand Down
77 changes: 45 additions & 32 deletions apps/desktop/src-tauri/src/posthog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ use std::{
sync::{OnceLock, PoisonError, RwLock},
time::Duration,
};
use tauri::AppHandle;
use tracing::error;

use crate::auth::AuthStore;

#[derive(Debug)]
pub enum PostHogEvent {
MultipartUploadComplete {
Expand All @@ -22,36 +25,42 @@ pub enum PostHogEvent {
},
}

impl From<PostHogEvent> for posthog_rs::Event {
fn from(event: PostHogEvent) -> Self {
match event {
PostHogEvent::MultipartUploadComplete {
duration,
length,
size,
} => {
let mut e = posthog_rs::Event::new_anon("multipart_upload_complete");
e.insert_prop("duration", duration.as_secs())
.map_err(|err| error!("Error adding PostHog property: {err:?}"))
.ok();
e.insert_prop("length", length.as_secs())
.map_err(|err| error!("Error adding PostHog property: {err:?}"))
.ok();
e.insert_prop("size", size)
.map_err(|err| error!("Error adding PostHog property: {err:?}"))
.ok();
e
}
PostHogEvent::MultipartUploadFailed { duration, error } => {
let mut e = posthog_rs::Event::new_anon("multipart_upload_failed");
e.insert_prop("duration", duration.as_secs())
.map_err(|err| error!("Error adding PostHog property: {err:?}"))
.ok();
e.insert_prop("error", error)
.map_err(|err| error!("Error adding PostHog property: {err:?}"))
.ok();
e
}
fn posthog_event(event: PostHogEvent, distinct_id: Option<&str>) -> posthog_rs::Event {
match event {
PostHogEvent::MultipartUploadComplete {
duration,
length,
size,
} => {
let mut e = match distinct_id {
Some(distinct_id) => {
posthog_rs::Event::new("multipart_upload_complete", distinct_id)
}
None => posthog_rs::Event::new_anon("multipart_upload_complete"),
};
e.insert_prop("duration", duration.as_secs())
.map_err(|err| error!("Error adding PostHog property: {err:?}"))
.ok();
e.insert_prop("length", length.as_secs())
.map_err(|err| error!("Error adding PostHog property: {err:?}"))
.ok();
e.insert_prop("size", size)
.map_err(|err| error!("Error adding PostHog property: {err:?}"))
.ok();
e
}
PostHogEvent::MultipartUploadFailed { duration, error } => {
let mut e = match distinct_id {
Some(distinct_id) => posthog_rs::Event::new("multipart_upload_failed", distinct_id),
None => posthog_rs::Event::new_anon("multipart_upload_failed"),
};
e.insert_prop("duration", duration.as_secs())
.map_err(|err| error!("Error adding PostHog property: {err:?}"))
.ok();
e.insert_prop("error", error)
.map_err(|err| error!("Error adding PostHog property: {err:?}"))
.ok();
e
}
}
}
Expand All @@ -76,10 +85,14 @@ pub fn set_server_url(url: &str) {

static API_SERVER_IS_CAP_CLOUD: OnceLock<RwLock<Option<bool>>> = OnceLock::new();

pub fn async_capture_event(event: PostHogEvent) {
pub fn async_capture_event(app: &AppHandle, event: PostHogEvent) {
if option_env!("VITE_POSTHOG_KEY").is_some() {
let distinct_id = AuthStore::get(app)
.ok()
.flatten()
.and_then(|auth| auth.user_id);
tokio::spawn(async move {
let mut e: posthog_rs::Event = event.into();
let mut e = posthog_event(event, distinct_id.as_deref());

e.insert_prop("cap_version", env!("CARGO_PKG_VERSION"))
.map_err(|err| error!("Error adding PostHog property: {err:?}"))
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src-tauri/src/screenshot_editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -356,8 +356,14 @@ impl ScreenshotEditorInstances {
let zoom_focus_interpolator = ZoomFocusInterpolator::new(
&cursor_events,
None,
current_config.cursor.click_spring_config(),
current_config.screen_movement_spring,
0.0,
current_config
.timeline
.as_ref()
.map(|t| t.zoom_segments.as_slice())
.unwrap_or(&[]),
);

let uniforms = ProjectUniforms::new(
Expand Down
68 changes: 37 additions & 31 deletions apps/desktop/src-tauri/src/upload.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,22 +146,25 @@ pub async fn upload_video(

emit_upload_complete(app, &video_id);

async_capture_event(match &video_result {
Ok(meta) => PostHogEvent::MultipartUploadComplete {
duration: start.elapsed(),
length: meta
.as_ref()
.map(|v| Duration::from_secs(v.duration_in_secs as u64))
.unwrap_or_default(),
size: std::fs::metadata(file_path)
.map(|m| ((m.len() as f64) / 1_000_000.0) as u64)
.unwrap_or_default(),
},
Err(err) => PostHogEvent::MultipartUploadFailed {
duration: start.elapsed(),
error: err.to_string(),
async_capture_event(
app,
match &video_result {
Ok(meta) => PostHogEvent::MultipartUploadComplete {
duration: start.elapsed(),
length: meta
.as_ref()
.map(|v| Duration::from_secs(v.duration_in_secs as u64))
.unwrap_or_default(),
size: std::fs::metadata(file_path)
.map(|m| ((m.len() as f64) / 1_000_000.0) as u64)
.unwrap_or_default(),
},
Err(err) => PostHogEvent::MultipartUploadFailed {
duration: start.elapsed(),
error: err.to_string(),
},
},
});
);

let _ = (video_result?, thumbnail_result?);

Expand Down Expand Up @@ -399,29 +402,32 @@ impl InstantMultipartUpload {
handle: spawn_actor(async move {
let start = Instant::now();
let result = Self::run(
app,
app.clone(),
file_path.clone(),
pre_created_video,
recording_dir,
realtime_upload_done,
)
.await;
async_capture_event(match &result {
Ok(meta) => PostHogEvent::MultipartUploadComplete {
duration: start.elapsed(),
length: meta
.as_ref()
.map(|v| Duration::from_secs(v.duration_in_secs as u64))
.unwrap_or_default(),
size: std::fs::metadata(file_path)
.map(|m| ((m.len() as f64) / 1_000_000.0) as u64)
.unwrap_or_default(),
},
Err(err) => PostHogEvent::MultipartUploadFailed {
duration: start.elapsed(),
error: err.to_string(),
async_capture_event(
&app,
match &result {
Ok(meta) => PostHogEvent::MultipartUploadComplete {
duration: start.elapsed(),
length: meta
.as_ref()
.map(|v| Duration::from_secs(v.duration_in_secs as u64))
.unwrap_or_default(),
size: std::fs::metadata(file_path)
.map(|m| ((m.len() as f64) / 1_000_000.0) as u64)
.unwrap_or_default(),
},
Err(err) => PostHogEvent::MultipartUploadFailed {
duration: start.elapsed(),
error: err.to_string(),
},
},
});
);

result.map(|_| ())
}),
Expand Down
12 changes: 11 additions & 1 deletion apps/desktop/src/routes/editor/ConfigSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@
friction: number;
};

const DEFAULT_CURSOR_MOTION_BLUR = 0.5;
const DEFAULT_CURSOR_MOTION_BLUR = 1.0;

const CURSOR_TYPE_OPTIONS = [
{
Expand Down Expand Up @@ -619,6 +619,16 @@
step={1}
/>
</Field>
<Field name="Tilt" icon={<IconLucideRotate3d class="size-4" />}>
<Slider
value={[project.cursor.rotationAmount ?? 0.15]}
onChange={(v) => setProject("cursor", "rotationAmount", v[0])}
minValue={0}
maxValue={1}
step={0.01}
formatTooltip={(value) => `${Math.round(value * 100)}%`}
/>
</Field>
<Field
name="Hide When Idle"
icon={<IconLucideTimer class="size-4" />}
Expand Down Expand Up @@ -3215,7 +3225,7 @@

setProject(
produce((proj) => {
const clips = (proj.clips ??= []);

Check failure on line 3228 in apps/desktop/src/routes/editor/ConfigSidebar.tsx

View workflow job for this annotation

GitHub Actions / Lint (Biome)

lint/suspicious/noAssignInExpressions

The assignment should not be in an expression.
let clip = clips.find(
(clip) => clip.index === (props.segment.recordingSegment ?? 0),
);
Expand Down
2 changes: 0 additions & 2 deletions apps/desktop/src/utils/frame-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,6 @@ function drainAndRenderLatestSharedWebGPU(maxDrain: number): boolean {
if (renderMode !== "webgpu" || !webgpuRenderer) return false;

let latest: { bytes: Uint8Array; release: () => void } | null = null;
let drained = 0;

for (let i = 0; i < maxDrain; i += 1) {
const borrowed = consumer.borrow(0);
Expand All @@ -427,7 +426,6 @@ function drainAndRenderLatestSharedWebGPU(maxDrain: number): boolean {
latest.release();
}
latest = { bytes: borrowed.data, release: borrowed.release };
drained += 1;
}

if (!latest) return false;
Expand Down
3 changes: 0 additions & 3 deletions apps/desktop/src/utils/socket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -624,7 +624,6 @@ export function createImageDataWS(
let renderFrameCount = 0;
let minFrameTime = Number.MAX_VALUE;
let maxFrameTime = 0;

const getLocalFpsStats = (): FpsStats => ({
fps:
frameCount > 0 && frameTimeSum > 0
Expand Down Expand Up @@ -699,7 +698,6 @@ export function createImageDataWS(
const yStride = meta.getUint32(0, true);
const height = meta.getUint32(4, true);
const width = meta.getUint32(8, true);
const frameNumber = meta.getUint32(12, true);

if (width > 0 && height > 0) {
const ySize = yStride * height;
Expand Down Expand Up @@ -765,7 +763,6 @@ export function createImageDataWS(
const yStride = meta.getUint32(0, true);
const height = meta.getUint32(4, true);
const width = meta.getUint32(8, true);
const frameNumber = meta.getUint32(12, true);

if (width > 0 && height > 0) {
const ySize = yStride * height;
Expand Down
6 changes: 2 additions & 4 deletions apps/desktop/src/utils/webgpu-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,8 +299,7 @@ export function renderFrameWebGPU(
colorAttachments: [
{
view: currentTexture.createView(),
clearValue: { r: 0, g: 0, b: 0, a: 1 },
loadOp: "clear",
loadOp: "load",
Copy link
Contributor

Choose a reason for hiding this comment

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

P1 loadOp: "load" may expose stale pixels on first frame

Switching from "clear" to "load" skips resetting the attachment to clearValue before the render pass. In WebGPU, on the very first frame (or after a resize/GPU-device-lost recovery), the texture contents are undefined"load" will then composite on top of garbage memory rather than a clean black background. This can manifest as single-frame visual glitches during playback start or after seeking.

The same change is applied to both renderFrameWebGPU (line 301) and renderNv12FrameWebGPU (line 415).

If the goal is to avoid the clear cost when the composite shader always writes every pixel, an alternative is to keep "clear" but only for the first frame render of a new session. Otherwise, at minimum add a comment confirming the shader guarantees full-pixel coverage so future readers understand the invariant.

Suggested change
loadOp: "load",
loadOp: "clear",
clearValue: { r: 0, g: 0, b: 0, a: 1 },
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/utils/webgpu-renderer.ts
Line: 302

Comment:
**`loadOp: "load"` may expose stale pixels on first frame**

Switching from `"clear"` to `"load"` skips resetting the attachment to `clearValue` before the render pass. In WebGPU, on the very first frame (or after a resize/GPU-device-lost recovery), the texture contents are **undefined**`"load"` will then composite on top of garbage memory rather than a clean black background. This can manifest as single-frame visual glitches during playback start or after seeking.

The same change is applied to both `renderFrameWebGPU` (line 301) and `renderNv12FrameWebGPU` (line 415).

If the goal is to avoid the clear cost when the composite shader always writes every pixel, an alternative is to keep `"clear"` but only for the first frame render of a new session. Otherwise, at minimum add a comment confirming the shader guarantees full-pixel coverage so future readers understand the invariant.

```suggestion
				loadOp: "clear",
				clearValue: { r: 0, g: 0, b: 0, a: 1 },
```

How can I resolve this? If you propose a fix, please make it concise.

storeOp: "store",
},
],
Expand Down Expand Up @@ -413,8 +412,7 @@ export function renderNv12FrameWebGPU(
colorAttachments: [
{
view: context.getCurrentTexture().createView(),
clearValue: { r: 0, g: 0, b: 0, a: 1 },
loadOp: "clear",
loadOp: "load",
Copy link
Contributor

Choose a reason for hiding this comment

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

P1 Same loadOp: "load" concern for Nv12 path

This is the same "clear""load" change applied to renderNv12FrameWebGPU. If the composite pass does not guarantee full pixel coverage on the first frame, the Nv12 render path will have the same stale-pixel risk.

Suggested change
loadOp: "load",
loadOp: "clear",
clearValue: { r: 0, g: 0, b: 0, a: 1 },
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/utils/webgpu-renderer.ts
Line: 415

Comment:
**Same `loadOp: "load"` concern for Nv12 path**

This is the same `"clear"``"load"` change applied to `renderNv12FrameWebGPU`. If the composite pass does not guarantee full pixel coverage on the first frame, the Nv12 render path will have the same stale-pixel risk.

```suggestion
				loadOp: "clear",
				clearValue: { r: 0, g: 0, b: 0, a: 1 },
```

How can I resolve this? If you propose a fix, please make it concise.

storeOp: "store",
},
],
Expand Down
Loading
Loading