Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 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
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 +2002 to +2006
Copy link

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 +2008 to +2014
Copy link

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