diff --git a/apps/desktop/src-tauri/src/general_settings.rs b/apps/desktop/src-tauri/src/general_settings.rs index 8ea2ce2fd9..ac95fae024 100644 --- a/apps/desktop/src-tauri/src/general_settings.rs +++ b/apps/desktop/src-tauri/src/general_settings.rs @@ -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, /// `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. @@ -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, diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 9c58541d52..ce0094df8c 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -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, 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) } @@ -3451,6 +3459,15 @@ async fn list_audio_devices() -> Result, ()> { Ok(MicrophoneFeed::list_names()) } +#[tauri::command] +#[specta::specta] +#[instrument] +async fn list_system_fonts() -> Vec { + tokio::task::spawn_blocking(cap_rendering::system_font_families) + .await + .unwrap_or_default() +} + #[derive(Serialize, Type, Debug, Clone)] pub struct UploadProgress { progress: f64, @@ -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, @@ -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, @@ -5112,7 +5093,48 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { .typ::() .typ::() .typ::() - .typ::(); + .typ::() +} + +#[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)] { @@ -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"); + } + } +} diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 31c97af352..48cdd475c7 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -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, _moves: Vec, max_duration: f64, + zoom_amount: f64, ) -> Vec { const MS_PER_SECOND: f64 = 1000.0; const START_MIN_MS: f64 = 1.0; @@ -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(); @@ -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, @@ -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 { // Build a temporary RecordingMeta so we can use the common implementation let recording_meta = RecordingMeta { @@ -3795,7 +3798,7 @@ 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. @@ -3803,6 +3806,7 @@ pub fn generate_zoom_segments_from_clicks( pub fn generate_zoom_segments_for_project( recording_meta: &RecordingMeta, recordings: &ProjectRecordingsMeta, + zoom_amount: f64, ) -> Vec { let RecordingMetaInner::Studio(studio_meta) = &recording_meta.inner else { return Vec::new(); @@ -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( @@ -3898,7 +3907,13 @@ fn project_config_from_recording( .collect::>(); 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() }; @@ -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(), @@ -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(), @@ -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); @@ -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); @@ -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); @@ -4358,7 +4385,12 @@ mod tests { }) .collect::>(); - 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(), diff --git a/apps/desktop/src/routes/(window-chrome)/settings/general.tsx b/apps/desktop/src/routes/(window-chrome)/settings/general.tsx index 46e7b20525..a0f6636579 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/general.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/general.tsx @@ -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, @@ -662,6 +662,26 @@ function Inner(props: { value={!!settings.autoZoomOnClicks} onChange={(value) => handleChange("autoZoomOnClicks", value)} /> + +
+ setSettings("defaultZoomAmount", v[0])} + onChangeEnd={(v) => handleChange("defaultZoomAmount", v[0])} + minValue={1} + maxValue={4.5} + step={0.1} + formatTooltip="x" + /> + + {`${(settings.defaultZoomAmount ?? 1.5).toFixed(1)}x`} + +
+
{ + const selection = editorState.timeline.selection; + return selection && selection.type !== "clip" ? selection : null; + }; + let scrollRef!: HTMLDivElement; return ( { // Clear any active selection first - if (editorState.timeline.selection) { + if (sidebarSelection()) { setEditorState("timeline", "selection", null); } if (editorState.timeline.audioPicker !== null) { @@ -639,7 +666,7 @@ export function ConfigSidebar() { {/** Center the indicator with the icon */} )} + @@ -1081,7 +1109,7 @@ export function ConfigSidebar() { @@ -1496,68 +1524,89 @@ export function ConfigSidebar() { return { selection: zoomSelection, segments }; })()} > - {(value) => ( -
-
-
+ {(value) => { + const totalZoomSegments = () => + project.timeline?.zoomSegments?.length ?? 0; + + // The sidebar header is narrow, so the count stays terse and + // "Select all" is an inline text action rather than a third + // full button, which would wrap. + const selectionLabel = () => { + const count = value().segments.length; + const total = totalZoomSegments(); + if (total > 1 && count === total) + return `All ${total} selected`; + if (total > 1) return `${count} of ${total} selected`; + return `${count} selected`; + }; + + return ( +
+
+
+ + setEditorState("timeline", "selection", null) + } + leftIcon={} + > + Done + + + {selectionLabel()} + + + + +
- setEditorState("timeline", "selection", null) - } - leftIcon={} + variant="danger" + onClick={() => { + projectActions.deleteZoomSegments( + value().segments.map((s) => s.index), + ); + }} + leftIcon={} > - Done + Delete - - {value().segments.length} zoom{" "} - {value().segments.length === 1 - ? "segment" - : "segments"}{" "} - selected -
- { - projectActions.deleteZoomSegments( - value().segments.map((s) => s.index), - ); - }} - leftIcon={} + + } > - Delete - + + {(item) => ( +
+ +
+ )} +
+
- - - {(item, index) => ( -
- -
- )} -
-
- } - > - - {(item) => ( -
- -
- )} -
- -
- )} + ); + }} { @@ -1705,79 +1754,6 @@ export function ConfigSidebar() { )} - { - const clipSelection = selection(); - if (clipSelection.type !== "clip") return; - - const segments = clipSelection.indices - .map((idx) => ({ - segment: project.timeline?.segments?.[idx], - index: idx, - })) - .filter( - (s): s is { segment: TimelineSegment; index: number } => - s.segment !== undefined, - ); - - if (segments.length === 0) return; - return { selection: clipSelection, segments }; - })()} - > - {(value) => ( - - {(firstSegment) => ( - 1} - fallback={ - - } - > -
-
-
- - setEditorState("timeline", "selection", null) - } - leftIcon={} - > - Done - - - {value().segments.length} clip{" "} - {value().segments.length === 1 - ? "segment" - : "segments"}{" "} - selected - -
- { - const indices = value().selection.indices; - - // Delete segments in reverse order to maintain indices - [...indices] - .sort((a, b) => b - a) - .forEach((idx) => { - projectActions.deleteClipSegment(idx); - }); - }} - leftIcon={} - > - Delete - -
-
-
- )} -
- )} -
)} @@ -3488,12 +3464,161 @@ function HexColorInput(props: { ); } +function TextStyleSelect(props: { + options: { label: string; value: T }[]; + value: T; + onChange: (value: T) => void; + fallbackLabel?: (value: T) => string; +}) { + const selected = () => + props.options.find((option) => option.value === props.value) ?? { + label: props.fallbackLabel?.(props.value) ?? String(props.value), + value: props.value, + }; + + return ( + { + if (option) props.onChange(option.value); + }} + itemComponent={(selectItemProps) => ( + + as={KSelect.Item} + item={selectItemProps.item} + > + + {selectItemProps.item.rawValue.label} + + + + + + )} + > + + class="truncate"> + {(state) => state.selectedOption()?.label ?? selected().label} + + + + + + + + as={KSelect.Content} + class={cx(topSlideAnimateClasses, "z-50")} + > + + class="overflow-y-auto max-h-52" + as={KSelect.Listbox} + /> + + + + ); +} + +const TEXT_PRESET_HOVER_FX: Record = { + fade: "group-hover:opacity-70", + slideUp: "group-hover:-translate-y-1", + slideDown: "group-hover:translate-y-1", + pop: "group-hover:scale-110", + typewriter: "", + none: "", +}; + +function TextPresetCard(props: { + preset: TextPreset; + active: boolean; + onApply: () => void; +}) { + const style = () => props.preset.style; + const stackCss = () => + style() + .fontStack.map((family) => + ["sans-serif", "serif", "monospace"].includes(family) + ? family + : `"${family}"`, + ) + .join(", "); + + return ( + + ); +} + +// The renderer also supports splitLeft/splitRight takeovers; only these two +// are exposed for now. +const TEXT_LAYOUT_OPTIONS: { + value: TextLayout; + label: string; + icon: ValidComponent; +}[] = [ + { value: "overlay", label: "Overlay", icon: IconLucideBoxSelect }, + { value: "fullscreen", label: "Fullscreen", icon: IconLucideMaximize }, +]; + +const TEXT_LAYOUT_CENTERS: Partial< + Record +> = { + fullscreen: { x: 0.5, y: 0.5 }, +}; + +const TEXT_ALIGN_OPTIONS: { value: TextAlign; icon: ValidComponent }[] = [ + { value: "left", icon: IconLucideAlignLeft }, + { value: "center", icon: IconLucideAlignCenter }, + { value: "right", icon: IconLucideAlignRight }, +]; + function TextSegmentConfig(props: { segmentIndex: number; segment: TextSegment; brandColorSwatches: OrganizationBrandColorSwatch[]; }) { const { setProject } = useEditorContext(); + const [installedFonts] = createResource(listSystemFonts, { + initialValue: [], + }); const clampNumber = (value: number, min: number, max: number) => Math.min(Math.max(Number.isFinite(value) ? value : min, min), max); @@ -3509,6 +3634,24 @@ function TextSegmentConfig(props: { ); }; + const activePresetId = createMemo(() => + matchTextPreset(props.segment, installedFonts()), + ); + + const setAnimationDuration = ( + key: "animationInDuration" | "animationOutDuration", + value: number, + ) => + updateSegment((segment) => { + segment[key] = clampNumber(value, 0, 3); + // Old builds only know fadeDuration; keep it tracking the slower + // edge so a project opened there still fades sensibly. + segment.fadeDuration = Math.max( + segment.animationInDuration ?? 0.15, + segment.animationOutDuration ?? 0.15, + ); + }); + return (
- }> - - updateSegment((segment) => { - const newFontSize = clampNumber( - value, - TEXT_FONT_SIZE_MIN, - TEXT_FONT_SIZE_MAX, - ); - const oldFontSize = segment.fontSize || 48; - const scale = newFontSize / oldFontSize; - - segment.fontSize = newFontSize; - - // Scale the box with the font so line wrapping is - // preserved; keep the top edge fixed since the renderer - // anchors text at the top of the box (the canvas overlay - // re-hugs the box to the exact glyph bounds when visible). - if (segment.size && segment.center) { - const topEdge = segment.center.y - segment.size.y / 2; - segment.size.x = Math.min(segment.size.x * scale, 1); - segment.size.y = segment.size.y * scale; - segment.center.y = topEdge + segment.size.y / 2; - } - }) - } - minValue={TEXT_FONT_SIZE_MIN} - maxValue={TEXT_FONT_SIZE_MAX} - step={1} - /> + }> +
+
+ + {(option) => ( + + )} + +
+ +

+ Pauses the video while the text is shown, then resumes where it + left off. +

+
+ +
+ Screen transition + + updateSegment((segment) => { + segment.layoutTransition = clampNumber(value, 0.1, 1.5); + }) + } + minValue={0.1} + maxValue={1.5} + step={0.05} + formatTooltip="s" + /> +
+
+
+
+ }> +
+ + {(preset) => ( + + updateSegment((segment) => + applyTextPreset(segment, preset, installedFonts()), + ) + } + /> + )} + +
- }> + }>
- { - if (!value) return; + updateSegment((segment) => { - segment.fontWeight = value.value; - }); - }} - itemComponent={(selectItemProps) => ( - - as={KSelect.Item} - item={selectItemProps.item} - > - - {selectItemProps.item.rawValue.label} - - - - - - )} - > - - class="truncate"> - {(state) => { - const selected = state.selectedOption(); - if (selected) return selected.label; - const weight = props.segment.fontWeight; - const option = [ - { label: "Normal", value: 400 }, - { label: "Medium", value: 500 }, - { label: "Bold", value: 700 }, - ].find((o) => o.value === weight); - if (option) return option.label; - if (weight != null) return `Custom (${weight})`; - return "Normal"; - }} - - - - - - - - as={KSelect.Content} - class={cx(topSlideAnimateClasses, "z-50")} - > - - class="overflow-y-auto max-h-40" - as={KSelect.Listbox} - /> - - - - -
- Italic - + segment.fontFamily = family; + }) + } + /> +
+
+ + updateSegment((segment) => { + segment.fontWeight = value; + }) + } + fallbackLabel={(value) => `Custom (${value})`} + /> +
+ +
+
+ Size + updateSegment((segment) => { - segment.italic = value; + const newFontSize = clampNumber( + value, + TEXT_FONT_SIZE_MIN, + TEXT_FONT_SIZE_MAX, + ); + const oldFontSize = segment.fontSize || 48; + const scale = newFontSize / oldFontSize; + + segment.fontSize = newFontSize; + + // Scale the box with the font so line wrapping is + // preserved; keep the top edge fixed since the renderer + // anchors text at the top of the box (the canvas overlay + // re-hugs the box to the exact glyph bounds when visible). + if (segment.size && segment.center) { + const topEdge = segment.center.y - segment.size.y / 2; + segment.size.x = Math.min(segment.size.x * scale, 1); + segment.size.y = segment.size.y * scale; + segment.center.y = topEdge + segment.size.y / 2; + } }) } + minValue={TEXT_FONT_SIZE_MIN} + maxValue={TEXT_FONT_SIZE_MAX} + step={1} />
- }> - - updateSegment((segment) => { - segment.color = value; - }) - } - /> + }> +
+
+ + {(option) => ( + + )} + +
+
+ Line height + + updateSegment((segment) => { + segment.lineHeight = clampNumber(value, 0.8, 2); + }) + } + minValue={0.8} + maxValue={2} + step={0.05} + /> +
+
+ Letter spacing + + updateSegment((segment) => { + segment.letterSpacing = clampNumber(value, -2, 20); + }) + } + minValue={-2} + maxValue={20} + step={0.5} + formatTooltip="px" + /> +
+
- }> - - updateSegment((segment) => { - segment.fadeDuration = clampNumber(value, 0, 1); - }) - } - minValue={0} - maxValue={1} - step={0.01} - formatTooltip="s" - /> + }> +
+ + updateSegment((segment) => { + segment.color = value; + }) + } + /> +
+ Opacity + + updateSegment((segment) => { + segment.opacity = clampNumber(value, 0, 1); + }) + } + minValue={0} + maxValue={1} + step={0.01} + /> +
+
+ Shadow + + updateSegment((segment) => { + segment.shadow = clampNumber(value, 0, 1); + }) + } + minValue={0} + maxValue={1} + step={0.01} + /> +
+
+
+ }> +
+
+ In + + updateSegment((segment) => { + segment.animationIn = value; + }) + } + /> + + + setAnimationDuration("animationInDuration", value) + } + minValue={0} + maxValue={3} + step={0.05} + formatTooltip="s" + /> + +
+
+ Out + + updateSegment((segment) => { + segment.animationOut = value; + }) + } + /> + + + setAnimationDuration("animationOutDuration", value) + } + minValue={0} + maxValue={3} + step={0.05} + formatTooltip="s" + /> + +
+
); @@ -5116,32 +5429,57 @@ function Camera3DSegmentConfig(props: { ); } +// Maps a zoom segment's start (held-output time on the edited timeline) to +// the recording-segment file and the time within it whose frame is on screen +// at that moment. Split/trimmed timelines mean neither can be read off the +// zoom segment directly. +function zoomPreviewSource( + timeline: + | { + segments: TimelineSegment[]; + transitions?: ClipTransition[]; + textSegments?: TextSegment[]; + } + | null + | undefined, + editedTime: number, +): { recordingSegment: number; sourceTime: number } { + const gapless = + editedTime - + heldTimeBefore(holdWindows(timeline?.textSegments), editedTime); + return ( + clipSourceTimeAt( + timeline?.segments ?? [], + timeline?.transitions ?? [], + gapless, + ) ?? { recordingSegment: 0, sourceTime: gapless } + ); +} + +// The mapping memos return fresh objects; compare by value so unrelated +// timeline edits don't restart the preview