Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
cfea08f
fix(rendering): render background blur as a separable two-pass gaussian
richiemcilroy Aug 14, 2026
133000f
feat(project): add style, animation, and layout fields to text segments
richiemcilroy Aug 14, 2026
91cc4ea
feat(project): pause the recording clock under fullscreen text segments
richiemcilroy Aug 14, 2026
b06a306
feat(rendering): text enter/exit animations, alignment, spacing, and …
richiemcilroy Aug 14, 2026
6606be1
feat(rendering): morph the display card aside for text takeover layouts
richiemcilroy Aug 14, 2026
d6dafc5
feat(desktop): configurable default auto-zoom amount
richiemcilroy Aug 14, 2026
21b2038
feat(desktop): expose installed system font families to the editor
richiemcilroy Aug 14, 2026
70f535d
chore(desktop): extract specta builder and export bindings from a test
richiemcilroy Aug 14, 2026
1814f3c
feat(desktop): default zoom amount control in general settings
richiemcilroy Aug 14, 2026
ab94137
feat(desktop): font picker backed by installed system fonts
richiemcilroy Aug 14, 2026
c97da29
feat(desktop): text segment style, animation, and layout model in the…
richiemcilroy Aug 14, 2026
883003c
feat(desktop): mirror fullscreen text holds in editor timeline math
richiemcilroy Aug 14, 2026
0c24101
feat(desktop): timeline ruler scrub, minimap, split snapping, and zoo…
richiemcilroy Aug 14, 2026
2351bfc
feat(desktop): text settings sidebar with presets and layout controls
richiemcilroy Aug 14, 2026
d8a1ec8
fix(desktop): project caption track into hold-extended output time
richiemcilroy Aug 14, 2026
bd8bc3c
fix(rendering): keep captions and keyboard visible during split takeo…
richiemcilroy Aug 14, 2026
ee5b7e1
fix(desktop): ripple-delete overlay tracks in hold-extended output time
richiemcilroy Aug 14, 2026
ccd8df6
chore(web): format loom.ts
richiemcilroy Aug 14, 2026
1689998
Merge branch 'main' into text-segments-v2
richiemcilroy Aug 14, 2026
58cae74
chore(web): format SEO pages
richiemcilroy Aug 14, 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
3 changes: 3 additions & 0 deletions apps/desktop/src-tauri/src/general_settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,8 @@ pub struct GeneralSettingsStore {
pub enable_native_camera_preview: bool,
#[serde(default = "default_true")]
pub auto_zoom_on_clicks: bool,
#[serde(default)]
pub default_zoom_amount: Option<f64>,
/// `None` until [`init`] seeds it from whether this machine has a notched
/// display. From then on it is the user's preference and nothing re-reads
/// the hardware, so moving between machines can't silently flip it.
Expand Down Expand Up @@ -328,6 +330,7 @@ impl Default for GeneralSettingsStore {
// Keep aligned with the field's serde `default_true`: auto zooms
// are on by default, matching configs that never stored the key.
auto_zoom_on_clicks: true,
default_zoom_amount: None,
macbook_notch_overlay: None,
capture_keyboard_events: cap_recording::DEFAULT_CAPTURE_KEYBOARD_EVENTS,
post_deletion_behaviour: PostDeletionBehaviour::DoNothing,
Expand Down
119 changes: 77 additions & 42 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3380,14 +3380,22 @@ async fn update_project_config_in_memory(

#[tauri::command]
#[specta::specta]
#[instrument(skip(editor_instance))]
#[instrument(skip(app, editor_instance))]
async fn generate_zoom_segments_from_clicks(
app: AppHandle,
editor_instance: WindowEditorInstance,
) -> Result<Vec<ZoomSegment>, String> {
let meta = editor_instance.meta();
let recordings = &editor_instance.recordings;

let zoom_segments = recording::generate_zoom_segments_for_project(meta, recordings);
let zoom_amount = GeneralSettingsStore::get(&app)
.ok()
.flatten()
.and_then(|settings| settings.default_zoom_amount)
.unwrap_or(recording::DEFAULT_AUTO_ZOOM_AMOUNT);

let zoom_segments =
recording::generate_zoom_segments_for_project(meta, recordings, zoom_amount);

Ok(zoom_segments)
}
Expand Down Expand Up @@ -3451,6 +3459,15 @@ async fn list_audio_devices() -> Result<Vec<String>, ()> {
Ok(MicrophoneFeed::list_names())
}

#[tauri::command]
#[specta::specta]
#[instrument]
async fn list_system_fonts() -> Vec<String> {
tokio::task::spawn_blocking(cap_rendering::system_font_families)
.await
.unwrap_or_default()
}

#[derive(Serialize, Type, Debug, Clone)]
pub struct UploadProgress {
progress: f64,
Expand Down Expand Up @@ -4858,45 +4875,8 @@ fn configure_camera_blur_recovery(
}
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) {
// Arm the unexpected-termination sentinel before anything else can crash, and
// report any previous session that died without a clean shutdown.
let previous_termination = crash_sentinel::init(&logs_dir, env!("CARGO_PKG_VERSION"));
configure_windows_graphics_recovery(previous_termination);

// Keep the sentinel's blur marker in sync with live BlurProcessor instances
// (camera preview and editor render alike), so a native blur crash is
// attributable on the next launch.
cap_camera_effects::set_blur_session_observer(|active| {
if active {
crash_sentinel::enter_blur_session();
} else {
crash_sentinel::exit_blur_session();
}
});

ffmpeg::init()
.map_err(|e| {
error!("Failed to initialize ffmpeg: {e}");
})
.ok();

// Detect the camera-preview quality profile once from total RAM. On low-RAM
// machines (<= 8GB) this opts the preview into a cheaper profile (smaller
// textures, 30fps, no background blur); higher-spec machines keep the exact
// current behaviour. Only the preview is affected — recording is untouched.
{
let mut system = sysinfo::System::new();
system.refresh_memory();
camera::init_preview_profile(system.total_memory());
}

telemetry::init();

let tauri_context = tauri::generate_context!();

let specta_builder = tauri_specta::Builder::new()
fn specta_builder() -> tauri_specta::Builder {
tauri_specta::Builder::new()
.commands(tauri_specta::collect_commands![
set_mic_input,
set_camera_input,
Expand Down Expand Up @@ -4927,6 +4907,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) {
windows::refresh_window_content_protection,
general_settings::get_default_excluded_windows,
list_audio_devices,
list_system_fonts,
close_recordings_overlay_window,
fake_window::set_fake_window_bounds,
fake_window::remove_fake_window,
Expand Down Expand Up @@ -5112,7 +5093,48 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) {
.typ::<cap_automation::ClipboardSource>()
.typ::<cap_automation::ExportFormat>()
.typ::<cap_automation::AutomationExportCompression>()
.typ::<cap_automation::ExportDestination>();
.typ::<cap_automation::ExportDestination>()
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) {
// Arm the unexpected-termination sentinel before anything else can crash, and
// report any previous session that died without a clean shutdown.
let previous_termination = crash_sentinel::init(&logs_dir, env!("CARGO_PKG_VERSION"));
configure_windows_graphics_recovery(previous_termination);

// Keep the sentinel's blur marker in sync with live BlurProcessor instances
// (camera preview and editor render alike), so a native blur crash is
// attributable on the next launch.
cap_camera_effects::set_blur_session_observer(|active| {
if active {
crash_sentinel::enter_blur_session();
} else {
crash_sentinel::exit_blur_session();
}
});

ffmpeg::init()
.map_err(|e| {
error!("Failed to initialize ffmpeg: {e}");
})
.ok();

// Detect the camera-preview quality profile once from total RAM. On low-RAM
// machines (<= 8GB) this opts the preview into a cheaper profile (smaller
// textures, 30fps, no background blur); higher-spec machines keep the exact
// current behaviour. Only the preview is affected — recording is untouched.
{
let mut system = sysinfo::System::new();
system.refresh_memory();
camera::init_preview_profile(system.total_memory());
}

telemetry::init();

let tauri_context = tauri::generate_context!();

let specta_builder = specta_builder();

#[cfg(debug_assertions)]
{
Expand Down Expand Up @@ -6902,3 +6924,16 @@ mod screenshot_share_cache_tests {
assert!(link.is_none());
}
}

#[cfg(test)]
mod typescript_bindings_tests {
#[test]
fn export_typescript_bindings() {
let bindings_path = std::path::Path::new("../src/utils/tauri.ts");
if bindings_path.parent().is_some_and(|parent| parent.exists()) {
super::specta_builder()
.export(specta_typescript::Typescript::default(), bindings_path)
.expect("failed to export TypeScript bindings");
}
}
}
56 changes: 44 additions & 12 deletions apps/desktop/src-tauri/src/recording.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3701,10 +3701,13 @@ async fn finalize_studio_recording(
Ok(())
}

pub const DEFAULT_AUTO_ZOOM_AMOUNT: f64 = 2.0;

fn generate_zoom_segments_from_clicks_impl(
mut clicks: Vec<CursorClickEvent>,
_moves: Vec<CursorMoveEvent>,
max_duration: f64,
zoom_amount: f64,
) -> Vec<ZoomSegment> {
const MS_PER_SECOND: f64 = 1000.0;
const START_MIN_MS: f64 = 1.0;
Expand All @@ -3713,7 +3716,6 @@ fn generate_zoom_segments_from_clicks_impl(
const CLICK_END_CLAMP_PADDING_MS: f64 = 800.0;
const TRAILING_CLICK_IGNORE_MS: f64 = 1000.0;
const MERGE_GAP_MS: f64 = 2500.0;
const AUTO_ZOOM_AMOUNT: f64 = 2.0;

if max_duration <= 0.0 {
return Vec::new();
Expand Down Expand Up @@ -3769,7 +3771,7 @@ fn generate_zoom_segments_from_clicks_impl(
.map(|(start, end)| ZoomSegment {
start: start.round() / MS_PER_SECOND,
end: end.round() / MS_PER_SECOND,
amount: AUTO_ZOOM_AMOUNT,
amount: zoom_amount,
mode: ZoomMode::Auto,
glide_direction: GlideDirection::None,
glide_speed: 0.5,
Expand All @@ -3784,6 +3786,7 @@ fn generate_zoom_segments_from_clicks_impl(
pub fn generate_zoom_segments_from_clicks(
recording: &studio_recording::CompletedRecording,
recordings: &ProjectRecordingsMeta,
zoom_amount: f64,
) -> Vec<ZoomSegment> {
// Build a temporary RecordingMeta so we can use the common implementation
let recording_meta = RecordingMeta {
Expand All @@ -3795,14 +3798,15 @@ pub fn generate_zoom_segments_from_clicks(
upload: None,
};

generate_zoom_segments_for_project(&recording_meta, recordings)
generate_zoom_segments_for_project(&recording_meta, recordings, zoom_amount)
}

/// Generates zoom segments from clicks for an existing project.
/// Used in the editor context where we have RecordingMeta.
pub fn generate_zoom_segments_for_project(
recording_meta: &RecordingMeta,
recordings: &ProjectRecordingsMeta,
zoom_amount: f64,
) -> Vec<ZoomSegment> {
let RecordingMetaInner::Studio(studio_meta) = &recording_meta.inner else {
return Vec::new();
Expand Down Expand Up @@ -3835,7 +3839,12 @@ pub fn generate_zoom_segments_for_project(
}
}

generate_zoom_segments_from_clicks_impl(all_clicks, all_moves, recordings.duration())
generate_zoom_segments_from_clicks_impl(
all_clicks,
all_moves,
recordings.duration(),
zoom_amount,
)
}

fn project_config_from_recording(
Expand Down Expand Up @@ -3898,7 +3907,13 @@ fn project_config_from_recording(
.collect::<Vec<_>>();

let zoom_segments = if settings.auto_zoom_on_clicks {
generate_zoom_segments_from_clicks(completed_recording, recordings)
generate_zoom_segments_from_clicks(
completed_recording,
recordings,
settings
.default_zoom_amount
.unwrap_or(DEFAULT_AUTO_ZOOM_AMOUNT),
)
} else {
Vec::new()
};
Expand Down Expand Up @@ -4271,8 +4286,12 @@ mod tests {

#[test]
fn skips_trailing_stop_click() {
let segments =
generate_zoom_segments_from_clicks_impl(vec![click_event(11_900.0)], vec![], 12.0);
let segments = generate_zoom_segments_from_clicks_impl(
vec![click_event(11_900.0)],
vec![],
12.0,
DEFAULT_AUTO_ZOOM_AMOUNT,
);

assert!(
segments.is_empty(),
Expand All @@ -4289,7 +4308,8 @@ mod tests {
move_event(1_940.0, 0.74, 0.78),
];

let segments = generate_zoom_segments_from_clicks_impl(clicks, moves, 20.0);
let segments =
generate_zoom_segments_from_clicks_impl(clicks, moves, 20.0, DEFAULT_AUTO_ZOOM_AMOUNT);

assert!(
!segments.is_empty(),
Expand Down Expand Up @@ -4317,7 +4337,12 @@ mod tests {
move_event(19_364.0, 0.44, 0.95),
];

let segments = generate_zoom_segments_from_clicks_impl(clicks, moves, 19.436_667);
let segments = generate_zoom_segments_from_clicks_impl(
clicks,
moves,
19.436_667,
DEFAULT_AUTO_ZOOM_AMOUNT,
);

assert_eq!(segments.len(), 2);
assert_eq!(segments[0].start, 1.971);
Expand All @@ -4330,7 +4355,8 @@ mod tests {
fn extends_segment_until_after_mouse_up() {
let clicks = vec![click_event(1_000.0), click_up_event(2_500.0)];

let segments = generate_zoom_segments_from_clicks_impl(clicks, vec![], 10.0);
let segments =
generate_zoom_segments_from_clicks_impl(clicks, vec![], 10.0, DEFAULT_AUTO_ZOOM_AMOUNT);

assert_eq!(segments.len(), 1);
assert_eq!(segments[0].start, 0.7);
Expand All @@ -4341,7 +4367,8 @@ mod tests {
fn clamps_zoom_end_before_recording_end() {
let clicks = vec![click_event(8_999.0), click_event(9_000.0)];

let segments = generate_zoom_segments_from_clicks_impl(clicks, vec![], 10.0);
let segments =
generate_zoom_segments_from_clicks_impl(clicks, vec![], 10.0, DEFAULT_AUTO_ZOOM_AMOUNT);

assert_eq!(segments.len(), 1);
assert_eq!(segments[0].start, 8.699);
Expand All @@ -4358,7 +4385,12 @@ mod tests {
})
.collect::<Vec<_>>();

let segments = generate_zoom_segments_from_clicks_impl(Vec::new(), jitter_moves, 15.0);
let segments = generate_zoom_segments_from_clicks_impl(
Vec::new(),
jitter_moves,
15.0,
DEFAULT_AUTO_ZOOM_AMOUNT,
);

assert!(
segments.is_empty(),
Expand Down
22 changes: 21 additions & 1 deletion apps/desktop/src/routes/(window-chrome)/settings/general.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import toast from "solid-toast";
import themePreviewAuto from "~/assets/theme-previews/auto.jpg";
import themePreviewDark from "~/assets/theme-previews/dark.jpg";
import themePreviewLight from "~/assets/theme-previews/light.jpg";
import { Input } from "~/routes/editor/ui";
import { Input, Slider } from "~/routes/editor/ui";
import {
authStore,
generalSettingsStore,
Expand Down Expand Up @@ -662,6 +662,26 @@ function Inner(props: {
value={!!settings.autoZoomOnClicks}
onChange={(value) => handleChange("autoZoomOnClicks", value)}
/>
<SettingItem
label="Default zoom amount"
description="Zoom level for newly created and auto-generated zoom segments."
>
<div class="flex gap-2 items-center w-52">
<Slider
class="flex-1"
value={[settings.defaultZoomAmount ?? 1.5]}
onChange={(v) => setSettings("defaultZoomAmount", v[0])}
onChangeEnd={(v) => handleChange("defaultZoomAmount", v[0])}
minValue={1}
maxValue={4.5}
step={0.1}
formatTooltip="x"
/>
<span class="w-9 text-xs text-right text-gray-11 tabular-nums">
{`${(settings.defaultZoomAmount ?? 1.5).toFixed(1)}x`}
</span>
</div>
</SettingItem>
<ToggleSettingItem
label="Capture keyboard presses"
description="Record key presses so you can add keyboard overlays in the editor."
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/routes/editor/CaptionsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -289,13 +289,17 @@ export function CaptionsTab(props: {
recordingSegments,
timeline.transitions ?? [],
sourceRange,
"outgoing",
timeline.textSegments,
);
const end = mapEditedTimeToSource(
timelineSegment.end,
timeline.segments,
recordingSegments,
timeline.transitions ?? [],
sourceRange,
"outgoing",
timeline.textSegments,
);
if (start !== null) source.start = start;
if (end !== null) source.end = end;
Expand Down
Loading
Loading