Skip to content
4,933 changes: 2,382 additions & 2,551 deletions apps/desktop/src/routes/editor/ConfigSidebar.tsx

Large diffs are not rendered by default.

415 changes: 415 additions & 0 deletions apps/desktop/src/routes/editor/SceneSegmentConfig.tsx

Large diffs are not rendered by default.

71 changes: 57 additions & 14 deletions apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
createEffect,
createMemo,
createRoot,
createSignal,
For,
Match,
mergeProps,
Expand Down Expand Up @@ -176,19 +177,26 @@
totalDuration,
micWaveforms,
systemAudioWaveforms,
metaQuery,
} = useEditorContext();

const { secsPerPixel, duration } = useTimelineContext();

const [startResizePreview, setStartResizePreview] = createSignal<{
index: number;
previewStart: number;
} | null>(null);

const segments = (): Array<TimelineSegment> =>
project.timeline?.segments ?? [{ start: 0, end: duration(), timescale: 1 }];

function onHandleReleased() {
const { transform } = editorState.timeline;

if (transform.position + transform.zoom > totalDuration() + 4) {
transform.updateZoom(totalDuration(), editorState.previewTime!);
transform.updateZoom(
totalDuration(),
editorState.previewTime ?? editorState.playbackTime,
);
}
}

Expand Down Expand Up @@ -439,20 +447,19 @@
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();
Expand All @@ -463,12 +470,48 @@
dispose();
resumeHistory();
update(e);
const p = startResizePreview();
if (p && p.index === i()) {
setProject(
"timeline",
"segments",
i(),
"start",
p.previewStart,
);
}
setStartResizePreview(null);
onHandleReleased();
},
});
});
}}
/>
<Show
when={(() => {
const p = startResizePreview();
return p && p.index === i();
})()}
>
{() => {

Check failure on line 496 in apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx

View workflow job for this annotation

GitHub Actions / Typecheck

No overload matches this call.
const p = startResizePreview();
const previewWidth = () =>
(segment.end - (p?.previewStart ?? segment.start)) /
secsPerPixel();
const leftOffset = () =>
((p?.previewStart ?? segment.start) - segment.start) /
secsPerPixel();
return (
<div
class="absolute z-20 inset-y-0 left-0 pointer-events-none bg-white/30 dark:bg-black/30 ring-1 ring-white/70 dark:ring-white/30 rounded-md"
style={{
left: `${Math.max(0, leftOffset())}px`,
width: `${Math.max(0, previewWidth())}px`,
}}
/>
);
}}
</Show>
Comment on lines +490 to +514
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fix the type error in Show component usage.

The static analysis correctly flags a type mismatch. The Show component passes its when condition result to the children function, but the function signature doesn't accept it.

Apply this diff to fix the type error and eliminate redundant signal access:

 <Show
   when={(() => {
     const p = startResizePreview();
     return p && p.index === i();
   })()}
 >
-  {() => {
-    const p = startResizePreview();
+  {(preview) => {
+    const p = startResizePreview()!;
     const previewWidth = () =>
-      (segment.end - (p?.previewStart ?? segment.start)) /
+      (segment.end - p.previewStart) /
       secsPerPixel();
     const leftOffset = () =>
-      ((p?.previewStart ?? segment.start) - segment.start) /
+      (p.previewStart - segment.start) /
       secsPerPixel();
     return (
       <div
         class="absolute z-20 inset-y-0 left-0 pointer-events-none bg-white/30 dark:bg-black/30 ring-1 ring-white/70 dark:ring-white/30 rounded-md"
         style={{
           left: `${Math.max(0, leftOffset())}px`,
           width: `${Math.max(0, previewWidth())}px`,
         }}
       />
     );
   }}
 </Show>

The non-null assertion on line 497 is now safe because the when condition already verified the preview exists.

🧰 Tools
🪛 GitHub Check: Typecheck

[failure] 496-496:
No overload matches this call.

<SegmentContent class="relative justify-center items-center">
{(() => {
const ctx = useSegmentContext();
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/routes/editor/Timeline/SceneTrack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ export function SceneTrack(props: {
return <IconLucideVideo class="size-3.5" />;
case "hideCamera":
return <IconLucideEyeOff class="size-3.5" />;
case "splitView":
return <IconLucideLayout class="size-3.5" />;
default:
return <IconLucideMonitor class="size-3.5" />;
}
Expand All @@ -70,6 +72,8 @@ export function SceneTrack(props: {
return "Camera Only";
case "hideCamera":
return "Hide Camera";
case "splitView":
return "Split View";
default:
return "Default";
}
Expand Down
71 changes: 70 additions & 1 deletion apps/desktop/src/routes/editor/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
},
Comment on lines +162 to +208
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Deep-clone splitViewSettings when duplicating to avoid shared nested refs

Shallow-spreading splitViewSettings shares cameraPosition/screenPosition objects between segments, causing cross-segment coupling if any in-place mutation slips in elsewhere.

Apply:

-              s.splice(segmentIndex + 1, 0, {
+              s.splice(segmentIndex + 1, 0, {
                 ...segment,
                 start: newSegmentStart,
                 end: newSegmentEnd,
-                splitViewSettings: segment.splitViewSettings
-                  ? { ...segment.splitViewSettings }
-                  : undefined,
+                splitViewSettings: segment.splitViewSettings
+                  ? structuredClone(segment.splitViewSettings)
+                  : undefined,
               });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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);
});
},
diff --git a/apps/desktop/src/routes/editor/context.ts b/apps/desktop/src/routes/editor/context.ts
index e69de29..b7e23f8 100644
++ b/apps/desktop/src/routes/editor/context.ts
@@ -162,19 +162,19 @@ export const editorContext = createContext<EditorContextValue | undefined>(unde
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 }
splitViewSettings: segment.splitViewSettings
? structuredClone(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);
});
},
🤖 Prompt for AI Agents
apps/desktop/src/routes/editor/context.ts around lines 162 to 208, the
duplication currently shallow-copies splitViewSettings which retains nested
object references (cameraPosition/screenPosition) and can cause cross-segment
coupling; fix by deep-cloning splitViewSettings when creating the new segment
(use structuredClone(segment.splitViewSettings) if available, otherwise use a
safe deep-clone utility such as lodash/cloneDeep or
JSON.parse(JSON.stringify(...))) and assign that cloned value to
splitViewSettings on the new segment so no nested object references are shared.

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 };
}
}),
);
},
Comment on lines +209 to +230
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Also deep-clone when copying settings from the “original”

Mirror the duplication fix here to prevent shared nested references.

-            if (s.mode === "splitView" && originalSegment.splitViewSettings) {
-              s.splitViewSettings = { ...originalSegment.splitViewSettings };
-            }
+            if (s.mode === "splitView" && originalSegment.splitViewSettings) {
+              s.splitViewSettings = structuredClone(originalSegment.splitViewSettings);
+            }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 };
}
}),
);
},
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 = structuredClone(originalSegment.splitViewSettings);
}
}),
);
},
🤖 Prompt for AI Agents
In apps/desktop/src/routes/editor/context.ts around lines 209 to 230, when
copying settings from the original segment the code uses a shallow spread ({
...originalSegment.splitViewSettings }) which leaves nested objects shared;
replace the shallow copy with a deep-clone of originalSegment.splitViewSettings
(e.g., use structuredClone(originalSegment.splitViewSettings) or your project’s
cloneDeep utility) and assign that deep-clone to s.splitViewSettings, ensuring
you handle the case where splitViewSettings may be undefined.

};

createEffect(
Expand Down Expand Up @@ -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 = {
Expand Down
6 changes: 4 additions & 2 deletions apps/desktop/src/utils/tauri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,15 +448,17 @@ 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 }
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<number>; screenPosition: XY<number>; 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 }
Expand Down
41 changes: 41 additions & 0 deletions crates/project/src/configuration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<f64>,
pub screen_position: XY<f64>,
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)]
Expand All @@ -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<SplitViewSettings>,
}

#[derive(Type, Serialize, Deserialize, Clone, Debug)]
Expand Down
10 changes: 4 additions & 6 deletions crates/rendering/src/composite_frame.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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],
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions crates/rendering/src/layers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ mod camera;
mod captions;
mod cursor;
mod display;
mod shadow;

pub use background::*;
pub use blur::*;
pub use camera::*;
pub use captions::*;
pub use cursor::*;
pub use display::*;
pub use shadow::*;
Loading
Loading