Skip to content

feat(desktop): add clip transitions and capture-area improvements#2013

Merged
richiemcilroy merged 10 commits into
mainfrom
feature/clip-transitions
Jul 16, 2026
Merged

feat(desktop): add clip transitions and capture-area improvements#2013
richiemcilroy merged 10 commits into
mainfrom
feature/clip-transitions

Conversation

@richiemcilroy

@richiemcilroy richiemcilroy commented Jul 16, 2026

Copy link
Copy Markdown
Member

Summary

  • create bounded clip overlaps with selectable cross-fade and fade-through-black transitions
  • render matching video and audio transitions in preview, playback, and export while keeping captions and secondary tracks aligned
  • add reusable locked area selections with quick aspect-ratio controls and debounced persistence
  • improve crop-grid contrast and place area-recording controls outside the captured region when space allows
  • preserve no-transition fast paths and incrementally prepare outgoing zoom state to avoid first-transition playback stalls

Validation

  • pnpm --dir apps/desktop exec tsc --noEmit
  • pnpm --dir apps/desktop exec vitest run src/routes/editor/clip-transitions.test.ts src/routes/editor/timeline-utils.ts src/routes/editor/captions.ts src/routes/editor/captions-export.ts (27 passed)
  • pnpm --dir apps/desktop exec vitest run src/utils/area-selection.test.ts (6 passed)
  • cargo check -p cap-rendering -p cap-editor -p cap-export -p cap-desktop --lib
  • cargo clippy -q -p cap-project -p cap-rendering -p cap-editor -p cap-export --all-targets -- -D warnings
  • cargo test -q -p cap-project --lib (39 passed)
  • cargo test -q -p cap-rendering --lib (117 passed, 1 ignored)
  • cargo test -q -p cap-editor -p cap-export --lib (27 passed)
  • cargo test -q -p cap-desktop fake_window::tests --lib (12 passed)
  • manual cap-desktop playback verification for transition orientation and smoothness

Greptile Summary

This PR introduces clip transitions (cross-fade and fade-through-black) across preview, playback, and export, alongside a locked area-selection feature with quick aspect-ratio controls for the capture overlay. It is a substantial, multi-layer change touching the GPU compositor, audio mixer, Rust project model, playback prefetcher, and SolidJS editor context.

  • Transitions: A new ClipTransition type is added to TimelineConfiguration; get_frame_mapping drives all rendering paths; the GPU compositor (transition.rs), audio cross-fade (audio.rs), and prefetch pipeline (playback.rs) all consume the same TimelineFrameMapping enum, keeping the fast-path (no transitions) zero-cost.
  • Area selection: AREA_SELECTION_STORAGE_SYNC now guards against multiple window.addEventListener calls via a subscribed flag (resolving the pre-existing listener-accumulation concern), and locked bounds are persisted with a 180 ms debounce.
  • Timeline utilities: cutClipSegmentsForRange and rippleDeleteAllTracks are transition-aware; caption and transcript deletion guards against cutting through a transition zone.

Confidence Score: 5/5

Safe to merge. The change is large but well-validated: 33 new tests across Rust and TypeScript, manual playback verification, and clippy/tsc clean. The one inconsistency (direct slice indexing in two export preview functions vs. the safe .get() used in preview.rs) would produce an unhelpful panic on a malformed project rather than a clean error, but does not affect correct projects.

All rendering paths (GPU, audio, zoom-spring, prefetch, export, captions, transcript) have been updated consistently to route through get_frame_mapping; the no-transition fast paths are preserved with explicit early returns; the subscribed guard in AREA_SELECTION_STORAGE_SYNC fixes the pre-existing listener accumulation issue; and extensive test coverage covers edge cases like fractional sample boundaries, audio midpoint silence, deduplication of transition JSON, and transition-aware cut geometry.

apps/desktop/src-tauri/src/export.rs — two preview functions access render_segments[recording_clip as usize] without bounds checking.

Important Files Changed

Filename Overview
crates/project/src/configuration.rs Adds ClipTransition, ClipTransitionType, TimelineFrameMapping, and TimelineSource types; adds get_frame_mapping and effective_transition to TimelineConfiguration; normalizes transition sort order on deserialization via BTreeMap; well-tested with 5 new unit tests.
crates/rendering/src/transition.rs New GPU transition compositor using wgpu: allocates textures for outgoing/incoming frames, writes a 4-byte uniform block (progress, kind, opaque, padding), and runs a fullscreen render pass via a custom WGSL shader; texture re-allocation only on size change.
crates/editor/src/audio.rs New render_timeline_transition_frame_raw path with per-sample crossfade mixing (equal-power for CrossFade, linear fade-through-silence for FadeThroughBlack); correctly delegates to get_frame_mapping; well-tested with 3 new audio transition tests.
crates/editor/src/playback.rs Extends prefetch pipeline to decode both outgoing and incoming frames concurrently for transition frames; correctly falls back to single-frame path when no transitions; outgoing_zoom_timelines built only when transitions exist; direct slice indexing used rather than safe .get() for segment_medias in render path.
crates/rendering/src/zoom_spring.rs Adds recording_clip-scoped time mapping so that the zoom spring correctly resolves timeline positions within transition zones for both outgoing and incoming clips; adds fast path for zero-zoom-segment timelines to avoid expensive precompute; verified with new test.
apps/desktop/src/routes/editor/clip-transitions.ts New module with all client-side transition math: offset computation, clamping, normalization, ripple, and segment-lifecycle helpers (split/delete/move); thoroughly tested; logic matches the Rust implementation.
apps/desktop/src/routes/editor/context.ts Adds setClipTransition, deleteClipTransition, and normalizeClipTransitions project actions; updates splitClipSegment and deleteClipSegment to maintain transition invariants; totalDuration now transition-aware; setTimescale moves timescale assignment earlier to compute correct new offsets.
apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx Adds drag-to-resize transition zones, a popover editor (type + duration slider), and an add-transition button per clip boundary; selectClip helper replaces duplicated multi-select logic; handle minimum sizes expanded to respect transition durations.
apps/desktop/src/routes/editor/timeline-utils.ts cutClipSegmentsForRange now accepts transitions and returns updated transitions; rippleDeleteFromTrack takes explicit shiftDuration; rippleDeleteAllTracks computes actual timeline delta after cut for correct ripple on all secondary tracks; new in-file test for overlap-aware cut.
apps/desktop/src-tauri/src/export.rs Transition-aware rendering in three preview paths; duration estimate now uses timeline.duration() instead of summing segment durations; outgoing clip accessed via direct slice indexing without bounds check in two paths.
apps/desktop/src/routes/target-select-overlay.tsx Adds persistent locked area selection (stored via AREA_SELECTION_STORAGE_SYNC with 180ms debounce), quick aspect-ratio toolbar outside the captured region, and recording controls styled with LIQUID_GLASS_SURFACE_CLASS; screenshot mode retains its own local aspect/snap state.
apps/desktop/src/utils/area-selection.ts New module for persisted area-selection preferences; AREA_SELECTION_STORAGE_SYNC uses a subscribed guard so window.addEventListener is called exactly once regardless of mount count; tested with 6 unit tests.

Comments Outside Diff (1)

  1. apps/desktop/src/routes/editor/timeline-utils.ts, line 1120-1128 (link)

    P2 Imports placed after executable code

    The import block for ClipTransition, clipTimelineDuration, clipTimelineOffsets, transitionsAfterClipDelete, and transitionsAfterClipSplit is placed at the end of the file, after the if (import.meta.vitest) test block. While ESM hoisting makes this work at runtime, static import declarations are conventionally placed at the top of the module — this would violate import/first lint rules and makes the dependency graph harder to follow. Move this import block to the top of the file alongside the other imports.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: apps/desktop/src/routes/editor/timeline-utils.ts
    Line: 1120-1128
    
    Comment:
    **Imports placed after executable code**
    
    The `import` block for `ClipTransition`, `clipTimelineDuration`, `clipTimelineOffsets`, `transitionsAfterClipDelete`, and `transitionsAfterClipSplit` is placed at the end of the file, after the `if (import.meta.vitest)` test block. While ESM hoisting makes this work at runtime, static `import` declarations are conventionally placed at the top of the module — this would violate `import/first` lint rules and makes the dependency graph harder to follow. Move this import block to the top of the file alongside the other imports.
    
    How can I resolve this? If you propose a fix, please make it concise.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Reviews (5): Last reviewed commit: "fix(recording): satisfy range clippy lin..." | Re-trigger Greptile

@cursor

cursor Bot commented Jul 16, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

Comment on lines +1118 to +1124
let transition_position = self
.transitions
.partition_point(|transition| transition.segment_index as usize <= segment_index);
let transition = self.transitions.get(transition_position.checked_sub(1)?)?;
if transition.segment_index as usize != segment_index {
return None;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

effective_transition relies on partition_point, which assumes self.transitions is sorted by segment_index. That’s true after deserializing, but since transitions is public it’s easy for in-memory mutations to accidentally break that invariant.

A simple scan keeps this robust and the list should stay tiny anyway:

Suggested change
let transition_position = self
.transitions
.partition_point(|transition| transition.segment_index as usize <= segment_index);
let transition = self.transitions.get(transition_position.checked_sub(1)?)?;
if transition.segment_index as usize != segment_index {
return None;
}
let transition = self
.transitions
.iter()
.rev()
.find(|transition| transition.segment_index as usize == segment_index)?;

.await
.map_err(|e| format!("Failed to render frame: {e}"))?;
let frame = if let Some((outgoing, kind, progress)) = transition_mapping {
let outgoing_segment = &render_segments[outgoing.segment.recording_clip as usize];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This index access can panic if recording_clip isn’t a dense 0..N mapping into render_segments (or if the timeline gets out of sync). Seems safer to turn it into a nice error like the other decode/render failure paths.

Suggested change
let outgoing_segment = &render_segments[outgoing.segment.recording_clip as usize];
let outgoing_segment = render_segments
.get(outgoing.segment.recording_clip as usize)
.ok_or_else(|| {
format!(
"Missing render segment for recording_clip={}",
outgoing.segment.recording_clip
)
})?;

}
}

pub fn ensure_size(&mut self, device: &wgpu::Device, width: u32, height: u32) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Minor edge case: wgpu will generally error if you try to create a 0x0 texture (can happen during resize/minimize). A small guard here avoids surprising failures.

Suggested change
pub fn ensure_size(&mut self, device: &wgpu::Device, width: u32, height: u32) {
if width == 0 || height == 0 {
self.textures = None;
return;
}

@richiemcilroy richiemcilroy changed the title feat(editor): add clip transitions feat(desktop): add clip transitions and capture-area improvements Jul 16, 2026
@richiemcilroy

Copy link
Copy Markdown
Member Author

hey @greptileai, please re-review the PR

Comment on lines +1 to +16
import type {
PersistenceSyncAPI,
PersistenceSyncData,
} from "@solid-primitives/storage";
import type { CropBounds, Ratio } from "~/components/Cropper";
import type { DisplayId } from "~/utils/tauri";

export const AREA_SELECTION_STORAGE_KEY = "target-select-area-preferences-v1";
export const AREA_SELECTION_STORAGE_SYNC: PersistenceSyncAPI = [
(subscriber) =>
window.addEventListener("storage", (event) => {
const data = areaSelectionSyncData(event);
if (data) subscriber(data);
}),
() => {},
];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

PersistenceSyncAPI's update hook is currently a no-op, so multiple makePersisted(...) consumers in the same window won't be notified (since storage events don't fire on the same document), and each subscriber adds another storage listener.

Suggested change
import type {
PersistenceSyncAPI,
PersistenceSyncData,
} from "@solid-primitives/storage";
import type { CropBounds, Ratio } from "~/components/Cropper";
import type { DisplayId } from "~/utils/tauri";
export const AREA_SELECTION_STORAGE_KEY = "target-select-area-preferences-v1";
export const AREA_SELECTION_STORAGE_SYNC: PersistenceSyncAPI = [
(subscriber) =>
window.addEventListener("storage", (event) => {
const data = areaSelectionSyncData(event);
if (data) subscriber(data);
}),
() => {},
];
import type {
PersistenceSyncAPI,
PersistenceSyncCallback,
PersistenceSyncData,
} from "@solid-primitives/storage";
import type { CropBounds, Ratio } from "~/components/Cropper";
import type { DisplayId } from "~/utils/tauri";
export const AREA_SELECTION_STORAGE_KEY = "target-select-area-preferences-v1";
const areaSelectionSubscribers = new Set<PersistenceSyncCallback>();
let areaSelectionListening = false;
export const AREA_SELECTION_STORAGE_SYNC: PersistenceSyncAPI = [
(subscriber) => {
areaSelectionSubscribers.add(subscriber);
if (areaSelectionListening) return;
areaSelectionListening = true;
window.addEventListener("storage", (event) => {
const data = areaSelectionSyncData(event);
if (!data) return;
for (const sub of areaSelectionSubscribers) sub(data);
});
},
(key, newValue) => {
const data: PersistenceSyncData = {
key,
newValue,
timeStamp: performance.now(),
};
for (const sub of areaSelectionSubscribers) sub(data);
},
];

export type TargetUnderCursor = { display_id: DisplayId | null; window: WindowUnderCursor | null }
export type TextSegment = { start: number; end: number; track?: number; enabled?: boolean; content?: string; center?: XY<number>; size?: XY<number>; fontFamily?: string; fontSize?: number; fontWeight?: number; italic?: boolean; color?: string; fadeDuration?: number }
export type TimelineConfiguration = { segments: TimelineSegment[]; zoomSegments: ZoomSegment[]; sceneSegments?: SceneSegment[]; maskSegments?: MaskSegment[]; textSegments?: TextSegment[]; captionSegments?: CaptionTrackSegment[]; keyboardSegments?: KeyboardTrackSegment[]; audioSegments?: AudioTrackSegment[] }
export type TimelineConfiguration = { segments: TimelineSegment[]; transitions: ClipTransition[]; zoomSegments: ZoomSegment[]; sceneSegments?: SceneSegment[]; maskSegments?: MaskSegment[]; textSegments?: TextSegment[]; captionSegments?: CaptionTrackSegment[]; keyboardSegments?: KeyboardTrackSegment[]; audioSegments?: AudioTrackSegment[] }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

TimelineConfiguration.transitions is marked required here, but the Rust config skips serializing transitions when empty. Most existing projects will load a timeline without this field, and a lot of the editor code already uses timeline.transitions ?? [].

Suggested change
export type TimelineConfiguration = { segments: TimelineSegment[]; transitions: ClipTransition[]; zoomSegments: ZoomSegment[]; sceneSegments?: SceneSegment[]; maskSegments?: MaskSegment[]; textSegments?: TextSegment[]; captionSegments?: CaptionTrackSegment[]; keyboardSegments?: KeyboardTrackSegment[]; audioSegments?: AudioTrackSegment[] }
export type TimelineConfiguration = { segments: TimelineSegment[]; transitions?: ClipTransition[]; zoomSegments: ZoomSegment[]; sceneSegments?: SceneSegment[]; maskSegments?: MaskSegment[]; textSegments?: TextSegment[]; captionSegments?: CaptionTrackSegment[]; keyboardSegments?: KeyboardTrackSegment[]; audioSegments?: AudioTrackSegment[] }

Comment thread apps/desktop/src/utils/area-selection.ts Outdated
Comment thread apps/desktop/src/utils/area-selection.ts Outdated
Comment thread apps/desktop/src/routes/editor/captions.ts
Comment thread crates/project/src/configuration.rs
Comment thread crates/editor/src/audio.rs
@richiemcilroy

Copy link
Copy Markdown
Member Author

hey @greptileai, please re-review the PR

@richiemcilroy

Copy link
Copy Markdown
Member Author

hey @greptileai, please re-review the PR

.await
.map_err(|e| format!("Failed to render frame: {e}"))?;
let frame = if let Some((outgoing, kind, progress)) = transition_mapping {
let outgoing_media = &editor.segment_medias[outgoing.segment.recording_clip as usize];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This index access can panic if recording_clip ever isn’t a dense 0..N mapping into segment_medias (or if the timeline gets out of sync). Feels nicer to return a structured error like the other decode/render paths.

Suggested change
let outgoing_media = &editor.segment_medias[outgoing.segment.recording_clip as usize];
let outgoing_media = editor
.segment_medias
.get(outgoing.segment.recording_clip as usize)
.ok_or_else(|| {
format!(
"Missing segment media for recording_clip={}",
outgoing.segment.recording_clip
)
})?;

is_initial_frame: bool,
fps: u32,
) -> Option<DecodedSegmentFrames> {
let render_segment = &render_segments[source.segment.recording_clip as usize];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Minor safety thing: this can panic if recording_clip doesn’t line up with render_segments (even if that’s “not supposed to happen”). Since this fn already returns Option, it can just fall back cleanly.

Suggested change
let render_segment = &render_segments[source.segment.recording_clip as usize];
let render_segment = render_segments.get(source.segment.recording_clip as usize)?;

@richiemcilroy

Copy link
Copy Markdown
Member Author

hey @greptileai, please re-review the PR

@richiemcilroy
richiemcilroy merged commit f21c8c8 into main Jul 16, 2026
22 of 24 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant