From e9c4e5690f34c1bc4f112b894c0210564ab2f8ca Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 22 Aug 2025 12:01:20 +0100 Subject: [PATCH 1/6] fix: Improve layout transitions --- crates/rendering/src/layout.rs | 137 ++++++++++++++----- packages/ui-solid/src/auto-imports.d.ts | 173 ++++++++++++------------ 2 files changed, 192 insertions(+), 118 deletions(-) diff --git a/crates/rendering/src/layout.rs b/crates/rendering/src/layout.rs index 03e6f1bfd5..0ce0e77691 100644 --- a/crates/rendering/src/layout.rs +++ b/crates/rendering/src/layout.rs @@ -1,6 +1,7 @@ use cap_project::{LayoutMode, LayoutSegment}; pub const LAYOUT_TRANSITION_DURATION: f64 = 0.3; +pub const MIN_GAP_FOR_TRANSITION: f64 = 0.5; #[derive(Debug, Clone, Copy)] pub struct LayoutSegmentsCursor<'a> { @@ -63,6 +64,23 @@ pub struct InterpolatedLayout { } impl InterpolatedLayout { + fn from_single_mode(mode: LayoutMode) -> Self { + let (camera_opacity, screen_opacity, camera_scale) = Self::get_layout_values(&mode); + + InterpolatedLayout { + camera_opacity, + screen_opacity, + camera_scale, + layout_mode: mode.clone(), + transition_progress: 1.0, + from_mode: mode.clone(), + to_mode: mode, + screen_blur: 0.0, + camera_only_zoom: 1.0, + camera_only_blur: 0.0, + } + } + pub fn new(cursor: LayoutSegmentsCursor) -> Self { let ease_in_out = bezier_easing::bezier_easing(0.42, 0.0, 0.58, 1.0).unwrap(); @@ -71,10 +89,26 @@ impl InterpolatedLayout { let transition_end = segment.end - LAYOUT_TRANSITION_DURATION; if cursor.time < segment.start && cursor.time >= transition_start { - let prev_mode = cursor - .prev_segment - .map(|s| s.mode.clone()) - .unwrap_or(LayoutMode::Default); + // Check if we should skip transition for small gaps + let prev_mode = if let Some(prev_seg) = cursor.prev_segment { + let gap = segment.start - prev_seg.end; + let same_mode = matches!( + (&prev_seg.mode, &segment.mode), + (LayoutMode::CameraOnly, LayoutMode::CameraOnly) | + (LayoutMode::Default, LayoutMode::Default) | + (LayoutMode::HideCamera, LayoutMode::HideCamera) + ); + if gap < MIN_GAP_FOR_TRANSITION && same_mode { + // Small gap between same modes, no transition needed + return InterpolatedLayout::from_single_mode(segment.mode.clone()); + } else if gap > 0.01 { + LayoutMode::Default + } else { + prev_seg.mode.clone() + } + } else { + LayoutMode::Default + }; let progress = (cursor.time - transition_start) / LAYOUT_TRANSITION_DURATION; ( prev_mode, @@ -83,14 +117,38 @@ impl InterpolatedLayout { ) } else if cursor.time >= transition_end && cursor.time < segment.end { if let Some(next_seg) = cursor.next_segment() { - let progress = (cursor.time - transition_end) / LAYOUT_TRANSITION_DURATION; - ( - segment.mode.clone(), - next_seg.mode.clone(), - ease_in_out(progress as f32) as f64, - ) + let gap = next_seg.start - segment.end; + + // For small gaps between same-mode segments, don't transition + let same_mode = matches!( + (&segment.mode, &next_seg.mode), + (LayoutMode::CameraOnly, LayoutMode::CameraOnly) | + (LayoutMode::Default, LayoutMode::Default) | + (LayoutMode::HideCamera, LayoutMode::HideCamera) + ); + if gap < MIN_GAP_FOR_TRANSITION && same_mode { + // Keep the current mode without transitioning + (segment.mode.clone(), segment.mode.clone(), 1.0) + } else if gap > 0.01 { + // There's a significant gap, so transition to default layout + let progress = ((cursor.time - transition_end) / LAYOUT_TRANSITION_DURATION).min(1.0); + ( + segment.mode.clone(), + LayoutMode::Default, + ease_in_out(progress as f32) as f64, + ) + } else { + // No gap, segments are back-to-back, transition directly if modes differ + let progress = ((cursor.time - transition_end) / LAYOUT_TRANSITION_DURATION).min(1.0); + ( + segment.mode.clone(), + next_seg.mode.clone(), + ease_in_out(progress as f32) as f64, + ) + } } else { - let progress = (cursor.time - transition_end) / LAYOUT_TRANSITION_DURATION; + // No next segment, transition to default + let progress = ((cursor.time - transition_end) / LAYOUT_TRANSITION_DURATION).min(1.0); ( segment.mode.clone(), LayoutMode::Default, @@ -102,36 +160,51 @@ impl InterpolatedLayout { } } else if let Some(next_segment) = cursor.next_segment() { let transition_start = next_segment.start - LAYOUT_TRANSITION_DURATION; - if cursor.time >= transition_start { - let prev_mode = cursor - .prev_segment - .map(|s| s.mode.clone()) - .unwrap_or(LayoutMode::Default); + + if let Some(prev_seg) = cursor.prev_segment { + let gap = next_segment.start - prev_seg.end; + + // For small gaps between same-mode segments, stay in that mode + let same_mode = matches!( + (&prev_seg.mode, &next_segment.mode), + (LayoutMode::CameraOnly, LayoutMode::CameraOnly) | + (LayoutMode::Default, LayoutMode::Default) | + (LayoutMode::HideCamera, LayoutMode::HideCamera) + ); + if gap < MIN_GAP_FOR_TRANSITION && same_mode { + (prev_seg.mode.clone(), prev_seg.mode.clone(), 1.0) + } else if cursor.time >= transition_start { + // Start transitioning into the next segment + let prev_mode = if gap > 0.01 { + LayoutMode::Default + } else { + prev_seg.mode.clone() + }; + let progress = (cursor.time - transition_start) / LAYOUT_TRANSITION_DURATION; + ( + prev_mode, + next_segment.mode.clone(), + ease_in_out(progress as f32) as f64, + ) + } else { + // We're in a gap that requires transition - should be at default + (LayoutMode::Default, LayoutMode::Default, 1.0) + } + } else if cursor.time >= transition_start { + // No previous segment, transitioning into the first segment let progress = (cursor.time - transition_start) / LAYOUT_TRANSITION_DURATION; ( - prev_mode, + LayoutMode::Default, next_segment.mode.clone(), ease_in_out(progress as f32) as f64, ) - } else if let Some(prev_segment) = cursor.prev_segment { - if cursor.time < prev_segment.end + 0.05 { - (prev_segment.mode.clone(), LayoutMode::Default, 1.0) - } else { - (LayoutMode::Default, LayoutMode::Default, 1.0) - } } else { (LayoutMode::Default, LayoutMode::Default, 1.0) } } else { - if let Some(prev_segment) = cursor.prev_segment { - if cursor.time < prev_segment.end + 0.05 { - (prev_segment.mode.clone(), LayoutMode::Default, 1.0) - } else { - (LayoutMode::Default, LayoutMode::Default, 1.0) - } - } else { - (LayoutMode::Default, LayoutMode::Default, 1.0) - } + // No next segment (at the end of timeline) + // The transition should have already completed inside the last segment + (LayoutMode::Default, LayoutMode::Default, 1.0) }; let (start_camera_opacity, start_screen_opacity, start_camera_scale) = diff --git a/packages/ui-solid/src/auto-imports.d.ts b/packages/ui-solid/src/auto-imports.d.ts index 054b44f3c1..2520525cb1 100644 --- a/packages/ui-solid/src/auto-imports.d.ts +++ b/packages/ui-solid/src/auto-imports.d.ts @@ -3,91 +3,92 @@ // @ts-nocheck // noinspection JSUnusedGlobalSymbols // Generated by unplugin-auto-import -export {}; +// biome-ignore lint: disable +export {} declare global { - const IconCapArrows: typeof import("~icons/cap/arrows.jsx")["default"]; - const IconCapAudioOn: typeof import("~icons/cap/audio-on.jsx")["default"]; - const IconCapBgBlur: typeof import("~icons/cap/bg-blur.jsx")["default"]; - const IconCapCamera: typeof import("~icons/cap/camera.jsx")["default"]; - const IconCapCaptions: typeof import("~icons/cap/captions.jsx")["default"]; - const IconCapChevronDown: typeof import("~icons/cap/chevron-down.jsx")["default"]; - const IconCapCircle: typeof import("~icons/cap/circle.jsx")["default"]; - const IconCapCircleCheck: typeof import("~icons/cap/circle-check.jsx")["default"]; - const IconCapCirclePlus: typeof import("~icons/cap/circle-plus.jsx")["default"]; - const IconCapCircleX: typeof import("~icons/cap/circle-x.jsx")["default"]; - const IconCapCopy: typeof import("~icons/cap/copy.jsx")["default"]; - const IconCapCorners: typeof import("~icons/cap/corners.jsx")["default"]; - const IconCapCrop: typeof import("~icons/cap/crop.jsx")["default"]; - const IconCapCursor: typeof import("~icons/cap/cursor.jsx")["default"]; - const IconCapEditor: typeof import("~icons/cap/editor.jsx")["default"]; - const IconCapEnlarge: typeof import("~icons/cap/enlarge.jsx")["default"]; - const IconCapFile: typeof import("~icons/cap/file.jsx")["default"]; - const IconCapFilmCut: typeof import("~icons/cap/film-cut.jsx")["default"]; - const IconCapGauge: typeof import("~icons/cap/gauge.jsx")["default"]; - const IconCapHotkeys: typeof import("~icons/cap/hotkeys.jsx")["default"]; - const IconCapImage: typeof import("~icons/cap/image.jsx")["default"]; - const IconCapInfo: typeof import("~icons/cap/info.jsx")["default"]; - const IconCapInstant: typeof import("~icons/cap/instant.jsx")["default"]; - const IconCapLayout: typeof import("~icons/cap/layout.jsx")["default"]; - const IconCapLink: typeof import("~icons/cap/link.jsx")["default"]; - const IconCapLogo: typeof import("~icons/cap/logo.jsx")["default"]; - const IconCapLogoFull: typeof import("~icons/cap/logo-full.jsx")["default"]; - const IconCapLogoFullDark: typeof import("~icons/cap/logo-full-dark.jsx")["default"]; - const IconCapMessageBubble: typeof import("~icons/cap/message-bubble.jsx")["default"]; - const IconCapMicrophone: typeof import("~icons/cap/microphone.jsx")["default"]; - const IconCapMoreVertical: typeof import("~icons/cap/more-vertical.jsx")["default"]; - const IconCapNext: typeof import("~icons/cap/next.jsx")["default"]; - const IconCapPadding: typeof import("~icons/cap/padding.jsx")["default"]; - const IconCapPause: typeof import("~icons/cap/pause.jsx")["default"]; - const IconCapPauseCircle: typeof import("~icons/cap/pause-circle.jsx")["default"]; - const IconCapPlay: typeof import("~icons/cap/play.jsx")["default"]; - const IconCapPlayCircle: typeof import("~icons/cap/play-circle.jsx")["default"]; - const IconCapPresets: typeof import("~icons/cap/presets.jsx")["default"]; - const IconCapPrev: typeof import("~icons/cap/prev.jsx")["default"]; - const IconCapRedo: typeof import("~icons/cap/redo.jsx")["default"]; - const IconCapRestart: typeof import("~icons/cap/restart.jsx")["default"]; - const IconCapScissors: typeof import("~icons/cap/scissors.jsx")["default"]; - const IconCapSettings: typeof import("~icons/cap/settings.jsx")["default"]; - const IconCapShadow: typeof import("~icons/cap/shadow.jsx")["default"]; - const IconCapSquare: typeof import("~icons/cap/square.jsx")["default"]; - const IconCapStopCircle: typeof import("~icons/cap/stop-circle.jsx")["default"]; - const IconCapTrash: typeof import("~icons/cap/trash.jsx")["default"]; - const IconCapUndo: typeof import("~icons/cap/undo.jsx")["default"]; - const IconCapUpload: typeof import("~icons/cap/upload.jsx")["default"]; - const IconCapZoomIn: typeof import("~icons/cap/zoom-in.jsx")["default"]; - const IconCapZoomOut: typeof import("~icons/cap/zoom-out.jsx")["default"]; - const IconFa6SolidDisplay: typeof import("~icons/fa6-solid/display.jsx")["default"]; - const IconHugeiconsEaseCurveControlPoints: typeof import("~icons/hugeicons/ease-curve-control-points.jsx")["default"]; - const IconIcBaselineMonitor: typeof import("~icons/ic/baseline-monitor.jsx")["default"]; - const IconIcRoundSearch: typeof import("~icons/ic/round-search.jsx")["default"]; - const IconLucideAppWindowMac: typeof import("~icons/lucide/app-window-mac.jsx")["default"]; - const IconLucideBell: typeof import("~icons/lucide/bell.jsx")["default"]; - const IconLucideBug: typeof import("~icons/lucide/bug.jsx")["default"]; - const IconLucideCheck: typeof import("~icons/lucide/check.jsx")["default"]; - const IconLucideClock: typeof import("~icons/lucide/clock.jsx")["default"]; - const IconLucideDatabase: typeof import("~icons/lucide/database.jsx")["default"]; - const IconLucideEdit: typeof import("~icons/lucide/edit.jsx")["default"]; - const IconLucideEye: typeof import("~icons/lucide/eye.jsx")["default"]; - const IconLucideEyeOff: typeof import("~icons/lucide/eye-off.jsx")["default"]; - const IconLucideFolder: typeof import("~icons/lucide/folder.jsx")["default"]; - const IconLucideGift: typeof import("~icons/lucide/gift.jsx")["default"]; - const IconLucideHardDrive: typeof import("~icons/lucide/hard-drive.jsx")["default"]; - const IconLucideLayout: typeof import("~icons/lucide/layout.jsx")["default"]; - const IconLucideLoaderCircle: typeof import("~icons/lucide/loader-circle.jsx")["default"]; - const IconLucideMessageSquarePlus: typeof import("~icons/lucide/message-square-plus.jsx")["default"]; - const IconLucideMicOff: typeof import("~icons/lucide/mic-off.jsx")["default"]; - const IconLucideMonitor: typeof import("~icons/lucide/monitor.jsx")["default"]; - const IconLucideRectangleHorizontal: typeof import("~icons/lucide/rectangle-horizontal.jsx")["default"]; - const IconLucideRotateCcw: typeof import("~icons/lucide/rotate-ccw.jsx")["default"]; - const IconLucideSearch: typeof import("~icons/lucide/search.jsx")["default"]; - const IconLucideSquarePlay: typeof import("~icons/lucide/square-play.jsx")["default"]; - const IconLucideUnplug: typeof import("~icons/lucide/unplug.jsx")["default"]; - const IconLucideVolume2: typeof import("~icons/lucide/volume2.jsx")["default"]; - const IconLucideVolumeX: typeof import("~icons/lucide/volume-x.jsx")["default"]; - const IconLucideX: typeof import("~icons/lucide/x.jsx")["default"]; - const IconMaterialSymbolsLightScreenshotFrame2: typeof import("~icons/material-symbols-light/screenshot-frame2.jsx")["default"]; - const IconMaterialSymbolsLightScreenshotFrame2MaterialSymbolsScreenshotFrame2Rounded: typeof import("~icons/material-symbols-light/screenshot-frame2-material-symbols-screenshot-frame2-rounded.jsx")["default"]; - const IconMaterialSymbolsScreenshotFrame2Rounded: typeof import("~icons/material-symbols/screenshot-frame2-rounded.jsx")["default"]; - const IconMdiMonitor: typeof import("~icons/mdi/monitor.jsx")["default"]; - const IconPhMonitorBold: typeof import("~icons/ph/monitor-bold.jsx")["default"]; + const IconCapArrows: typeof import('~icons/cap/arrows.jsx')['default'] + const IconCapAudioOn: typeof import('~icons/cap/audio-on.jsx')['default'] + const IconCapBgBlur: typeof import('~icons/cap/bg-blur.jsx')['default'] + const IconCapCamera: typeof import('~icons/cap/camera.jsx')['default'] + const IconCapCaptions: typeof import('~icons/cap/captions.jsx')['default'] + const IconCapChevronDown: typeof import('~icons/cap/chevron-down.jsx')['default'] + const IconCapCircle: typeof import('~icons/cap/circle.jsx')['default'] + const IconCapCircleCheck: typeof import('~icons/cap/circle-check.jsx')['default'] + const IconCapCirclePlus: typeof import('~icons/cap/circle-plus.jsx')['default'] + const IconCapCircleX: typeof import('~icons/cap/circle-x.jsx')['default'] + const IconCapCopy: typeof import('~icons/cap/copy.jsx')['default'] + const IconCapCorners: typeof import('~icons/cap/corners.jsx')['default'] + const IconCapCrop: typeof import('~icons/cap/crop.jsx')['default'] + const IconCapCursor: typeof import('~icons/cap/cursor.jsx')['default'] + const IconCapEditor: typeof import("~icons/cap/editor.jsx")["default"] + const IconCapEnlarge: typeof import('~icons/cap/enlarge.jsx')['default'] + const IconCapFile: typeof import('~icons/cap/file.jsx')['default'] + const IconCapFilmCut: typeof import('~icons/cap/film-cut.jsx')['default'] + const IconCapGauge: typeof import('~icons/cap/gauge.jsx')['default'] + const IconCapHotkeys: typeof import('~icons/cap/hotkeys.jsx')['default'] + const IconCapImage: typeof import('~icons/cap/image.jsx')['default'] + const IconCapInfo: typeof import('~icons/cap/info.jsx')['default'] + const IconCapInstant: typeof import('~icons/cap/instant.jsx')['default'] + const IconCapLayout: typeof import('~icons/cap/layout.jsx')['default'] + const IconCapLink: typeof import('~icons/cap/link.jsx')['default'] + const IconCapLogo: typeof import('~icons/cap/logo.jsx')['default'] + const IconCapLogoFull: typeof import('~icons/cap/logo-full.jsx')['default'] + const IconCapLogoFullDark: typeof import('~icons/cap/logo-full-dark.jsx')['default'] + const IconCapMessageBubble: typeof import('~icons/cap/message-bubble.jsx')['default'] + const IconCapMicrophone: typeof import('~icons/cap/microphone.jsx')['default'] + const IconCapMoreVertical: typeof import('~icons/cap/more-vertical.jsx')['default'] + const IconCapNext: typeof import('~icons/cap/next.jsx')['default'] + const IconCapPadding: typeof import('~icons/cap/padding.jsx')['default'] + const IconCapPause: typeof import('~icons/cap/pause.jsx')['default'] + const IconCapPauseCircle: typeof import('~icons/cap/pause-circle.jsx')['default'] + const IconCapPlay: typeof import('~icons/cap/play.jsx')['default'] + const IconCapPlayCircle: typeof import('~icons/cap/play-circle.jsx')['default'] + const IconCapPresets: typeof import('~icons/cap/presets.jsx')['default'] + const IconCapPrev: typeof import('~icons/cap/prev.jsx')['default'] + const IconCapRedo: typeof import('~icons/cap/redo.jsx')['default'] + const IconCapRestart: typeof import('~icons/cap/restart.jsx')['default'] + const IconCapScissors: typeof import('~icons/cap/scissors.jsx')['default'] + const IconCapSettings: typeof import('~icons/cap/settings.jsx')['default'] + const IconCapShadow: typeof import('~icons/cap/shadow.jsx')['default'] + const IconCapSquare: typeof import('~icons/cap/square.jsx')['default'] + const IconCapStopCircle: typeof import('~icons/cap/stop-circle.jsx')['default'] + const IconCapTrash: typeof import('~icons/cap/trash.jsx')['default'] + const IconCapUndo: typeof import('~icons/cap/undo.jsx')['default'] + const IconCapUpload: typeof import("~icons/cap/upload.jsx")["default"] + const IconCapZoomIn: typeof import('~icons/cap/zoom-in.jsx')['default'] + const IconCapZoomOut: typeof import('~icons/cap/zoom-out.jsx')['default'] + const IconFa6SolidDisplay: typeof import("~icons/fa6-solid/display.jsx")["default"] + const IconHugeiconsEaseCurveControlPoints: typeof import('~icons/hugeicons/ease-curve-control-points.jsx')['default'] + const IconIcBaselineMonitor: typeof import("~icons/ic/baseline-monitor.jsx")["default"] + const IconIcRoundSearch: typeof import("~icons/ic/round-search.jsx")["default"] + const IconLucideAppWindowMac: typeof import("~icons/lucide/app-window-mac.jsx")["default"] + const IconLucideBell: typeof import('~icons/lucide/bell.jsx')['default'] + const IconLucideBug: typeof import('~icons/lucide/bug.jsx')['default'] + const IconLucideCheck: typeof import('~icons/lucide/check.jsx')['default'] + const IconLucideClock: typeof import('~icons/lucide/clock.jsx')['default'] + const IconLucideDatabase: typeof import("~icons/lucide/database.jsx")["default"] + const IconLucideEdit: typeof import('~icons/lucide/edit.jsx')['default'] + const IconLucideEye: typeof import("~icons/lucide/eye.jsx")["default"] + const IconLucideEyeOff: typeof import("~icons/lucide/eye-off.jsx")["default"] + const IconLucideFolder: typeof import('~icons/lucide/folder.jsx')['default'] + const IconLucideGift: typeof import('~icons/lucide/gift.jsx')['default'] + const IconLucideHardDrive: typeof import('~icons/lucide/hard-drive.jsx')['default'] + const IconLucideLayout: typeof import('~icons/lucide/layout.jsx')['default'] + const IconLucideLoaderCircle: typeof import('~icons/lucide/loader-circle.jsx')['default'] + const IconLucideMessageSquarePlus: typeof import('~icons/lucide/message-square-plus.jsx')['default'] + const IconLucideMicOff: typeof import('~icons/lucide/mic-off.jsx')['default'] + const IconLucideMonitor: typeof import('~icons/lucide/monitor.jsx')['default'] + const IconLucideRectangleHorizontal: typeof import('~icons/lucide/rectangle-horizontal.jsx')['default'] + const IconLucideRotateCcw: typeof import('~icons/lucide/rotate-ccw.jsx')['default'] + const IconLucideSearch: typeof import('~icons/lucide/search.jsx')['default'] + const IconLucideSquarePlay: typeof import('~icons/lucide/square-play.jsx')['default'] + const IconLucideUnplug: typeof import('~icons/lucide/unplug.jsx')['default'] + const IconLucideVolume2: typeof import('~icons/lucide/volume2.jsx')['default'] + const IconLucideVolumeX: typeof import("~icons/lucide/volume-x.jsx")["default"] + const IconLucideX: typeof import("~icons/lucide/x.jsx")["default"] + const IconMaterialSymbolsLightScreenshotFrame2: typeof import("~icons/material-symbols-light/screenshot-frame2.jsx")["default"] + const IconMaterialSymbolsLightScreenshotFrame2MaterialSymbolsScreenshotFrame2Rounded: typeof import("~icons/material-symbols-light/screenshot-frame2-material-symbols-screenshot-frame2-rounded.jsx")["default"] + const IconMaterialSymbolsScreenshotFrame2Rounded: typeof import("~icons/material-symbols/screenshot-frame2-rounded.jsx")["default"] + const IconMdiMonitor: typeof import("~icons/mdi/monitor.jsx")["default"] + const IconPhMonitorBold: typeof import('~icons/ph/monitor-bold.jsx')['default'] } From 1a4103bab572042cb368651df0cb9d7b3b605f19 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 25 Aug 2025 23:32:35 +0100 Subject: [PATCH 2/6] Refactor timeline layout to scene segments Replaces 'layoutSegments' with 'sceneSegments' throughout the codebase, including type and variable renames in the editor and timeline components. Removes LayoutTrack and introduces SceneTrack in the timeline. Updates selection handling and config components to use scene segments instead of layout segments. --- apps/desktop/src-tauri/src/recording.rs | 2 +- .../src/routes/(window-chrome)/(main).tsx | 8 +- .../src/routes/editor/ConfigSidebar.tsx | 4506 +++++++++-------- .../routes/editor/Timeline/LayoutTrack.tsx | 482 -- .../src/routes/editor/Timeline/SceneTrack.tsx | 479 ++ .../src/routes/editor/Timeline/index.tsx | 504 +- apps/desktop/src/routes/editor/context.ts | 6 +- apps/desktop/src/utils/tauri.ts | 1241 ++--- crates/project/src/configuration.rs | 8 +- crates/rendering/src/lib.rs | 40 +- crates/rendering/src/{layout.rs => scene.rs} | 172 +- packages/ui-solid/src/auto-imports.d.ts | 168 +- 12 files changed, 3635 insertions(+), 3981 deletions(-) delete mode 100644 apps/desktop/src/routes/editor/Timeline/LayoutTrack.tsx create mode 100644 apps/desktop/src/routes/editor/Timeline/SceneTrack.tsx rename crates/rendering/src/{layout.rs => scene.rs} (65%) diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index aeb8fad368..b7c6158fc8 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -1009,7 +1009,7 @@ fn project_config_from_recording( } else { Vec::new() }, - layout_segments: Vec::new(), + scene_segments: Vec::new(), }), ..default_config.unwrap_or_default() } diff --git a/apps/desktop/src/routes/(window-chrome)/(main).tsx b/apps/desktop/src/routes/(window-chrome)/(main).tsx index de8c35011d..fb012fed4a 100644 --- a/apps/desktop/src/routes/(window-chrome)/(main).tsx +++ b/apps/desktop/src/routes/(window-chrome)/(main).tsx @@ -147,10 +147,10 @@ function Page() { if (rawOptions.captureTarget.variant === "display") { const screenId = rawOptions.captureTarget.id; - screen = _screens()?.find((s) => s.id === screenId) ?? _screens()?.[0]; + screen = _screens()?.find((s: any) => s.id === screenId) ?? _screens()?.[0]; } else if (rawOptions.captureTarget.variant === "area") { const screenId = rawOptions.captureTarget.screen; - screen = _screens()?.find((s) => s.id === screenId) ?? _screens()?.[0]; + screen = _screens()?.find((s: any) => s.id === screenId) ?? _screens()?.[0]; } return screen; @@ -160,7 +160,7 @@ function Page() { if (rawOptions.captureTarget.variant === "window") { const windowId = rawOptions.captureTarget.id; - win = _windows()?.find((s) => s.id === windowId) ?? _windows()?.[0]; + win = _windows()?.find((s: any) => s.id === windowId) ?? _windows()?.[0]; } return win; @@ -173,7 +173,7 @@ function Page() { if ("DeviceID" in cameraID && c.device_id === cameraID.DeviceID) return c; }), - micName: () => mics.data?.find((name) => name === rawOptions.micName), + micName: () => mics.data?.find((name: any) => name === rawOptions.micName), }; // if target is window and no windows are available, switch to screen capture diff --git a/apps/desktop/src/routes/editor/ConfigSidebar.tsx b/apps/desktop/src/routes/editor/ConfigSidebar.tsx index 4ba780823e..cf47efa8c0 100644 --- a/apps/desktop/src/routes/editor/ConfigSidebar.tsx +++ b/apps/desktop/src/routes/editor/ConfigSidebar.tsx @@ -1,10 +1,10 @@ import { - Collapsible, - Collapsible as KCollapsible, + Collapsible, + Collapsible as KCollapsible, } from "@kobalte/core/collapsible"; import { - RadioGroup as KRadioGroup, - RadioGroup, + RadioGroup as KRadioGroup, + RadioGroup, } from "@kobalte/core/radio-group"; import { Select as KSelect } from "@kobalte/core/select"; import { Tabs as KTabs } from "@kobalte/core/tabs"; @@ -17,19 +17,19 @@ import { BaseDirectory, writeFile } from "@tauri-apps/plugin-fs"; import { type as ostype } from "@tauri-apps/plugin-os"; import { cx } from "cva"; import { - batch, - createEffect, - createMemo, - createResource, - createRoot, - createSignal, - For, - Index, - on, - onMount, - Show, - Suspense, - type ValidComponent, + batch, + createEffect, + createMemo, + createResource, + createRoot, + createSignal, + For, + Index, + on, + onMount, + Show, + Suspense, + type ValidComponent, } from "solid-js"; import { createStore } from "solid-js/store"; import { Dynamic } from "solid-js/web"; @@ -41,473 +41,473 @@ import transparentBg from "~/assets/illustrations/transparent.webp"; import { Toggle } from "~/components/Toggle"; import { generalSettingsStore } from "~/store"; import { - type BackgroundSource, - type CameraShape, - commands, - type LayoutSegment, - type StereoMode, - type TimelineSegment, - type ZoomSegment, + type BackgroundSource, + type CameraShape, + commands, + type SceneSegment, + type StereoMode, + type TimelineSegment, + type ZoomSegment, } from "~/utils/tauri"; import IconLucideMonitor from "~icons/lucide/monitor"; import IconLucideSparkles from "~icons/lucide/sparkles"; import { CaptionsTab } from "./CaptionsTab"; import { useEditorContext } from "./context"; import { - DEFAULT_GRADIENT_FROM, - DEFAULT_GRADIENT_TO, - type RGBColor, + DEFAULT_GRADIENT_FROM, + DEFAULT_GRADIENT_TO, + type RGBColor, } from "./projectConfig"; import ShadowSettings from "./ShadowSettings"; import { TextInput } from "./TextInput"; import { - ComingSoonTooltip, - EditorButton, - Field, - MenuItem, - MenuItemList, - PopperContent, - Slider, - Subfield, - topSlideAnimateClasses, + ComingSoonTooltip, + EditorButton, + Field, + MenuItem, + MenuItemList, + PopperContent, + Slider, + Subfield, + topSlideAnimateClasses, } from "./ui"; const BACKGROUND_SOURCES = { - wallpaper: "Wallpaper", - image: "Image", - color: "Color", - gradient: "Gradient", + wallpaper: "Wallpaper", + image: "Image", + color: "Color", + gradient: "Gradient", } satisfies Record; const BACKGROUND_ICONS = { - wallpaper: imageBg, - image: transparentBg, - color: colorBg, - gradient: gradientBg, + wallpaper: imageBg, + image: transparentBg, + color: colorBg, + gradient: gradientBg, } satisfies Record; const BACKGROUND_SOURCES_LIST = [ - "wallpaper", - "image", - "color", - "gradient", + "wallpaper", + "image", + "color", + "gradient", ] satisfies Array; const BACKGROUND_COLORS = [ - "#FF0000", // Red - "#FF4500", // Orange-Red - "#FF8C00", // Orange - "#FFD700", // Gold - "#FFFF00", // Yellow - "#ADFF2F", // Green-Yellow - "#32CD32", // Lime Green - "#008000", // Green - "#00CED1", // Dark Turquoise - "#4785FF", // Dodger Blue - "#0000FF", // Blue - "#4B0082", // Indigo - "#800080", // Purple - "#A9A9A9", // Dark Gray - "#FFFFFF", // White - "#000000", // Black + "#FF0000", // Red + "#FF4500", // Orange-Red + "#FF8C00", // Orange + "#FFD700", // Gold + "#FFFF00", // Yellow + "#ADFF2F", // Green-Yellow + "#32CD32", // Lime Green + "#008000", // Green + "#00CED1", // Dark Turquoise + "#4785FF", // Dodger Blue + "#0000FF", // Blue + "#4B0082", // Indigo + "#800080", // Purple + "#A9A9A9", // Dark Gray + "#FFFFFF", // White + "#000000", // Black ]; const BACKGROUND_GRADIENTS = [ - { from: [15, 52, 67], to: [52, 232, 158] }, // Dark Blue to Teal - { from: [34, 193, 195], to: [253, 187, 45] }, // Turquoise to Golden Yellow - { from: [29, 253, 251], to: [195, 29, 253] }, // Cyan to Purple - { from: [69, 104, 220], to: [176, 106, 179] }, // Blue to Violet - { from: [106, 130, 251], to: [252, 92, 125] }, // Soft Blue to Pinkish Red - { from: [131, 58, 180], to: [253, 29, 29] }, // Purple to Red - { from: [249, 212, 35], to: [255, 78, 80] }, // Yellow to Coral Red - { from: [255, 94, 0], to: [255, 42, 104] }, // Orange to Reddish Pink - { from: [255, 0, 150], to: [0, 204, 255] }, // Pink to Sky Blue - { from: [0, 242, 96], to: [5, 117, 230] }, // Green to Blue - { from: [238, 205, 163], to: [239, 98, 159] }, // Peach to Soft Pink - { from: [44, 62, 80], to: [52, 152, 219] }, // Dark Gray Blue to Light Blue - { from: [168, 239, 255], to: [238, 205, 163] }, // Light Blue to Peach - { from: [74, 0, 224], to: [143, 0, 255] }, // Deep Blue to Bright Purple - { from: [252, 74, 26], to: [247, 183, 51] }, // Deep Orange to Soft Yellow - { from: [0, 255, 255], to: [255, 20, 147] }, // Cyan to Deep Pink - { from: [255, 127, 0], to: [255, 255, 0] }, // Orange to Yellow - { from: [255, 0, 255], to: [0, 255, 0] }, // Magenta to Green + { from: [15, 52, 67], to: [52, 232, 158] }, // Dark Blue to Teal + { from: [34, 193, 195], to: [253, 187, 45] }, // Turquoise to Golden Yellow + { from: [29, 253, 251], to: [195, 29, 253] }, // Cyan to Purple + { from: [69, 104, 220], to: [176, 106, 179] }, // Blue to Violet + { from: [106, 130, 251], to: [252, 92, 125] }, // Soft Blue to Pinkish Red + { from: [131, 58, 180], to: [253, 29, 29] }, // Purple to Red + { from: [249, 212, 35], to: [255, 78, 80] }, // Yellow to Coral Red + { from: [255, 94, 0], to: [255, 42, 104] }, // Orange to Reddish Pink + { from: [255, 0, 150], to: [0, 204, 255] }, // Pink to Sky Blue + { from: [0, 242, 96], to: [5, 117, 230] }, // Green to Blue + { from: [238, 205, 163], to: [239, 98, 159] }, // Peach to Soft Pink + { from: [44, 62, 80], to: [52, 152, 219] }, // Dark Gray Blue to Light Blue + { from: [168, 239, 255], to: [238, 205, 163] }, // Light Blue to Peach + { from: [74, 0, 224], to: [143, 0, 255] }, // Deep Blue to Bright Purple + { from: [252, 74, 26], to: [247, 183, 51] }, // Deep Orange to Soft Yellow + { from: [0, 255, 255], to: [255, 20, 147] }, // Cyan to Deep Pink + { from: [255, 127, 0], to: [255, 255, 0] }, // Orange to Yellow + { from: [255, 0, 255], to: [0, 255, 0] }, // Magenta to Green ] satisfies Array<{ from: RGBColor; to: RGBColor }>; const WALLPAPER_NAMES = [ - // macOS wallpapers - "macOS/sequoia-dark", - "macOS/sequoia-light", - "macOS/sonoma-clouds", - "macOS/sonoma-dark", - "macOS/sonoma-evening", - "macOS/sonoma-fromabove", - "macOS/sonoma-horizon", - "macOS/sonoma-light", - "macOS/sonoma-river", - "macOS/ventura-dark", - "macOS/ventura-semi-dark", - "macOS/ventura", - // Blue wallpapers - "blue/1", - "blue/2", - "blue/3", - "blue/4", - "blue/5", - "blue/6", - // Purple wallpapers - "purple/1", - "purple/2", - "purple/3", - "purple/4", - "purple/5", - "purple/6", - // Dark wallpapers - "dark/1", - "dark/2", - "dark/3", - "dark/4", - "dark/5", - "dark/6", - // Orange wallpapers - "orange/1", - "orange/2", - "orange/3", - "orange/4", - "orange/5", - "orange/6", - "orange/7", - "orange/8", - "orange/9", + // macOS wallpapers + "macOS/sequoia-dark", + "macOS/sequoia-light", + "macOS/sonoma-clouds", + "macOS/sonoma-dark", + "macOS/sonoma-evening", + "macOS/sonoma-fromabove", + "macOS/sonoma-horizon", + "macOS/sonoma-light", + "macOS/sonoma-river", + "macOS/ventura-dark", + "macOS/ventura-semi-dark", + "macOS/ventura", + // Blue wallpapers + "blue/1", + "blue/2", + "blue/3", + "blue/4", + "blue/5", + "blue/6", + // Purple wallpapers + "purple/1", + "purple/2", + "purple/3", + "purple/4", + "purple/5", + "purple/6", + // Dark wallpapers + "dark/1", + "dark/2", + "dark/3", + "dark/4", + "dark/5", + "dark/6", + // Orange wallpapers + "orange/1", + "orange/2", + "orange/3", + "orange/4", + "orange/5", + "orange/6", + "orange/7", + "orange/8", + "orange/9", ] as const; const STEREO_MODES = [ - { name: "Stereo", value: "stereo" }, - { name: "Mono L", value: "monoL" }, - { name: "Mono R", value: "monoR" }, + { name: "Stereo", value: "stereo" }, + { name: "Mono L", value: "monoL" }, + { name: "Mono R", value: "monoR" }, ] satisfies Array<{ name: string; value: StereoMode }>; const CAMERA_SHAPES = [ - { - name: "Square", - value: "square", - }, - { - name: "Source", - value: "source", - }, + { + name: "Square", + value: "square", + }, + { + name: "Source", + value: "source", + }, ] satisfies Array<{ name: string; value: CameraShape }>; const BACKGROUND_THEMES = { - macOS: "macOS", - dark: "Dark", - blue: "Blue", - purple: "Purple", - orange: "Orange", + macOS: "macOS", + dark: "Dark", + blue: "Blue", + purple: "Purple", + orange: "Orange", }; const TAB_IDS = { - background: "background", - camera: "camera", - transcript: "transcript", - audio: "audio", - cursor: "cursor", - hotkeys: "hotkeys", + background: "background", + camera: "camera", + transcript: "transcript", + audio: "audio", + cursor: "cursor", + hotkeys: "hotkeys", } as const; export function ConfigSidebar() { - const { - project, - setProject, - setEditorState, - projectActions, - editorInstance, - editorState, - meta, - } = useEditorContext(); - - const [state, setState] = createStore({ - selectedTab: "background" as - | "background" - | "camera" - | "transcript" - | "audio" - | "cursor" - | "hotkeys" - | "captions", - }); - - let scrollRef!: HTMLDivElement; - - return ( - - - s.camera === null, - ), - }, - { id: TAB_IDS.audio, icon: IconCapAudioOn }, - { - id: TAB_IDS.cursor, - icon: IconCapCursor, - disabled: !( - meta().type === "multiple" && (meta() as any).segments[0].cursor - ), - }, - window.FLAGS.captions && { - id: "captions" as const, - icon: IconCapMessageBubble, - }, - // { id: "hotkeys" as const, icon: IconCapHotkeys }, - ].filter(Boolean)} - > - {(item) => ( - { - setState("selectedTab", item.id); - scrollRef.scrollTo({ - top: 0, - }); - }} - disabled={item.disabled} - > -
- -
-
- )} -
- - {/** Center the indicator with the icon */} - -
- - -
- - - - } - > - - setProject("audio", "mute", v)} - /> - - {editorInstance.recordings.segments[0].mic?.channels === 2 && ( - - - options={STEREO_MODES} - optionValue="value" - optionTextValue="name" - value={STEREO_MODES.find( - (v) => v.value === project.audio.micStereoMode, - )} - onChange={(v) => { - if (v) setProject("audio", "micStereoMode", v.value); - }} - disallowEmptySelection - itemComponent={(props) => ( - - as={KSelect.Item} - item={props.item} - > - - {props.item.rawValue.name} - - - )} - > - - class="flex-1 text-sm text-left truncate text-[--gray-500] font-normal"> - {(state) => {state.selectedOption().name}} - - - as={(props) => ( - - )} - /> - - - - as={KSelect.Content} - class={cx(topSlideAnimateClasses, "z-50")} - > - - class="overflow-y-auto max-h-32" - as={KSelect.Listbox} - /> - - - - - )} - - {/* + const { + project, + setProject, + setEditorState, + projectActions, + editorInstance, + editorState, + meta, + } = useEditorContext(); + + const [state, setState] = createStore({ + selectedTab: "background" as + | "background" + | "camera" + | "transcript" + | "audio" + | "cursor" + | "hotkeys" + | "captions", + }); + + let scrollRef!: HTMLDivElement; + + return ( + + + s.camera === null + ), + }, + { id: TAB_IDS.audio, icon: IconCapAudioOn }, + { + id: TAB_IDS.cursor, + icon: IconCapCursor, + disabled: !( + meta().type === "multiple" && (meta() as any).segments[0].cursor + ), + }, + window.FLAGS.captions && { + id: "captions" as const, + icon: IconCapMessageBubble, + }, + // { id: "hotkeys" as const, icon: IconCapHotkeys }, + ].filter(Boolean)} + > + {(item) => ( + { + setState("selectedTab", item.id); + scrollRef.scrollTo({ + top: 0, + }); + }} + disabled={item.disabled} + > +
+ +
+
+ )} +
+ + {/** Center the indicator with the icon */} + +
+ + +
+ + + + } + > + + setProject("audio", "mute", v)} + /> + + {editorInstance.recordings.segments[0].mic?.channels === 2 && ( + + + options={STEREO_MODES} + optionValue="value" + optionTextValue="name" + value={STEREO_MODES.find( + (v) => v.value === project.audio.micStereoMode + )} + onChange={(v) => { + if (v) setProject("audio", "micStereoMode", v.value); + }} + disallowEmptySelection + itemComponent={(props) => ( + + as={KSelect.Item} + item={props.item} + > + + {props.item.rawValue.name} + + + )} + > + + class="flex-1 text-sm text-left truncate text-[--gray-500] font-normal"> + {(state) => {state.selectedOption().name}} + + + as={(props) => ( + + )} + /> + + + + as={KSelect.Content} + class={cx(topSlideAnimateClasses, "z-50")} + > + + class="overflow-y-auto max-h-32" + as={KSelect.Listbox} + /> + + + + + )} + + {/* setProject("audio", "mute", v)} /> */} - {/* + {/* */} - - {meta().hasMicrophone && ( - } - > - setProject("audio", "micVolumeDb", v[0])} - minValue={-30} - maxValue={10} - step={0.1} - formatTooltip={(v) => - v <= -30 ? "Muted" : `${v > 0 ? "+" : ""}${v.toFixed(1)} dB` - } - /> - - )} - {meta().hasSystemAudio && ( - } - > - setProject("audio", "systemVolumeDb", v[0])} - minValue={-30} - maxValue={10} - step={0.1} - formatTooltip={(v) => - v <= -30 ? "Muted" : `${v > 0 ? "+" : ""}${v.toFixed(1)} dB` - } - /> - - )} - - - } - value={ - { - setProject("cursor", "hide", !v); - }} - /> - } - /> - - }> - setProject("cursor", "size", v[0])} - minValue={20} - maxValue={300} - step={1} - /> - - - } - value={ - { - setProject("cursor", "raw", !value); - }} - /> - } - /> - - {/* if Content has padding or margin the animation doesn't look as good */} -
- - setProject("cursor", "tension", v[0])} - minValue={1} - maxValue={500} - step={1} - /> - - - setProject("cursor", "friction", v[0])} - minValue={0} - maxValue={50} - step={0.1} - /> - - - setProject("cursor", "mass", v[0])} - minValue={0.1} - maxValue={10} - step={0.01} - /> - -
-
-
- } - value={ - { - setProject("cursor", "useSvg" as any, value); - }} - /> - } - /> -
- - {/* + + {meta().hasMicrophone && ( + } + > + setProject("audio", "micVolumeDb", v[0])} + minValue={-30} + maxValue={10} + step={0.1} + formatTooltip={(v) => + v <= -30 ? "Muted" : `${v > 0 ? "+" : ""}${v.toFixed(1)} dB` + } + /> + + )} + {meta().hasSystemAudio && ( + } + > + setProject("audio", "systemVolumeDb", v[0])} + minValue={-30} + maxValue={10} + step={0.1} + formatTooltip={(v) => + v <= -30 ? "Muted" : `${v > 0 ? "+" : ""}${v.toFixed(1)} dB` + } + /> + + )} +
+ + } + value={ + { + setProject("cursor", "hide", !v); + }} + /> + } + /> + + }> + setProject("cursor", "size", v[0])} + minValue={20} + maxValue={300} + step={1} + /> + + + } + value={ + { + setProject("cursor", "raw", !value); + }} + /> + } + /> + + {/* if Content has padding or margin the animation doesn't look as good */} +
+ + setProject("cursor", "tension", v[0])} + minValue={1} + maxValue={500} + step={1} + /> + + + setProject("cursor", "friction", v[0])} + minValue={0} + maxValue={50} + step={0.1} + /> + + + setProject("cursor", "mass", v[0])} + minValue={0.1} + maxValue={10} + step={0.01} + /> + +
+
+
+ } + value={ + { + setProject("cursor", "useSvg" as any, value); + }} + /> + } + /> +
+ + {/* setProject("cursor", "motionBlur", v[0])} @@ -516,7 +516,7 @@ export function ConfigSidebar() { step={0.001} /> */} - {/* }> + {/* }> */} -
- - }> - - - - - - - - - - -
- - {(selection) => ( -
- - { - const zoomSelection = selection(); - if (zoomSelection.type !== "zoom") return; - - const segments = zoomSelection.indices - .map((index) => ({ - index, - segment: project.timeline?.zoomSegments?.[index], - })) - .filter( - (item): item is { index: number; segment: ZoomSegment } => - item.segment !== undefined, - ); - - if (segments.length === 0) { - setEditorState("timeline", "selection", null); - return; - } - return { selection: zoomSelection, segments }; - })()} - > - {(value) => ( -
-
-
- - setEditorState("timeline", "selection", null) - } - leftIcon={} - > - Done - - - {value().segments.length} zoom{" "} - {value().segments.length === 1 - ? "segment" - : "segments"}{" "} - selected - -
- { - projectActions.deleteZoomSegments( - value().segments.map((s) => s.index), - ); - }} - leftIcon={} - > - Delete - -
- - - {(item, index) => ( -
- -
- )} -
-
- } - > - - {(item) => ( -
- -
- )} -
-
-
- )} -
- { - const layoutSelection = selection(); - if (layoutSelection.type !== "layout") return; - - const segment = - project.timeline?.layoutSegments?.[layoutSelection.index]; - if (!segment) return; - - return { selection: layoutSelection, segment }; - })()} - > - {(value) => ( - - )} - - { - const clipSegment = selection(); - if (clipSegment.type !== "clip") return; - - const segment = - project.timeline?.segments?.[clipSegment.index]; - if (!segment) return; - - return { selection: clipSegment, segment }; - })()} - > - {(value) => ( - - )} - - -
- )} - -
- ); +
+ + }> + + + + + + + + + + +
+ + {(selection) => ( +
+ + { + const zoomSelection = selection(); + if (zoomSelection.type !== "zoom") return; + + const segments = zoomSelection.indices + .map((index) => ({ + index, + segment: project.timeline?.zoomSegments?.[index], + })) + .filter( + (item): item is { index: number; segment: ZoomSegment } => + item.segment !== undefined + ); + + if (segments.length === 0) { + setEditorState("timeline", "selection", null); + return; + } + return { selection: zoomSelection, segments }; + })()} + > + {(value) => ( +
+
+
+ + setEditorState("timeline", "selection", null) + } + leftIcon={} + > + Done + + + {value().segments.length} zoom{" "} + {value().segments.length === 1 + ? "segment" + : "segments"}{" "} + selected + +
+ { + projectActions.deleteZoomSegments( + value().segments.map((s) => s.index) + ); + }} + leftIcon={} + > + Delete + +
+ + + {(item, index) => ( +
+ +
+ )} +
+
+ } + > + + {(item) => ( +
+ +
+ )} +
+
+
+ )} +
+ { + const sceneSelection = selection(); + if (sceneSelection.type !== "scene") return; + + const segment = + project.timeline?.sceneSegments?.[sceneSelection.index]; + if (!segment) return; + + return { selection: sceneSelection, segment }; + })()} + > + {(value) => ( + + )} + + { + const clipSegment = selection(); + if (clipSegment.type !== "clip") return; + + const segment = + project.timeline?.segments?.[clipSegment.index]; + if (!segment) return; + + return { selection: clipSegment, segment }; + })()} + > + {(value) => ( + + )} + + +
+ )} + +
+ ); } function BackgroundConfig(props: { scrollRef: HTMLDivElement }) { - const { project, setProject, projectHistory } = useEditorContext(); - - // Background tabs - const [backgroundTab, setBackgroundTab] = - createSignal("macOS"); - - const [wallpapers] = createResource(async () => { - // Only load visible wallpapers initially - const visibleWallpaperPaths = WALLPAPER_NAMES.map(async (id) => { - try { - const path = await resolveResource(`assets/backgrounds/${id}.jpg`); - return { id, path }; - } catch (err) { - return { id, path: null }; - } - }); - - // Load initial batch - const initialPaths = await Promise.all(visibleWallpaperPaths); - - return initialPaths - .filter((p) => p.path !== null) - .map(({ id, path }) => ({ - id, - url: convertFileSrc(path!), - rawPath: path!, - })); - }); - - // set padding if background is selected - const ensurePaddingForBackground = () => { - if (project.background.padding === 0) - setProject("background", "padding", 10); - }; - - // Validate background source path on mount - onMount(async () => { - if ( - project.background.source.type === "wallpaper" || - project.background.source.type === "image" - ) { - const path = project.background.source.path; - - if (path) { - if (project.background.source.type === "wallpaper") { - // If the path is just the wallpaper ID (e.g. "sequoia-dark"), get the full path - if ( - WALLPAPER_NAMES.includes(path as (typeof WALLPAPER_NAMES)[number]) - ) { - // Wait for wallpapers to load - const loadedWallpapers = wallpapers(); - if (!loadedWallpapers) return; - - // Find the wallpaper with matching ID - const wallpaper = loadedWallpapers.find((w) => w.id === path); - if (!wallpaper?.url) return; - - // Directly trigger the radio group's onChange handler - const radioGroupOnChange = async (photoUrl: string) => { - try { - const wallpaper = wallpapers()?.find((w) => w.url === photoUrl); - if (!wallpaper) return; - - // Get the raw path without any URL prefixes - const rawPath = decodeURIComponent( - photoUrl.replace("file://", ""), - ); - - debouncedSetProject(rawPath); - } catch (err) { - toast.error("Failed to set wallpaper"); - } - }; - - await radioGroupOnChange(wallpaper.url); - } - } else if (project.background.source.type === "image") { - (async () => { - try { - const convertedPath = convertFileSrc(path); - await fetch(convertedPath, { method: "HEAD" }); - } catch (err) { - setProject("background", "source", { - type: "image", - path: null, - }); - } - })(); - } - } - } - }); - - const filteredWallpapers = createMemo(() => { - const currentTab = backgroundTab(); - return wallpapers()?.filter((wp) => wp.id.startsWith(currentTab)) || []; - }); - - const [scrollX, setScrollX] = createSignal(0); - const [reachedEndOfScroll, setReachedEndOfScroll] = createSignal(false); - - const [backgroundRef, setBackgroundRef] = createSignal(); - - createEventListenerMap( - () => backgroundRef() ?? [], - { - /** Handle background tabs overflowing to show fade */ - scroll: () => { - const el = backgroundRef(); - if (el) { - setScrollX(el.scrollLeft); - const reachedEnd = el.scrollWidth - el.clientWidth - el.scrollLeft; - setReachedEndOfScroll(reachedEnd === 0); - } - }, - //Mouse wheel and touchpad support - wheel: (e: WheelEvent) => { - const el = backgroundRef(); - if (el) { - e.preventDefault(); - el.scrollLeft += - Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY; - } - }, - }, - { passive: false }, - ); - - let fileInput!: HTMLInputElement; - - // Optimize the debounced set project function - const debouncedSetProject = (wallpaperPath: string) => { - const resumeHistory = projectHistory.pause(); - queueMicrotask(() => { - batch(() => { - setProject("background", "source", { - type: "wallpaper", - path: wallpaperPath, - } as const); - resumeHistory(); - }); - }); - }; - - const backgrounds: { - [K in BackgroundSource["type"]]: Extract; - } = { - wallpaper: { - type: "wallpaper", - path: null, - }, - image: { - type: "image", - path: null, - }, - color: { - type: "color", - value: DEFAULT_GRADIENT_FROM, - }, - gradient: { - type: "gradient", - from: DEFAULT_GRADIENT_FROM, - to: DEFAULT_GRADIENT_TO, - }, - }; - - const generalSettings = generalSettingsStore.createQuery(); - const hapticsEnabled = () => - generalSettings.data?.hapticsEnabled && ostype() === "macos"; - - return ( - - } name="Background Image"> - { - const tab = v as BackgroundSource["type"]; - ensurePaddingForBackground(); - switch (tab) { - case "image": { - setProject("background", "source", { - type: "image", - path: - project.background.source.type === "image" - ? project.background.source.path - : null, - }); - break; - } - case "color": { - setProject("background", "source", { - type: "color", - value: - project.background.source.type === "color" - ? project.background.source.value - : DEFAULT_GRADIENT_FROM, - }); - break; - } - case "gradient": { - setProject("background", "source", { - type: "gradient", - from: - project.background.source.type === "gradient" - ? project.background.source.from - : DEFAULT_GRADIENT_FROM, - to: - project.background.source.type === "gradient" - ? project.background.source.to - : DEFAULT_GRADIENT_TO, - angle: - project.background.source.type === "gradient" - ? project.background.source.angle - : 90, - }); - break; - } - case "wallpaper": { - setProject("background", "source", { - type: "wallpaper", - path: - project.background.source.type === "wallpaper" - ? project.background.source.path - : null, - }); - break; - } - } - }} - > - - - {(item) => { - const el = (props?: object) => ( - -
- {(() => { - const getGradientBackground = () => { - const angle = - project.background.source.type === "gradient" - ? project.background.source.angle - : 90; - const fromColor = - project.background.source.type === "gradient" - ? project.background.source.from - : DEFAULT_GRADIENT_FROM; - const toColor = - project.background.source.type === "gradient" - ? project.background.source.to - : DEFAULT_GRADIENT_TO; - - return ( -
- ); - }; - - const getColorBackground = () => { - const backgroundColor = - project.background.source.type === "color" - ? project.background.source.value - : hexToRgb(BACKGROUND_COLORS[9]); - - return ( -
- ); - }; - - const getImageBackground = () => { - // Always start with the default icon - let imageSrc: string = BACKGROUND_ICONS[item]; - - // Only override for "image" if a valid path exists - if ( - item === "image" && - project.background.source.type === "image" && - project.background.source.path - ) { - const convertedPath = convertFileSrc( - project.background.source.path, - ); - // Only use converted path if it's valid - if (convertedPath) { - imageSrc = convertedPath; - } - } - // Only override for "wallpaper" if a valid wallpaper is found - else if ( - item === "wallpaper" && - project.background.source.type === "wallpaper" && - project.background.source.path - ) { - const selectedWallpaper = wallpapers()?.find((w) => - ( - project.background.source as { path?: string } - ).path?.includes(w.id), - ); - // Only use wallpaper URL if it exists - if (selectedWallpaper?.url) { - imageSrc = selectedWallpaper.url; - } - } - - return ( - {BACKGROUND_SOURCES[item]} - ); - }; - - switch (item) { - case "gradient": - return getGradientBackground(); - case "color": - return getColorBackground(); - case "image": - case "wallpaper": - return getImageBackground(); - default: - return null; - } - })()} - {BACKGROUND_SOURCES[item]} -
- - ); - - return el({}); - }} - - - {/** Dashed divider */} -
- - {/** Background Tabs */} - - 0 ? "24px" : "0" - }, black calc(100% - ${ - reachedEndOfScroll() ? "0px" : "24px" - }), transparent)`, - - "mask-image": `linear-gradient(to right, transparent, black ${ - scrollX() > 0 ? "24px" : "0" - }, black calc(100% - ${ - reachedEndOfScroll() ? "0px" : "24px" - }), transparent);`, - }} - > - - {([key, value]) => ( - <> - - setBackgroundTab( - key as keyof typeof BACKGROUND_THEMES, - ) - } - value={key} - class="flex relative z-10 flex-1 justify-center items-center px-4 py-2 bg-transparent rounded-lg border transition-colors duration-200 text-gray-11 ui-not-selected:hover:border-gray-7 ui-selected:bg-gray-3 ui-selected:border-gray-3 group ui-selected:text-gray-12 disabled:opacity-50 focus:outline-none" - > - {value} - - - )} - - - - {/** End of Background Tabs */} - - ( - project.background.source as { path?: string } - ).path?.includes(w.id), - )?.url ?? undefined) - : undefined - } - onChange={(photoUrl) => { - try { - const wallpaper = wallpapers()?.find( - (w) => w.url === photoUrl, - ); - if (!wallpaper) return; - - // Get the raw path without any URL prefixes - - debouncedSetProject(wallpaper.rawPath); - - ensurePaddingForBackground(); - } catch (err) { - toast.error("Failed to set wallpaper"); - } - }} - class="grid grid-cols-7 gap-2 h-auto" - > - -
-
- Loading wallpapers... -
-
- } - > - - {(photo) => ( - - - - Wallpaper option - - - )} - - - -
- - {(photo) => ( - - - - Wallpaper option - - - )} - -
-
-
-
-
-
- - fileInput.click()} - class="p-6 bg-gray-2 text-[13px] w-full rounded-[0.5rem] border border-gray-5 border-dashed flex flex-col items-center justify-center gap-[0.5rem] hover:bg-gray-3 transition-colors duration-100" - > - - - Click to select or drag and drop image - - - } - > - {(source) => ( -
- Selected background -
- -
-
- )} -
- { - const file = e.currentTarget.files?.[0]; - if (!file) return; - - /* + const { project, setProject, projectHistory } = useEditorContext(); + + // Background tabs + const [backgroundTab, setBackgroundTab] = + createSignal("macOS"); + + const [wallpapers] = createResource(async () => { + // Only load visible wallpapers initially + const visibleWallpaperPaths = WALLPAPER_NAMES.map(async (id) => { + try { + const path = await resolveResource(`assets/backgrounds/${id}.jpg`); + return { id, path }; + } catch (err) { + return { id, path: null }; + } + }); + + // Load initial batch + const initialPaths = await Promise.all(visibleWallpaperPaths); + + return initialPaths + .filter((p) => p.path !== null) + .map(({ id, path }) => ({ + id, + url: convertFileSrc(path!), + rawPath: path!, + })); + }); + + // set padding if background is selected + const ensurePaddingForBackground = () => { + if (project.background.padding === 0) + setProject("background", "padding", 10); + }; + + // Validate background source path on mount + onMount(async () => { + if ( + project.background.source.type === "wallpaper" || + project.background.source.type === "image" + ) { + const path = project.background.source.path; + + if (path) { + if (project.background.source.type === "wallpaper") { + // If the path is just the wallpaper ID (e.g. "sequoia-dark"), get the full path + if ( + WALLPAPER_NAMES.includes(path as (typeof WALLPAPER_NAMES)[number]) + ) { + // Wait for wallpapers to load + const loadedWallpapers = wallpapers(); + if (!loadedWallpapers) return; + + // Find the wallpaper with matching ID + const wallpaper = loadedWallpapers.find((w) => w.id === path); + if (!wallpaper?.url) return; + + // Directly trigger the radio group's onChange handler + const radioGroupOnChange = async (photoUrl: string) => { + try { + const wallpaper = wallpapers()?.find((w) => w.url === photoUrl); + if (!wallpaper) return; + + // Get the raw path without any URL prefixes + const rawPath = decodeURIComponent( + photoUrl.replace("file://", "") + ); + + debouncedSetProject(rawPath); + } catch (err) { + toast.error("Failed to set wallpaper"); + } + }; + + await radioGroupOnChange(wallpaper.url); + } + } else if (project.background.source.type === "image") { + (async () => { + try { + const convertedPath = convertFileSrc(path); + await fetch(convertedPath, { method: "HEAD" }); + } catch (err) { + setProject("background", "source", { + type: "image", + path: null, + }); + } + })(); + } + } + } + }); + + const filteredWallpapers = createMemo(() => { + const currentTab = backgroundTab(); + return wallpapers()?.filter((wp) => wp.id.startsWith(currentTab)) || []; + }); + + const [scrollX, setScrollX] = createSignal(0); + const [reachedEndOfScroll, setReachedEndOfScroll] = createSignal(false); + + const [backgroundRef, setBackgroundRef] = createSignal(); + + createEventListenerMap( + () => backgroundRef() ?? [], + { + /** Handle background tabs overflowing to show fade */ + scroll: () => { + const el = backgroundRef(); + if (el) { + setScrollX(el.scrollLeft); + const reachedEnd = el.scrollWidth - el.clientWidth - el.scrollLeft; + setReachedEndOfScroll(reachedEnd === 0); + } + }, + //Mouse wheel and touchpad support + wheel: (e: WheelEvent) => { + const el = backgroundRef(); + if (el) { + e.preventDefault(); + el.scrollLeft += + Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY; + } + }, + }, + { passive: false } + ); + + let fileInput!: HTMLInputElement; + + // Optimize the debounced set project function + const debouncedSetProject = (wallpaperPath: string) => { + const resumeHistory = projectHistory.pause(); + queueMicrotask(() => { + batch(() => { + setProject("background", "source", { + type: "wallpaper", + path: wallpaperPath, + } as const); + resumeHistory(); + }); + }); + }; + + const backgrounds: { + [K in BackgroundSource["type"]]: Extract; + } = { + wallpaper: { + type: "wallpaper", + path: null, + }, + image: { + type: "image", + path: null, + }, + color: { + type: "color", + value: DEFAULT_GRADIENT_FROM, + }, + gradient: { + type: "gradient", + from: DEFAULT_GRADIENT_FROM, + to: DEFAULT_GRADIENT_TO, + }, + }; + + const generalSettings = generalSettingsStore.createQuery(); + const hapticsEnabled = () => + generalSettings.data?.hapticsEnabled && ostype() === "macos"; + + return ( + + } name="Background Image"> + { + const tab = v as BackgroundSource["type"]; + ensurePaddingForBackground(); + switch (tab) { + case "image": { + setProject("background", "source", { + type: "image", + path: + project.background.source.type === "image" + ? project.background.source.path + : null, + }); + break; + } + case "color": { + setProject("background", "source", { + type: "color", + value: + project.background.source.type === "color" + ? project.background.source.value + : DEFAULT_GRADIENT_FROM, + }); + break; + } + case "gradient": { + setProject("background", "source", { + type: "gradient", + from: + project.background.source.type === "gradient" + ? project.background.source.from + : DEFAULT_GRADIENT_FROM, + to: + project.background.source.type === "gradient" + ? project.background.source.to + : DEFAULT_GRADIENT_TO, + angle: + project.background.source.type === "gradient" + ? project.background.source.angle + : 90, + }); + break; + } + case "wallpaper": { + setProject("background", "source", { + type: "wallpaper", + path: + project.background.source.type === "wallpaper" + ? project.background.source.path + : null, + }); + break; + } + } + }} + > + + + {(item) => { + const el = (props?: object) => ( + +
+ {(() => { + const getGradientBackground = () => { + const angle = + project.background.source.type === "gradient" + ? project.background.source.angle + : 90; + const fromColor = + project.background.source.type === "gradient" + ? project.background.source.from + : DEFAULT_GRADIENT_FROM; + const toColor = + project.background.source.type === "gradient" + ? project.background.source.to + : DEFAULT_GRADIENT_TO; + + return ( +
+ ); + }; + + const getColorBackground = () => { + const backgroundColor = + project.background.source.type === "color" + ? project.background.source.value + : hexToRgb(BACKGROUND_COLORS[9]); + + return ( +
+ ); + }; + + const getImageBackground = () => { + // Always start with the default icon + let imageSrc: string = BACKGROUND_ICONS[item]; + + // Only override for "image" if a valid path exists + if ( + item === "image" && + project.background.source.type === "image" && + project.background.source.path + ) { + const convertedPath = convertFileSrc( + project.background.source.path + ); + // Only use converted path if it's valid + if (convertedPath) { + imageSrc = convertedPath; + } + } + // Only override for "wallpaper" if a valid wallpaper is found + else if ( + item === "wallpaper" && + project.background.source.type === "wallpaper" && + project.background.source.path + ) { + const selectedWallpaper = wallpapers()?.find((w) => + ( + project.background.source as { path?: string } + ).path?.includes(w.id) + ); + // Only use wallpaper URL if it exists + if (selectedWallpaper?.url) { + imageSrc = selectedWallpaper.url; + } + } + + return ( + {BACKGROUND_SOURCES[item]} + ); + }; + + switch (item) { + case "gradient": + return getGradientBackground(); + case "color": + return getColorBackground(); + case "image": + case "wallpaper": + return getImageBackground(); + default: + return null; + } + })()} + {BACKGROUND_SOURCES[item]} +
+ + ); + + return el({}); + }} + + + {/** Dashed divider */} +
+ + {/** Background Tabs */} + + 0 ? "24px" : "0" + }, black calc(100% - ${ + reachedEndOfScroll() ? "0px" : "24px" + }), transparent)`, + + "mask-image": `linear-gradient(to right, transparent, black ${ + scrollX() > 0 ? "24px" : "0" + }, black calc(100% - ${ + reachedEndOfScroll() ? "0px" : "24px" + }), transparent);`, + }} + > + + {([key, value]) => ( + <> + + setBackgroundTab( + key as keyof typeof BACKGROUND_THEMES + ) + } + value={key} + class="flex relative z-10 flex-1 justify-center items-center px-4 py-2 bg-transparent rounded-lg border transition-colors duration-200 text-gray-11 ui-not-selected:hover:border-gray-7 ui-selected:bg-gray-3 ui-selected:border-gray-3 group ui-selected:text-gray-12 disabled:opacity-50 focus:outline-none" + > + {value} + + + )} + + + + {/** End of Background Tabs */} + + ( + project.background.source as { path?: string } + ).path?.includes(w.id) + )?.url ?? undefined + : undefined + } + onChange={(photoUrl) => { + try { + const wallpaper = wallpapers()?.find( + (w) => w.url === photoUrl + ); + if (!wallpaper) return; + + // Get the raw path without any URL prefixes + + debouncedSetProject(wallpaper.rawPath); + + ensurePaddingForBackground(); + } catch (err) { + toast.error("Failed to set wallpaper"); + } + }} + class="grid grid-cols-7 gap-2 h-auto" + > + +
+
+ Loading wallpapers... +
+
+ } + > + + {(photo) => ( + + + + Wallpaper option + + + )} + + + +
+ + {(photo) => ( + + + + Wallpaper option + + + )} + +
+
+
+
+
+
+ + fileInput.click()} + class="p-6 bg-gray-2 text-[13px] w-full rounded-[0.5rem] border border-gray-5 border-dashed flex flex-col items-center justify-center gap-[0.5rem] hover:bg-gray-3 transition-colors duration-100" + > + + + Click to select or drag and drop image + + + } + > + {(source) => ( +
+ Selected background +
+ +
+
+ )} +
+ { + const file = e.currentTarget.files?.[0]; + if (!file) return; + + /* this is a Tauri bug in WebKit so we need to validate the file type manually https://github.com/tauri-apps/tauri/issues/9158 */ - const validExtensions = [ - "jpg", - "jpeg", - "png", - "gif", - "webp", - "bmp", - ]; - const extension = file.name.split(".").pop()?.toLowerCase(); - if (!extension || !validExtensions.includes(extension)) { - toast.error("Invalid image file type"); - return; - } - - try { - const fileName = `bg-${Date.now()}-${file.name}`; - const arrayBuffer = await file.arrayBuffer(); - const uint8Array = new Uint8Array(arrayBuffer); - - const fullPath = `${await appDataDir()}/${fileName}`; - - await writeFile(fileName, uint8Array, { - baseDir: BaseDirectory.AppData, - }); - - setProject("background", "source", { - type: "image", - path: fullPath, - }); - } catch (err) { - toast.error("Failed to save image"); - } - }} - /> -
- - -
-
- { - setProject("background", "source", { - type: "color", - value, - }); - }} - /> -
- -
- - {(color) => ( -