Skip to content

fix(projects): streamline import and save flow#679

Open
webadderall wants to merge 7 commits into
mainfrom
pr/project-save-flow
Open

fix(projects): streamline import and save flow#679
webadderall wants to merge 7 commits into
mainfrom
pr/project-save-flow

Conversation

@webadderall

@webadderall webadderall commented Jun 15, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • allow importing Recordly project files alongside media files from the launch flow and project browser
  • replace first-time project save with an in-app modal; existing project Save As uses the named-project rename/update flow
  • stop transient webcam attachment fields from causing unsaved-change prompts while still tracking webcam presentation edits
  • validate project file shape before committing imported project state
  • register .recordly as a macOS document type using the Recordly app icon

Validation

  • npm exec -- vitest run src/components/video-editor/captionEditing.test.ts src/components/video-editor/autoCaptionSource.test.ts src/components/video-editor/projectDirtyState.test.ts electron/ipc/project/manager.test.ts
  • npm exec -- biome check electron-builder.json5 electron/ipc/project/manager.ts electron/ipc/project/manager.test.ts src/components/video-editor/projectDirtyState.ts src/components/video-editor/projectDirtyState.test.ts electron/electron-env.d.ts electron/ipc/register/captions.ts electron/preload.ts src/components/launch/hooks/useLaunchWindowActions.ts src/components/video-editor/ProjectBrowserDialog.tsx src/components/video-editor/VideoEditor.tsx src/components/video-editor/videoPlayback/zoomRegionUtils.ts
  • npm exec -- tsc --noEmit
  • node JSON5 parse check for electron-builder.json5

Summary by CodeRabbit

  • New Features
    • Import Recordly projects (and media) from the media picker and jump straight into editing.
    • Added an Import button to the project browser.
    • Added promise-based Save Project / Save As flow.
    • Enabled editing active auto-captions from playback UI (including sound-effect cues).
  • Bug Fixes
    • Unsaved changes detection now ignores transient webcam attachment/media fields.
    • Invalid project files are rejected cleanly before media handling.
    • Caption editing/generation now better handles empty/invalid word entries.
  • Refactor
    • Improved preview skipping between keyframes; reduced zoom/pan animation transitions.
    • Updated annotation rendering to better track transformed viewports.
  • Packaging
    • macOS now advertises the Recordly Project file type.

@coderabbitai

coderabbitai Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The PR extends the Electron video file picker to support project file selection alongside media with early validation to reject malformed projects before media approval, introduces a Promise-based "Save Project" dialog to VideoEditor, adds an Import button to ProjectBrowserDialog, adds unified media/project import handling in the editor, enables inline auto-caption editing in VideoPlayback with improved caption word normalization for blank entries and editor wiring, refactors AnnotationOverlay to use a scene-transform-based coordinate system and threads scene transformations through the annotation export rendering pipeline, fixes transient webcam fields being counted as unsaved changes, registers the Recordly project file type on macOS, and removes the connected zoom transition path from zoomRegionUtils.

Changes

Project Import, Validation, and Save As Dialog

Layer / File(s) Summary
File picker IPC contract and handler
electron/electron-env.d.ts, electron/ipc/register/captions.ts, electron/preload.ts
openVideoFilePicker type is expanded to accept { includeProjects? } and return kind: "media" | "project" with extension/project payloads; the IPC handler conditionally adds project filters, loads project files via loadProjectFromPath, and returns typed results; the preload bridge forwards the new options argument.
Project file format validation
electron/ipc/project/manager.ts, electron/ipc/project/manager.test.ts
Introduces isLoadableProjectData schema validator that checks parsed project JSON before media-path resolution; loadProjectFromPath rejects invalid payloads early with "Invalid project file format" message, preventing approval side-effects on malformed files. Tests confirm validation occurs before media path approval.
Launch window project import routing
src/components/launch/hooks/useLaunchWindowActions.ts
openVideoFile passes { includeProjects: true } to the picker and adds an early-return branch switching to the editor directly when result.kind === "project".
ProjectBrowserDialog import button
src/components/video-editor/ProjectBrowserDialog.tsx
Adds optional onImportFile prop; conditionally renders an Import button in both inline and floating header layouts when the prop is provided.
Save Project dialog state and Promise contract
src/components/video-editor/VideoEditor.tsx
Defines PendingProjectSaveDialog type, adds React state for open/draft-name/saving, adds refs for the input element and pending resolver, and adds a useEffect that focuses the dialog input when it opens.
Save dialog callbacks and saveProject routing
src/components/video-editor/VideoEditor.tsx
Implements resolveProjectSaveDialog/openProjectSaveDialog as a Promise pair; updates saveProject to route forceSaveAs/no-path through the dialog; adds handleProjectSaveDialogSubmit that validates, saves, and resolves or re-focuses on failure. Refactors preview skip-back/skip-forward keyframe seeking via array search with fallback offsets.
Editor import handler, dialog JSX, layout wiring, and caption auto-enable removal
src/components/video-editor/VideoEditor.tsx
Adds handleImportMediaOrProject for unified project/media import; wires it to the project browser; defines projectSaveDialog JSX; renders the dialog on loading, error, and main editor screens. Removes automatic enabling of autoCaptionSettings after caption generation and reformats showCaptionSidecarOption to a conditional expression.

Inline Auto-Caption Editing in Playback

Layer / File(s) Summary
Caption edit session and VideoPlayback props
src/components/video-editor/VideoPlayback.tsx
Introduces CaptionEditSession and scene-transform state types; adds optional onEditAutoCaption prop to VideoPlaybackProps for handling caption text edits.
Caption edit UI, interaction logic, and annotation scene transform state
src/components/video-editor/VideoPlayback.tsx
Adds caption edit textarea refs/state, computes edit session derived state and memoized sizing, implements edit control flow (begin/commit/cancel) with focus/resize effects, updates caption container styling to enable pointer interaction, adds keyboard/click accessibility, switches caption rendering between word-by-word display and editable textarea. Tracks annotationSceneTransform (scale/x/y) in React state and updates it during animation ticks with epsilon checks to avoid state churn.
Caption word normalization for blank entries
src/components/video-editor/captionLayout.ts, src/components/video-editor/captionEditing.ts, src/components/video-editor/captionEditing.test.ts
Updates splitCaptionWords to check that resulting word array is non-empty after filtering before returning; updates normalizeCaptionWords to validate that word text normalizes to non-empty after trim/whitespace collapse. Adds test ensuring sound-effect captions with blank word entries remain editable.
Editor caption edit handler and prop wiring
src/components/video-editor/VideoEditor.tsx
Adds handleSaveAutoCaptionEdit to update auto-caption cues and emit success toast, then wires it into VideoPlayback via the onEditAutoCaption prop.

Annotation Scene Transform Support in Export Rendering

Layer / File(s) Summary
AnnotationOverlay scene-based coordinate system
src/components/video-editor/AnnotationOverlay.tsx
Refactors AnnotationOverlay to replace containerWidth/containerHeight props with recordingRect and sceneTransform (including scale); introduces clampPercent and screenRectToRecordingPercent helper for drag/resize conversion; scales text padding/font/border-radius by scene scale and recomputes blur scale factor from recording sizing and scene scale.
AnnotationSceneTransform type and rendering helpers
src/lib/exporter/annotationRenderer.ts
Introduces AnnotationSceneTransform and AnnotationCoordinateRect types and transformAnnotationRect helper that translates/scales annotation rectangles when a scene transform is provided; computes effective scale factors combining renderer scale with scene transform scale; updates annotation/blur/arrow rendering to use the effective scale.
Export rendering pipeline with scene transforms
src/lib/exporter/frameRenderer.ts, src/lib/exporter/modernFrameRenderer.ts
Updates renderAnnotations calls to pass scene transforms derived from temporal snapshots or animation state; adjusts annotation container parenting in modernFrameRenderer to nest under cameraContainer; reorders type imports.

Transient Webcam Dirty State Fix

Layer / File(s) Summary
omitTransientWebcamMediaFields and dirty-state update
src/components/video-editor/projectDirtyState.ts, src/components/video-editor/projectDirtyState.test.ts
Adds omitTransientWebcamMediaFields to strip enabled, sourcePath, and timeOffsetMs from the webcam snapshot; updates hasUnsavedProjectChanges to apply this helper before deep equality comparison. Tests verify transient fields are ignored while persistent presentation changes are detected.

macOS Project File Type Registration

Layer / File(s) Summary
electron-builder.json5 project file type metadata
electron-builder.json5
Extends mac.extendInfo with CFBundleDocumentTypes and UTExportedTypeDeclarations declaring the app's custom project document type (extension recordly, MIME type application/x-recordly-project).

Zoom Region Transition Removal

Layer / File(s) Summary
Drop connected zoom transition logic
src/components/video-editor/videoPlayback/zoomRegionUtils.ts
Narrows mathUtils import to remove cubicBezier, removes lerp/easing/getLinearFocus helpers, and removes getConnectedRegionTransition so findDominantRegion always returns transition: null.

Sequence Diagram(s)

sequenceDiagram
  rect rgba(100, 149, 237, 0.5)
    Note over Renderer,loadProjectFromPath: Project/Media Import Flow with Validation
  end
  participant Renderer as VideoEditor / LaunchWindow
  participant PreloadBridge as electronAPI.openVideoFilePicker
  participant IPCHandler as open-video-file-picker
  participant loadProjectFromPath as loadProjectFromPath + validation

  Renderer->>PreloadBridge: openVideoFilePicker({ includeProjects: true })
  PreloadBridge->>IPCHandler: ipcRenderer.invoke("open-video-file-picker", options)
  IPCHandler->>IPCHandler: show OS dialog (video + project filters)
  alt project file selected
    IPCHandler->>loadProjectFromPath: loadProjectFromPath(filePath)
    alt valid project
      loadProjectFromPath-->>IPCHandler: projectResult
      IPCHandler-->>PreloadBridge: { success, kind: "project", project, ... }
    else invalid project
      loadProjectFromPath-->>IPCHandler: { success: false, message: "Invalid project file format" }
      IPCHandler-->>PreloadBridge: error result
    end
    PreloadBridge-->>Renderer: result
    Renderer->>Renderer: applyLoadedProjectState / switchToEditor
  else media file selected
    IPCHandler-->>PreloadBridge: { success, kind: "media", path, extension }
    PreloadBridge-->>Renderer: result
    Renderer->>Renderer: setCurrentVideoPath / reset editor state
  end
Loading
sequenceDiagram
  rect rgba(144, 238, 144, 0.5)
    Note over saveProject,saveProjectWithName: Save Project Dialog Flow
  end
  participant saveProject
  participant openProjectSaveDialog
  participant SaveDialog as Save Project Dialog UI
  participant handleProjectSaveDialogSubmit
  participant saveProjectWithName

  saveProject->>openProjectSaveDialog: forceSaveAs or no project path
  openProjectSaveDialog->>SaveDialog: open dialog, set draft name
  SaveDialog-->>saveProject: Promise<boolean> (pending)
  Note over SaveDialog: user types name, clicks Save
  SaveDialog->>handleProjectSaveDialogSubmit: submit
  handleProjectSaveDialogSubmit->>saveProjectWithName: trimmed name
  saveProjectWithName-->>handleProjectSaveDialogSubmit: result
  handleProjectSaveDialogSubmit->>openProjectSaveDialog: resolve(true)
  openProjectSaveDialog-->>saveProject: saved = true
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

  • webadderallorg/Recordly#545: Introduced the projectDirtyState.ts module and wired VideoEditor.tsx to it — this PR extends that same module with transient webcam field exclusion.
  • webadderallorg/Recordly#648: Both PRs modify zoomRegionUtils.ts to disable the connected zoom transition path in findDominantRegion.
  • webadderallorg/Recordly#392: Both PRs add inline auto-caption editing to VideoPlayback.tsx via the onEditAutoCaption prop and related caption edit session state and interaction handlers.

Suggested labels

enhancement, video-editing, project-management

Poem

🐰 A rabbit hops through picker flows so bright,
Projects imported, validated right!
Captions dance in editable view,
Save dialogs promise what is true,
Webcam transience fades to night, zoom transitions take their flight! 🎬

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix(projects): streamline import and save flow' directly summarizes the main changes - it addresses project import and save workflow improvements, which align with the PR's core objectives.
Description check ✅ Passed The description covers key sections: a clear summary of changes, validation steps performed, and relates to the PR's main objectives. However, it lacks some template sections like Motivation, Type of Change, Related Issue(s), and Testing Guide.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pr/project-save-flow

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/components/video-editor/projectDirtyState.ts`:
- Around line 56-68: After stripping transient webcam fields (enabled,
sourcePath, timeOffsetMs) in the destructuring assignment, add a check to
determine whether the remaining persistentWebcamFields object is empty. When
constructing the return object, conditionally include the webcam key only if
persistentWebcamFields has at least one property; if it is empty, omit the
webcam key entirely from the editor object. This ensures both snapshots are
normalized consistently—either both have an empty webcam object or neither has
the webcam key—preventing false dirty state comparisons when only transient
webcam properties have changed.

In `@src/components/video-editor/VideoEditor.tsx`:
- Around line 2877-2883: The Save As functionality (triggered by forceSaveAs)
incorrectly uses saveProjectWithName() which calls the IPC path
save-project-file-named that deletes the active project when the target differs,
converting Save As into a move operation instead of creating a copy. Either
create a new distinct IPC handler path for saving a copy with a new name (that
preserves the active project) or modify the existing save-project-file-named
handler to accept an explicit mode parameter that prevents deletion of the
original file. Apply this fix at both the location where openProjectSaveDialog
is called (around line 2877-2883) and where its result is processed (around line
3069-3099).
- Around line 3168-3227: The handleImportMediaOrProject callback applies a
loaded project or imports media and resets editor state without checking for
unsaved changes, which can discard work. Add an unsaved changes guard at the
start of this handler after the initial error checks: if hasUnsavedChanges is
true, either show a confirmation dialog asking the user to confirm they want to
discard changes before proceeding, or require them to save first. This guard
should run before applyLoadedProject is called and before the media import
branch executes the state reset operations.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: eb8c6c33-9f68-40cb-badc-9545cca57237

📥 Commits

Reviewing files that changed from the base of the PR and between 8c7d23d and a3a152e.

📒 Files selected for processing (8)
  • electron/electron-env.d.ts
  • electron/ipc/register/captions.ts
  • electron/preload.ts
  • src/components/launch/hooks/useLaunchWindowActions.ts
  • src/components/video-editor/ProjectBrowserDialog.tsx
  • src/components/video-editor/VideoEditor.tsx
  • src/components/video-editor/projectDirtyState.ts
  • src/components/video-editor/videoPlayback/zoomRegionUtils.ts

Comment on lines +56 to +68
const {
enabled: _enabled,
sourcePath: _sourcePath,
timeOffsetMs: _timeOffsetMs,
...persistentWebcamFields
} = webcam;

return {
...project,
editor: {
...editor,
webcam: persistentWebcamFields,
},

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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Normalize away empty webcam objects after stripping transient fields.

Stripping transient keys can leave webcam: {} on one snapshot while the other snapshot has no webcam key at all; that still compares as dirty even when only transient webcam state changed.

Suggested fix
 	const {
 		enabled: _enabled,
 		sourcePath: _sourcePath,
 		timeOffsetMs: _timeOffsetMs,
 		...persistentWebcamFields
 	} = webcam;
+
+	const { webcam: _webcam, ...editorWithoutWebcam } = editor;
+	const hasPersistentWebcamFields = Object.keys(persistentWebcamFields).length > 0;
 
 	return {
 		...project,
 		editor: {
-			...editor,
-			webcam: persistentWebcamFields,
+			...editorWithoutWebcam,
+			...(hasPersistentWebcamFields ? { webcam: persistentWebcamFields } : {}),
 		},
 	};
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/video-editor/projectDirtyState.ts` around lines 56 - 68, After
stripping transient webcam fields (enabled, sourcePath, timeOffsetMs) in the
destructuring assignment, add a check to determine whether the remaining
persistentWebcamFields object is empty. When constructing the return object,
conditionally include the webcam key only if persistentWebcamFields has at least
one property; if it is empty, omit the webcam key entirely from the editor
object. This ensures both snapshots are normalized consistently—either both have
an empty webcam object or neither has the webcam key—preventing false dirty
state comparisons when only transient webcam properties have changed.

Comment on lines +2877 to +2883
if (forceSaveAs || !targetProjectPath) {
if (options?.silent) {
return false;
}

return openProjectSaveDialog(projectDisplayName || fileNameBase);
}

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.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Keep Save As separate from the rename-style named-save path.

forceSaveAs now opens this dialog, but submit calls saveProjectWithName(), whose IPC path save-project-file-named deletes the active project when the new named target differs. That makes Save As remove the original project instead of creating a copy; use a distinct “save copy as named project” path or pass an explicit mode that preserves the active file. See electron/ipc/register/project.ts:354-429 for the delete-on-rename behavior.

Also applies to: 3069-3099

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/video-editor/VideoEditor.tsx` around lines 2877 - 2883, The
Save As functionality (triggered by forceSaveAs) incorrectly uses
saveProjectWithName() which calls the IPC path save-project-file-named that
deletes the active project when the target differs, converting Save As into a
move operation instead of creating a copy. Either create a new distinct IPC
handler path for saving a copy with a new name (that preserves the active
project) or modify the existing save-project-file-named handler to accept an
explicit mode parameter that prevents deletion of the original file. Apply this
fix at both the location where openProjectSaveDialog is called (around line
2877-2883) and where its result is processed (around line 3069-3099).

Comment on lines +3168 to +3227
const handleImportMediaOrProject = useCallback(async () => {
const result = await window.electronAPI.openVideoFilePicker({ includeProjects: true });

if (result.canceled) {
return;
}

if (!result.success) {
toast.error(result.message || "Failed to import file");
return;
}

if (result.kind === "project" || result.project) {
const restored = await applyLoadedProject(result.project, result.path ?? null);
if (!restored) {
toast.error("Invalid project file format");
return;
}

setProjectBrowserOpen(false);
await refreshProjectLibrary();
toast.success(result.path ? `Project loaded from ${result.path}` : "Project loaded");
return;
}

if (!result.path) {
toast.error("No media file selected");
return;
}

const sourcePath = fromFileUrl(result.path);
const sourceVideoUrl = await resolveVideoUrl(sourcePath);
try {
videoPlaybackRef.current?.pause();
} catch {
// no-op
}

setIsPlaying(false);
setCurrentTime(0);
setDuration(0);
setVideoSourcePath(sourcePath);
setVideoPath(sourceVideoUrl);
setCurrentProjectPath(null);
setLastSavedSnapshot(null);
resetSourceScopedEditorState();
pendingFreshRecordingAutoZoomPathRef.current = autoApplyFreshRecordingAutoZooms
? sourceVideoUrl
: null;
setWebcam((prev) => ({
...prev,
enabled: false,
sourcePath: null,
timeOffsetMs: DEFAULT_WEBCAM_TIME_OFFSET_MS,
}));
applySessionPresentation(null);
await window.electronAPI.setCurrentVideoPath(sourcePath, { preserveProjectPath: false });
setProjectBrowserOpen(false);
await refreshProjectLibrary();
toast.success("Media imported");

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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Gate imports on unsaved-change handling before replacing editor state.

This handler applies a loaded project or media file and resets the current editor state without checking hasUnsavedChanges, so importing from the project browser can discard an unsaved/first-time project. Save first or require an explicit discard confirmation before applyLoadedProject or the media reset branch runs.

Possible localized guard
 		if (!result.success) {
 			toast.error(result.message || "Failed to import file");
 			return;
 		}
 
+		if (hasUnsavedChanges) {
+			const saved = await saveProject(false);
+			if (!saved) {
+				return;
+			}
+		}
+
 		if (result.kind === "project" || result.project) {
 	}, [
 		applyLoadedProject,
 		applySessionPresentation,
 		autoApplyFreshRecordingAutoZooms,
+		hasUnsavedChanges,
 		refreshProjectLibrary,
 		resetSourceScopedEditorState,
+		saveProject,
 	]);
📝 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
const handleImportMediaOrProject = useCallback(async () => {
const result = await window.electronAPI.openVideoFilePicker({ includeProjects: true });
if (result.canceled) {
return;
}
if (!result.success) {
toast.error(result.message || "Failed to import file");
return;
}
if (result.kind === "project" || result.project) {
const restored = await applyLoadedProject(result.project, result.path ?? null);
if (!restored) {
toast.error("Invalid project file format");
return;
}
setProjectBrowserOpen(false);
await refreshProjectLibrary();
toast.success(result.path ? `Project loaded from ${result.path}` : "Project loaded");
return;
}
if (!result.path) {
toast.error("No media file selected");
return;
}
const sourcePath = fromFileUrl(result.path);
const sourceVideoUrl = await resolveVideoUrl(sourcePath);
try {
videoPlaybackRef.current?.pause();
} catch {
// no-op
}
setIsPlaying(false);
setCurrentTime(0);
setDuration(0);
setVideoSourcePath(sourcePath);
setVideoPath(sourceVideoUrl);
setCurrentProjectPath(null);
setLastSavedSnapshot(null);
resetSourceScopedEditorState();
pendingFreshRecordingAutoZoomPathRef.current = autoApplyFreshRecordingAutoZooms
? sourceVideoUrl
: null;
setWebcam((prev) => ({
...prev,
enabled: false,
sourcePath: null,
timeOffsetMs: DEFAULT_WEBCAM_TIME_OFFSET_MS,
}));
applySessionPresentation(null);
await window.electronAPI.setCurrentVideoPath(sourcePath, { preserveProjectPath: false });
setProjectBrowserOpen(false);
await refreshProjectLibrary();
toast.success("Media imported");
const handleImportMediaOrProject = useCallback(async () => {
const result = await window.electronAPI.openVideoFilePicker({ includeProjects: true });
if (result.canceled) {
return;
}
if (!result.success) {
toast.error(result.message || "Failed to import file");
return;
}
if (hasUnsavedChanges) {
const saved = await saveProject(false);
if (!saved) {
return;
}
}
if (result.kind === "project" || result.project) {
const restored = await applyLoadedProject(result.project, result.path ?? null);
if (!restored) {
toast.error("Invalid project file format");
return;
}
setProjectBrowserOpen(false);
await refreshProjectLibrary();
toast.success(result.path ? `Project loaded from ${result.path}` : "Project loaded");
return;
}
if (!result.path) {
toast.error("No media file selected");
return;
}
const sourcePath = fromFileUrl(result.path);
const sourceVideoUrl = await resolveVideoUrl(sourcePath);
try {
videoPlaybackRef.current?.pause();
} catch {
// no-op
}
setIsPlaying(false);
setCurrentTime(0);
setDuration(0);
setVideoSourcePath(sourcePath);
setVideoPath(sourceVideoUrl);
setCurrentProjectPath(null);
setLastSavedSnapshot(null);
resetSourceScopedEditorState();
pendingFreshRecordingAutoZoomPathRef.current = autoApplyFreshRecordingAutoZooms
? sourceVideoUrl
: null;
setWebcam((prev) => ({
...prev,
enabled: false,
sourcePath: null,
timeOffsetMs: DEFAULT_WEBCAM_TIME_OFFSET_MS,
}));
applySessionPresentation(null);
await window.electronAPI.setCurrentVideoPath(sourcePath, { preserveProjectPath: false });
setProjectBrowserOpen(false);
await refreshProjectLibrary();
toast.success("Media imported");
}, [
applyLoadedProject,
applySessionPresentation,
autoApplyFreshRecordingAutoZooms,
hasUnsavedChanges,
refreshProjectLibrary,
resetSourceScopedEditorState,
saveProject,
]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/video-editor/VideoEditor.tsx` around lines 3168 - 3227, The
handleImportMediaOrProject callback applies a loaded project or imports media
and resets editor state without checking for unsaved changes, which can discard
work. Add an unsaved changes guard at the start of this handler after the
initial error checks: if hasUnsavedChanges is true, either show a confirmation
dialog asking the user to confirm they want to discard changes before
proceeding, or require them to save first. This guard should run before
applyLoadedProject is called and before the media import branch executes the
state reset operations.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/components/video-editor/VideoEditor.tsx`:
- Around line 2835-2839: The handleSaveAutoCaptionEdit function always displays
a success toast message regardless of whether the caption edit actually modified
the state. Since updateCaptionCuesForEditedTarget returns the original cues
unchanged for empty normalized text or empty targets, you should add a check to
verify that the caption was actually updated before calling the success toast.
Compare the result of updateCaptionCuesForEditedTarget with the previous
captions state to determine if a real change occurred, and only emit the success
toast when the captions have genuinely been modified.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 228577cd-a431-462d-b50b-0f511fe8f5d2

📥 Commits

Reviewing files that changed from the base of the PR and between e0e6199 and ff9ccde.

📒 Files selected for processing (2)
  • src/components/video-editor/VideoEditor.tsx
  • src/components/video-editor/VideoPlayback.tsx

Comment on lines +2835 to +2839
const handleSaveAutoCaptionEdit = useCallback(
(target: CaptionEditTarget, text: string) => {
setAutoCaptions((captions) => updateCaptionCuesForEditedTarget(captions, target, text));
toast.success(t("settings.captions.editSaved", "Caption updated"));
},

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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Avoid showing “Caption updated” when the edit is rejected as a no-op.

updateCaptionCuesForEditedTarget() returns the original cues for empty normalized text or empty targets, but this handler always emits success. Clearing a caption can therefore appear saved while no state changes.

Possible localized guard
 	const handleSaveAutoCaptionEdit = useCallback(
 		(target: CaptionEditTarget, text: string) => {
+			if (target.words.length === 0 || text.trim().length === 0) {
+				toast.error(
+					t("settings.captions.editTextRequired", "Enter caption text to save"),
+				);
+				return;
+			}
 			setAutoCaptions((captions) => updateCaptionCuesForEditedTarget(captions, target, text));
 			toast.success(t("settings.captions.editSaved", "Caption updated"));
 		},
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/video-editor/VideoEditor.tsx` around lines 2835 - 2839, The
handleSaveAutoCaptionEdit function always displays a success toast message
regardless of whether the caption edit actually modified the state. Since
updateCaptionCuesForEditedTarget returns the original cues unchanged for empty
normalized text or empty targets, you should add a check to verify that the
caption was actually updated before calling the success toast. Compare the
result of updateCaptionCuesForEditedTarget with the previous captions state to
determine if a real change occurred, and only emit the success toast when the
captions have genuinely been modified.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/components/video-editor/captionLayout.ts (1)

98-112: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reindex cueWordIndex after removing blank words.

Line 102 assigns cueWordIndex before Line 111 removes trimmed-empty words. With mixed blank/non-blank cue.words, this leaves holey indices and can cause edit replacement to append instead of replace in updateCaptionCuesForEditedTarget.

Proposed fix
 		const words = cue.words
 			.filter((word): word is CaptionCueWord =>
 				Boolean(word && typeof word.text === "string"),
 			)
-			.map((word, cueWordIndex) => ({
+			.map((word) => ({
 				cueId: cue.id,
-				cueWordIndex,
 				text: word.text.trim(),
 				forcedBreakBefore: false,
 				leadingSpace: Boolean(word.leadingSpace),
 				startMs: word.startMs,
 				endMs: word.endMs,
 			}))
-			.filter((word) => word.text.length > 0);
+			.filter((word) => word.text.length > 0)
+			.map((word, cueWordIndex) => ({
+				...word,
+				cueWordIndex,
+			}));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/video-editor/captionLayout.ts` around lines 98 - 112, The
cueWordIndex is being assigned based on the original word array indices before
the empty words filter is applied, creating holey indices when blank words are
removed. This causes issues in updateCaptionCuesForEditedTarget where edit
replacements append instead of replace. Reorder the operations so that
cueWordIndex is assigned after filtering out blank words: first filter the words
for valid text and trim them, then in a separate map operation assign sequential
cueWordIndex values (0, 1, 2, etc.) to the filtered results. This ensures the
indices are contiguous and reflect the actual position in the final words array.
🧹 Nitpick comments (1)
src/lib/exporter/annotationRenderer.ts (1)

11-15: 💤 Low value

Consider exporting AnnotationSceneTransform for caller type safety.

The interface is used in the exported renderAnnotations function signature but isn't exported itself. While TypeScript's structural typing allows callers to pass inline objects, exporting the type would let callers reference it for better type safety and documentation.

-interface AnnotationSceneTransform {
+export interface AnnotationSceneTransform {
 	scale: number;
 	x: number;
 	y: number;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/exporter/annotationRenderer.ts` around lines 11 - 15, The
AnnotationSceneTransform interface is currently not exported but is used in the
signature of the exported renderAnnotations function, which prevents callers
from directly referencing the type. Add the export keyword to the
AnnotationSceneTransform interface definition to allow callers to import and use
the type directly for better type safety and clearer API documentation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/lib/exporter/modernFrameRenderer.ts`:
- Around line 1589-1593: The composeBlurAnnotationFrame call is using mutable
this.animationState for the transform properties (scale, x, y) instead of the
temporal snapshot transform. Replace the object passed to
composeBlurAnnotationFrame that reads from this.animationState with
resolvedSnapshot.sceneTransform to ensure blur annotations are rendered at the
correct transform that matches the selected snapshot, preventing misalignment
when temporal zoom blur is enabled.

---

Outside diff comments:
In `@src/components/video-editor/captionLayout.ts`:
- Around line 98-112: The cueWordIndex is being assigned based on the original
word array indices before the empty words filter is applied, creating holey
indices when blank words are removed. This causes issues in
updateCaptionCuesForEditedTarget where edit replacements append instead of
replace. Reorder the operations so that cueWordIndex is assigned after filtering
out blank words: first filter the words for valid text and trim them, then in a
separate map operation assign sequential cueWordIndex values (0, 1, 2, etc.) to
the filtered results. This ensures the indices are contiguous and reflect the
actual position in the final words array.

---

Nitpick comments:
In `@src/lib/exporter/annotationRenderer.ts`:
- Around line 11-15: The AnnotationSceneTransform interface is currently not
exported but is used in the signature of the exported renderAnnotations
function, which prevents callers from directly referencing the type. Add the
export keyword to the AnnotationSceneTransform interface definition to allow
callers to import and use the type directly for better type safety and clearer
API documentation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 788a8c0b-788a-477c-ab5b-ce59a456ee45

📥 Commits

Reviewing files that changed from the base of the PR and between ff9ccde and dfd3692.

📒 Files selected for processing (8)
  • src/components/video-editor/AnnotationOverlay.tsx
  • src/components/video-editor/VideoPlayback.tsx
  • src/components/video-editor/captionEditing.test.ts
  • src/components/video-editor/captionEditing.ts
  • src/components/video-editor/captionLayout.ts
  • src/lib/exporter/annotationRenderer.ts
  • src/lib/exporter/frameRenderer.ts
  • src/lib/exporter/modernFrameRenderer.ts
✅ Files skipped from review due to trivial changes (1)
  • src/components/video-editor/AnnotationOverlay.tsx

Comment on lines +1589 to +1593
{
scale: this.animationState.appliedScale,
x: this.animationState.x,
y: this.animationState.y,
},

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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use the temporal snapshot transform when compositing blur annotations.

composeBlurAnnotationFrame reads mutable this.animationState, but the temporal path calls it with resolvedSnapshot.timeMs after sampling has advanced state to the last sample. If temporal zoom blur is enabled, annotations can be rendered at a different transform than the selected snapshot; pass resolvedSnapshot.sceneTransform through instead.

Proposed fix
 private async composeBlurAnnotationFrame(
 	timeMs: number,
 	sourceCanvas?: CanvasImageSource,
+	sceneTransform: { scale: number; x: number; y: number } = {
+		scale: this.animationState.appliedScale,
+		x: this.animationState.x,
+		y: this.animationState.y,
+	},
 ): Promise<void> {
@@
 		await renderAnnotations(
 			context,
 			this.config.annotationRegions ?? [],
 			this.config.width,
 			this.config.height,
 			timeMs,
 			this.annotationScaleFactor,
 			this.annotationAssets ?? undefined,
-			{
-				scale: this.animationState.appliedScale,
-				x: this.animationState.x,
-				y: this.animationState.y,
-			},
+			sceneTransform,
 		);
-			await this.composeBlurAnnotationFrame(resolvedSnapshot.timeMs, compositeState.canvas);
+			await this.composeBlurAnnotationFrame(
+				resolvedSnapshot.timeMs,
+				compositeState.canvas,
+				resolvedSnapshot.sceneTransform,
+			);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/exporter/modernFrameRenderer.ts` around lines 1589 - 1593, The
composeBlurAnnotationFrame call is using mutable this.animationState for the
transform properties (scale, x, y) instead of the temporal snapshot transform.
Replace the object passed to composeBlurAnnotationFrame that reads from
this.animationState with resolvedSnapshot.sceneTransform to ensure blur
annotations are rendered at the correct transform that matches the selected
snapshot, preventing misalignment when temporal zoom blur is enabled.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/lib/exporter/modernFrameRenderer.ts`:
- Around line 1617-1627: The `setupAnnotationLayer()` method is being called in
the `initialize()` method before `layoutCache` is populated by `updateLayout()`,
which causes the annotation positioning logic to always use the fallback
full-canvas dimensions instead of the actual masked rectangle. To fix this,
either reorder the calls in `initialize()` so that `updateLayout()` is invoked
before `setupAnnotationLayer()`, or alternatively defer the annotation sprite
positioning calculations from `setupAnnotationLayer()` to occur later during the
first `renderFrame()` call after `layoutCache` has been properly initialized,
similar to how blur annotations correctly use the actual `maskRect` at render
time.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 7680a615-b778-429f-ba0a-1b77eed14589

📥 Commits

Reviewing files that changed from the base of the PR and between dfd3692 and 347c36a.

📒 Files selected for processing (6)
  • src/components/video-editor/AnnotationOverlay.tsx
  • src/components/video-editor/VideoEditor.tsx
  • src/components/video-editor/VideoPlayback.tsx
  • src/lib/exporter/annotationRenderer.ts
  • src/lib/exporter/frameRenderer.ts
  • src/lib/exporter/modernFrameRenderer.ts
💤 Files with no reviewable changes (1)
  • src/components/video-editor/VideoEditor.tsx
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/lib/exporter/frameRenderer.ts
  • src/lib/exporter/annotationRenderer.ts
  • src/components/video-editor/VideoPlayback.tsx

Comment thread src/lib/exporter/modernFrameRenderer.ts
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