Skip to content

Add subtitle export IPC and hidden-cursor click effects#631

Closed
webadderall wants to merge 3 commits into
mainfrom
feature/click-effects-auto-zoom-fps-fixes
Closed

Add subtitle export IPC and hidden-cursor click effects#631
webadderall wants to merge 3 commits into
mainfrom
feature/click-effects-auto-zoom-fps-fixes

Conversation

@webadderall
Copy link
Copy Markdown
Collaborator

@webadderall webadderall commented May 30, 2026

Summary

  • add IPC + preload wiring for subtitle file export and tests
  • make cursor click effects render even when the cursor is hidden
  • keep native static-layout fallback behavior correct for active click effects

Validation

  • npm exec -- tsc --noEmit --pretty false
  • npx vitest run src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts

Summary by CodeRabbit

  • New Features

    • Added subtitle export functionality supporting SRT and VTT formats via a new "Subtitles" dropdown in the export toolbar
  • Improvements

    • Enhanced cursor visibility and rendering behavior during video playback
  • Tests

    • Added tests for subtitle export serialization and error handling

Review Change Stack

@webadderall
Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@copilot review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 30, 2026

📝 Walkthrough

Walkthrough

This PR introduces end-to-end subtitle export functionality (SRT/VTT formats) via Electron file dialogs and VideoEditor UI controls, alongside a cursor visibility tracking refinement that gates sprite rendering independently and updates export validation logic for cursor click effects.

Changes

Subtitle Export Feature

Layer / File(s) Summary
Type definitions and contracts
src/lib/exporter/types.ts, electron/electron-env.d.ts, electron/preload.ts
SubtitleExportFormat union type ("srt" | "vtt") is declared and exposed across the Electron API boundary and preload.
Core subtitle serialization and file export
electron/ipc/captions/exportSubtitleFile.ts, electron/ipc/captions/exportSubtitleFile.test.ts
Cue normalization (timing rounding, text newline normalization, malformed-cue skipping with warnings), SRT/VTT formatting with correct delimiters (, for SRT, . for VTT), WEBVTT header generation, file dialog filtering by format, dialog cancellation handling, UTF-8 file writing, and structured success/failure responses. Comprehensive tests cover SRT/VTT serialization edge cases and write-failure paths.
IPC handler registration
electron/ipc/register/captions.ts
Imports exportSubtitleFile and registers the export-subtitle-file IPC handler.
UI integration and export controls
src/components/video-editor/VideoEditor.tsx
Sanitized subtitle filename generation from project/source paths, concurrent-export state tracking to prevent re-entry, handler callback that invokes the Electron API and reports outcomes via toasts, and toolbar dropdown with "Export SRT" and "Export VTT" options (disabled when captions are absent or export is in progress).

Cursor Visibility Refinement

Layer / File(s) Summary
Cursor visibility tracking and sprite gating
src/components/video-editor/videoPlayback/cursorRenderer.ts
Adds private cursorVisible flag to track independent cursor rendering state; gates getSmoothedCursorSnapshot() returns, early-exit visibility logic (hiding container, clearing click effects, resetting motion state), motion smoothing, and stateful sprite rendering behind visibility checks; updates lastRenderedPoint recording to only occur when sprite is shown; clears flag on reset.
Cursor click effect detection in export
src/lib/exporter/modernVideoExporter.ts, src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts
Removes config.showCursor === true requirement from hasCursorClickEffect logic; cursor click effects are now detected solely from cursor telemetry presence and non-"none" effect type. Adds test case validating the new behavior with click effects and hidden cursor.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Suggested labels

Checked

🐰 Subtitle paths now flow through dialog doors,
Cursors blink and hide with newfound decor,
SRT and VTT in files so neat,
Electron's gift makes exports complete! ✨

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description covers the core changes and validation steps but lacks the structured sections and details required by the repository template. Add Description, Motivation, and Type of Change sections; include links to related issues; provide screenshots/video and detailed testing guide following the template structure.
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely summarizes both main changes: subtitle export IPC wiring and hidden-cursor click effect rendering improvements.
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 docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/click-effects-auto-zoom-fps-fixes

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


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
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 30, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Fetch PR diff and files changed
gh pr view 631 --repo webadderallorg/Recordly --json title,body,files,additions,deletions
echo "---FILES CHANGED---"
gh pr diff 631 --repo webadderallorg/Recordly

Length of output: 28908


🏁 Script executed:

#!/bin/bash
# Check what approveUserPath does and how it's used in other handlers
rg -n "approveUserPath" --type ts -A 5 -B 2

Length of output: 9235



@webadderall Here's the review:


Review of PR #631

electron/ipc/captions/exportSubtitleFile.tsapproveUserPath order ✅

Checked against the existing export handlers in electron/ipc/register/export.ts (e.g. lines 868–869, 996–998). The pattern of writing the file first and then calling approveUserPath is consistent with the rest of the codebase — it adds the path to the allow-list for future reads. No issue here.


src/components/video-editor/VideoEditor.tsxgetErrorMessage(result.error) swallows the error string

toast.error(
    result.message ||
    getErrorMessage(result.error) ||   // ← always returns "Something went wrong"
    "Failed to export subtitle file",
);

getErrorMessage returns the literal string "Something went wrong" for every input. result.error (which contains the actual error text) is never shown to the user. Since result.message is always populated on the server side, this is benign in practice, but the getErrorMessage(result.error) branch is dead code that misleads readers.

Suggestion:

toast.error(
    result.message || result.error || "Failed to export subtitle file",
);

electron/ipc/captions/exportSubtitleFile.test.ts — SRT serializer test uses legacy fields without explanation

// SRT test — uses legacy start/end
{ start: 0, end: 1500, text: "Hello" },

// VTT test — uses startMs/endMs
{ startMs: 0, endMs: 1500, text: "Hello" },

This inconsistency silently exercises the getCueTimeMs fallback path in the SRT test, but it reads like a mistake rather than an intentional coverage decision. A brief comment or a dedicated fallback test would make the intent clear.


electron/ipc/captions/exportSubtitleFile.test.tsexportSubtitleFile block only covers the failure path

The describe("exportSubtitleFile") block has a single test for an unwritable path. It's missing coverage for:

  • Successful write + correct return shape ({ success: true, path: ... })
  • User cancellation via dialog ({ canceled: true })
  • Invalid format (format !== "srt" | "vtt")

src/components/video-editor/VideoEditor.tsxgetSubtitleExportFileName edge case

const safeBaseName = sourceName.replace(/[\x00-\x1f<>:"\/\\|?*]+/g, "-").trim();
return `${safeBaseName || "captions"}.${format}`;

.trim() removes whitespace but not hyphens, so a source name consisting entirely of sanitized characters (e.g. :::) produces ---.srt rather than falling back to captions.srt. A quick fix:

const safeBaseName = sourceName
    .replace(/[\x00-\x1f<>:"\/\\|?*]+/g, "-")
    .replace(/^-+|-+$/g, "")  // strip leading/trailing hyphens
    .trim();

src/components/video-editor/videoPlayback/cursorRenderer.ts — overall ✅

The refactor looks correct. Key observations:

  • cursorVisible is properly reset in reset() and in all early-exit paths.
  • lastRenderedTimeMs being updated on click-effect-only frames is intentional — it ensures the cursor snaps rather than animates from a stale position when it re-appears after a gap.
  • shouldFreezeCursorMotion = true as a safe default is correct for the click-effect-only branch where the spring state is not updated.
  • The getCursorTrailState !this.cursorVisible guard is safe — click ring position derives from projectedClickSample/projectedTarget directly, not from the trail.

src/lib/exporter/modernVideoExporter.ts + test ✅

Removing the showCursor === true guard from hasCursorClickEffect is the right fix, and the new test case (showCursor: false + cursorClickEffect: "echo""unsupported-cursor-click-effect") closes the coverage gap cleanly.


Summary: No blockers. The main actionable items are the dead getErrorMessage(result.error) call in the toast handler and the thin test coverage for the happy/cancel paths in the exportSubtitleFile IPC handler.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 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`:
- Line 365: The regex used to build safeBaseName unnecessarily escapes the
forward slash inside the character class; update the assignment that constructs
safeBaseName (the line using sourceName.replace(...).trim()) to remove the
backslash before the "/" in the character class so the pattern reads the same
but without the unnecessary escape, leaving the control-range \x00-\x1f and
other forbidden chars intact and preserving .trim().

In `@src/components/video-editor/videoPlayback/cursorRenderer.ts`:
- Around line 1314-1339: When the cursor is hidden the persistent
SmoothedCursorState (this.state) is left unchanged so when the cursor reappears
within CURSOR_TIME_DISCONTINUITY_MS it updates from stale coordinates; fix by
resetting or reinitializing this.state to a fresh SmoothedCursorState (or call
its reset/initialize method) whenever you take the hide paths (the branch that
returns when samples empty/viewport invalid and the branch that returns when
interpolateCursorPosition returns falsy). Ensure the reset happens before the
early return so subsequent frames start from the current target rather than old
coordinates.
🪄 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: e72e2593-072d-4ca7-ac5e-96f52d32b819

📥 Commits

Reviewing files that changed from the base of the PR and between 3f1123f and dab0b3a.

📒 Files selected for processing (10)
  • electron/electron-env.d.ts
  • electron/ipc/captions/exportSubtitleFile.test.ts
  • electron/ipc/captions/exportSubtitleFile.ts
  • electron/ipc/register/captions.ts
  • electron/preload.ts
  • src/components/video-editor/VideoEditor.tsx
  • src/components/video-editor/videoPlayback/cursorRenderer.ts
  • src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts
  • src/lib/exporter/modernVideoExporter.ts
  • src/lib/exporter/types.ts
💤 Files with no reviewable changes (1)
  • src/lib/exporter/modernVideoExporter.ts

?.split(/[\\/]/)
.pop()
?.replace(/\.[^.]+$/, "") ?? "captions";
const safeBaseName = sourceName.replace(/[\x00-\x1f<>:"\/\\|?*]+/g, "-").trim();
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

Remove unnecessary escape in regex character class.

The forward slash doesn't need escaping inside a character class. The control character range (\x00-\x1f) is intentional for filename sanitization and can be ignored, but the / escape should be removed.

🔧 Proposed fix
-	const safeBaseName = sourceName.replace(/[\x00-\x1f<>:"\/\\|?*]+/g, "-").trim();
+	const safeBaseName = sourceName.replace(/[\x00-\x1f<>:"/\\|?*]+/g, "-").trim();
📝 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 safeBaseName = sourceName.replace(/[\x00-\x1f<>:"\/\\|?*]+/g, "-").trim();
const safeBaseName = sourceName.replace(/[\x00-\x1f<>:"/\\|?*]+/g, "-").trim();
🧰 Tools
🪛 Biome (2.4.15)

[error] 365-365: Unexpected control character in a regular expression.

(lint/suspicious/noControlCharactersInRegex)


[error] 365-365: Unexpected control character in a regular expression.

(lint/suspicious/noControlCharactersInRegex)


[error] 365-365: The character doesn't need to be escaped.

(lint/complexity/noUselessEscapeInRegex)

🤖 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` at line 365, The regex used to
build safeBaseName unnecessarily escapes the forward slash inside the character
class; update the assignment that constructs safeBaseName (the line using
sourceName.replace(...).trim()) to remove the backslash before the "/" in the
character class so the pattern reads the same but without the unnecessary
escape, leaving the control-range \x00-\x1f and other forbidden chars intact and
preserving .trim().

Comment on lines +1314 to 1339
if (samples.length === 0 || viewport.width <= 0 || viewport.height <= 0) {
this.container.visible = false;
this.cursorVisible = false;
this.clickRingGraphics.clear();
this.lastRenderedPoint = null;
this.lastRenderedTimeMs = null;
this.swayRotation = 0;
resetSpringState(this.swaySpring, 0);
this.cursorMotionBlurFilter.velocity = { x: 0, y: 0 };
this.customCursorShadowSprite.visible = false;
this.customCursorSprite.visible = false;
for (const shadowSprite of Object.values(this.cursorShadowSprites)) {
shadowSprite.visible = false;
}
for (const sprite of Object.values(this.cursorSprites)) {
sprite.visible = false;
}
return;
}

const target = interpolateCursorPosition(samples, timeMs);
if (!target) {
this.container.visible = false;
this.cursorVisible = false;
this.clickRingGraphics.clear();
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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Reset SmoothedCursorState when the sprite is hidden.

After the hide paths on Lines 1314-1339 and Lines 1369-1423, this.state stays initialized. If the cursor becomes visible again within CURSOR_TIME_DISCONTINUITY_MS, the next frame runs this.state.update(...) from stale coordinates, so the cursor can slide in from the old position instead of snapping to the current target.

Suggested fix
 		if (samples.length === 0 || viewport.width <= 0 || viewport.height <= 0) {
+			this.state.reset();
 			this.container.visible = false;
 			this.cursorVisible = false;
 			this.clickRingGraphics.clear();
@@
 		const target = interpolateCursorPosition(samples, timeMs);
 		if (!target) {
+			this.state.reset();
 			this.container.visible = false;
 			this.cursorVisible = false;
 			this.clickRingGraphics.clear();
 			return;
 		}
@@
 		if (!shouldShowCursorSprite && !shouldDrawClickEffect) {
+			this.state.reset();
 			this.container.visible = false;
 			this.cursorVisible = false;
 			this.clickRingGraphics.clear();
@@
 		} else {
+			this.state.reset();
 			this.customCursorShadowSprite.visible = false;
 			this.customCursorSprite.visible = false;
 			for (const shadowSprite of Object.values(this.cursorShadowSprites)) {

Also applies to: 1369-1423

🤖 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/videoPlayback/cursorRenderer.ts` around lines
1314 - 1339, When the cursor is hidden the persistent SmoothedCursorState
(this.state) is left unchanged so when the cursor reappears within
CURSOR_TIME_DISCONTINUITY_MS it updates from stale coordinates; fix by resetting
or reinitializing this.state to a fresh SmoothedCursorState (or call its
reset/initialize method) whenever you take the hide paths (the branch that
returns when samples empty/viewport invalid and the branch that returns when
interpolateCursorPosition returns falsy). Ensure the reset happens before the
early return so subsequent frames start from the current target rather than old
coordinates.

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