Skip to content

fix: prevent stale-config frames from appearing in exported video near trim boundaries#1711

Open
MinitJain wants to merge 4 commits intoCapSoftware:mainfrom
MinitJain:fix/trim-export-flicker
Open

fix: prevent stale-config frames from appearing in exported video near trim boundaries#1711
MinitJain wants to merge 4 commits intoCapSoftware:mainfrom
MinitJain:fix/trim-export-flicker

Conversation

@MinitJain
Copy link
Copy Markdown

@MinitJain MinitJain commented Apr 5, 2026

Problem

When exporting a video with trimmed clips, frames from outside the trimmed region would appear in the exported video at or near trim boundaries — particularly in sections with high motion. This is a race condition in the frontend reactive pipeline between two effects that both fire when the user adjusts a trim handle:

  1. Effect A — reads frameNumberToRender() and emits a renderFrameEvent Tauri event (fast, in-process) directly to the Rust preview renderer
  2. Effect B — reads project (the timeline config including trim boundaries) and calls updateProjectConfigInMemory (async Tauri IPC command) to push the new config to Rust

Because Effect A was created before Effect B in the component tree, SolidJS fires it first within the same reactive batch. This means Rust receives a frame render request referencing the old trim boundaries before the config update arrives. The result: a frame from outside the new trimmed region gets rendered and written into the export.

The same issue existed during preview (flickering while dragging trim handles), but its most visible and impactful symptom was in the exported video.

Root Cause

Two compounding issues:

1. Non-atomic state updates in ClipTrack.tsx

The trim handle mousemove handlers were updating project.timeline.segments[i].start (or .end) and setPreviewTime as two separate signal writes. Between those two writes, SolidJS effects could fire with an inconsistent state — frameNumberToRender would reflect the new previewTime before the segment bounds were updated, causing the wrong frame to be rendered.

2. Wrong effect creation order in Editor.tsx

SolidJS fires independent createEffect calls in creation order when they are triggered in the same reactive batch. The render-frame effect was created before the config-update effect, so it always fired first — emitting renderFrameEvent with the stale config before updateProjectConfigInMemory could run.

Fix

ClipTrack.tsx — wrap both state writes inside batch() so segment.start/segment.end and previewTime are committed atomically before any effects fire:

batch(() => {
  setProject("timeline", "segments", i(), "start", clampedStart);
  setPreviewTime(prevDuration());
});

Editor.tsx — two changes:

  1. Move the config-update effect (trackDeep(project)updateProjectConfigInMemory) to be created before the render-frame effect so it fires first in any shared reactive batch.

  2. Add a skipRenderFrameForConfigUpdate flag. When the config-update effect fires, it sets this flag synchronously and schedules a queueMicrotask to clear it. The render-frame effect checks the flag and short-circuits — preventing a redundant renderFrameEvent from racing with the in-flight updateProjectConfigInMemory.

let skipRenderFrameForConfigUpdate = false;

const emitRenderFrame = (time: number) => {
  if (skipRenderFrameForConfigUpdate) {
    skipRenderFrameForConfigUpdate = false;
    return;
  }
  // ...
};

// Created FIRST — fires first
createEffect(on(() => { trackDeep(project); ... }, () => {
  skipRenderFrameForConfigUpdate = true;
  queueMicrotask(() => { skipRenderFrameForConfigUpdate = false; });
  updateConfigAndRender(frameNumberToRender());
}, { defer: true }));

// Created SECOND — fires second, sees the flag
createEffect(on(() => [frameNumberToRender(), previewResolutionBase()], ([number]) => {
  if (editorState.playing) return;
  renderFrame(number as number);
}, { defer: false }));

Testing

Manually verified on macOS (Apple Silicon) by:

  • Recording a screen capture with high-motion content
  • Trimming both the start and end of the clip
  • Exporting and inspecting the output frame-by-frame

Before the fix: frames from before the trim-start or after the trim-end were visible in the exported video at cut points.
After the fix: exports are clean with no out-of-bounds frames at trim boundaries.

Files Changed

File Change
apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx Wrap trim handle state updates in batch()
apps/desktop/src/routes/editor/Editor.tsx Reorder effects + add skipRenderFrameForConfigUpdate flag

Greptile Summary

This PR fixes a race condition in the desktop video editor where frames from outside the trimmed region appeared in exported videos near trim boundaries. Two root causes are addressed: non-atomic state updates in ClipTrack.tsx (now wrapped in batch()) and incorrect effect creation order in Editor.tsx (config-update effect moved before render-frame effect, plus a skipRenderFrameForConfigUpdate flag to block the leading-edge render until the updated config reaches Rust).

  • ClipTrack.tsx: Both the start and end trim handle mousemove handlers now wrap setProject and setPreviewTime inside batch(), ensuring segment boundaries and preview time are committed atomically before any downstream effects fire.
  • Editor.tsx: The config-update effect is created before the render-frame effect, guaranteeing it fires first in any shared reactive batch. A skipRenderFrameForConfigUpdate boolean flag is set synchronously when the config-update effect runs; the render-frame effect checks this flag and skips the leading-edge render. A queueMicrotask clears the flag after the current synchronous tick.
  • Minor concern: The flag is also cleared eagerly inside emitRenderFrame itself (redundant with the microtask), which could prematurely unblock a second synchronous emitRenderFrame caller in the same flush before the Rust config update completes.
  • Minor concern: The trailing debounce (trailingRenderFrame) fires after the microtask clears the flag and is therefore never blocked by the guard — it implicitly relies on Tauri IPC completing within the ~49 ms debounce window.

Confidence Score: 4/5

This PR is safe to merge — the race condition fix is architecturally sound and the observable export artifact bug is resolved.

The effect-reordering and batch() combination correctly prevents stale-config frames from being rendered at trim boundaries. Two minor style concerns remain: the skip flag is cleared redundantly in emitRenderFrame (could weaken the guard in edge cases with multiple synchronous emitRenderFrame callers), and the trailing debounce relies on an implicit timing assumption about Tauri IPC latency.

apps/desktop/src/routes/editor/Editor.tsx — specifically the skipRenderFrameForConfigUpdate flag management (redundant eager reset) and the unguarded trailingRenderFrame path.

Important Files Changed

Filename Overview
apps/desktop/src/routes/editor/Editor.tsx Config-update effect reordered before render-frame effect and skipRenderFrameForConfigUpdate guard added; flag reset inside emitRenderFrame is redundant and trailingRenderFrame remains unguarded by the flag
apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx Start and end trim handle state updates wrapped in batch() for atomic signal commits — clean and correct fix with no issues

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["MouseMove on trim handle"] --> B["batch(setProject + setPreviewTime)\nAtomic signal update"]
    B --> C["SolidJS reactive batch fires"]
    C --> D["Effect B fires FIRST\n(config-update, created first)"]
    D --> E["skipRenderFrameForConfigUpdate = true"]
    D --> F["queueMicrotask → clear flag"]
    D --> G["updateConfigAndRender()\nthrottledConfigUpdate IPC dispatched immediately"]
    C --> H["Effect A fires SECOND\n(render-frame, created second)"]
    H --> I["renderFrame() → throttledRenderFrame()\n→ emitRenderFrame()"]
    I --> J{"skipRenderFrameForConfigUpdate?"}
    J -- "true" --> K["Leading-edge render SKIPPED ✓"]
    J -- "false" --> L["renderFrameEvent sent to Rust"]
    K --> M["microtask runs: flag cleared"]
    M --> N["trailingRenderFrame fires ~49ms later\n→ emitRenderFrame flag=false\n→ renderFrameEvent sent to Rust"]
    G --> O["updateProjectConfigInMemory IPC\narrives at Rust with NEW config"]
    O --> P["Rust renders frame with correct\ntrim boundaries ✓"]
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: apps/desktop/src/routes/editor/Editor.tsx
Line: 370-374

Comment:
**Redundant flag reset inside `emitRenderFrame`**

The `skipRenderFrameForConfigUpdate` flag is cleared in two places: eagerly inside `emitRenderFrame` at line 372, and again by the `queueMicrotask` at line 436. The reset inside `emitRenderFrame` is redundant since the microtask already owns cleanup.

More importantly, this early reset means that if `emitRenderFrame` is invoked more than once synchronously within the same reactive flush — e.g., the leading-edge throttled render fires *and* an `isExportMode`/`isCropMode` exit handler calls `emitRenderFrame` directly before the microtask runs — only the first call is blocked. The flag is cleared prematurely and the second call bypasses the guard before `updateProjectConfigInMemory` has completed. While the current UI should prevent those callers from coinciding, letting the microtask be the sole owner of flag cleanup is simpler and more defensive:

```suggestion
	const emitRenderFrame = (time: number) => {
		if (skipRenderFrameForConfigUpdate) {
			return;
		}
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: apps/desktop/src/routes/editor/Editor.tsx
Line: 444-453

Comment:
**`trailingRenderFrame` is not protected by the skip flag**

`renderFrame()` at line 449 enqueues both `throttledRenderFrame` (leading-edge, synchronously guarded by the flag) and `trailingRenderFrame` (a debounce with delay `1000/FPS + 16` ms). By the time the trailing debounce fires, the `queueMicrotask` has already cleared `skipRenderFrameForConfigUpdate`, so the trailing render is never blocked by the guard.

In practice this is acceptable — `throttledConfigUpdate` dispatches the IPC on the leading edge and Tauri IPC is typically sub-millisecond, well within the ~49 ms trailing window. However, this is an implicit timing assumption: under system load, if the IPC call takes longer than the debounce window, `renderFrameEvent` can still arrive at Rust before `updateProjectConfigInMemory` completes — recreating the same race this PR intends to fix. Consider adding a comment documenting why the trailing window is safe, or coordinating the trailing render with the in-flight config promise.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "Fix trim handle flickering in editor" | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

(2/5) Greptile learns from your feedback when you react with thumbs up/down!

Wrap trim handle state updates in batch() so project segment and
previewTime update atomically before effects fire.

Reorder effects in Editor.tsx so the config-update effect is created
before the render-frame effect (SolidJS fires effects in creation order).
Add skipRenderFrameForConfigUpdate flag so renderFrameEvent is suppressed
when updateConfigAndRender already handles the render, eliminating the
race condition where a stale-config frame was emitted before the async
config update completed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Comment on lines 370 to +374
const emitRenderFrame = (time: number) => {
if (skipRenderFrameForConfigUpdate) {
skipRenderFrameForConfigUpdate = false;
return;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Redundant flag reset inside emitRenderFrame

The skipRenderFrameForConfigUpdate flag is cleared in two places: eagerly inside emitRenderFrame at line 372, and again by the queueMicrotask at line 436. The reset inside emitRenderFrame is redundant since the microtask already owns cleanup.

More importantly, this early reset means that if emitRenderFrame is invoked more than once synchronously within the same reactive flush — e.g., the leading-edge throttled render fires and an isExportMode/isCropMode exit handler calls emitRenderFrame directly before the microtask runs — only the first call is blocked. The flag is cleared prematurely and the second call bypasses the guard before updateProjectConfigInMemory has completed. While the current UI should prevent those callers from coinciding, letting the microtask be the sole owner of flag cleanup is simpler and more defensive:

Suggested change
const emitRenderFrame = (time: number) => {
if (skipRenderFrameForConfigUpdate) {
skipRenderFrameForConfigUpdate = false;
return;
}
const emitRenderFrame = (time: number) => {
if (skipRenderFrameForConfigUpdate) {
return;
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/routes/editor/Editor.tsx
Line: 370-374

Comment:
**Redundant flag reset inside `emitRenderFrame`**

The `skipRenderFrameForConfigUpdate` flag is cleared in two places: eagerly inside `emitRenderFrame` at line 372, and again by the `queueMicrotask` at line 436. The reset inside `emitRenderFrame` is redundant since the microtask already owns cleanup.

More importantly, this early reset means that if `emitRenderFrame` is invoked more than once synchronously within the same reactive flush — e.g., the leading-edge throttled render fires *and* an `isExportMode`/`isCropMode` exit handler calls `emitRenderFrame` directly before the microtask runs — only the first call is blocked. The flag is cleared prematurely and the second call bypasses the guard before `updateProjectConfigInMemory` has completed. While the current UI should prevent those callers from coinciding, letting the microtask be the sole owner of flag cleanup is simpler and more defensive:

```suggestion
	const emitRenderFrame = (time: number) => {
		if (skipRenderFrameForConfigUpdate) {
			return;
		}
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +444 to +453
createEffect(
on(
() => [frameNumberToRender(), previewResolutionBase()],
([number]) => {
if (editorState.playing) return;
renderFrame(number as number);
},
{ defer: false },
),
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 trailingRenderFrame is not protected by the skip flag

renderFrame() at line 449 enqueues both throttledRenderFrame (leading-edge, synchronously guarded by the flag) and trailingRenderFrame (a debounce with delay 1000/FPS + 16 ms). By the time the trailing debounce fires, the queueMicrotask has already cleared skipRenderFrameForConfigUpdate, so the trailing render is never blocked by the guard.

In practice this is acceptable — throttledConfigUpdate dispatches the IPC on the leading edge and Tauri IPC is typically sub-millisecond, well within the ~49 ms trailing window. However, this is an implicit timing assumption: under system load, if the IPC call takes longer than the debounce window, renderFrameEvent can still arrive at Rust before updateProjectConfigInMemory completes — recreating the same race this PR intends to fix. Consider adding a comment documenting why the trailing window is safe, or coordinating the trailing render with the in-flight config promise.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/routes/editor/Editor.tsx
Line: 444-453

Comment:
**`trailingRenderFrame` is not protected by the skip flag**

`renderFrame()` at line 449 enqueues both `throttledRenderFrame` (leading-edge, synchronously guarded by the flag) and `trailingRenderFrame` (a debounce with delay `1000/FPS + 16` ms). By the time the trailing debounce fires, the `queueMicrotask` has already cleared `skipRenderFrameForConfigUpdate`, so the trailing render is never blocked by the guard.

In practice this is acceptable — `throttledConfigUpdate` dispatches the IPC on the leading edge and Tauri IPC is typically sub-millisecond, well within the ~49 ms trailing window. However, this is an implicit timing assumption: under system load, if the IPC call takes longer than the debounce window, `renderFrameEvent` can still arrive at Rust before `updateProjectConfigInMemory` completes — recreating the same race this PR intends to fix. Consider adding a comment documenting why the trailing window is safe, or coordinating the trailing render with the in-flight config promise.

How can I resolve this? If you propose a fix, please make it concise.

…te reset

Removing the early reset inside emitRenderFrame prevents a second
synchronous call (e.g. export/crop mode exit handlers) from bypassing
the guard before updateProjectConfigInMemory completes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@MinitJain
Copy link
Copy Markdown
Author

Addressed Fix 1 — the early reset inside emitRenderFrame is removed. queueMicrotask is now the sole owner of flag cleanup.

On Fix 2 (trailing render not covered by the flag): agreed this is an implicit timing assumption. The trailing debounce fires at 1000/FPS + 16 ms (~49ms at 30fps), by which point updateProjectConfigInMemory has long completed — Tauri in-process IPC consistently resolves in under 1ms in practice. The trailing render exists solely to catch frames missed by the leading-edge throttle, not to race with config updates.

This codebase has a strict no-comments policy so I haven't added inline documentation, but happy to explore coordinating the trailing render with the config promise if the maintainers feel the timing assumption needs to be made explicit in code rather than prose.

@MinitJain
Copy link
Copy Markdown
Author

@noeljackson
Addressed all review feedback (removed early reset from emitRenderFrame,
queueMicrotask is sole owner of flag cleanup).

No conflicts, just needs maintainer approval for the Vercel workflow to run.

Ready for review!

@noeljackson
Copy link
Copy Markdown
Contributor

@MinitJain i am not a maintainer, i just had 1 pr on this project.

@MinitJain
Copy link
Copy Markdown
Author

@noeljackson Apologies for the confusion!
My bad for assuming. Do you happen to know who the maintainer of this project is?

@noeljackson
Copy link
Copy Markdown
Contributor

@richiemcilroy is, @MinitJain

MinitJain and others added 2 commits April 6, 2026 16:01
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Biome uses case-sensitive ASCII sort (uppercase before lowercase),
so 'type ComponentProps' (C) must precede 'createEffect' (c).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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.

2 participants