diff --git a/apps/desktop/src/routes/editor/ConfigSidebar.tsx b/apps/desktop/src/routes/editor/ConfigSidebar.tsx index 3663d04e7..e1962ca6e 100644 --- a/apps/desktop/src/routes/editor/ConfigSidebar.tsx +++ b/apps/desktop/src/routes/editor/ConfigSidebar.tsx @@ -1,11 +1,11 @@ import { NumberField } from "@kobalte/core"; 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"; @@ -18,19 +18,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, produce } from "solid-js/store"; import { Dynamic } from "solid-js/web"; @@ -42,2623 +42,2454 @@ import transparentBg from "~/assets/illustrations/transparent.webp"; import { Toggle } from "~/components/Toggle"; import { generalSettingsStore } from "~/store"; import { - type BackgroundSource, - type CameraShape, - type ClipOffsets, - commands, - type SceneSegment, - type StereoMode, - type TimelineSegment, - type ZoomSegment, + type BackgroundSource, + type CameraShape, + type ClipOffsets, + commands, + type SceneSegment, + type SplitViewSettings, + 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 { SceneSegmentConfig as SceneSegmentConfigComponent } from "./SceneSegmentConfig"; 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", + "#FF4500", + "#FF8C00", + "#FFD700", + "#FFFF00", + "#ADFF2F", + "#32CD32", + "#008000", + "#00CED1", + "#4785FF", + "#0000FF", + "#4B0082", + "#800080", + "#A9A9A9", + "#FFFFFF", + "#000000", ]; 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] }, + { from: [34, 193, 195], to: [253, 187, 45] }, + { from: [29, 253, 251], to: [195, 29, 253] }, + { from: [69, 104, 220], to: [176, 106, 179] }, + { from: [106, 130, 251], to: [252, 92, 125] }, + { from: [131, 58, 180], to: [253, 29, 29] }, + { from: [249, 212, 35], to: [255, 78, 80] }, + { from: [255, 94, 0], to: [255, 42, 104] }, + { from: [255, 0, 150], to: [0, 204, 255] }, + { from: [0, 242, 96], to: [5, 117, 230] }, + { from: [238, 205, 163], to: [239, 98, 159] }, + { from: [44, 62, 80], to: [52, 152, 219] }, + { from: [168, 239, 255], to: [238, 205, 163] }, + { from: [74, 0, 224], to: [143, 0, 255] }, + { from: [252, 74, 26], to: [247, 183, 51] }, + { from: [0, 255, 255], to: [255, 20, 147] }, + { from: [255, 127, 0], to: [255, 255, 0] }, + { from: [255, 0, 255], to: [0, 255, 0] }, ] 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/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/1", + "blue/2", + "blue/3", + "blue/4", + "blue/5", + "blue/6", + "purple/1", + "purple/2", + "purple/3", + "purple/4", + "purple/5", + "purple/6", + "dark/1", + "dark/2", + "dark/3", + "dark/4", + "dark/5", + "dark/6", + "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) => ( - { - // Clear any active selection first - if (editorState.timeline.selection) { - setEditorState("timeline", "selection", null); - } - 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); - }} - /> - } - /> -
- - {/* - setProject("cursor", "motionBlur", v[0])} - minValue={0} - maxValue={1} - step={0.001} - /> - */} - {/* }> - { - setProject( - "cursor", - "animationStyle", - value as CursorAnimationStyle - ); + 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, + }, + ].filter(Boolean)} + > + {(item) => ( + { + if (editorState.timeline.selection) { + setEditorState("timeline", "selection", null); + } + setState("selectedTab", item.id); + scrollRef.scrollTo({ + top: 0, + }); }} - class="flex flex-col gap-2" - disabled + disabled={item.disabled} > - {( - Object.entries(CURSOR_ANIMATION_STYLES) as [ - CursorAnimationStyle, - string - ][] - ).map(([value, label]) => ( - - - + +
+ + )} + + + + +
+ + + +
+ + + + } + > + + 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} + /> + + + + + )} + + {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); + }} /> - - {label} - - - ))} - - */} - - - }> - - - - - - - - - - -
- - {(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) => ( - - )} - - -
- )} -
- - ); + } + /> + +
+ + 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); + }} + /> + } + /> + + + + }> + + + + + + + + + + +
+ + {(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; - - /* - 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) => ( -
- - - - - {(source) => { - const max = 360; - - const { projectHistory } = useEditorContext(); - - const angle = () => source().angle ?? 90; - - return ( - <> -
-
- { - backgrounds.gradient.from = from; - setProject("background", "source", { - type: "gradient", - from, - }); - }} - /> - { - backgrounds.gradient.to = to; - setProject("background", "source", { - type: "gradient", - to, - }); - }} - /> -
{ - const start = angle(); - const resumeHistory = projectHistory.pause(); - - createRoot((dispose) => - createEventListenerMap(window, { - mouseup: () => dispose(), - mousemove: (moveEvent) => { - const rawNewAngle = - Math.round( - start + - (downEvent.clientY - moveEvent.clientY), - ) % max; - const newAngle = moveEvent.shiftKey - ? rawNewAngle - : Math.round(rawNewAngle / 45) * 45; - - if ( - !moveEvent.shiftKey && - hapticsEnabled() && - project.background.source.type === - "gradient" && - project.background.source.angle !== newAngle - ) { - commands.performHapticFeedback( - "Alignment", - "Now", - ); - } - - setProject("background", "source", { - type: "gradient", - angle: - newAngle < 0 ? newAngle + max : newAngle, - }); - }, - }), - ); - }} - > -
-
-
-
- - {(gradient) => ( -
- - ); - }} - - - - - - }> - setProject("background", "blur", v[0])} - minValue={0} - maxValue={100} - step={0.1} - formatTooltip="%" - /> - - {/** Dashed divider */} -
- }> - setProject("background", "padding", v[0])} - minValue={0} - maxValue={40} - step={0.1} - formatTooltip="%" - /> - - }> - setProject("background", "rounding", v[0])} - minValue={0} - maxValue={100} - step={0.1} - formatTooltip="%" - /> - - } - value={ - { - const prev = project.background.border ?? { - enabled: false, - width: 5.0, - color: [0, 0, 0], - opacity: 50.0, - }; - - setProject("background", "border", { - ...prev, - enabled, - }); - }} - /> - } - /> - - }> - - setProject("background", "border", { - ...(project.background.border ?? { - enabled: true, - width: 5.0, - color: [0, 0, 0], - opacity: 50.0, - }), - width: v[0], - }) - } - minValue={1} - maxValue={20} - step={0.1} - formatTooltip="px" - /> - - }> - - setProject("background", "border", { - ...(project.background.border ?? { - enabled: true, - width: 5.0, - color: [0, 0, 0], - opacity: 50.0, - }), - color, - }) - } - /> - - }> - - setProject("background", "border", { - ...(project.background.border ?? { - enabled: true, - width: 5.0, - color: [0, 0, 0], - opacity: 50.0, - }), - opacity: v[0], - }) - } - minValue={0} - maxValue={100} - step={0.1} - formatTooltip="%" - /> - - - }> - { - batch(() => { - setProject("background", "shadow", v[0]); - // Initialize advanced shadow settings if they don't exist and shadow is enabled - if (v[0] > 0 && !project.background.advancedShadow) { - setProject("background", "advancedShadow", { - size: 50, - opacity: 18, - blur: 50, - }); - } - }); - }} - minValue={0} - maxValue={100} - step={0.1} - formatTooltip="%" - /> - { - setProject("background", "advancedShadow", { - ...(project.background.advancedShadow ?? { - size: 50, - opacity: 18, - blur: 50, - }), - size: v[0], - }); - }, - }} - opacity={{ - value: [project.background.advancedShadow?.opacity ?? 18], - onChange: (v) => { - setProject("background", "advancedShadow", { - ...(project.background.advancedShadow ?? { - size: 50, - opacity: 18, - blur: 50, - }), - opacity: v[0], - }); - }, - }} - blur={{ - value: [project.background.advancedShadow?.blur ?? 50], - onChange: (v) => { - setProject("background", "advancedShadow", { - ...(project.background.advancedShadow ?? { - size: 50, - opacity: 18, - blur: 50, - }), - blur: v[0], - }); - }, - }} - /> - - {/* - }> - setProject("background", "inset", v[0])} - minValue={0} - maxValue={100} - /> - - */} - - ); + {value} + + + )} + + + + + ( + 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; + + 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 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) => ( +
+ + + + + {(source) => { + const max = 360; + + const { projectHistory } = useEditorContext(); + + const angle = () => source().angle ?? 90; + + return ( + <> +
+
+ { + backgrounds.gradient.from = from; + setProject("background", "source", { + type: "gradient", + from, + }); + }} + /> + { + backgrounds.gradient.to = to; + setProject("background", "source", { + type: "gradient", + to, + }); + }} + /> +
{ + const start = angle(); + const resumeHistory = projectHistory.pause(); + + createRoot((dispose) => + createEventListenerMap(window, { + mouseup: () => dispose(), + mousemove: (moveEvent) => { + const rawNewAngle = + Math.round( + start + + (downEvent.clientY - moveEvent.clientY) + ) % max; + const newAngle = moveEvent.shiftKey + ? rawNewAngle + : Math.round(rawNewAngle / 45) * 45; + + if ( + !moveEvent.shiftKey && + hapticsEnabled() && + project.background.source.type === + "gradient" && + project.background.source.angle !== newAngle + ) { + commands.performHapticFeedback( + "Alignment", + "Now" + ); + } + + setProject("background", "source", { + type: "gradient", + angle: + newAngle < 0 ? newAngle + max : newAngle, + }); + }, + }) + ); + }} + > +
+
+
+
+ + {(gradient) => ( +
+ + ); + }} + + + + + + }> + setProject("background", "blur", v[0])} + minValue={0} + maxValue={100} + step={0.1} + formatTooltip="%" + /> + +
+ }> + setProject("background", "padding", v[0])} + minValue={0} + maxValue={40} + step={0.1} + formatTooltip="%" + /> + + }> + setProject("background", "rounding", v[0])} + minValue={0} + maxValue={100} + step={0.1} + formatTooltip="%" + /> + + } + value={ + { + const prev = project.background.border ?? { + enabled: false, + width: 5.0, + color: [0, 0, 0], + opacity: 50.0, + }; + + setProject("background", "border", { + ...prev, + enabled, + }); + }} + /> + } + /> + + }> + + setProject("background", "border", { + ...(project.background.border ?? { + enabled: true, + width: 5.0, + color: [0, 0, 0], + opacity: 50.0, + }), + width: v[0], + }) + } + minValue={1} + maxValue={20} + step={0.1} + formatTooltip="px" + /> + + }> + + setProject("background", "border", { + ...(project.background.border ?? { + enabled: true, + width: 5.0, + color: [0, 0, 0], + opacity: 50.0, + }), + color, + }) + } + /> + + }> + + setProject("background", "border", { + ...(project.background.border ?? { + enabled: true, + width: 5.0, + color: [0, 0, 0], + opacity: 50.0, + }), + opacity: v[0], + }) + } + minValue={0} + maxValue={100} + step={0.1} + formatTooltip="%" + /> + + + }> + { + batch(() => { + setProject("background", "shadow", v[0]); + if (v[0] > 0 && !project.background.advancedShadow) { + setProject("background", "advancedShadow", { + size: 50, + opacity: 18, + blur: 50, + }); + } + }); + }} + minValue={0} + maxValue={100} + step={0.1} + formatTooltip="%" + /> + { + setProject("background", "advancedShadow", { + ...(project.background.advancedShadow ?? { + size: 50, + opacity: 18, + blur: 50, + }), + size: v[0], + }); + }, + }} + opacity={{ + value: [project.background.advancedShadow?.opacity ?? 18], + onChange: (v) => { + setProject("background", "advancedShadow", { + ...(project.background.advancedShadow ?? { + size: 50, + opacity: 18, + blur: 50, + }), + opacity: v[0], + }); + }, + }} + blur={{ + value: [project.background.advancedShadow?.blur ?? 50], + onChange: (v) => { + setProject("background", "advancedShadow", { + ...(project.background.advancedShadow ?? { + size: 50, + opacity: 18, + blur: 50, + }), + blur: v[0], + }); + }, + }} + /> + + + ); } function CameraConfig(props: { scrollRef: HTMLDivElement }) { - const { project, setProject } = useEditorContext(); - - return ( - - } name="Camera"> -
-
- - { - const [x, y] = v.split(":"); - setProject("camera", "position", { x, y } as any); - }} - class="mt-[0.75rem] rounded-[0.5rem] border border-gray-3 bg-gray-2 w-full h-[7.5rem] relative" - > - - {(item) => ( - - - setProject("camera", "position", item)} - > -
- - - )} - - -
- - setProject("camera", "hide", hide)} - /> - - - setProject("camera", "mirror", mirror)} - /> - - - - options={CAMERA_SHAPES} - optionValue="value" - optionTextValue="name" - value={CAMERA_SHAPES.find( - (v) => v.value === project.camera.shape, - )} - onChange={(v) => { - if (v) setProject("camera", "shape", 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 } = useEditorContext(); + + return ( + + } name="Camera"> +
+
+ + { + const [x, y] = v.split(":"); + setProject("camera", "position", { x, y } as any); + }} + class="mt-[0.75rem] rounded-[0.5rem] border border-gray-3 bg-gray-2 w-full h-[7.5rem] relative" + > + + {(item) => ( + + + setProject("camera", "position", item)} + > +
+ + + )} + + +
+ setProject("camera", "use_camera_aspect", v)} + checked={project.camera.hide} + onChange={(hide) => setProject("camera", "hide", hide)} /> - */} -
- - {/** Dashed divider */} -
- }> - setProject("camera", "size", v[0])} - minValue={20} - maxValue={80} - step={0.1} - formatTooltip="%" - /> - - }> - setProject("camera", "zoom_size", v[0])} - minValue={10} - maxValue={60} - step={0.1} - formatTooltip="%" - /> - - }> - setProject("camera", "rounding", v[0])} - minValue={0} - maxValue={100} - step={0.1} - formatTooltip="%" - /> - - }> -
- setProject("camera", "shadow", v[0])} - minValue={0} - maxValue={100} - step={0.1} - formatTooltip="%" - /> - { - setProject("camera", "advanced_shadow", { - ...(project.camera.advanced_shadow ?? { - size: 50, - opacity: 18, - blur: 50, - }), - size: v[0], - }); - }, - }} - opacity={{ - value: [project.camera.advanced_shadow?.opacity ?? 18], - onChange: (v) => { - setProject("camera", "advanced_shadow", { - ...(project.camera.advanced_shadow ?? { - size: 50, - opacity: 18, - blur: 50, - }), - opacity: v[0], - }); - }, - }} - blur={{ - value: [project.camera.advanced_shadow?.blur ?? 50], - onChange: (v) => { - setProject("camera", "advanced_shadow", { - ...(project.camera.advanced_shadow ?? { - size: 50, - opacity: 18, - blur: 50, - }), - blur: v[0], - }); - }, - }} - /> -
-
- {/* - }> - setProject("camera", "shadow", v[0])} - minValue={0} - maxValue={100} - /> - - */} - - ); + + + setProject("camera", "mirror", mirror)} + /> + + + + options={CAMERA_SHAPES} + optionValue="value" + optionTextValue="name" + value={CAMERA_SHAPES.find( + (v) => v.value === project.camera.shape + )} + onChange={(v) => { + if (v) setProject("camera", "shape", 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("camera", "size", v[0])} + minValue={20} + maxValue={80} + step={0.1} + formatTooltip="%" + /> + + }> + setProject("camera", "zoom_size", v[0])} + minValue={10} + maxValue={60} + step={0.1} + formatTooltip="%" + /> + + }> + setProject("camera", "rounding", v[0])} + minValue={0} + maxValue={100} + step={0.1} + formatTooltip="%" + /> + + }> +
+ setProject("camera", "shadow", v[0])} + minValue={0} + maxValue={100} + step={0.1} + formatTooltip="%" + /> + { + setProject("camera", "advanced_shadow", { + ...(project.camera.advanced_shadow ?? { + size: 50, + opacity: 18, + blur: 50, + }), + size: v[0], + }); + }, + }} + opacity={{ + value: [project.camera.advanced_shadow?.opacity ?? 18], + onChange: (v) => { + setProject("camera", "advanced_shadow", { + ...(project.camera.advanced_shadow ?? { + size: 50, + opacity: 18, + blur: 50, + }), + opacity: v[0], + }); + }, + }} + blur={{ + value: [project.camera.advanced_shadow?.blur ?? 50], + onChange: (v) => { + setProject("camera", "advanced_shadow", { + ...(project.camera.advanced_shadow ?? { + size: 50, + opacity: 18, + blur: 50, + }), + blur: v[0], + }); + }, + }} + /> +
+
+ + ); } function ZoomSegmentPreview(props: { - segmentIndex: number; - segment: ZoomSegment; + segmentIndex: number; + segment: ZoomSegment; }) { - const { project, editorInstance } = useEditorContext(); - - const start = createMemo(() => props.segment.start); - - const segmentIndex = createMemo(() => { - const st = start(); - const i = project.timeline?.segments.findIndex( - (s) => s.start <= st && s.end > st, - ); - if (i === undefined || i === -1) return 0; - return i; - }); - - const relativeTime = createMemo(() => { - const st = start(); - const segment = project.timeline?.segments[segmentIndex()]; - if (!segment) return 0; - return Math.max(0, st - segment.start); - }); - - const video = document.createElement("video"); - createEffect(() => { - const path = convertFileSrc( - `${editorInstance.path}/content/segments/segment-${segmentIndex()}/display.mp4`, - ); - video.src = path; - video.preload = "auto"; - video.load(); - }); - - createEffect(() => { - const t = relativeTime(); - if (t === undefined) return; - - if (video.readyState >= 2) { - video.currentTime = t; - } else { - const handleCanPlay = () => { - video.currentTime = t; - video.removeEventListener("canplay", handleCanPlay); - }; - video.addEventListener("canplay", handleCanPlay); - } - }); - - const render = () => { - if (!canvasRef || video.readyState < 2) return; - - const ctx = canvasRef.getContext("2d"); - if (!ctx) return; - - ctx.imageSmoothingEnabled = false; - ctx.clearRect(0, 0, canvasRef.width, canvasRef.height); - - const raw = editorInstance.recordings.segments[0].display; - const croppedPosition = project.background.crop?.position || { x: 0, y: 0 }; - const croppedSize = project.background.crop?.size || { - x: raw.width, - y: raw.height, - }; - - ctx.drawImage( - video, - croppedPosition.x, - croppedPosition.y, - croppedSize.x, - croppedSize.y, - 0, - 0, - canvasRef.width, - canvasRef.height, - ); - }; - - const [loaded, setLoaded] = createSignal(false); - video.onloadeddata = () => { - setLoaded(true); - render(); - }; - video.onseeked = render; - video.onerror = () => { - setTimeout(() => video.load(), 100); - }; - - let canvasRef!: HTMLCanvasElement; - - return ( - <> -
-
- Zoom {props.segmentIndex + 1} -
-
- - -

- Loading... -

-
-
-
-
- -

{props.segment.amount.toFixed(1)}x

-
- - ); + const { project, editorInstance } = useEditorContext(); + + const start = createMemo(() => props.segment.start); + + const segmentIndex = createMemo(() => { + const st = start(); + const i = project.timeline?.segments.findIndex( + (s) => s.start <= st && s.end > st + ); + if (i === undefined || i === -1) return 0; + return i; + }); + + const relativeTime = createMemo(() => { + const st = start(); + const segment = project.timeline?.segments[segmentIndex()]; + if (!segment) return 0; + return Math.max(0, st - segment.start); + }); + + const video = document.createElement("video"); + createEffect(() => { + const path = convertFileSrc( + `${ + editorInstance.path + }/content/segments/segment-${segmentIndex()}/display.mp4` + ); + video.src = path; + video.preload = "auto"; + video.load(); + }); + + createEffect(() => { + const t = relativeTime(); + if (t === undefined) return; + + if (video.readyState >= 2) { + video.currentTime = t; + } else { + const handleCanPlay = () => { + video.currentTime = t; + video.removeEventListener("canplay", handleCanPlay); + }; + video.addEventListener("canplay", handleCanPlay); + } + }); + + const render = () => { + if (!canvasRef || video.readyState < 2) return; + + const ctx = canvasRef.getContext("2d"); + if (!ctx) return; + + ctx.imageSmoothingEnabled = false; + ctx.clearRect(0, 0, canvasRef.width, canvasRef.height); + + const raw = editorInstance.recordings.segments[0].display; + const croppedPosition = project.background.crop?.position || { x: 0, y: 0 }; + const croppedSize = project.background.crop?.size || { + x: raw.width, + y: raw.height, + }; + + ctx.drawImage( + video, + croppedPosition.x, + croppedPosition.y, + croppedSize.x, + croppedSize.y, + 0, + 0, + canvasRef.width, + canvasRef.height + ); + }; + + const [loaded, setLoaded] = createSignal(false); + video.onloadeddata = () => { + setLoaded(true); + render(); + }; + video.onseeked = render; + video.onerror = () => { + setTimeout(() => video.load(), 100); + }; + + let canvasRef!: HTMLCanvasElement; + + return ( + <> +
+
+ Zoom {props.segmentIndex + 1} +
+
+ + +

+ Loading... +

+
+
+
+
+ +

{props.segment.amount.toFixed(1)}x

+
+ + ); } function ZoomSegmentConfig(props: { - segmentIndex: number; - segment: ZoomSegment; + segmentIndex: number; + segment: ZoomSegment; }) { - const generalSettings = generalSettingsStore.createQuery(); - const { - project, - setProject, - editorInstance, - setEditorState, - projectHistory, - } = useEditorContext(); - - const states = { - manual: - props.segment.mode === "auto" - ? { x: 0.5, y: 0.5 } - : props.segment.mode.manual, - }; - - return ( - <> - } - > - - setProject( - "timeline", - "zoomSegments", - props.segmentIndex, - "amount", - v[0], - ) - } - minValue={1} - maxValue={4.5} - step={0.001} - formatTooltip="x" - /> - - }> - { - setProject( - "timeline", - "zoomSegments", - props.segmentIndex, - "mode", - v === "auto" ? "auto" : { manual: states.manual }, - ); - }} - > - - - Auto - - - Manual - - -
- - - - { - const m = props.segment.mode; - if (m === "auto") return; - - return m.manual; - })()} - > - {(mode) => { - const start = createMemo((prev) => { - if (projectHistory.isPaused()) return prev; - - return props.segment.start; - }, 0); - - const segmentIndex = createMemo((prev) => { - if (projectHistory.isPaused()) return prev; - - const st = start(); - const i = project.timeline?.segments.findIndex( - (s) => s.start <= st && s.end > st, - ); - if (i === undefined || i === -1) return 0; - return i; - }, 0); - - // Calculate the time relative to the video segment - const relativeTime = createMemo(() => { - const st = start(); - const segment = project.timeline?.segments[segmentIndex()]; - if (!segment) return 0; - // The time within the actual video file - return Math.max(0, st - segment.start); - }); - - const video = document.createElement("video"); - createEffect(() => { - const path = convertFileSrc( - // TODO: this shouldn't be so hardcoded - `${ - editorInstance.path - }/content/segments/segment-${segmentIndex()}/display.mp4`, - ); - video.src = path; - video.preload = "auto"; - // Force reload if video fails to load - video.load(); - }); - - createEffect(() => { - const t = relativeTime(); - if (t === undefined) return; - - // Ensure video is ready before seeking - if (video.readyState >= 2) { - video.currentTime = t; - } else { - // Wait for video to be ready, then seek - const handleCanPlay = () => { - video.currentTime = t; - video.removeEventListener("canplay", handleCanPlay); - }; - video.addEventListener("canplay", handleCanPlay); - } - }); - - createEffect( - on( - () => { - croppedPosition(); - croppedSize(); - }, - () => { - if (loaded()) { - render(); - } - }, - ), - ); - - const render = () => { - if (!canvasRef || video.readyState < 2) return; - - const ctx = canvasRef.getContext("2d"); - if (!ctx) return; - - ctx.imageSmoothingEnabled = false; - // Clear canvas first - ctx.clearRect(0, 0, canvasRef.width, canvasRef.height); - // Draw video frame - ctx.drawImage( - video, - croppedPosition().x, - croppedPosition().y, - croppedSize().x, - croppedSize().y, - 0, - 0, - canvasRef.width!, - canvasRef.height!, - ); - }; - - const [loaded, setLoaded] = createSignal(false); - video.onloadeddata = () => { - setLoaded(true); - render(); - }; - video.onseeked = render; - - // Add error handling - video.onerror = (e) => { - console.error("Failed to load video for zoom preview:", e); - // Try to reload after a short delay - setTimeout(() => { - video.load(); - }, 100); - }; - - let canvasRef!: HTMLCanvasElement; - - const [ref, setRef] = createSignal(); - const bounds = createElementBounds(ref); - const rawSize = () => { - const raw = editorInstance.recordings.segments[0].display; - return { x: raw.width, y: raw.height }; - }; - - const croppedPosition = () => { - const cropped = project.background.crop?.position; - if (cropped) return cropped; - - return { x: 0, y: 0 }; - }; - - const croppedSize = () => { - const cropped = project.background.crop?.size; - if (cropped) return cropped; - - return rawSize(); - }; - - const visualHeight = () => - (bounds.width! / croppedSize().x) * croppedSize().y; - - return ( -
{ - const bounds = - downEvent.currentTarget.getBoundingClientRect(); - - createRoot((dispose) => - createEventListenerMap(window, { - mouseup: () => dispose(), - mousemove: (moveEvent) => { - setProject( - "timeline", - "zoomSegments", - props.segmentIndex, - "mode", - "manual", - { - x: Math.max( - Math.min( - (moveEvent.clientX - bounds.left) / - bounds.width, - 1, - ), - 0, - ), - y: Math.max( - Math.min( - (moveEvent.clientY - bounds.top) / - bounds.height, - 1, - ), - 0, - ), - }, - ); - }, - }), - ); - }} - > -
-
-
-
- - -
-
- Loading preview... -
-
-
-
-
- ); - }} - - - - - - ); + const generalSettings = generalSettingsStore.createQuery(); + const { + project, + setProject, + editorInstance, + setEditorState, + projectHistory, + } = useEditorContext(); + + const states = { + manual: + props.segment.mode === "auto" + ? { x: 0.5, y: 0.5 } + : props.segment.mode.manual, + }; + + return ( + <> + } + > + + setProject( + "timeline", + "zoomSegments", + props.segmentIndex, + "amount", + v[0] + ) + } + minValue={1} + maxValue={4.5} + step={0.001} + formatTooltip="x" + /> + + }> + { + setProject( + "timeline", + "zoomSegments", + props.segmentIndex, + "mode", + v === "auto" ? "auto" : { manual: states.manual } + ); + }} + > + + + Auto + + + Manual + + +
+ + + + { + const m = props.segment.mode; + if (m === "auto") return; + + return m.manual; + })()} + > + {(mode) => { + const start = createMemo((prev) => { + if (projectHistory.isPaused()) return prev; + + return props.segment.start; + }, 0); + + const segmentIndex = createMemo((prev) => { + if (projectHistory.isPaused()) return prev; + + const st = start(); + const i = project.timeline?.segments.findIndex( + (s) => s.start <= st && s.end > st + ); + if (i === undefined || i === -1) return 0; + return i; + }, 0); + + const relativeTime = createMemo(() => { + const st = start(); + const segment = project.timeline?.segments[segmentIndex()]; + if (!segment) return 0; + return Math.max(0, st - segment.start); + }); + + const video = document.createElement("video"); + createEffect(() => { + const path = convertFileSrc( + `${ + editorInstance.path + }/content/segments/segment-${segmentIndex()}/display.mp4` + ); + video.src = path; + video.preload = "auto"; + video.load(); + }); + + createEffect(() => { + const t = relativeTime(); + if (t === undefined) return; + + if (video.readyState >= 2) { + video.currentTime = t; + } else { + const handleCanPlay = () => { + video.currentTime = t; + video.removeEventListener("canplay", handleCanPlay); + }; + video.addEventListener("canplay", handleCanPlay); + } + }); + + createEffect( + on( + () => { + croppedPosition(); + croppedSize(); + }, + () => { + if (loaded()) { + render(); + } + } + ) + ); + + const render = () => { + if (!canvasRef || video.readyState < 2) return; + + const ctx = canvasRef.getContext("2d"); + if (!ctx) return; + + ctx.imageSmoothingEnabled = false; + ctx.clearRect(0, 0, canvasRef.width, canvasRef.height); + ctx.drawImage( + video, + croppedPosition().x, + croppedPosition().y, + croppedSize().x, + croppedSize().y, + 0, + 0, + canvasRef.width!, + canvasRef.height! + ); + }; + + const [loaded, setLoaded] = createSignal(false); + video.onloadeddata = () => { + setLoaded(true); + render(); + }; + video.onseeked = render; + + video.onerror = (e) => { + console.error("Failed to load video for zoom preview:", e); + setTimeout(() => { + video.load(); + }, 100); + }; + + let canvasRef!: HTMLCanvasElement; + + const [ref, setRef] = createSignal(); + const bounds = createElementBounds(ref); + const rawSize = () => { + const raw = editorInstance.recordings.segments[0].display; + return { x: raw.width, y: raw.height }; + }; + + const croppedPosition = () => { + const cropped = project.background.crop?.position; + if (cropped) return cropped; + + return { x: 0, y: 0 }; + }; + + const croppedSize = () => { + const cropped = project.background.crop?.size; + if (cropped) return cropped; + + return rawSize(); + }; + + const visualHeight = () => + (bounds.width! / croppedSize().x) * croppedSize().y; + + return ( +
{ + const bounds = + downEvent.currentTarget.getBoundingClientRect(); + + createRoot((dispose) => + createEventListenerMap(window, { + mouseup: () => dispose(), + mousemove: (moveEvent) => { + setProject( + "timeline", + "zoomSegments", + props.segmentIndex, + "mode", + "manual", + { + x: Math.max( + Math.min( + (moveEvent.clientX - bounds.left) / + bounds.width, + 1 + ), + 0 + ), + y: Math.max( + Math.min( + (moveEvent.clientY - bounds.top) / + bounds.height, + 1 + ), + 0 + ), + } + ); + }, + }) + ); + }} + > +
+
+
+
+ + +
+
+ Loading preview... +
+
+
+
+
+ ); + }} + + + + + + ); } function ClipSegmentConfig(props: { - segmentIndex: number; - segment: TimelineSegment; + segmentIndex: number; + segment: TimelineSegment; }) { - const { setProject, setEditorState, project, projectActions, meta } = - useEditorContext(); - - // Get current clip configuration - const clipConfig = () => - project.clips?.find((c) => c.index === props.segmentIndex); - const offsets = () => clipConfig()?.offsets || {}; - - function setOffset(type: keyof ClipOffsets, offset: number) { - if (Number.isNaN(offset)) return; - - setProject( - produce((proj) => { - const clips = (proj.clips ??= []); - let clip = clips.find( - (clip) => clip.index === (props.segment.recordingSegment ?? 0), - ); - if (!clip) { - clip = { index: 0, offsets: {} }; - clips.push(clip); - } - - clip.offsets[type] = offset / 1000; - }), - ); - } - - return ( - <> -
-
- setEditorState("timeline", "selection", null)} - leftIcon={} - > - Done - -
- { - projectActions.deleteClipSegment(props.segmentIndex); - }} - disabled={ - ( - project.timeline?.segments.filter( - (s) => s.recordingSegment === props.segment.recordingSegment, - ) ?? [] - ).length < 2 - } - leftIcon={} - > - Delete - -
- -
-

Clip Settings

-

- These settings apply to all segments for the current clip -

-
- - {meta().hasSystemAudio && ( - { - setOffset("system_audio", offset); - }} - /> - )} - {meta().hasMicrophone && ( - { - setOffset("mic", offset); - }} - /> - )} - {meta().hasCamera && ( - { - setOffset("camera", offset); - }} - /> - )} - - {/* - } /> - - - } - /> - */} - - ); + const { setProject, setEditorState, project, projectActions, meta } = + useEditorContext(); + + const clipConfig = () => + project.clips?.find((c) => c.index === props.segmentIndex); + const offsets = () => clipConfig()?.offsets || {}; + + function setOffset(type: keyof ClipOffsets, offset: number) { + if (Number.isNaN(offset)) return; + + setProject( + produce((proj) => { + const clips = proj.clips ?? []; + let clip = clips.find( + (clip) => clip.index === (props.segment.recordingSegment ?? 0) + ); + if (!clip) { + clip = { index: 0, offsets: {} }; + clips.push(clip); + } + + clip.offsets[type] = offset / 1000; + }) + ); + } + + return ( + <> +
+
+ setEditorState("timeline", "selection", null)} + leftIcon={} + > + Done + +
+ { + projectActions.deleteClipSegment(props.segmentIndex); + }} + disabled={ + ( + project.timeline?.segments.filter( + (s) => s.recordingSegment === props.segment.recordingSegment + ) ?? [] + ).length < 2 + } + leftIcon={} + > + Delete + +
+ +
+

Clip Settings

+

+ These settings apply to all segments for the current clip +

+
+ + {meta().hasSystemAudio && ( + { + setOffset("system_audio", offset); + }} + /> + )} + {meta().hasMicrophone && ( + { + setOffset("mic", offset); + }} + /> + )} + {meta().hasCamera && ( + { + setOffset("camera", offset); + }} + /> + )} + + ); } function SourceOffsetField(props: { - name: string; - // seconds - value?: number; - onChange: (value: number) => void; + name: string; + value?: number; + onChange: (value: number) => void; }) { - const rawValue = () => Math.round((props.value ?? 0) * 1000); - - const [value, setValue] = createSignal(rawValue().toString()); - - return ( - -
-
- { - props.onChange(v); - }} - > - { - if (!rawValue() || value() === "" || Number.isNaN(rawValue())) { - setValue("0"); - props.onChange(0); - } - }} - class="w-[5rem] p-[0.375rem] border rounded-[0.5rem] bg-gray-1 focus-visible:outline-none" - /> - - ms -
-
- {[-100, -10, 10, 100].map((v) => ( - - ))} -
-
-
- ); + const rawValue = () => Math.round((props.value ?? 0) * 1000); + + const [value, setValue] = createSignal(rawValue().toString()); + + return ( + +
+
+ { + props.onChange(v); + }} + > + { + if (!rawValue() || value() === "" || Number.isNaN(rawValue())) { + setValue("0"); + props.onChange(0); + } + }} + class="w-[5rem] p-[0.375rem] border rounded-[0.5rem] bg-gray-1 focus-visible:outline-none" + /> + + ms +
+
+ {[-100, -10, 10, 100].map((v) => ( + + ))} +
+
+
+ ); } function SceneSegmentConfig(props: { - segmentIndex: number; - segment: SceneSegment; + segmentIndex: number; + segment: SceneSegment; }) { - const { setProject, setEditorState, projectActions } = useEditorContext(); - - return ( - <> -
-
- setEditorState("timeline", "selection", null)} - leftIcon={} - > - Done - -
- { - projectActions.deleteSceneSegment(props.segmentIndex); - }} - leftIcon={} - > - Delete - -
- }> - { - setProject( - "timeline", - "sceneSegments", - props.segmentIndex, - "mode", - v as "default" | "cameraOnly" | "hideCamera", - ); - }} - > - -
- - Default - - - Camera Only - - - Hide Camera - - -
- -
- -
-
-
-
-
- {props.segment.mode === "cameraOnly" - ? "Shows only the camera feed" - : props.segment.mode === "hideCamera" - ? "Shows only the screen recording" - : "Shows both screen and camera"} -
-
-
- - - - - ); + const { setProject, setEditorState, projectActions } = useEditorContext(); + + return ( + <> +
+
+ setEditorState("timeline", "selection", null)} + leftIcon={} + > + Done + +
+ { + projectActions.deleteSceneSegment(props.segmentIndex); + }} + leftIcon={} + > + Delete + +
+ }> + { + setProject( + "timeline", + "sceneSegments", + props.segmentIndex, + "mode", + v as "default" | "cameraOnly" | "hideCamera" + ); + }} + > + +
+ + Default + + + Camera Only + + + Hide Camera + + +
+ +
+ +
+
+
+
+
+ {props.segment.mode === "cameraOnly" + ? "Shows only the camera feed" + : props.segment.mode === "hideCamera" + ? "Shows only the screen recording" + : "Shows both screen and camera"} +
+
+
+ + + + + ); } - function RgbInput(props: { - value: [number, number, number]; - onChange: (value: [number, number, number]) => void; + value: [number, number, number]; + onChange: (value: [number, number, number]) => void; }) { - const [text, setText] = createWritableMemo(() => rgbToHex(props.value)); - let prevHex = rgbToHex(props.value); - - let colorInput!: HTMLInputElement; - - return ( -
-
- ); + const [text, setText] = createWritableMemo(() => rgbToHex(props.value)); + let prevHex = rgbToHex(props.value); + + let colorInput!: HTMLInputElement; + + return ( +
+
+ ); } function rgbToHex(rgb: [number, number, number]) { - return `#${rgb - .map((c) => c.toString(16).padStart(2, "0")) - .join("") - .toUpperCase()}`; + return `#${rgb + .map((c) => c.toString(16).padStart(2, "0")) + .join("") + .toUpperCase()}`; } function hexToRgb(hex: string): [number, number, number] | null { - const match = hex.match(/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i); - if (!match) return null; - return match.slice(1).map((c) => Number.parseInt(c, 16)) as any; + const match = hex.match(/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i); + if (!match) return null; + return match.slice(1).map((c) => Number.parseInt(c, 16)) as any; } diff --git a/apps/desktop/src/routes/editor/SceneSegmentConfig.tsx b/apps/desktop/src/routes/editor/SceneSegmentConfig.tsx new file mode 100644 index 000000000..71af25104 --- /dev/null +++ b/apps/desktop/src/routes/editor/SceneSegmentConfig.tsx @@ -0,0 +1,415 @@ +import { RadioGroup as KRadioGroup } from "@kobalte/core/radio-group"; +import { createSignal, For, Show } from "solid-js"; +import { Toggle } from "~/components/Toggle"; +import type { SceneSegment, SplitViewSettings } from "~/utils/tauri"; +import IconCapTrash from "~icons/iconoir/trash"; +import IconLucideAlignLeft from "~icons/lucide/align-left"; +import IconLucideAlignRight from "~icons/lucide/align-right"; +import IconLucideCheck from "~icons/lucide/check"; +import IconLucideClipboardCopy from "~icons/lucide/clipboard-copy"; +import IconLucideCopy from "~icons/lucide/copy"; +import IconLucideEyeOff from "~icons/lucide/eye-off"; +import IconLucideLayout from "~icons/lucide/layout"; +import IconLucideMaximize from "~icons/lucide/maximize"; +import IconLucideMinimize from "~icons/lucide/minimize"; +import IconLucideMonitor from "~icons/lucide/monitor"; +import IconLucideSettings from "~icons/lucide/settings"; +import IconLucideVideo from "~icons/lucide/video"; +import { useEditorContext } from "./context"; +import { EditorButton, Slider } from "./ui"; + +function SimplePositionControl(props: { + position: { x: number; y: number }; + onChange: (position: { x: number; y: number }) => void; + label: string; +}) { + const [isDragging, setIsDragging] = createSignal(false); + + return ( +
{ + const rect = e.currentTarget.getBoundingClientRect(); + setIsDragging(true); + + const updatePosition = (clientX: number, clientY: number) => { + const x = Math.max( + 0, + Math.min(1, (clientX - rect.left) / rect.width), + ); + const y = Math.max( + 0, + Math.min(1, (clientY - rect.top) / rect.height), + ); + props.onChange({ x, y }); + }; + + updatePosition(e.clientX, e.clientY); + + const handleMouseMove = (moveEvent: MouseEvent) => { + updatePosition(moveEvent.clientX, moveEvent.clientY); + }; + + const handleMouseUp = () => { + setIsDragging(false); + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + }; + + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + }} + > + {/* Grid lines for reference */} +
+
+
+
+ + {/* Position indicator */} +
+
+ ); +} + +export function SceneSegmentConfig(props: { + segmentIndex: number; + segment: SceneSegment; +}) { + const { setProject, setEditorState, projectActions, project, totalDuration } = + useEditorContext(); + + // Initialize split view settings if not present + const splitViewSettings = (): SplitViewSettings => + props.segment.splitViewSettings || { + cameraPosition: { x: 0.5, y: 0.5 }, + screenPosition: { x: 0.5, y: 0.5 }, + cameraSide: "right", + cameraZoom: 1.0, + screenZoom: 1.0, + fullscreen: false, + }; + + const layoutOptions = [ + { + value: "default", + label: "Default", + icon: , + description: "Screen with camera overlay", + }, + { + value: "splitView", + label: "Split View", + icon: , + description: "Side-by-side layout", + }, + { + value: "cameraOnly", + label: "Camera Only", + icon: , + description: "Full screen camera", + }, + { + value: "hideCamera", + label: "Hide Camera", + icon: , + description: "Screen recording only", + }, + ]; + + // Check if duplication is possible + const canDuplicate = () => { + const segmentDuration = props.segment.end - props.segment.start; + const newSegmentEnd = props.segment.end + segmentDuration; + + // Check if it would exceed timeline duration + if (newSegmentEnd > totalDuration()) { + return false; + } + + // Check for overlaps with other scene segments + const wouldOverlap = project.timeline?.sceneSegments?.some((s, i) => { + if (i === props.segmentIndex) return false; + return props.segment.end < s.end && newSegmentEnd > s.start; + }); + + return !wouldOverlap; + }; + + return ( +
+
+ setEditorState("timeline", "selection", null)} + leftIcon={} + > + Done + +
+ { + projectActions.duplicateSceneSegment(props.segmentIndex); + }} + leftIcon={} + disabled={!canDuplicate()} + title={!canDuplicate() ? "Not enough space in timeline" : undefined} + > + Duplicate + + { + projectActions.deleteSceneSegment(props.segmentIndex); + }} + leftIcon={} + class="text-red-11" + > + Delete + +
+
+ +
+
+ + Camera Layout +
+ + { + setProject( + "timeline", + "sceneSegments", + props.segmentIndex, + "mode", + v as "default" | "cameraOnly" | "hideCamera" | "splitView", + ); + }} + class="grid grid-cols-2 gap-2" + > + + {(option) => ( + + + +
+ {option.icon} + + {option.label} + +
+ {option.description} +
+
+ )} +
+
+
+ + +
+
+ + + Split View Settings + +
+ +
+
+
+
+ + + Fill entire frame without padding + +
+ { + const currentSettings = splitViewSettings(); + setProject( + "timeline", + "sceneSegments", + props.segmentIndex, + "splitViewSettings", + { ...currentSettings, fullscreen: checked }, + ); + }} + /> +
+
+ +
+ + { + const currentSettings = splitViewSettings(); + setProject( + "timeline", + "sceneSegments", + props.segmentIndex, + "splitViewSettings", + { + ...currentSettings, + cameraSide: value as "left" | "right", + }, + ); + }} + class="grid grid-cols-2 gap-2" + > + + + + + Left + + + + + + + Right + + + +
+ +
+
+
+ + { + const currentSettings = splitViewSettings(); + setProject( + "timeline", + "sceneSegments", + props.segmentIndex, + "splitViewSettings", + { ...currentSettings, cameraPosition: pos }, + ); + }} + label="Camera" + /> +
+
+
+ + + {((splitViewSettings().cameraZoom || 1) * 100).toFixed(0)} + % + +
+ { + const currentSettings = splitViewSettings(); + setProject( + "timeline", + "sceneSegments", + props.segmentIndex, + "splitViewSettings", + { ...currentSettings, cameraZoom: value }, + ); + }} + /> +
+
+ +
+
+ + { + const currentSettings = splitViewSettings(); + setProject( + "timeline", + "sceneSegments", + props.segmentIndex, + "splitViewSettings", + { ...currentSettings, screenPosition: pos }, + ); + }} + label="Screen" + /> +
+
+
+ + + {((splitViewSettings().screenZoom || 1) * 100).toFixed(0)} + % + +
+ { + const currentSettings = splitViewSettings(); + setProject( + "timeline", + "sceneSegments", + props.segmentIndex, + "splitViewSettings", + { ...currentSettings, screenZoom: value }, + ); + }} + /> +
+
+
+
+
+
+ + i !== props.segmentIndex && s.mode === props.segment.mode, + )} + > +
+ { + projectActions.copySceneSettingsFromOriginal(props.segmentIndex); + }} + leftIcon={} + class="w-full" + > + Copy Settings from Original + +
+
+
+ ); +} diff --git a/apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx b/apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx index 4ab4faf98..a60c95a2f 100644 --- a/apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx +++ b/apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx @@ -8,6 +8,7 @@ import { createEffect, createMemo, createRoot, + createSignal, For, Match, mergeProps, @@ -176,11 +177,15 @@ export function ClipTrack( totalDuration, micWaveforms, systemAudioWaveforms, - metaQuery, } = useEditorContext(); const { secsPerPixel, duration } = useTimelineContext(); + const [startResizePreview, setStartResizePreview] = createSignal<{ + index: number; + previewStart: number; + } | null>(null); + const segments = (): Array => project.timeline?.segments ?? [{ start: 0, end: duration(), timescale: 1 }]; @@ -188,7 +193,10 @@ export function ClipTrack( const { transform } = editorState.timeline; if (transform.position + transform.zoom > totalDuration() + 4) { - transform.updateZoom(totalDuration(), editorState.previewTime!); + transform.updateZoom( + totalDuration(), + editorState.previewTime ?? editorState.playbackTime, + ); } } @@ -439,20 +447,19 @@ export function ClipTrack( start + (event.clientX - downEvent.clientX) * secsPerPixel(); - setProject( - "timeline", - "segments", - i(), - "start", - Math.min( - Math.max( - newStart, - prevSegmentIsSameClip ? prevSegment.end : 0, - segment.end - maxDuration, - ), - segment.end - 1, + const constrained = Math.min( + Math.max( + newStart, + prevSegmentIsSameClip ? prevSegment.end : 0, + segment.end - maxDuration, ), + segment.end - 1, ); + + setStartResizePreview({ + index: i(), + previewStart: constrained, + }); } const resumeHistory = projectHistory.pause(); @@ -463,12 +470,48 @@ export function ClipTrack( dispose(); resumeHistory(); update(e); + const p = startResizePreview(); + if (p && p.index === i()) { + setProject( + "timeline", + "segments", + i(), + "start", + p.previewStart, + ); + } + setStartResizePreview(null); onHandleReleased(); }, }); }); }} /> + { + const p = startResizePreview(); + return p && p.index === i(); + })()} + > + {() => { + const p = startResizePreview(); + const previewWidth = () => + (segment.end - (p?.previewStart ?? segment.start)) / + secsPerPixel(); + const leftOffset = () => + ((p?.previewStart ?? segment.start) - segment.start) / + secsPerPixel(); + return ( +
+ ); + }} + {(() => { const ctx = useSegmentContext(); diff --git a/apps/desktop/src/routes/editor/Timeline/SceneTrack.tsx b/apps/desktop/src/routes/editor/Timeline/SceneTrack.tsx index 4c12bc27f..2981e4aab 100644 --- a/apps/desktop/src/routes/editor/Timeline/SceneTrack.tsx +++ b/apps/desktop/src/routes/editor/Timeline/SceneTrack.tsx @@ -59,6 +59,8 @@ export function SceneTrack(props: { return ; case "hideCamera": return ; + case "splitView": + return ; default: return ; } @@ -70,6 +72,8 @@ export function SceneTrack(props: { return "Camera Only"; case "hideCamera": return "Hide Camera"; + case "splitView": + return "Split View"; default: return "Default"; } diff --git a/apps/desktop/src/routes/editor/context.ts b/apps/desktop/src/routes/editor/context.ts index a504fc7a6..d01ec4b88 100644 --- a/apps/desktop/src/routes/editor/context.ts +++ b/apps/desktop/src/routes/editor/context.ts @@ -159,6 +159,75 @@ export const [EditorContextProvider, useEditorContext] = createContextProvider( setEditorState("timeline", "selection", null); }); }, + duplicateSceneSegment: (segmentIndex: number) => { + if (!project.timeline?.sceneSegments?.[segmentIndex]) return; + const segment = project.timeline.sceneSegments[segmentIndex]; + const segmentDuration = segment.end - segment.start; + const newSegmentStart = segment.end; + const newSegmentEnd = newSegmentStart + segmentDuration; + + const timelineDuration = totalDuration(); + if (newSegmentEnd > timelineDuration) { + return; + } + + const wouldOverlap = project.timeline.sceneSegments.some((s, i) => { + if (i === segmentIndex) return false; // Skip the original segment + return newSegmentStart < s.end && newSegmentEnd > s.start; + }); + + if (wouldOverlap) { + return; + } + + batch(() => { + setProject( + "timeline", + "sceneSegments", + produce((s) => { + if (!s) return; + s.splice(segmentIndex + 1, 0, { + ...segment, + start: newSegmentStart, + end: newSegmentEnd, + splitViewSettings: segment.splitViewSettings + ? { ...segment.splitViewSettings } + : undefined, + }); + }), + ); + setEditorState("timeline", "selection", { + type: "scene", + index: segmentIndex + 1, + }); + setEditorState("playbackTime", newSegmentStart); + const currentZoom = editorState.timeline.transform.zoom; + const targetPosition = Math.max(0, newSegmentStart - currentZoom / 2); + editorState.timeline.transform.setPosition(targetPosition); + }); + }, + copySceneSettingsFromOriginal: (segmentIndex: number) => { + if (!project.timeline?.sceneSegments?.[segmentIndex]) return; + + const currentSegment = project.timeline.sceneSegments[segmentIndex]; + const originalSegment = project.timeline.sceneSegments.find( + (s, i) => i !== segmentIndex && s.mode === currentSegment.mode, + ); + + if (!originalSegment) return; + + setProject( + "timeline", + "sceneSegments", + segmentIndex, + produce((s) => { + if (!s) return; + if (s.mode === "splitView" && originalSegment.splitViewSettings) { + s.splitViewSettings = { ...originalSegment.splitViewSettings }; + } + }), + ); + }, }; createEffect( @@ -338,7 +407,7 @@ function transformMeta({ pretty_name, ...rawMeta }: RecordingMeta) { throw new Error("Instant mode recordings cannot be edited"); } - let meta; + let meta = null; if ("segments" in rawMeta) { meta = { diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index 14ad46d54..11e44e497 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -448,8 +448,8 @@ export type RequestOpenSettings = { page: string } export type RequestScreenCapturePrewarm = { force?: boolean } export type RequestStartRecording = { mode: RecordingMode } export type S3UploadMeta = { id: string } -export type SceneMode = "default" | "cameraOnly" | "hideCamera" -export type SceneSegment = { start: number; end: number; mode?: SceneMode } +export type SceneMode = "default" | "cameraOnly" | "hideCamera" | "splitView" +export type SceneSegment = { start: number; end: number; mode?: SceneMode; splitViewSettings?: SplitViewSettings | null } export type ScreenCaptureTarget = { variant: "window"; id: WindowId } | { variant: "display"; id: DisplayId } | { variant: "area"; screen: DisplayId; bounds: LogicalBounds } export type SegmentRecordings = { display: Video; camera: Video | null; mic: Audio | null; system_audio: Audio | null } export type SerializedEditorInstance = { framesSocketUrl: string; recordingDuration: number; savedProjectConfig: ProjectConfiguration; recordings: ProjectRecordingsMeta; path: string } @@ -457,6 +457,8 @@ export type ShadowConfiguration = { size: number; opacity: number; blur: number export type SharingMeta = { id: string; link: string } export type ShowCapWindow = "Setup" | { Main: { init_target_mode: RecordingTargetMode | null } } | { Settings: { page: string | null } } | { Editor: { project_path: string } } | "RecordingsOverlay" | { WindowCaptureOccluder: { screen_id: DisplayId } } | { TargetSelectOverlay: { display_id: DisplayId } } | { CaptureArea: { screen_id: DisplayId } } | "Camera" | { InProgressRecording: { countdown: number | null } } | "Upgrade" | "ModeSelect" export type SingleSegment = { display: VideoMeta; camera?: VideoMeta | null; audio?: AudioMeta | null; cursor?: string | null } +export type SplitViewSettings = { cameraPosition: XY; screenPosition: XY; cameraSide: SplitViewSide; cameraZoom?: number; screenZoom?: number; fullscreen?: boolean } +export type SplitViewSide = "left" | "right" export type StartRecordingInputs = { capture_target: ScreenCaptureTarget; capture_system_audio?: boolean; mode: RecordingMode } export type StereoMode = "stereo" | "monoL" | "monoR" export type StudioRecordingMeta = { segment: SingleSegment } | { inner: MultipleSegments } diff --git a/crates/project/src/configuration.rs b/crates/project/src/configuration.rs index 289824234..434a07a2b 100644 --- a/crates/project/src/configuration.rs +++ b/crates/project/src/configuration.rs @@ -487,6 +487,45 @@ pub enum SceneMode { Default, CameraOnly, HideCamera, + SplitView, +} + +#[derive(Type, Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct SplitViewSettings { + pub camera_position: XY, + pub screen_position: XY, + pub camera_side: SplitViewSide, + #[serde(default = "default_zoom")] + pub camera_zoom: f64, + #[serde(default = "default_zoom")] + pub screen_zoom: f64, + #[serde(default)] + pub fullscreen: bool, +} + +fn default_zoom() -> f64 { + 1.0 +} + +impl Default for SplitViewSettings { + fn default() -> Self { + Self { + camera_position: XY { x: 0.5, y: 0.5 }, + screen_position: XY { x: 0.5, y: 0.5 }, + camera_side: SplitViewSide::Right, + camera_zoom: 1.0, + screen_zoom: 1.0, + fullscreen: false, + } + } +} + +#[derive(Type, Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub enum SplitViewSide { + Left, + Right, } #[derive(Type, Serialize, Deserialize, Clone, Debug)] @@ -496,6 +535,8 @@ pub struct SceneSegment { pub end: f64, #[serde(default)] pub mode: SceneMode, + #[serde(skip_serializing_if = "Option::is_none")] + pub split_view_settings: Option, } #[derive(Type, Serialize, Deserialize, Clone, Debug)] diff --git a/crates/rendering/src/composite_frame.rs b/crates/rendering/src/composite_frame.rs index ca0061865..220d80f8f 100644 --- a/crates/rendering/src/composite_frame.rs +++ b/crates/rendering/src/composite_frame.rs @@ -26,13 +26,12 @@ pub struct CompositeVideoFrameUniforms { pub shadow_opacity: f32, pub shadow_blur: f32, pub opacity: f32, + pub rounding_mask: f32, pub border_enabled: f32, pub border_width: f32, pub _padding0: f32, - pub _padding1: [f32; 2], - pub _padding1b: [f32; 2], pub border_color: [f32; 4], - pub _padding2: [f32; 4], + pub _padding1: [f32; 2], } impl Default for CompositeVideoFrameUniforms { @@ -53,13 +52,12 @@ impl Default for CompositeVideoFrameUniforms { shadow_opacity: Default::default(), shadow_blur: Default::default(), opacity: 1.0, + rounding_mask: 15.0, border_enabled: 0.0, border_width: 5.0, _padding0: 0.0, - _padding1: [0.0; 2], - _padding1b: [0.0; 2], border_color: [1.0, 1.0, 1.0, 0.8], - _padding2: [0.0; 4], + _padding1: [0.0; 2], } } } diff --git a/crates/rendering/src/layers/mod.rs b/crates/rendering/src/layers/mod.rs index 1469690f2..8f81d2f8d 100644 --- a/crates/rendering/src/layers/mod.rs +++ b/crates/rendering/src/layers/mod.rs @@ -4,6 +4,7 @@ mod camera; mod captions; mod cursor; mod display; +mod shadow; pub use background::*; pub use blur::*; @@ -11,3 +12,4 @@ pub use camera::*; pub use captions::*; pub use cursor::*; pub use display::*; +pub use shadow::*; diff --git a/crates/rendering/src/layers/shadow.rs b/crates/rendering/src/layers/shadow.rs new file mode 100644 index 000000000..916addf20 --- /dev/null +++ b/crates/rendering/src/layers/shadow.rs @@ -0,0 +1,76 @@ +use wgpu::util::DeviceExt; + +use crate::composite_frame::{CompositeVideoFramePipeline, CompositeVideoFrameUniforms}; + +pub struct ShadowLayer { + frame_texture: wgpu::Texture, + frame_texture_view: wgpu::TextureView, + uniforms_buffer: wgpu::Buffer, + pipeline: CompositeVideoFramePipeline, + bind_group: Option, + hidden: bool, +} + +impl ShadowLayer { + pub fn new(device: &wgpu::Device) -> Self { + let frame_texture = CompositeVideoFramePipeline::create_frame_texture(device, 1, 1); + let frame_texture_view = frame_texture.create_view(&Default::default()); + + let pipeline = CompositeVideoFramePipeline::new(device); + + let uniforms_buffer = device.create_buffer_init( + &(wgpu::util::BufferInitDescriptor { + label: Some("ShadowLayer Uniforms Buffer"), + contents: bytemuck::cast_slice(&[CompositeVideoFrameUniforms::default()]), + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + }), + ); + + let bind_group = Some(pipeline.bind_group(device, &uniforms_buffer, &frame_texture_view)); + + Self { + frame_texture, + frame_texture_view, + uniforms_buffer, + pipeline, + bind_group, + hidden: true, + } + } + + pub fn prepare( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + uniforms: Option, + ) { + self.hidden = uniforms.is_none(); + + let Some(uniforms) = uniforms else { + return; + }; + + if self.frame_texture.width() != 1 || self.frame_texture.height() != 1 { + self.frame_texture = CompositeVideoFramePipeline::create_frame_texture(device, 1, 1); + self.frame_texture_view = self.frame_texture.create_view(&Default::default()); + + self.bind_group = Some(self.pipeline.bind_group( + device, + &self.uniforms_buffer, + &self.frame_texture_view, + )); + } + + queue.write_buffer(&self.uniforms_buffer, 0, bytemuck::cast_slice(&[uniforms])); + } + + pub fn render(&self, pass: &mut wgpu::RenderPass<'_>) { + if !self.hidden + && let Some(bind_group) = &self.bind_group + { + pass.set_pipeline(&self.pipeline.render_pipeline); + pass.set_bind_group(0, bind_group, &[]); + pass.draw(0..4, 0..1); + } + } +} diff --git a/crates/rendering/src/lib.rs b/crates/rendering/src/lib.rs index 8fdfc48f6..bd2ad7d03 100644 --- a/crates/rendering/src/lib.rs +++ b/crates/rendering/src/lib.rs @@ -12,6 +12,7 @@ use futures::FutureExt; use futures::future::OptionFuture; use layers::{ Background, BackgroundLayer, BlurLayer, CameraLayer, CaptionsLayer, CursorLayer, DisplayLayer, + ShadowLayer, }; use specta::Type; use spring_mass_damper::SpringMassDamperSimulationConfig; @@ -351,6 +352,9 @@ pub struct ProjectUniforms { display: CompositeVideoFrameUniforms, camera: Option, camera_only: Option, + split_view_camera: Option, + split_view_display: Option, + split_view_shadow: Option, interpolated_cursor: Option, pub project: ProjectConfiguration, pub zoom: InterpolatedZoom, @@ -724,7 +728,11 @@ impl ProjectUniforms { velocity_uv: velocity, motion_blur_amount: (motion_blur_amount + scene.screen_blur as f32 * 0.8).min(1.0), camera_motion_blur_amount: 0.0, - shadow: project.background.shadow, + shadow: if scene.is_split_view() || scene.is_transitioning_split_view() { + 0.0 + } else { + project.background.shadow + }, shadow_size: project .background .advanced_shadow @@ -741,6 +749,7 @@ impl ProjectUniforms { .as_ref() .map_or(50.0, |s| s.blur), opacity: scene.screen_opacity as f32, + rounding_mask: 15.0, border_enabled: if project .background .border @@ -753,8 +762,6 @@ impl ProjectUniforms { }, border_width: project.background.border.as_ref().map_or(5.0, |b| b.width), _padding0: 0.0, - _padding1: [0.0; 2], - _padding1b: [0.0; 2], border_color: if let Some(b) = project.background.border.as_ref() { [ b.color[0] as f32 / 255.0, @@ -765,7 +772,7 @@ impl ProjectUniforms { } else { [1.0, 1.0, 1.0, 0.8] }, - _padding2: [0.0; 4], + _padding1: [0.0; 2], } }; @@ -874,13 +881,12 @@ impl ProjectUniforms { .as_ref() .map_or(50.0, |s| s.blur), opacity: scene.regular_camera_transition_opacity() as f32, + rounding_mask: 15.0, border_enabled: 0.0, border_width: 0.0, _padding0: 0.0, - _padding1: [0.0; 2], - _padding1b: [0.0; 2], border_color: [0.0, 0.0, 0.0, 0.0], - _padding2: [0.0; 4], + _padding1: [0.0; 2], } }); @@ -909,16 +915,11 @@ impl ProjectUniforms { position[1] + size[1], ]; - // In camera-only mode, we ignore the camera shape setting (Square/Source) - // and just apply the minimum crop needed to fill the output aspect ratio. - // This prevents excessive zooming when shape is set to Square. let crop_bounds = if aspect > output_aspect { - // Camera is wider than output - crop left and right let visible_width = frame_size[1] * output_aspect; let crop_x = (frame_size[0] - visible_width) / 2.0; [crop_x, 0.0, frame_size[0] - crop_x, frame_size[1]] } else { - // Camera is taller than output - crop top and bottom let visible_height = frame_size[0] / output_aspect; let crop_y = (frame_size[1] - visible_height) / 2.0; [0.0, crop_y, frame_size[0], frame_size[1] - crop_y] @@ -943,16 +944,324 @@ impl ProjectUniforms { shadow_opacity: 0.0, shadow_blur: 0.0, opacity: scene.camera_only_transition_opacity() as f32, + rounding_mask: 15.0, border_enabled: 0.0, border_width: 0.0, _padding0: 0.0, + border_color: [0.0, 0.0, 0.0, 0.0], _padding1: [0.0; 2], - _padding1b: [0.0; 2], + } + }); + + // TODO: Split view functionality is not implemented in the current codebase + let (split_view_camera, split_view_display, split_view_shadow) = (None, None, None); + + /* + if scene.is_split_view() + || scene.is_transitioning_split_view() + { + let split_settings = project + .timeline + .as_ref() + .and_then(|t| { + let current = t + .scene_segments + .iter() + .find(|s| frame_time as f64 >= s.start && (frame_time as f64) < s.end) + .and_then(|s| s.split_view_settings.as_ref()); + + if current.is_none() && scene.is_transitioning_split_view() { + t.scene_segments + .iter() + .find(|s| { + matches!(s.mode, cap_project::SceneMode::SplitView) + && s.start > frame_time as f64 + && (s.start - frame_time as f64) + < scene::SCENE_TRANSITION_DURATION + }) + .and_then(|s| s.split_view_settings.as_ref()) + } else { + current + } + }) + .cloned() + .unwrap_or_else(SplitViewSettings::default); + + let output_size_f32 = [output_size.0 as f32, output_size.1 as f32]; + + let (split_width, split_height, split_x_offset, split_y_offset) = + if split_settings.fullscreen { + (output_size_f32[0], output_size_f32[1], 0.0, 0.0) + } else { + let display_offset = Self::display_offset(&options, project, resolution_base); + let display_size = Self::display_size(&options, project, resolution_base); + + let display_width = display_size.coord.x as f32; + let display_height = display_size.coord.y as f32; + let display_x = display_offset.coord.x as f32; + let display_y = display_offset.coord.y as f32; + + (display_width, display_height, display_x, display_y) + }; + + let half_width = split_width / 2.0; + + let (camera_x_offset, screen_x_offset) = match split_settings.camera_side { + SplitViewSide::Left => (split_x_offset, split_x_offset + half_width), + SplitViewSide::Right => (split_x_offset + half_width, split_x_offset), + }; + + let split_display = { + let screen_size = options.screen_size; + let crop = Self::get_crop(&options, project); + let frame_size = [screen_size.x as f32, screen_size.y as f32]; + + let crop_size = [crop.size.x as f32, crop.size.y as f32]; + let source_aspect = crop_size[0] / crop_size[1]; + let target_aspect = half_width / split_height; + + let position_factor = split_settings.screen_position.x as f32; + let zoom = split_settings.screen_zoom as f32; + + let zoomed_crop_size = [crop_size[0] / zoom, crop_size[1] / zoom]; + + let (crop_x, crop_y, crop_width, crop_height) = if source_aspect > target_aspect { + let visible_width = zoomed_crop_size[1] * target_aspect; + let max_crop_offset = crop_size[0] - visible_width; + let crop_offset = max_crop_offset * position_factor; + + let zoom_offset_x = (crop_size[0] - zoomed_crop_size[0]) * position_factor; + let zoom_offset_y = (crop_size[1] - zoomed_crop_size[1]) + * split_settings.screen_position.y as f32; + + ( + crop.position.x as f32 + crop_offset + zoom_offset_x, + crop.position.y as f32 + zoom_offset_y, + visible_width, + zoomed_crop_size[1], + ) + } else { + let visible_height = zoomed_crop_size[0] / target_aspect; + let max_crop_offset = crop_size[1] - visible_height; + let crop_offset = max_crop_offset * (split_settings.screen_position.y as f32); + + let zoom_offset_x = (crop_size[0] - zoomed_crop_size[0]) * position_factor; + let zoom_offset_y = (crop_size[1] - zoomed_crop_size[1]) + * split_settings.screen_position.y as f32; + + ( + crop.position.x as f32 + zoom_offset_x, + crop.position.y as f32 + crop_offset + zoom_offset_y, + zoomed_crop_size[0], + visible_height, + ) + }; + + Some(CompositeVideoFrameUniforms { + output_size: output_size_f32, + frame_size, + crop_bounds: [crop_x, crop_y, crop_x + crop_width, crop_y + crop_height], + target_bounds: [ + screen_x_offset, + split_y_offset, + screen_x_offset + half_width, + split_y_offset + split_height, + ], + target_size: [half_width, split_height], + rounding_px: if split_settings.fullscreen { + 0.0 + } else { + let min_axis = split_width.min(split_height); + (project.background.rounding / 100.0 * 0.5 * min_axis as f64) as f32 + }, + mirror_x: 0.0, + velocity_uv: [0.0, 0.0], + motion_blur_amount: 0.0, + camera_motion_blur_amount: 0.0, + shadow: 0.0, + shadow_size: project + .background + .advanced_shadow + .as_ref() + .map_or(50.0, |s| s.size), + shadow_opacity: project + .background + .advanced_shadow + .as_ref() + .map_or(18.0, |s| s.opacity), + shadow_blur: project + .background + .advanced_shadow + .as_ref() + .map_or(50.0, |s| s.blur), + opacity: (scene.split_view_transition_opacity() * scene.screen_opacity) as f32, + rounding_mask: if split_settings.fullscreen { + 0.0 + } else { + match split_settings.camera_side { + SplitViewSide::Left => 10.0, + SplitViewSide::Right => 5.0, + } + }, + border_enabled: 0.0, + border_width: 0.0, + _padding0: 0.0, border_color: [0.0, 0.0, 0.0, 0.0], - _padding2: [0.0; 4], + _padding1: [0.0; 2], + }) + }; + + let split_camera = options.camera_size.map(|camera_size| { + let frame_size = [camera_size.x as f32, camera_size.y as f32]; + let source_aspect = frame_size[0] / frame_size[1]; + let target_aspect = half_width / split_height; + + let cam_pos_x = split_settings.camera_position.x as f32; + let cam_pos_y = split_settings.camera_position.y as f32; + let cam_zoom = split_settings.camera_zoom as f32; + + let zoomed_frame_size = [frame_size[0] / cam_zoom, frame_size[1] / cam_zoom]; + + let crop_bounds = if source_aspect > target_aspect { + let visible_width = zoomed_frame_size[1] * target_aspect; + let max_crop_offset = frame_size[0] - visible_width; + let crop_x = max_crop_offset * cam_pos_x; + + let zoom_offset_x = (frame_size[0] - zoomed_frame_size[0]) * cam_pos_x; + let zoom_offset_y = (frame_size[1] - zoomed_frame_size[1]) * cam_pos_y; + + [ + crop_x + zoom_offset_x, + zoom_offset_y, + crop_x + zoom_offset_x + visible_width, + zoom_offset_y + zoomed_frame_size[1], + ] + } else { + let visible_height = zoomed_frame_size[0] / target_aspect; + let max_crop_offset = frame_size[1] - visible_height; + let crop_y = max_crop_offset * cam_pos_y; + + let zoom_offset_x = (frame_size[0] - zoomed_frame_size[0]) * cam_pos_x; + let zoom_offset_y = (frame_size[1] - zoomed_frame_size[1]) * cam_pos_y; + + [ + zoom_offset_x, + crop_y + zoom_offset_y, + zoom_offset_x + zoomed_frame_size[0], + crop_y + zoom_offset_y + visible_height, + ] + }; + + CompositeVideoFrameUniforms { + output_size: output_size_f32, + frame_size, + crop_bounds, + target_bounds: [ + camera_x_offset, + split_y_offset, + camera_x_offset + half_width, + split_y_offset + split_height, + ], + target_size: [half_width, split_height], + rounding_px: if split_settings.fullscreen { + 0.0 + } else { + let min_axis = split_width.min(split_height); + (project.background.rounding / 100.0 * 0.5 * min_axis as f64) as f32 + }, + mirror_x: if project.camera.mirror { 1.0 } else { 0.0 }, + velocity_uv: [0.0, 0.0], + motion_blur_amount: 0.0, + camera_motion_blur_amount: 0.0, + shadow: 0.0, + shadow_size: project + .background + .advanced_shadow + .as_ref() + .map_or(50.0, |s| s.size), + shadow_opacity: project + .background + .advanced_shadow + .as_ref() + .map_or(18.0, |s| s.opacity), + shadow_blur: project + .background + .advanced_shadow + .as_ref() + .map_or(50.0, |s| s.blur), + opacity: (scene.split_view_transition_opacity() * scene.camera_opacity) as f32, + rounding_mask: if split_settings.fullscreen { + 0.0 + } else { + match split_settings.camera_side { + SplitViewSide::Left => 5.0, + SplitViewSide::Right => 10.0, + } + }, + border_enabled: 0.0, + border_width: 0.0, + _padding0: 0.0, + border_color: [0.0, 0.0, 0.0, 0.0], + _padding1: [0.0; 2], } }); + let split_view_shadow = if split_settings.fullscreen { + None + } else { + let crop = Self::get_crop(&options, project); + let crop_x = crop.position.x as f32; + let crop_y = crop.position.y as f32; + let crop_w = crop.size.x as f32; + let crop_h = crop.size.y as f32; + + let transition_factor = scene.split_view_transition_opacity() as f32; + let adv = &project.background.advanced_shadow; + let adv_size = adv.as_ref().map_or(50.0, |s| s.size); + let adv_opacity = adv.as_ref().map_or(18.0, |s| s.opacity); + let adv_blur = adv.as_ref().map_or(50.0, |s| s.blur); + + Some(CompositeVideoFrameUniforms { + output_size: output_size_f32, + frame_size: [options.screen_size.x as f32, options.screen_size.y as f32], + crop_bounds: [crop_x, crop_y, crop_x + crop_w, crop_y + crop_h], + target_bounds: [ + split_x_offset, + split_y_offset, + split_x_offset + split_width, + split_y_offset + split_height, + ], + target_size: [split_width, split_height], + rounding_px: if split_settings.fullscreen { + 0.0 + } else { + let min_axis = split_width.min(split_height); + (project.background.rounding / 100.0 * 0.5 * min_axis as f64) as f32 + }, + mirror_x: 0.0, + velocity_uv: [0.0, 0.0], + motion_blur_amount: 0.0, + camera_motion_blur_amount: 0.0, + shadow: project.background.shadow * transition_factor, + shadow_size: adv_size, + shadow_opacity: adv_opacity * transition_factor, + shadow_blur: adv_blur, + opacity: 0.0, + rounding_mask: if split_settings.fullscreen { 0.0 } else { 15.0 }, + border_enabled: 0.0, + border_width: 0.0, + _padding0: 0.0, + border_color: [0.0, 0.0, 0.0, 0.0], + _padding1: [0.0; 2], + }) + }; + + (split_camera, split_display, split_view_shadow) + } else { + (None, None, None) + }; + */ + Self { output_size, cursor_size: project.cursor.size as f32, @@ -960,6 +1269,9 @@ impl ProjectUniforms { display, camera, camera_only, + split_view_camera, + split_view_display, + split_view_shadow: split_view_shadow, project: project.clone(), zoom, scene, @@ -1028,6 +1340,9 @@ pub struct RendererLayers { cursor: CursorLayer, camera: CameraLayer, camera_only: CameraLayer, + split_view_camera: CameraLayer, + split_view_display: DisplayLayer, + split_view_shadow: ShadowLayer, #[allow(unused)] captions: CaptionsLayer, } @@ -1041,6 +1356,9 @@ impl RendererLayers { cursor: CursorLayer::new(device), camera: CameraLayer::new(device), camera_only: CameraLayer::new(device), + split_view_camera: CameraLayer::new(device), + split_view_display: DisplayLayer::new(device), + split_view_shadow: ShadowLayer::new(device), captions: CaptionsLayer::new(device, queue), } } @@ -1105,6 +1423,32 @@ impl RendererLayers { })(), ); + self.split_view_display.prepare( + &constants.device, + &constants.queue, + segment_frames, + constants.options.screen_size, + uniforms.split_view_display.unwrap_or_default(), + ); + + self.split_view_camera.prepare( + &constants.device, + &constants.queue, + (|| { + Some(( + uniforms.split_view_camera?, + constants.options.camera_size?, + segment_frames.camera_frame.as_ref()?, + )) + })(), + ); + + self.split_view_shadow.prepare( + &constants.device, + &constants.queue, + uniforms.split_view_shadow, + ); + Ok(()) } @@ -1134,12 +1478,27 @@ impl RendererLayers { }; } - { + let split_view_fullscreen = uniforms + .split_view_display + .map(|u| u.target_bounds[0] < 1.0 && u.target_bounds[1] < 1.0) + .unwrap_or(false); + + // Only skip background when fully in split view, not during transitions + let skip_background = uniforms.scene.is_split_view() + && !uniforms.scene.is_transitioning_split_view() + && split_view_fullscreen; + + if !skip_background { let mut pass = render_pass!( session.current_texture_view(), wgpu::LoadOp::Clear(wgpu::Color::BLACK) ); self.background.render(&mut pass); + } else { + let _pass = render_pass!( + session.current_texture_view(), + wgpu::LoadOp::Clear(wgpu::Color::BLACK) + ); } if self.background_blur.blur_amount > 0.0 { @@ -1150,29 +1509,41 @@ impl RendererLayers { session.swap_textures(); } - if uniforms.scene.should_render_screen() { + // During split view transitions, render screen content for cross-fade effect + let should_render_regular_screen = uniforms.scene.should_render_screen() + && (!split_view_fullscreen || uniforms.scene.is_transitioning_split_view()); + + if should_render_regular_screen { let mut pass = render_pass!(session.current_texture_view(), wgpu::LoadOp::Load); self.display.render(&mut pass); } - if uniforms.scene.should_render_screen() { + if should_render_regular_screen { let mut pass = render_pass!(session.current_texture_view(), wgpu::LoadOp::Load); self.cursor.render(&mut pass); } - // Render camera-only layer when transitioning with CameraOnly mode if uniforms.scene.is_transitioning_camera_only() { let mut pass = render_pass!(session.current_texture_view(), wgpu::LoadOp::Load); self.camera_only.render(&mut pass); } - // Also render regular camera overlay during transitions when its opacity > 0 if uniforms.scene.should_render_camera() && uniforms.scene.regular_camera_transition_opacity() > 0.01 { let mut pass = render_pass!(session.current_texture_view(), wgpu::LoadOp::Load); self.camera.render(&mut pass); } + + if uniforms.scene.is_split_view() || uniforms.scene.is_transitioning_split_view() { + if uniforms.split_view_shadow.is_some() { + let mut pass = render_pass!(session.current_texture_view(), wgpu::LoadOp::Load); + self.split_view_shadow.render(&mut pass); + } + let mut pass = render_pass!(session.current_texture_view(), wgpu::LoadOp::Load); + self.split_view_display.render(&mut pass); + self.split_view_camera.render(&mut pass); + } } } diff --git a/crates/rendering/src/scene.rs b/crates/rendering/src/scene.rs index 9203633cc..4b85aba25 100644 --- a/crates/rendering/src/scene.rs +++ b/crates/rendering/src/scene.rs @@ -97,6 +97,7 @@ impl InterpolatedScene { (SceneMode::CameraOnly, SceneMode::CameraOnly) | (SceneMode::Default, SceneMode::Default) | (SceneMode::HideCamera, SceneMode::HideCamera) + | (SceneMode::SplitView, SceneMode::SplitView) ); if gap < MIN_GAP_FOR_TRANSITION && same_mode { // Small gap between same modes, no transition needed @@ -121,6 +122,7 @@ impl InterpolatedScene { (SceneMode::CameraOnly, SceneMode::CameraOnly) | (SceneMode::Default, SceneMode::Default) | (SceneMode::HideCamera, SceneMode::HideCamera) + | (SceneMode::SplitView, SceneMode::SplitView) ); if gap < MIN_GAP_FOR_TRANSITION && same_mode { // Keep the current mode without transitioning @@ -169,6 +171,7 @@ impl InterpolatedScene { (SceneMode::CameraOnly, SceneMode::CameraOnly) | (SceneMode::Default, SceneMode::Default) | (SceneMode::HideCamera, SceneMode::HideCamera) + | (SceneMode::SplitView, SceneMode::SplitView) ); if gap < MIN_GAP_FOR_TRANSITION && same_mode { (prev_seg.mode, prev_seg.mode, 1.0) @@ -288,6 +291,7 @@ impl InterpolatedScene { SceneMode::Default => (1.0, 1.0, 1.0), SceneMode::CameraOnly => (1.0, 1.0, 1.0), SceneMode::HideCamera => (0.0, 1.0, 1.0), + SceneMode::SplitView => (1.0, 1.0, 1.0), } } @@ -300,23 +304,107 @@ impl InterpolatedScene { } pub fn should_render_screen(&self) -> bool { + // Don't render regular screen during split view (except during transitions) + if matches!(self.scene_mode, SceneMode::SplitView) && !self.is_transitioning_split_view() { + return false; + } + // Always render screen during split view transitions for cross-fade effect + if self.is_transitioning_split_view() { + return true; + } self.screen_opacity > 0.01 || self.screen_blur > 0.01 } + pub fn regular_screen_transition_opacity(&self) -> f64 { + // Handle transitions to/from CameraOnly + if matches!(self.to_mode, SceneMode::CameraOnly) + && !matches!(self.from_mode, SceneMode::CameraOnly) + { + // Keep screen visible underneath camera-only transition + // but fade it slightly to create the overlay effect + let fade = (1.0 - self.transition_progress * 0.3).max(0.7); + fade * self.screen_opacity + } else if matches!(self.from_mode, SceneMode::CameraOnly) + && !matches!(self.to_mode, SceneMode::CameraOnly) + { + // Restore screen opacity as camera-only fades out + let fade = (0.7 + self.transition_progress * 0.3).min(1.0); + fade * self.screen_opacity + } + // Handle transitions to/from SplitView + else if matches!(self.to_mode, SceneMode::SplitView) + && !matches!(self.from_mode, SceneMode::SplitView) + { + // Keep screen visible underneath split view transition for cross-fade effect + // Similar to camera-only, fade slightly but keep visible + let fade = (1.0 - self.transition_progress * 0.3).max(0.7); + fade * self.screen_opacity + } else if matches!(self.from_mode, SceneMode::SplitView) + && !matches!(self.to_mode, SceneMode::SplitView) + { + // Restore screen opacity as split view fades out + let fade = (0.7 + self.transition_progress * 0.3).min(1.0); + fade * self.screen_opacity + } else if matches!(self.from_mode, SceneMode::SplitView) + && matches!(self.to_mode, SceneMode::SplitView) + { + 0.0 + } else { + self.screen_opacity + } + } + pub fn is_transitioning_camera_only(&self) -> bool { matches!(self.from_mode, SceneMode::CameraOnly) || matches!(self.to_mode, SceneMode::CameraOnly) } + pub fn is_split_view(&self) -> bool { + matches!(self.scene_mode, SceneMode::SplitView) + } + + pub fn is_transitioning_split_view(&self) -> bool { + matches!(self.from_mode, SceneMode::SplitView) + || matches!(self.to_mode, SceneMode::SplitView) + } + + pub fn split_view_transition_opacity(&self) -> f64 { + if matches!(self.from_mode, SceneMode::SplitView) + && !matches!(self.to_mode, SceneMode::SplitView) + { + 1.0 - self.transition_progress + } else if !matches!(self.from_mode, SceneMode::SplitView) + && matches!(self.to_mode, SceneMode::SplitView) + { + self.transition_progress + } else if matches!(self.from_mode, SceneMode::SplitView) + && matches!(self.to_mode, SceneMode::SplitView) + { + 1.0 + } else { + 0.0 + } + } + pub fn camera_only_transition_opacity(&self) -> f64 { if matches!(self.from_mode, SceneMode::CameraOnly) && !matches!(self.to_mode, SceneMode::CameraOnly) { - 1.0 - self.transition_progress + // Maintain full opacity until late in transition to ensure coverage + if self.transition_progress < 0.7 { + 1.0 + } else { + ((1.0 - self.transition_progress) / 0.3).max(0.0) + } } else if !matches!(self.from_mode, SceneMode::CameraOnly) && matches!(self.to_mode, SceneMode::CameraOnly) { - self.transition_progress + // Start fading in early to ensure no gaps + if self.transition_progress > 0.3 { + 1.0 + } else { + (self.transition_progress / 0.3).min(1.0) + } } else if matches!(self.from_mode, SceneMode::CameraOnly) && matches!(self.to_mode, SceneMode::CameraOnly) { @@ -327,20 +415,47 @@ impl InterpolatedScene { } pub fn regular_camera_transition_opacity(&self) -> f64 { + // Handle transitions to/from CameraOnly if matches!(self.to_mode, SceneMode::CameraOnly) && !matches!(self.from_mode, SceneMode::CameraOnly) { - let fast_fade = (1.0 - self.transition_progress * 1.5).max(0.0); - fast_fade * self.camera_opacity + // Fade out quickly but maintain full opacity initially to prevent background showing + if self.transition_progress < 0.3 { + self.camera_opacity + } else { + let fast_fade = ((1.0 - self.transition_progress) / 0.7).max(0.0); + fast_fade * self.camera_opacity + } } else if matches!(self.from_mode, SceneMode::CameraOnly) && !matches!(self.to_mode, SceneMode::CameraOnly) { - let fast_fade = (self.transition_progress * 1.5).min(1.0); - fast_fade * self.camera_opacity + // Fade in quickly but ensure no gap + if self.transition_progress > 0.7 { + self.camera_opacity + } else { + let fast_fade = (self.transition_progress / 0.7).min(1.0); + fast_fade * self.camera_opacity + } } else if matches!(self.from_mode, SceneMode::CameraOnly) && matches!(self.to_mode, SceneMode::CameraOnly) { 0.0 + } + // Handle transitions to/from SplitView + else if matches!(self.to_mode, SceneMode::SplitView) + && !matches!(self.from_mode, SceneMode::SplitView) + { + // Fade out regular camera when transitioning to split view + (1.0 - self.transition_progress) * self.camera_opacity + } else if matches!(self.from_mode, SceneMode::SplitView) + && !matches!(self.to_mode, SceneMode::SplitView) + { + // Fade in regular camera when transitioning from split view + self.transition_progress * self.camera_opacity + } else if matches!(self.from_mode, SceneMode::SplitView) + && matches!(self.to_mode, SceneMode::SplitView) + { + 0.0 } else { self.camera_opacity } diff --git a/crates/rendering/src/shaders/composite-video-frame.wgsl b/crates/rendering/src/shaders/composite-video-frame.wgsl index ecfb17d7c..80b119e08 100644 --- a/crates/rendering/src/shaders/composite-video-frame.wgsl +++ b/crates/rendering/src/shaders/composite-video-frame.wgsl @@ -14,11 +14,12 @@ struct Uniforms { shadow_opacity: f32, shadow_blur: f32, opacity: f32, + rounding_mask: f32, // Bitmask: 1=TL, 2=TR, 4=BL, 8=BR border_enabled: f32, border_width: f32, - _padding1: vec2, + _padding0: f32, border_color: vec4, - _padding2: vec4, + _padding1: vec2, }; @group(0) @binding(0) var uniforms: Uniforms; @@ -223,17 +224,37 @@ fn sample_texture(uv: vec2, crop_bounds_uv: vec4) -> vec4 { } fn apply_rounded_corners(current_color: vec4, target_uv: vec2) -> vec4 { - let target_coord = abs(target_uv * uniforms.target_size - uniforms.target_size / 2.0); + let target_coord = target_uv * uniforms.target_size - uniforms.target_size / 2.0; + let abs_coord = abs(target_coord); let rounding_point = uniforms.target_size / 2.0 - uniforms.rounding_px; - let target_rounding_coord = target_coord - rounding_point; - - let distance = abs(length(target_rounding_coord)) - uniforms.rounding_px; + let target_rounding_coord = abs_coord - rounding_point; - let distance_blur = 1.0; - - if target_rounding_coord.x >= 0.0 && target_rounding_coord.y >= 0.0 && distance >= -distance_blur/2.0 { - return vec4(0.0); - // return mix(current_color, vec4(0.0), min(distance / distance_blur + 0.5, 1.0)); + // Determine which corner we're in + let is_left = target_coord.x < 0.0; + let is_top = target_coord.y < 0.0; + + // Calculate corner mask bit (1=TL, 2=TR, 4=BL, 8=BR) + var corner_bit: f32 = 0.0; + if is_top && is_left { + corner_bit = 1.0; // Top-left + } else if is_top && !is_left { + corner_bit = 2.0; // Top-right + } else if !is_top && is_left { + corner_bit = 4.0; // Bottom-left + } else { + corner_bit = 8.0; // Bottom-right + } + + // Check if this corner should be rounded + let should_round = (u32(uniforms.rounding_mask) & u32(corner_bit)) != 0u; + + if target_rounding_coord.x >= 0.0 && target_rounding_coord.y >= 0.0 && should_round { + let distance = abs(length(target_rounding_coord)) - uniforms.rounding_px; + let distance_blur = 1.0; + + if distance >= -distance_blur/2.0 { + return vec4(0.0); + } } return current_color; diff --git a/packages/ui-solid/src/auto-imports.d.ts b/packages/ui-solid/src/auto-imports.d.ts index 22674b3e1..fcb72248f 100644 --- a/packages/ui-solid/src/auto-imports.d.ts +++ b/packages/ui-solid/src/auto-imports.d.ts @@ -22,7 +22,7 @@ declare global { 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 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'] @@ -57,11 +57,13 @@ declare global { 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 IconCapUpload: typeof import('~icons/cap/upload.jsx')['default'] const IconCapX: typeof import('~icons/cap/x.jsx')['default'] const IconCapZoomIn: typeof import('~icons/cap/zoom-in.jsx')['default'] const IconCapZoomOut: typeof import('~icons/cap/zoom-out.jsx')['default'] const IconHugeiconsEaseCurveControlPoints: typeof import('~icons/hugeicons/ease-curve-control-points.jsx')['default'] + const IconLucideAlignLeft: typeof import('~icons/lucide/align-left.jsx')['default'] + const IconLucideAlignRight: typeof import('~icons/lucide/align-right.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'] @@ -69,7 +71,7 @@ declare global { 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 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'] @@ -86,7 +88,7 @@ declare global { const IconLucideUnplug: typeof import('~icons/lucide/unplug.jsx')['default'] const IconLucideVideo: typeof import('~icons/lucide/video.jsx')['default'] const IconLucideVolume2: typeof import('~icons/lucide/volume2.jsx')['default'] - const IconLucideVolumeX: typeof import("~icons/lucide/volume-x.jsx")["default"] + const IconLucideVolumeX: typeof import('~icons/lucide/volume-x.jsx')['default'] const IconMaterialSymbolsScreenshotFrame2Rounded: typeof import('~icons/material-symbols/screenshot-frame2-rounded.jsx')['default'] const IconMdiLoading: typeof import("~icons/mdi/loading.jsx")["default"] const IconMdiMonitor: typeof import('~icons/mdi/monitor.jsx')['default']