Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
71 changes: 71 additions & 0 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2030,6 +2030,76 @@ async fn generate_zoom_segments_from_clicks(
Ok(zoom_segments)
}

fn load_keyboard_events_from_display_path(
display_path: &relative_path::RelativePathBuf,
meta: &cap_project::RecordingMeta,
) -> cap_project::keyboard::KeyboardEvents {
let Some(dir) = display_path.parent() else {
return Default::default();
};
let kb_path = dir.join("keyboard.json");
let full = meta.path(&kb_path);
if full.exists() {
cap_project::keyboard::KeyboardEvents::load_from_file(&full).unwrap_or_default()
} else {
Default::default()
}
}

#[tauri::command]
#[specta::specta]
#[instrument(skip(editor_instance))]
async fn generate_keyboard_segments(
editor_instance: WindowEditorInstance,
grouping_threshold_ms: f64,
display_duration_ms: f64,
show_modifiers: bool,
show_special_keys: bool,
) -> Result<Vec<cap_project::keyboard::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,
StudioRecordingMeta::SingleSegment { segment } => {
let events = load_keyboard_events_from_display_path(&segment.display.path, meta);
return Ok(cap_project::keyboard::group_key_events(
&events,
grouping_threshold_ms,
display_duration_ms,
show_modifiers,
show_special_keys,
));
}
};

let mut all_events = cap_project::keyboard::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)
});

let grouped = cap_project::keyboard::group_key_events(
&all_events,
grouping_threshold_ms,
display_duration_ms,
show_modifiers,
show_special_keys,
);

Ok(grouped)
}

#[tauri::command]
#[specta::specta]
#[instrument]
Expand Down Expand Up @@ -3019,6 +3089,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
1 change: 1 addition & 0 deletions apps/desktop/src-tauri/src/recording.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2370,6 +2370,7 @@ fn project_config_from_recording(
scene_segments: Vec::new(),
mask_segments: Vec::new(),
text_segments: Vec::new(),
keyboard_segments: Vec::new(),
});

config
Expand Down
181 changes: 179 additions & 2 deletions apps/desktop/src/routes/editor/ConfigSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,12 @@
import IconLucideType from "~icons/lucide/type";
import IconLucideWind from "~icons/lucide/wind";
import { CaptionsTab } from "./CaptionsTab";
import { type CornerRoundingType, useEditorContext } from "./context";
import {
type CornerRoundingType,
type KeyboardTrackSegment,
useEditorContext,
} from "./context";
import { KeyboardTab } from "./KeyboardTab";
import { evaluateMask, type MaskKind, type MaskSegment } from "./masks";
import {
DEFAULT_GRADIENT_FROM,
Expand All @@ -79,6 +84,7 @@
ComingSoonTooltip,
EditorButton,
Field,
Input,
MenuItem,
MenuItemList,
PopperContent,
Expand Down Expand Up @@ -370,7 +376,8 @@
| "audio"
| "cursor"
| "hotkeys"
| "captions",
| "captions"
| "keyboard",
});

let scrollRef!: HTMLDivElement;
Expand Down Expand Up @@ -403,6 +410,10 @@
id: "captions" as const,
icon: IconCapMessageBubble,
},
{
id: "keyboard" as const,
icon: IconLucideKeyboard,
},
// { id: "hotkeys" as const, icon: IconCapHotkeys },
].filter(Boolean)}
>
Expand Down Expand Up @@ -819,6 +830,12 @@
>
<CaptionsTab />
</KTabs.Content>
<KTabs.Content
value="keyboard"
class="flex flex-col flex-1 gap-6 p-4 min-h-0"
>
<KeyboardTab />
</KTabs.Content>
</div>
<div
style={{
Expand Down Expand Up @@ -1185,6 +1202,75 @@
</Show>
)}
</Show>
<Show
when={(() => {
const kbSelection = selection();
if (kbSelection.type !== "keyboard") return;

const segments = kbSelection.indices
.map((idx) => ({
segment: project.timeline?.keyboardSegments?.[idx],
index: idx,
}))
.filter(
(
s,
): s is {
index: number;
segment: KeyboardTrackSegment;
} => s.segment !== undefined,
);

if (segments.length === 0) {
setEditorState("timeline", "selection", null);
return;
}
return { selection: kbSelection, segments };
})()}
>
{(value) => (
<div class="space-y-4">
<div class="flex flex-row justify-between items-center">
<div class="flex gap-2 items-center">
<EditorButton
onClick={() =>
setEditorState("timeline", "selection", null)
}
leftIcon={<IconLucideCheck />}
>
Done
</EditorButton>
<span class="text-sm text-gray-10">
{value().segments.length} keyboard{" "}
{value().segments.length === 1
? "segment"
: "segments"}{" "}
selected
</span>
</div>
<EditorButton
variant="danger"
onClick={() =>
projectActions.deleteKeyboardSegments(
value().segments.map((s) => s.index),
)
}
leftIcon={<IconCapTrash />}
>
Delete
</EditorButton>
</div>
<For each={value().segments}>
{(item) => (
<KeyboardSegmentConfig
segment={item.segment}
segmentIndex={item.index}
/>
)}
</For>
</div>
)}
</Show>
</Suspense>
)}
</Show>
Expand Down Expand Up @@ -3345,7 +3431,7 @@

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

Check failure on line 3434 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 Expand Up @@ -3729,4 +3815,95 @@
return [...rgb, 255];
}

function KeyboardSegmentConfig(props: {
segment: KeyboardTrackSegment;
segmentIndex: number;
}) {
const { project, setProject } = useEditorContext();

const getFadeDuration = () => {
const settings = project?.keyboard?.settings;
return settings?.fadeDurationSecs ?? 0.15;
};

return (
<div class="p-4 rounded-lg border border-gray-200 space-y-3">
<Subfield name="Display Text">
<Input
type="text"
value={props.segment.displayText}
onChange={(e) =>
setProject(
"timeline",
"keyboardSegments",
props.segmentIndex,
"displayText",
e.target.value,
)
}
/>
</Subfield>
<Subfield name="Start Time">
<Input
type="number"
value={props.segment.start.toFixed(2)}
step="0.1"
min={0}
onChange={(e) =>
setProject(
"timeline",
"keyboardSegments",
props.segmentIndex,
"start",
Number.parseFloat(e.target.value),
)
}
/>
</Subfield>
<Subfield name="End Time">
<Input
type="number"
value={props.segment.end.toFixed(2)}
step="0.1"
min={props.segment.start}
onChange={(e) =>
setProject(
"timeline",
"keyboardSegments",
props.segmentIndex,
"end",
Number.parseFloat(e.target.value),
)
}
/>
</Subfield>
<Subfield name="Fade Duration">
<Slider
value={[
(props.segment.fadeDurationOverride ?? getFadeDuration()) * 100,
]}
onChange={(v) =>
setProject(
"timeline",
"keyboardSegments",
props.segmentIndex,
"fadeDurationOverride",
v[0] / 100,
)
}
minValue={0}
maxValue={50}
step={1}
/>
<span class="text-xs text-gray-11 text-right">
{(
(props.segment.fadeDurationOverride ?? getFadeDuration()) * 1000
).toFixed(0)}
ms
</span>
</Subfield>
</div>
);
}

const CHECKERED_BUTTON_BACKGROUND = `url("data:image/svg+xml,%3Csvg width='16' height='16' xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='8' height='8' fill='%23a0a0a0'/%3E%3Crect x='8' y='8' width='8' height='8' fill='%23a0a0a0'/%3E%3C/svg%3E")`;
Loading
Loading