Skip to content
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
82 commits
Select commit Hold shift + click to select a range
1c0b0cb
feat: add keyboard event types and word grouping algorithm to cap-pro…
cursoragent Feb 18, 2026
86640c2
feat: add keyboard field to MultipleSegment recording metadata
cursoragent Feb 18, 2026
03019a1
feat: add caption/keyboard track segments, keyboard settings, and bac…
cursoragent Feb 18, 2026
73b5d07
feat: record keyboard presses alongside cursor in studio recording
cursoragent Feb 18, 2026
0f6dadb
feat: add keyboard events to RenderSegment, SegmentMedia, and export …
cursoragent Feb 18, 2026
5b121ee
feat: add keyboard overlay rendering layer with fade and character bu…
cursoragent Feb 18, 2026
17e9327
feat: add caption and keyboard track types to editor context and time…
cursoragent Feb 18, 2026
78763c2
feat: add CaptionsTrack and KeyboardTrack timeline components with fu…
cursoragent Feb 18, 2026
c0f843b
feat: add KeyboardTab sidebar, per-segment caption overrides, and key…
cursoragent Feb 18, 2026
5a43fb1
feat: add generate_keyboard_segments Tauri command for keyboard track…
cursoragent Feb 18, 2026
83348f2
chore: format Rust code with cargo fmt
cursoragent Feb 18, 2026
783d887
fix: adjust caption and keyboard segments when clip timescale changes
cursoragent Feb 18, 2026
2388f2d
Merge branch 'main' into cursor/keyboard-and-captions-tracks-8d45
richiemcilroy Feb 18, 2026
b59adc8
fix(recording): update Meta keycode to LMeta
richiemcilroy Feb 19, 2026
5d85c7a
feat(project): add keyboard path fallback resolution for segments
richiemcilroy Feb 19, 2026
6349804
feat(project): add keyboard and caption segment fields to structs
richiemcilroy Feb 19, 2026
9dedbbf
feat(rendering): add recording_time field to ProjectUniforms
richiemcilroy Feb 19, 2026
6f5a42c
refactor(rendering): simplify caption layer to use timeline segments
richiemcilroy Feb 19, 2026
d59897d
refactor(rendering): simplify keyboard layer fade with per-segment ov…
richiemcilroy Feb 19, 2026
13ea9d5
chore: update auto-generated tauri bindings
richiemcilroy Feb 19, 2026
41b04c4
chore: update auto-generated icon imports
richiemcilroy Feb 19, 2026
d27b706
feat(editor): add badge prop to Field component
richiemcilroy Feb 19, 2026
2f78e95
feat(editor): migrate CaptionsTab to timeline-based caption segments
richiemcilroy Feb 19, 2026
72192cb
feat(editor): add keyboard segment generation and redesign settings UI
richiemcilroy Feb 19, 2026
c9cd085
feat(editor): add keyboard segment selection and config panel
richiemcilroy Feb 19, 2026
315cafe
style(editor): update caption track icon and empty state text
richiemcilroy Feb 19, 2026
572802f
style(editor): update keyboard track segment colors to gray
richiemcilroy Feb 19, 2026
b15209b
Merge branch 'main' into cursor/keyboard-and-captions-tracks-8d45
richiemcilroy Mar 23, 2026
0b40e55
chore(biome): extend formatter ignores and relax CSS lint rules
richiemcilroy Mar 25, 2026
f5b55a8
chore(vendor/tao): allow dead_code on macOS-only APIs
richiemcilroy Mar 25, 2026
cebf249
refactor(storybook): type Storybook package path helper as string
richiemcilroy Mar 25, 2026
a1f9b6f
refactor(web-domain): tighten optional schema generic constraints
richiemcilroy Mar 25, 2026
ed219f1
fix(database): guard session token id with optional chaining
richiemcilroy Mar 25, 2026
0cd47c0
chore(ui-solid): declare lucide chevron icon auto-imports
richiemcilroy Mar 25, 2026
0338478
fix(discord-bot): validate GitHub workflow token claims
richiemcilroy Mar 25, 2026
eb4cdc6
chore(media-server): tighten tests and drop unused imports
richiemcilroy Mar 25, 2026
ba966d4
test(web): prefer optional chaining in schema unit tests
richiemcilroy Mar 25, 2026
a4332b8
fix(web): remove documentation search autofocus
richiemcilroy Mar 25, 2026
452e298
style(web): remove unnecessary important from prose overrides
richiemcilroy Mar 25, 2026
858d4c1
refactor(web): harden docs headings and release metadata parsing
richiemcilroy Mar 25, 2026
958ac83
refactor(web): prefix unused transcribe workflow helpers
richiemcilroy Mar 25, 2026
a4021a2
refactor(web): silence unused translated transcript variable
richiemcilroy Mar 25, 2026
31b4459
feat(project): add binary keyboard events and update styling defaults
richiemcilroy Mar 25, 2026
051d785
chore(editor): add caption and keyboard segments to playback benchmar…
richiemcilroy Mar 25, 2026
cebe8ce
fix(enc-avfoundation): satisfy clippy in mp4 encoder tests
richiemcilroy Mar 25, 2026
a7bb64e
refactor(recording): simplify benchmark runner string formatting
richiemcilroy Mar 25, 2026
c0a631c
refactor(recording): use async muxer setup in pipeline tests
richiemcilroy Mar 25, 2026
c769272
fix(recording): ignore duplicate camera feed sender registrations
richiemcilroy Mar 25, 2026
6bb6b50
feat(recording): improve recovery inspection and keyboard capture pip…
richiemcilroy Mar 25, 2026
b3367a2
feat(rendering): stack keyboard overlay around active captions
richiemcilroy Mar 25, 2026
9aa3b80
refactor(rendering): simplify cursor decimation unit test
richiemcilroy Mar 25, 2026
b0ef6db
feat(desktop): extend general settings for hints and keyboard capture
richiemcilroy Mar 25, 2026
ce240b5
feat(desktop): pass transcription hints into whisper initial prompt
richiemcilroy Mar 25, 2026
919cd95
feat(desktop): wire inspect recovery, remux, and keyboard capture toggle
richiemcilroy Mar 25, 2026
fa938a6
fix(desktop): restore camera window safely and widen settings layout
richiemcilroy Mar 25, 2026
aeffebc
feat(desktop): add shared general settings helpers
richiemcilroy Mar 25, 2026
37de84b
test(desktop): cover transcription hint normalization
richiemcilroy Mar 25, 2026
aaf75b7
refactor(desktop): read general settings types from shared helper
richiemcilroy Mar 25, 2026
08ffa66
feat(desktop): add editor caption utilities and text style controls
richiemcilroy Mar 25, 2026
4e5ffd4
feat(desktop): add transcription settings page and route
richiemcilroy Mar 25, 2026
b708c68
feat(desktop): surface studio recording toggles in general settings
richiemcilroy Mar 25, 2026
fb97d3e
refactor(desktop): tighten settings typing and external actions
richiemcilroy Mar 25, 2026
ad5d36d
refactor(desktop): harden window chrome and client mount guards
richiemcilroy Mar 25, 2026
d3e9107
refactor(desktop): tighten shared utility typings
richiemcilroy Mar 25, 2026
e5972ce
refactor(desktop): remove unsafe assertions in chrome and overlay routes
richiemcilroy Mar 25, 2026
d543352
feat(desktop): add captions and keyboard data to editor timeline
richiemcilroy Mar 25, 2026
403f39c
feat(desktop): expand captions and keyboard editor side panels
richiemcilroy Mar 25, 2026
40a71bc
feat(desktop): integrate new tracks into editor shell and playback
richiemcilroy Mar 25, 2026
472ac37
bits
richiemcilroy Mar 25, 2026
0e0d11e
refactor(desktop): generic composeEventHandlers for keyboard inputs
richiemcilroy Mar 25, 2026
251d421
refactor(desktop): simplify Tauri event listener payload typing
richiemcilroy Mar 25, 2026
a12c215
fix(editor): read hovered mask time once when previewing segments
richiemcilroy Mar 25, 2026
79e1948
fix(editor): preserve section markers with a single adjacent boundary
richiemcilroy Mar 25, 2026
f9d7150
fix(editor): initialize keyboard track when importing captions
richiemcilroy Mar 25, 2026
154a18e
refactor(editor): narrow ComingSoonTooltip prop types
richiemcilroy Mar 25, 2026
804b79d
fix(web): skip doc headings when regex captures are missing
richiemcilroy Mar 25, 2026
10df2da
fix(recording): label space key for keyboard event capture
richiemcilroy Mar 25, 2026
405c3e4
fix(recording): build keyboard recovery paths without unwrap
richiemcilroy Mar 25, 2026
b6c1de1
fix(project): skip empty keyboard segments on group flush
richiemcilroy Mar 25, 2026
3cf1eff
test(project): cover backspace-to-empty keyboard segment regression
richiemcilroy Mar 25, 2026
4d90f02
fix(editor): generate unique IDs for split keyboard and caption segments
richiemcilroy Mar 25, 2026
f515eae
fix(editor): defer caption track enable until generation succeeds
richiemcilroy Mar 25, 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
1 change: 1 addition & 0 deletions apps/desktop/src-tauri/src/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@ pub async fn generate_export_preview(
.iter()
.map(|s| RenderSegment {
cursor: s.cursor.clone(),
keyboard: s.keyboard.clone(),
decoders: s.decoders.clone(),
})
.collect();
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src-tauri/src/import.rs
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,7 @@ pub async fn start_video_import(app: AppHandle, source_path: PathBuf) -> Result<
mic: None,
system_audio: None,
cursor: None,
keyboard: None,
}],
cursors: Cursors::default(),
status: Some(StudioRecordingStatus::InProgress),
Expand Down Expand Up @@ -599,6 +600,7 @@ pub async fn start_video_import(app: AppHandle, source_path: PathBuf) -> Result<
mic: None,
system_audio,
cursor: None,
keyboard: None,
}],
cursors: Cursors::default(),
status: Some(StudioRecordingStatus::Complete),
Expand Down
46 changes: 46 additions & 0 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1971,6 +1971,51 @@ async fn generate_zoom_segments_from_clicks(
Ok(zoom_segments)
}

#[tauri::command]
#[specta::specta]
#[instrument(skip(editor_instance))]
async fn generate_keyboard_segments(
editor_instance: WindowEditorInstance,
grouping_threshold_ms: f64,
linger_duration_ms: f64,
show_modifiers: bool,
show_special_keys: bool,
) -> Result<Vec<cap_project::KeyboardTrackSegment>, String> {
let meta = editor_instance.meta();

let RecordingMetaInner::Studio(studio_meta) = &meta.inner else {
return Ok(vec![]);
};

let segments = match studio_meta.as_ref() {
StudioRecordingMeta::MultipleSegments { inner, .. } => &inner.segments,
_ => return Ok(vec![]),
};

let mut all_events = cap_project::KeyboardEvents { presses: vec![] };

for segment in segments {
let events = segment.keyboard_events(&meta);
all_events.presses.extend(events.presses);
}

all_events.presses.sort_by(|a, b| {
a.time_ms
.partial_cmp(&b.time_ms)
.unwrap_or(std::cmp::Ordering::Equal)
});
Comment on lines +2154 to +2158
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

partial_cmp + unwrap_or(Equal) can hide NaNs and makes ordering less explicit. Since this is f64, total_cmp is a nice drop-in here.

Suggested change
all_events.presses.sort_by(|a, b| {
a.time_ms
.partial_cmp(&b.time_ms)
.unwrap_or(std::cmp::Ordering::Equal)
});
all_events
.presses
.sort_by(|a, b| a.time_ms.total_cmp(&b.time_ms));


let grouped = cap_project::group_key_events(
&all_events,
grouping_threshold_ms,
linger_duration_ms,
show_modifiers,
show_special_keys,
);
Comment on lines +2160 to +2166
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Minor robustness: if these come from the UI as floats, clamping to non-negative avoids end < start segments when values go negative.

Suggested change
let grouped = cap_project::group_key_events(
&all_events,
grouping_threshold_ms,
linger_duration_ms,
show_modifiers,
show_special_keys,
);
let grouping_threshold_ms = grouping_threshold_ms.max(0.0);
let linger_duration_ms = linger_duration_ms.max(0.0);
let grouped = cap_project::group_key_events(
&all_events,
grouping_threshold_ms,
linger_duration_ms,
show_modifiers,
show_special_keys,
);


Ok(grouped)
}

#[tauri::command]
#[specta::specta]
#[instrument]
Expand Down Expand Up @@ -2960,6 +3005,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) {
set_project_config,
update_project_config_in_memory,
generate_zoom_segments_from_clicks,
generate_keyboard_segments,
permissions::open_permission_settings,
permissions::do_permissions_check,
permissions::request_permission,
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src-tauri/src/recording.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2370,6 +2370,8 @@ fn project_config_from_recording(
scene_segments: Vec::new(),
mask_segments: Vec::new(),
text_segments: Vec::new(),
caption_segments: Vec::new(),
keyboard_segments: Vec::new(),
});

config
Expand Down
272 changes: 124 additions & 148 deletions apps/desktop/src/routes/editor/CaptionsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,31 @@ export function CaptionsTab() {
if (result && result.segments.length > 0) {
setProject("captions", "segments", result.segments);
updateCaptionSetting("enabled", true);

const trackSegments = result.segments.map(
(seg: {
id: string;
start: number;
end: number;
text: string;
words?: Array<{ text: string; start: number; end: number }>;
}) => ({
id: seg.id,
start: seg.start,
end: seg.end,
text: seg.text,
words: seg.words ?? [],
fadeDurationOverride: null,
lingerDurationOverride: null,
positionOverride: null,
colorOverride: null,
backgroundColorOverride: null,
fontSizeOverride: null,
}),
);
setProject("timeline", "captionSegments", trackSegments);
setEditorState("timeline", "tracks", "caption", true);

toast.success("Captions generated successfully!");
} else {
toast.error(
Expand Down Expand Up @@ -395,52 +420,14 @@ export function CaptionsTab() {
}
};

const deleteSegment = (id: string) => {
if (!project?.captions?.segments) return;

setProject(
"captions",
"segments",
project.captions.segments.filter((segment) => segment.id !== id),
);
};

const updateSegment = (
id: string,
updates: Partial<{ start: number; end: number; text: string }>,
) => {
if (!project?.captions?.segments) return;

setProject(
"captions",
"segments",
project.captions.segments.map((segment) =>
segment.id === id ? { ...segment, ...updates } : segment,
),
);
};

const addSegment = (time: number) => {
if (!project?.captions) return;

const id = `segment-${Date.now()}`;
setProject("captions", "segments", [
...project.captions.segments,
{
id,
start: time,
end: time + 2,
text: "New caption",
},
]);
};

const hasCaptions = createMemo(
() => (project.captions?.segments?.length ?? 0) > 0,
() =>
(project.timeline?.captionSegments?.length ?? 0) > 0 ||
(project.captions?.segments?.length ?? 0) > 0,
);

return (
<Field name="Captions" icon={<IconCapMessageBubble />}>
<Field name="Captions" icon={<IconCapMessageBubble />} badge="Beta">
<div class="flex flex-col gap-4">
<div class="space-y-6 transition-all duration-200">
<div class="space-y-4">
Expand Down Expand Up @@ -890,117 +877,106 @@ export function CaptionsTab() {
</Field>
</div>

<Show when={hasCaptions()}>
<Field name="Caption Segments" icon={<IconCapMessageBubble />}>
<div class="space-y-4">
<div class="flex items-center justify-between">
<Button
onClick={() => addSegment(editorState.playbackTime)}
class="w-full"
>
Add at Current Time
</Button>
</div>

<div class="max-h-[300px] overflow-y-auto space-y-3 pr-2">
<For each={project.captions?.segments}>
{(segment) => (
<div class="bg-gray-2 border border-gray-3 rounded-lg p-4 space-y-4">
<div class="flex flex-col space-y-4">
<div class="flex space-x-4">
<div class="flex-1">
<label class="text-xs text-gray-11">
Start Time
</label>
<Input
type="number"
class="w-full"
value={segment.start.toFixed(1)}
step="0.1"
min={0}
onChange={(e) =>
updateSegment(segment.id, {
start: parseFloat(e.target.value),
})
}
/>
</div>
<div class="flex-1">
<label class="text-xs text-gray-11">
End Time
</label>
<Input
type="number"
class="w-full"
value={segment.end.toFixed(1)}
step="0.1"
min={segment.start}
onChange={(e) =>
updateSegment(segment.id, {
end: parseFloat(e.target.value),
})
}
/>
</div>
</div>

<div class="space-y-2">
<label class="text-xs text-gray-11">
Caption Text
</label>
<div class="w-full px-3 py-2 bg-gray-2 border border-gray-3 rounded-lg text-sm focus-within:border-blue-9 focus-within:ring-1 focus-within:ring-blue-9 transition-colors">
<textarea
class="w-full resize-none outline-none bg-transparent text-[--text-primary]"
value={segment.text}
rows={2}
onChange={(e) =>
updateSegment(segment.id, {
text: e.target.value,
})
}
/>
</div>
</div>

<div class="flex justify-end">
<Button
variant="destructive"
size="sm"
onClick={() => deleteSegment(segment.id)}
class="text-gray-11 inline-flex items-center gap-1.5"
>
<IconDelete />
Delete
</Button>
</div>
</div>
<Show
when={
editorState.timeline.selection?.type === "caption" &&
editorState.timeline.selection.indices.length === 1
}
>
{(() => {
const selectedIndex = () =>
editorState.timeline.selection?.type === "caption"
? editorState.timeline.selection.indices[0]
: -1;
const selectedSegment = () =>
project.timeline?.captionSegments?.[selectedIndex()];

return (
<Field
name="Selected Caption Override"
icon={<IconCapMessageBubble />}
>
<Show when={selectedSegment()}>
{(seg) => (
<div class="space-y-3">
<Subfield name="Start Time">
<Input
type="number"
value={seg().start.toFixed(2)}
step="0.1"
min={0}
onChange={(e) =>
setProject(
"timeline",
"captionSegments",
selectedIndex(),
"start",
Number.parseFloat(e.target.value),
)
}
/>
</Subfield>
<Subfield name="End Time">
<Input
type="number"
value={seg().end.toFixed(2)}
step="0.1"
min={seg().start}
onChange={(e) =>
setProject(
"timeline",
"captionSegments",
selectedIndex(),
"end",
Number.parseFloat(e.target.value),
)
}
/>
</Subfield>
<Subfield name="Caption Text">
<Input
type="text"
value={seg().text}
onChange={(e) =>
setProject(
"timeline",
"captionSegments",
selectedIndex(),
"text",
e.target.value,
)
}
/>
</Subfield>
<Subfield name="Fade Duration Override">
<Slider
value={[
(seg().fadeDurationOverride ??
getSetting("fadeDuration")) * 100,
]}
onChange={(v) =>
setProject(
"timeline",
"captionSegments",
selectedIndex(),
"fadeDurationOverride",
v[0] / 100,
)
}
minValue={0}
maxValue={50}
step={1}
/>
</Subfield>
</div>
)}
</For>
</div>
</div>
</Field>
</Show>
</Field>
);
})()}
</Show>
</div>
</div>
</Field>
);
}

function IconDelete() {
return (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="size-4"
>
<path
d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"
fill="currentColor"
/>
</svg>
);
}
Loading
Loading